@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/agent.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { imageLocation } from "./registry.js";
|
|
4
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
5
|
import { Readable } from 'node:stream';
|
|
5
6
|
import { pipeline } from 'node:stream/promises';
|
|
6
7
|
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
7
8
|
import path from 'node:path';
|
|
8
|
-
import {
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import net from 'node:net';
|
|
10
11
|
import dns from 'node:dns/promises';
|
|
11
12
|
import { WebSocket } from 'ws';
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { addressBoundServices,
|
|
14
|
+
import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
|
|
15
|
+
import { containerSpecs, networkName, volumeName } from "./container-spec.js";
|
|
16
|
+
import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
|
|
14
17
|
import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
|
|
15
18
|
import { LiveOperationEngine } from "./live.js";
|
|
16
|
-
import {
|
|
19
|
+
import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
|
|
17
20
|
import { ensureDir, errMessage, isPublicAddress, now, parseDuration } from "./util.js";
|
|
18
21
|
import { openAgentDb } from "./db.js";
|
|
19
|
-
import {
|
|
22
|
+
import { createHostRunner } from "./runners.js";
|
|
20
23
|
import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
|
|
21
24
|
import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
|
|
22
25
|
const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
|
|
@@ -30,7 +33,7 @@ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
|
|
|
30
33
|
const MESH = process.env.TUG_MESH === 'true';
|
|
31
34
|
export function defaultContext() {
|
|
32
35
|
return {
|
|
33
|
-
docker:
|
|
36
|
+
docker: new DockerEngine(),
|
|
34
37
|
http: fetch,
|
|
35
38
|
nodeName: NODE_NAME,
|
|
36
39
|
stateDir: STATE_DIR,
|
|
@@ -230,29 +233,18 @@ export async function resolveLiveContainer(ctx, request) {
|
|
|
230
233
|
const rolling = state.services[request.service];
|
|
231
234
|
if (rolling && !rolling.active)
|
|
232
235
|
throw new Error('Rolling service has no active revision');
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
'--quiet',
|
|
241
|
-
'--filter',
|
|
242
|
-
'label=com.docker.compose.project=' + project,
|
|
243
|
-
'--filter',
|
|
244
|
-
'label=com.docker.compose.service=' + request.service,
|
|
245
|
-
'--filter',
|
|
246
|
-
'label=com.docker.compose.oneoff=False',
|
|
247
|
-
]);
|
|
248
|
-
const ids = result.out.trim().split(/\s+/).filter(Boolean);
|
|
249
|
-
if (ids.length !== 1)
|
|
250
|
-
throw new Error(ids.length
|
|
236
|
+
const containers = await projectContainers(ctx.docker, request.project, request.service);
|
|
237
|
+
// A rolling service has the draining revision beside the serving one; only the serving one is addressed.
|
|
238
|
+
const candidates = rolling?.active
|
|
239
|
+
? containers.filter((c) => c.Names.includes(`/${rolling.active.container}`))
|
|
240
|
+
: containers;
|
|
241
|
+
if (candidates.length !== 1)
|
|
242
|
+
throw new Error(candidates.length
|
|
251
243
|
? 'Service has multiple containers; select an unambiguous service'
|
|
252
244
|
: 'Service has no container on this node');
|
|
253
|
-
if (!/^[a-f0-9]{12,64}$/.test(
|
|
245
|
+
if (!/^[a-f0-9]{12,64}$/.test(candidates[0].Id))
|
|
254
246
|
throw new Error('Docker returned an invalid container ID');
|
|
255
|
-
return
|
|
247
|
+
return candidates[0].Id;
|
|
256
248
|
}
|
|
257
249
|
export class AgentLiveOperations {
|
|
258
250
|
ctx;
|
|
@@ -263,6 +255,7 @@ export class AgentLiveOperations {
|
|
|
263
255
|
this.ctx = ctx;
|
|
264
256
|
this.emit = emit;
|
|
265
257
|
this.engine = new LiveOperationEngine({
|
|
258
|
+
docker: ctx.docker,
|
|
266
259
|
...options,
|
|
267
260
|
emit: (event) => {
|
|
268
261
|
if (!this.sessions.has(event.sessionId))
|
|
@@ -381,6 +374,8 @@ export class AgentLiveOperations {
|
|
|
381
374
|
this.engine.cancel(id);
|
|
382
375
|
}
|
|
383
376
|
async open(request, entry) {
|
|
377
|
+
const current = () => this.sessions.get(request.sessionId) === entry;
|
|
378
|
+
const started = () => this.emit({ v: 1, type: 'live.started', sessionId: request.sessionId });
|
|
384
379
|
try {
|
|
385
380
|
if (this.sessions.size > 16)
|
|
386
381
|
throw new Error('Live session limit reached');
|
|
@@ -392,83 +387,71 @@ export class AgentLiveOperations {
|
|
|
392
387
|
!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(request.project))
|
|
393
388
|
throw new Error('Invalid project');
|
|
394
389
|
if (request.operation === 'ps') {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
];
|
|
404
|
-
this.engine.start({ operation: 'ps', sessionId: request.sessionId, projects });
|
|
390
|
+
// A query's result and exit follow `started` immediately, so announce it first.
|
|
391
|
+
started();
|
|
392
|
+
await this.engine.start({
|
|
393
|
+
operation: 'ps',
|
|
394
|
+
sessionId: request.sessionId,
|
|
395
|
+
projects: [request.project],
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
405
398
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
clearTimeout(timer);
|
|
422
|
-
}
|
|
423
|
-
if (this.sessions.get(request.sessionId) !== entry)
|
|
424
|
-
return;
|
|
425
|
-
if (request.operation === 'exec')
|
|
426
|
-
this.engine.start({
|
|
427
|
-
operation: 'exec',
|
|
428
|
-
sessionId: request.sessionId,
|
|
429
|
-
container,
|
|
430
|
-
command: request.command,
|
|
431
|
-
interactive: request.interactive,
|
|
432
|
-
tty: request.tty,
|
|
433
|
-
columns: request.columns,
|
|
434
|
-
rows: request.rows,
|
|
435
|
-
});
|
|
436
|
-
else if (request.operation === 'logs')
|
|
437
|
-
this.engine.start({
|
|
438
|
-
operation: 'logs',
|
|
439
|
-
sessionId: request.sessionId,
|
|
440
|
-
container,
|
|
441
|
-
tail: request.tail,
|
|
442
|
-
});
|
|
443
|
-
else
|
|
444
|
-
this.engine.start({ operation: 'inspect', sessionId: request.sessionId, container });
|
|
399
|
+
if (!['exec', 'logs', 'inspect'].includes(request.operation))
|
|
400
|
+
throw new Error('Invalid live operation');
|
|
401
|
+
let timer;
|
|
402
|
+
let container;
|
|
403
|
+
try {
|
|
404
|
+
container = await Promise.race([
|
|
405
|
+
resolveLiveContainer(this.ctx, request),
|
|
406
|
+
new Promise((_resolve, reject) => {
|
|
407
|
+
timer = setTimeout(() => reject(new Error('Live container resolution timed out')), LIVE_START_TIMEOUT_MS);
|
|
408
|
+
timer.unref();
|
|
409
|
+
}),
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
clearTimeout(timer);
|
|
445
414
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
415
|
+
if (!current())
|
|
416
|
+
return;
|
|
417
|
+
if (request.operation === 'inspect') {
|
|
418
|
+
started();
|
|
419
|
+
await this.engine.start({ operation: 'inspect', sessionId: request.sessionId, container });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (request.operation === 'exec')
|
|
423
|
+
await this.engine.start({
|
|
424
|
+
operation: 'exec',
|
|
425
|
+
sessionId: request.sessionId,
|
|
426
|
+
container,
|
|
427
|
+
command: request.command,
|
|
428
|
+
interactive: request.interactive,
|
|
429
|
+
tty: request.tty,
|
|
430
|
+
columns: request.columns,
|
|
431
|
+
rows: request.rows,
|
|
432
|
+
});
|
|
433
|
+
else
|
|
434
|
+
await this.engine.start({
|
|
435
|
+
operation: 'logs',
|
|
436
|
+
sessionId: request.sessionId,
|
|
437
|
+
container,
|
|
438
|
+
tail: request.tail,
|
|
439
|
+
});
|
|
440
|
+
if (current())
|
|
441
|
+
started();
|
|
442
|
+
else
|
|
443
|
+
this.engine.cancel(request.sessionId);
|
|
448
444
|
}
|
|
449
445
|
catch (error) {
|
|
450
446
|
this.fail(request.sessionId, entry, error);
|
|
451
447
|
}
|
|
452
448
|
}
|
|
453
449
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
/** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
|
|
461
|
-
const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
|
|
462
|
-
export async function containerIp(ctx, project, service, cwd, composeFile) {
|
|
463
|
-
const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out
|
|
464
|
-
.trim()
|
|
465
|
-
.split(/\s+/)
|
|
466
|
-
.filter(Boolean);
|
|
467
|
-
if (!ids.length)
|
|
468
|
-
throw new Error(`${service}: no container found`);
|
|
469
|
-
const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
|
|
470
|
-
const networks = inspected?.NetworkSettings?.Networks || {};
|
|
471
|
-
return Object.values(networks)[0]?.IPAddress || '127.0.0.1';
|
|
450
|
+
/** The container's address on the project network, which health checks dial. */
|
|
451
|
+
export async function containerIp(ctx, project, container) {
|
|
452
|
+
const networks = (await ctx.docker.inspectContainer(container)).NetworkSettings?.Networks || {};
|
|
453
|
+
const net = networks[networkName(project)] ?? Object.values(networks)[0];
|
|
454
|
+
return net?.IPAddress || net?.GlobalIPv6Address || '127.0.0.1';
|
|
472
455
|
}
|
|
473
456
|
function tcpCheck(host, port, timeoutMs = 2000) {
|
|
474
457
|
return new Promise((resolve, reject) => {
|
|
@@ -483,7 +466,7 @@ function tcpCheck(host, port, timeoutMs = 2000) {
|
|
|
483
466
|
s.on('error', reject);
|
|
484
467
|
});
|
|
485
468
|
}
|
|
486
|
-
export async function waitForHealth(ctx, project, service, health
|
|
469
|
+
export async function waitForHealth(ctx, project, container, service, health) {
|
|
487
470
|
if (!ctx.apply || !health)
|
|
488
471
|
return;
|
|
489
472
|
const retries = Number(health.retries || 10);
|
|
@@ -492,20 +475,17 @@ export async function waitForHealth(ctx, project, service, health, cwd, composeF
|
|
|
492
475
|
for (let i = 0; i < retries; i++) {
|
|
493
476
|
try {
|
|
494
477
|
if (health.type === 'docker') {
|
|
495
|
-
const
|
|
496
|
-
.trim()
|
|
497
|
-
.split(/\s+/)[0];
|
|
498
|
-
const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
|
|
478
|
+
const state = (await ctx.docker.inspectContainer(container)).State;
|
|
499
479
|
if (!state?.Running)
|
|
500
480
|
throw new Error(`${service}: not running`);
|
|
501
481
|
if (state.Health && state.Health.Status !== 'healthy')
|
|
502
482
|
throw new Error(`${service}: docker health ${state.Health.Status}`);
|
|
503
483
|
}
|
|
504
484
|
else if (health.type === 'tcp') {
|
|
505
|
-
await tcpCheck(await containerIp(ctx, project,
|
|
485
|
+
await tcpCheck(await containerIp(ctx, project, container), health.port, parseDuration(health.timeout || '2s'));
|
|
506
486
|
}
|
|
507
487
|
else if (health.type === 'http') {
|
|
508
|
-
const ip = await containerIp(ctx, project,
|
|
488
|
+
const ip = await containerIp(ctx, project, container);
|
|
509
489
|
const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
|
|
510
490
|
signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')),
|
|
511
491
|
});
|
|
@@ -535,19 +515,22 @@ function describeHealthCheck(health) {
|
|
|
535
515
|
return 'docker health check';
|
|
536
516
|
}
|
|
537
517
|
export { parseDuration };
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
* listen on all interfaces or on a public IP, from `docker ps --format '{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Ports}}'`.
|
|
541
|
-
*/
|
|
542
|
-
export function publicListeners(psOutput, projects) {
|
|
518
|
+
/** Containers of Tug projects (`projects`) that publish a port on all interfaces or on a public IP. */
|
|
519
|
+
export function publicListeners(containers, projects) {
|
|
543
520
|
const out = [];
|
|
544
|
-
for (const
|
|
545
|
-
const
|
|
546
|
-
if (!project || !projects.
|
|
521
|
+
for (const c of containers) {
|
|
522
|
+
const project = c.Labels?.['tug.project'];
|
|
523
|
+
if (!project || !projects.includes(project))
|
|
547
524
|
continue;
|
|
548
|
-
for (const
|
|
549
|
-
if (isPublicAddress(
|
|
550
|
-
|
|
525
|
+
for (const p of c.Ports || []) {
|
|
526
|
+
if (!p.PublicPort || !p.IP || !isPublicAddress(p.IP))
|
|
527
|
+
continue;
|
|
528
|
+
const host = p.IP.includes(':') ? `[${p.IP}]` : p.IP;
|
|
529
|
+
out.push({
|
|
530
|
+
project,
|
|
531
|
+
container: (c.Names?.[0] || c.Id).replace(/^\//, ''),
|
|
532
|
+
listen: `${host}:${p.PublicPort}->${p.PrivatePort}/${p.Type}`,
|
|
533
|
+
});
|
|
551
534
|
}
|
|
552
535
|
}
|
|
553
536
|
return out;
|
|
@@ -558,14 +541,8 @@ async function publicPortsCheck(ctx) {
|
|
|
558
541
|
const projects = await fs
|
|
559
542
|
.readdir(path.join(ctx.stateDir, 'projects'))
|
|
560
543
|
.catch(() => []);
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
'--filter',
|
|
564
|
-
'label=com.docker.compose.project',
|
|
565
|
-
'--format',
|
|
566
|
-
'{{.Label "com.docker.compose.project"}}\t{{.Names}}\t{{.Ports}}',
|
|
567
|
-
]);
|
|
568
|
-
const listeners = publicListeners(ps.out, projects);
|
|
544
|
+
const containers = await ctx.docker.listContainers({ label: ['tug.project'] }, false);
|
|
545
|
+
const listeners = publicListeners(containers, projects);
|
|
569
546
|
const stdout = listeners.map((l) => `${l.project}\t${l.container}\t${l.listen}`).join('\n');
|
|
570
547
|
return listeners.length
|
|
571
548
|
? {
|
|
@@ -584,25 +561,30 @@ async function publicPortsCheck(ctx) {
|
|
|
584
561
|
return { name: 'ports.public', ok: false, error: errMessage(e).slice(-4000) };
|
|
585
562
|
}
|
|
586
563
|
}
|
|
564
|
+
const MIN_API_VERSION = API_VERSION.replace(/^v/, '');
|
|
565
|
+
/** The minor number of an Engine API version such as `1.41`; 0 when unparseable. */
|
|
566
|
+
const apiMinor = (version) => Number(version?.split('.')[1]) || 0;
|
|
587
567
|
export async function executeCommand(command, ctx = defaultContext()) {
|
|
588
568
|
const payload = typeof command.payload_json === 'string'
|
|
589
569
|
? JSON.parse(command.payload_json || '{}')
|
|
590
570
|
: command.payload || {};
|
|
591
571
|
if (command.type === 'diagnostics.run') {
|
|
592
572
|
const checks = [];
|
|
593
|
-
|
|
594
|
-
['docker.info',
|
|
595
|
-
[
|
|
596
|
-
|
|
597
|
-
|
|
573
|
+
const daemon = [
|
|
574
|
+
['docker.info', async () => JSON.stringify(await ctx.docker.info(), null, 2)],
|
|
575
|
+
[
|
|
576
|
+
'docker.version',
|
|
577
|
+
async () => {
|
|
578
|
+
const version = await ctx.docker.version();
|
|
579
|
+
if (apiMinor(version.ApiVersion) < apiMinor(MIN_API_VERSION))
|
|
580
|
+
throw new Error(`Docker Engine API ${version.ApiVersion} is older than the ${MIN_API_VERSION} the agent needs (Docker 20.10+)`);
|
|
581
|
+
return JSON.stringify(version, null, 2);
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
];
|
|
585
|
+
for (const [name, fn] of daemon) {
|
|
598
586
|
try {
|
|
599
|
-
|
|
600
|
-
checks.push({
|
|
601
|
-
name,
|
|
602
|
-
ok: true,
|
|
603
|
-
stdout: result.out.slice(-4000),
|
|
604
|
-
stderr: result.err.slice(-4000),
|
|
605
|
-
});
|
|
587
|
+
checks.push({ name, ok: true, stdout: (await fn()).slice(-4000), stderr: '' });
|
|
606
588
|
}
|
|
607
589
|
catch (e) {
|
|
608
590
|
checks.push({ name, ok: false, error: errMessage(e).slice(-4000) });
|
|
@@ -616,7 +598,10 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
616
598
|
if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
|
|
617
599
|
extra.push([
|
|
618
600
|
'ingress.caddy',
|
|
619
|
-
() =>
|
|
601
|
+
async () => ({
|
|
602
|
+
out: String((await ctx.docker.inspectContainer(CADDY_CONTAINER)).State?.Status),
|
|
603
|
+
err: '',
|
|
604
|
+
}),
|
|
620
605
|
]);
|
|
621
606
|
}
|
|
622
607
|
for (const [name, fn] of extra) {
|
|
@@ -641,10 +626,8 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
641
626
|
};
|
|
642
627
|
}
|
|
643
628
|
if (command.type === 'service.restart') {
|
|
644
|
-
const project = payload
|
|
645
|
-
|
|
646
|
-
const dir = path.join(ctx.stateDir, 'projects', project);
|
|
647
|
-
await ctx.docker(['compose', '-p', project, 'restart', service], { cwd: dir });
|
|
629
|
+
const { project, service } = payload;
|
|
630
|
+
await ctx.docker.restartContainer(await resolveLiveContainer(ctx, { project, service }));
|
|
648
631
|
return {
|
|
649
632
|
commandId: command.id,
|
|
650
633
|
state: 'succeeded',
|
|
@@ -652,28 +635,33 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
652
635
|
};
|
|
653
636
|
}
|
|
654
637
|
if (command.type === 'logs.fetch') {
|
|
655
|
-
const project = payload
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
const
|
|
659
|
-
const
|
|
638
|
+
const { project, service } = payload;
|
|
639
|
+
const id = await resolveLiveContainer(ctx, { project, service });
|
|
640
|
+
const logs = await ctx.docker.logs(id, { follow: false, tail: Number(payload.tail || 200) });
|
|
641
|
+
const out = [];
|
|
642
|
+
const err = [];
|
|
643
|
+
logs.stdout.on('data', (d) => out.push(d));
|
|
644
|
+
logs.stderr.on('data', (d) => err.push(d));
|
|
645
|
+
await new Promise((resolve, reject) => {
|
|
646
|
+
logs.on('error', reject);
|
|
647
|
+
logs.on('close', () => resolve());
|
|
648
|
+
});
|
|
660
649
|
return {
|
|
661
650
|
commandId: command.id,
|
|
662
651
|
state: 'succeeded',
|
|
663
|
-
result: {
|
|
652
|
+
result: {
|
|
653
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
654
|
+
stderr: Buffer.concat(err).toString('utf8'),
|
|
655
|
+
},
|
|
664
656
|
};
|
|
665
657
|
}
|
|
666
658
|
if (command.type === 'volume.delete') {
|
|
667
|
-
const project = payload
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
await ctx.docker(['volume', 'rm', composeVolume]);
|
|
672
|
-
}
|
|
673
|
-
catch (e) {
|
|
674
|
-
if (!errMessage(e).includes('No such volume'))
|
|
659
|
+
const { project, volume } = payload;
|
|
660
|
+
// Volumes are never removed by reconciliation; this explicit command is the only way one goes away.
|
|
661
|
+
await ctx.docker.removeVolume(volumeName(project, volume)).catch((e) => {
|
|
662
|
+
if (!isNotFound(e))
|
|
675
663
|
throw e;
|
|
676
|
-
}
|
|
664
|
+
});
|
|
677
665
|
return {
|
|
678
666
|
commandId: command.id,
|
|
679
667
|
state: 'succeeded',
|
|
@@ -731,23 +719,26 @@ export async function ensureArtifact(ctx, artifact) {
|
|
|
731
719
|
if (!refs.includes(artifact.imageRef))
|
|
732
720
|
await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
|
|
733
721
|
};
|
|
734
|
-
const inspect =
|
|
722
|
+
const inspect = () => ctx.docker.inspectImage(artifact.imageRef);
|
|
735
723
|
try {
|
|
736
724
|
const image = await inspect();
|
|
737
|
-
if (
|
|
738
|
-
(
|
|
739
|
-
identities[artifact.imageRef]?.
|
|
740
|
-
|
|
725
|
+
if (image &&
|
|
726
|
+
(image?.Id === artifact.imageId ||
|
|
727
|
+
(identities[artifact.imageRef]?.daemonId === image?.Id &&
|
|
728
|
+
identities[artifact.imageRef]?.artifactId === artifact.imageId &&
|
|
729
|
+
identities[artifact.imageRef]?.sha256 === artifact.sha256)) &&
|
|
741
730
|
`${image.Os}/${image.Architecture}` === artifact.platform) {
|
|
742
731
|
await remember(image.Id);
|
|
743
732
|
return;
|
|
744
733
|
}
|
|
745
734
|
}
|
|
746
735
|
catch { }
|
|
747
|
-
const host =
|
|
748
|
-
const arch =
|
|
749
|
-
host.Architecture
|
|
750
|
-
|
|
736
|
+
const host = await ctx.docker.info();
|
|
737
|
+
const arch = host.Architecture
|
|
738
|
+
? { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] ||
|
|
739
|
+
host.Architecture
|
|
740
|
+
: undefined;
|
|
741
|
+
if (host.OSType && arch && `${host.OSType}/${arch}` !== artifact.platform)
|
|
751
742
|
throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
|
|
752
743
|
const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
|
|
753
744
|
const file = path.join(downloadDir, 'image.tar.gz');
|
|
@@ -759,7 +750,7 @@ export async function ensureArtifact(ctx, artifact) {
|
|
|
759
750
|
const archive = await validateImageArchive(file, artifact.imageRef);
|
|
760
751
|
if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
|
|
761
752
|
throw new Error('downloaded archive image metadata differs from desired artifact');
|
|
762
|
-
await ctx.docker(
|
|
753
|
+
await ctx.docker.loadImage(createReadStream(file));
|
|
763
754
|
const image = await inspect();
|
|
764
755
|
if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
|
|
765
756
|
throw new Error('loaded image identity or platform does not match desired artifact');
|
|
@@ -780,12 +771,11 @@ export async function pruneManagedImages(ctx, retained) {
|
|
|
780
771
|
continue;
|
|
781
772
|
}
|
|
782
773
|
try {
|
|
783
|
-
|
|
784
|
-
if (used) {
|
|
774
|
+
if ((await ctx.docker.listContainers({ ancestor: ref })).length) {
|
|
785
775
|
remaining.push(ref);
|
|
786
776
|
continue;
|
|
787
777
|
}
|
|
788
|
-
await ctx.docker(
|
|
778
|
+
await ctx.docker.removeImage(ref);
|
|
789
779
|
}
|
|
790
780
|
catch {
|
|
791
781
|
remaining.push(ref);
|
|
@@ -815,11 +805,12 @@ const publicPortWarnings = new Map();
|
|
|
815
805
|
export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}, ports = { address: null, loopback: {} }) {
|
|
816
806
|
const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
|
|
817
807
|
await ensureDir(dir);
|
|
818
|
-
const
|
|
819
|
-
const serviceNames = Object.keys(plannedCompose.services || {});
|
|
808
|
+
const serviceNames = Object.keys(containerSpecs(desiredState, ctx.nodeName).specs);
|
|
820
809
|
if (!serviceNames.length) {
|
|
821
|
-
if (ctx.apply)
|
|
822
|
-
await
|
|
810
|
+
if (ctx.apply) {
|
|
811
|
+
await sweepOrphans(ctx.docker, desiredState.project, new Set()).catch(() => { });
|
|
812
|
+
await clearRolloutState(dir).catch(() => { });
|
|
813
|
+
}
|
|
823
814
|
return {
|
|
824
815
|
project: desiredState.project,
|
|
825
816
|
observedVersion: desiredState.version,
|
|
@@ -847,23 +838,17 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
847
838
|
const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
|
|
848
839
|
const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
|
|
849
840
|
const db = await agentDb(ctx.stateDir);
|
|
850
|
-
const previousRow =
|
|
851
|
-
const previous = previousRow
|
|
852
|
-
? {
|
|
853
|
-
version: previousRow.version,
|
|
854
|
-
revision: previousRow.revision,
|
|
855
|
-
secretVersions: JSON.parse(previousRow.secret_versions_json),
|
|
856
|
-
}
|
|
857
|
-
: {};
|
|
841
|
+
const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
|
|
858
842
|
const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
|
|
859
843
|
const rollingSet = new Set(rolling);
|
|
860
844
|
const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
const sameDesiredVersion =
|
|
864
|
-
?
|
|
865
|
-
|
|
866
|
-
|
|
845
|
+
// Published images are pulled only for a new desired revision. Pulling on every poll would let a tag that moved
|
|
846
|
+
// upstream (`mariadb:11`) recreate the container without a deploy.
|
|
847
|
+
const sameDesiredVersion = previousRow
|
|
848
|
+
? desiredState.revision
|
|
849
|
+
? previousRow.revision === desiredState.revision
|
|
850
|
+
: previousRow.version === desiredState.version
|
|
851
|
+
: false;
|
|
867
852
|
const loopback = new Set(ports.loopback[desiredState.project] || []);
|
|
868
853
|
// Resolve only when some port actually binds to the node address; a hostname nobody needs must not fail the apply.
|
|
869
854
|
const needsAddress = addressBoundServices(desiredState.compose, serviceNames, loopback).length > 0;
|
|
@@ -871,15 +856,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
871
856
|
address: needsAddress ? await resolveBindAddress(ports.address) : '127.0.0.1',
|
|
872
857
|
loopback,
|
|
873
858
|
};
|
|
874
|
-
const
|
|
859
|
+
const plan = containerSpecs(desiredState, ctx.nodeName, {
|
|
875
860
|
secrets: sec.secrets || {},
|
|
861
|
+
secretVersions: sec.versions || {},
|
|
876
862
|
endpoints,
|
|
877
|
-
exclude: rollingSet,
|
|
878
863
|
bind,
|
|
879
864
|
});
|
|
880
865
|
await warnPublicPorts(ctx, desiredState, serviceNames, bind);
|
|
881
|
-
await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
|
|
882
|
-
await writeEnv(path.join(dir, '.env'), sec.secrets || {});
|
|
883
866
|
const failedStatus = (error) => ({
|
|
884
867
|
project: desiredState.project,
|
|
885
868
|
observedVersion: desiredState.version,
|
|
@@ -889,99 +872,80 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
889
872
|
serviceStates,
|
|
890
873
|
error,
|
|
891
874
|
});
|
|
892
|
-
// Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision
|
|
893
|
-
//
|
|
875
|
+
// Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision
|
|
876
|
+
// or any container definition (which includes the secret versions it uses) changes, when an apply fails (once)
|
|
877
|
+
// and when it recovers (once).
|
|
894
878
|
const eventFingerprint = createHash('sha256')
|
|
895
879
|
.update(JSON.stringify([
|
|
896
880
|
desiredState.revision || desiredState.version,
|
|
897
|
-
|
|
898
|
-
mainServices.map((service) => currentSecretVersions[service] || {}),
|
|
881
|
+
mainServices.map((service) => plan.specs[service].hash),
|
|
899
882
|
]))
|
|
900
883
|
.digest('hex')
|
|
901
884
|
.slice(0, 16);
|
|
902
885
|
const knownEvents = mainServices.length
|
|
903
|
-
?
|
|
886
|
+
? db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
|
|
904
887
|
: undefined;
|
|
905
888
|
const announce = knownEvents?.fingerprint !== eventFingerprint;
|
|
906
889
|
const wasFailed = !announce && knownEvents?.phase === 'failed';
|
|
907
890
|
const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
|
|
891
|
+
const deps = {
|
|
892
|
+
waitForHealth: (container, service, dependency) => waitForHealth(ctx, desiredState.project, container, service, desiredState.services[service]?.health ?? (dependency ? { type: 'docker' } : null)),
|
|
893
|
+
ensureArtifact: async (service) => {
|
|
894
|
+
const artifact = desiredState.services[service].artifact;
|
|
895
|
+
if (artifact)
|
|
896
|
+
await ensureArtifact(ctx, artifact);
|
|
897
|
+
},
|
|
898
|
+
pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image, sec.registryAuth?.[imageLocation(plan.specs[service].body.Image).host]),
|
|
899
|
+
emit,
|
|
900
|
+
};
|
|
908
901
|
let failing;
|
|
909
902
|
if (ctx.apply) {
|
|
910
903
|
try {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
...(keepLegacy ? [] : ['--remove-orphans']),
|
|
949
|
-
], { cwd: dir });
|
|
950
|
-
}
|
|
951
|
-
if (sameDesiredVersion && secretChangedServices.length) {
|
|
952
|
-
for (const service of secretChangedServices)
|
|
953
|
-
await emit(service, 'secret_recreate', 'secret version changed; recreating service');
|
|
954
|
-
await ctx.docker([
|
|
955
|
-
'compose',
|
|
956
|
-
'-p',
|
|
957
|
-
desiredState.project,
|
|
958
|
-
'up',
|
|
959
|
-
'-d',
|
|
960
|
-
'--no-deps',
|
|
961
|
-
'--force-recreate',
|
|
962
|
-
...secretChangedServices,
|
|
963
|
-
], { cwd: dir });
|
|
964
|
-
}
|
|
965
|
-
for (const service of mainServices) {
|
|
966
|
-
serviceStates[service].rolloutState = 'health_checking';
|
|
967
|
-
if (announce)
|
|
968
|
-
await emit(service, 'health_checking');
|
|
969
|
-
failing = service;
|
|
970
|
-
await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
|
|
971
|
-
failing = undefined;
|
|
972
|
-
serviceStates[service].rolloutState = 'serving';
|
|
973
|
-
serviceStates[service].healthyAt = now();
|
|
974
|
-
if (announce || wasFailed)
|
|
975
|
-
await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
|
|
976
|
-
}
|
|
904
|
+
await ensureProjectResources(ctx.docker, desiredState.project, plan);
|
|
905
|
+
await sweepOrphans(ctx.docker, desiredState.project, new Set(serviceNames));
|
|
906
|
+
if (mainServices.length)
|
|
907
|
+
await reconcileRecreate(ctx.docker, {
|
|
908
|
+
desired: desiredState,
|
|
909
|
+
dir,
|
|
910
|
+
plan,
|
|
911
|
+
nodeName: ctx.nodeName,
|
|
912
|
+
deps,
|
|
913
|
+
services: mainServices,
|
|
914
|
+
pull: !sameDesiredVersion,
|
|
915
|
+
}, {
|
|
916
|
+
pulling: async (service) => {
|
|
917
|
+
serviceStates[service].rolloutState = 'pulling';
|
|
918
|
+
if (announce)
|
|
919
|
+
await emit(service, 'pulling');
|
|
920
|
+
},
|
|
921
|
+
starting: async (service) => {
|
|
922
|
+
serviceStates[service].rolloutState = 'starting';
|
|
923
|
+
if (announce)
|
|
924
|
+
await emit(service, 'starting');
|
|
925
|
+
},
|
|
926
|
+
checking: async (service) => {
|
|
927
|
+
serviceStates[service].rolloutState = 'health_checking';
|
|
928
|
+
if (announce)
|
|
929
|
+
await emit(service, 'health_checking');
|
|
930
|
+
},
|
|
931
|
+
serving: async (service) => {
|
|
932
|
+
serviceStates[service].rolloutState = 'serving';
|
|
933
|
+
serviceStates[service].healthyAt = now();
|
|
934
|
+
if (announce || wasFailed)
|
|
935
|
+
await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
|
|
936
|
+
},
|
|
937
|
+
failing: (service) => {
|
|
938
|
+
failing = service;
|
|
939
|
+
},
|
|
940
|
+
});
|
|
977
941
|
if (mainServices.length && (announce || wasFailed))
|
|
978
|
-
|
|
942
|
+
markEvents('serving');
|
|
979
943
|
}
|
|
980
944
|
catch (e) {
|
|
981
945
|
if (mainServices.length && !wasFailed) {
|
|
982
946
|
// Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
|
|
983
947
|
await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
|
|
984
|
-
|
|
948
|
+
markEvents('failed');
|
|
985
949
|
}
|
|
986
950
|
// Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
|
|
987
951
|
if (!rolling.length)
|
|
@@ -990,25 +954,14 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
990
954
|
return failedStatus(errMessage(e));
|
|
991
955
|
}
|
|
992
956
|
if (rolling.length) {
|
|
993
|
-
const rolled = await reconcileRolling(ctx, {
|
|
957
|
+
const rolled = await reconcileRolling(ctx.docker, {
|
|
994
958
|
desired: desiredState,
|
|
995
959
|
dir,
|
|
996
960
|
services: rolling,
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
bind,
|
|
1002
|
-
}),
|
|
1003
|
-
deps: {
|
|
1004
|
-
waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
|
|
1005
|
-
ensureArtifact: async (service) => {
|
|
1006
|
-
const artifact = desiredState.services[service].artifact;
|
|
1007
|
-
if (artifact)
|
|
1008
|
-
await ensureArtifact(ctx, artifact);
|
|
1009
|
-
},
|
|
1010
|
-
emit,
|
|
1011
|
-
},
|
|
961
|
+
plan,
|
|
962
|
+
nodeName: ctx.nodeName,
|
|
963
|
+
rolloutPorts: ctx.rolloutPorts,
|
|
964
|
+
deps,
|
|
1012
965
|
});
|
|
1013
966
|
Object.assign(serviceStates, rolled.serviceStates);
|
|
1014
967
|
const errors = Object.entries(rolled.errors);
|
|
@@ -1016,13 +969,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
1016
969
|
return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
|
|
1017
970
|
}
|
|
1018
971
|
else
|
|
1019
|
-
await
|
|
972
|
+
await clearRolloutState(dir);
|
|
1020
973
|
}
|
|
1021
974
|
else {
|
|
1022
975
|
for (const service of serviceNames)
|
|
1023
976
|
serviceStates[service].rolloutState = 'serving';
|
|
1024
977
|
}
|
|
1025
|
-
|
|
978
|
+
db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
|
|
1026
979
|
VALUES(?,?,?,?,?,?)
|
|
1027
980
|
ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
|
|
1028
981
|
service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
|
|
@@ -1170,19 +1123,6 @@ async function readJoinKey() {
|
|
|
1170
1123
|
}
|
|
1171
1124
|
return null;
|
|
1172
1125
|
}
|
|
1173
|
-
function dockerLogin(registry, user, password) {
|
|
1174
|
-
return new Promise((resolve, reject) => {
|
|
1175
|
-
const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], {
|
|
1176
|
-
stdio: ['pipe', 'ignore', 'pipe'],
|
|
1177
|
-
});
|
|
1178
|
-
let err = '';
|
|
1179
|
-
p.stderr.on('data', (d) => (err += d));
|
|
1180
|
-
p.on('error', reject);
|
|
1181
|
-
p.on('close', (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
|
|
1182
|
-
p.stdin.write(password);
|
|
1183
|
-
p.stdin.end();
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
1126
|
async function enroll(joinKey) {
|
|
1187
1127
|
let decoded;
|
|
1188
1128
|
try {
|
|
@@ -1232,8 +1172,6 @@ async function enroll(joinKey) {
|
|
|
1232
1172
|
agentToken: out.agentToken,
|
|
1233
1173
|
nodeName: out.node?.name || NODE_NAME,
|
|
1234
1174
|
controlUrl,
|
|
1235
|
-
registry: out.registry || null,
|
|
1236
|
-
registryToken: out.registryToken || null,
|
|
1237
1175
|
};
|
|
1238
1176
|
await saveCreds(creds);
|
|
1239
1177
|
if (wgPublicKey)
|
|
@@ -1257,14 +1195,6 @@ export async function loadOrEnroll() {
|
|
|
1257
1195
|
NODE_NAME = creds.nodeName || NODE_NAME;
|
|
1258
1196
|
if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
|
|
1259
1197
|
CONTROL_URL = creds.controlUrl;
|
|
1260
|
-
if (APPLY && creds.registry && creds.registryToken) {
|
|
1261
|
-
try {
|
|
1262
|
-
await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
|
|
1263
|
-
}
|
|
1264
|
-
catch (e) {
|
|
1265
|
-
console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
1198
|
return creds;
|
|
1269
1199
|
}
|
|
1270
1200
|
export async function main() {
|