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