@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/rollout.js DELETED
@@ -1,385 +0,0 @@
1
- // Rolling rollouts on the agent. A service with `x-tug.rollout.strategy: rolling` does not live in the
2
- // node's main Compose project. Every distinct rendering of it runs as its own Compose project
3
- // (`<project>-<service>-<hash>`) with a published host port taken from a free range, so the previous
4
- // revision keeps serving while the new one starts and is health-checked. Control learns the new port
5
- // from the status report and re-renders the ingress; after a drain delay the previous revision is
6
- // removed. State lives in `<project dir>/rollout.json` so an agent restart resumes mid-rollout.
7
- import crypto from 'node:crypto';
8
- import fs from 'node:fs/promises';
9
- import path from 'node:path';
10
- import { dumpYaml, isRolling, publishedPort } from "./compose.js";
11
- import { ensureDir, errMessage, now, parseDuration } from "./util.js";
12
- export const DEFAULT_DRAIN = '30s';
13
- export const DEFAULT_PORT_RANGE = '20000-20999';
14
- const LABEL_PROJECT = 'com.docker.compose.project';
15
- const LABEL_SERVICE = 'com.docker.compose.service';
16
- export function parsePortRange(value) {
17
- const m = (value || DEFAULT_PORT_RANGE).trim().match(/^(\d+)-(\d+)$/);
18
- const lo = Number(m?.[1]);
19
- const hi = Number(m?.[2]);
20
- if (!m || lo < 1024 || hi > 65535 || lo > hi)
21
- throw new Error(`invalid rollout port range ${JSON.stringify(value)} (expected LOW-HIGH within 1024-65535, e.g. ${DEFAULT_PORT_RANGE})`);
22
- return [lo, hi];
23
- }
24
- /** Compose project names allow lowercase letters, digits, dashes and underscores. */
25
- export function revisionProject(project, service, hash) {
26
- return `${project}-${service}-${hash.slice(0, 8)}`.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
27
- }
28
- /** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
29
- export function remapPublishedPort(ports, port) {
30
- return ports.map((entry) => {
31
- if (typeof entry === 'number')
32
- return entry;
33
- if (typeof entry === 'object')
34
- return entry.published !== undefined ? { ...entry, published: port } : entry;
35
- const [spec, ...protocol] = String(entry).split('/');
36
- const parts = spec.split(':');
37
- if (parts.length < 2)
38
- return entry;
39
- parts[parts.length - 2] = String(port);
40
- return [parts.join(':'), ...protocol].join('/');
41
- });
42
- }
43
- /** Identity of one revision of a service: its rendered definition and the versions of the secrets it uses. */
44
- export function serviceRevisionHash(desired, rendered, service, secretVersions = {}) {
45
- const artifact = desired.services[service]?.artifact;
46
- const definition = { ...rendered, image: artifact?.imageId ?? rendered.image };
47
- return crypto
48
- .createHash('sha256')
49
- .update(JSON.stringify({ definition, secretVersions }))
50
- .digest('hex')
51
- .slice(0, 16);
52
- }
53
- /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
54
- export function rollingCompose(full, project, service, port) {
55
- const svc = { ...full.services[service] };
56
- delete svc.depends_on;
57
- if (port !== null && svc.ports)
58
- svc.ports = remapPublishedPort(svc.ports, port);
59
- const out = {
60
- services: { [service]: svc },
61
- networks: { default: { name: `${project}_default`, external: true } },
62
- };
63
- const volumes = {};
64
- for (const mount of svc.volumes || []) {
65
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
66
- const def = source ? full.volumes?.[source] : undefined;
67
- if (!source || !def)
68
- continue;
69
- // Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
70
- volumes[source] =
71
- def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
72
- }
73
- if (Object.keys(volumes).length)
74
- out.volumes = volumes;
75
- return out;
76
- }
77
- const stateFile = (dir) => path.join(dir, 'rollout.json');
78
- export async function loadRolloutState(dir) {
79
- try {
80
- const parsed = JSON.parse(await fs.readFile(stateFile(dir), 'utf8'));
81
- for (const s of Object.values(parsed.services))
82
- s.draining ||= [];
83
- return parsed;
84
- }
85
- catch {
86
- return { services: {} };
87
- }
88
- }
89
- async function saveRolloutState(dir, state) {
90
- const tmp = `${stateFile(dir)}.tmp`;
91
- await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
92
- await fs.rename(tmp, stateFile(dir));
93
- }
94
- // ---- Docker helpers ----------------------------------------------------------------------------
95
- /** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
96
- export function composeBase(project, dir, file) {
97
- return ['compose', '-p', project, '--project-directory', dir, '-f', file];
98
- }
99
- async function containerIds(ctx, project, service) {
100
- const filters = [
101
- '--filter',
102
- `label=${LABEL_PROJECT}=${project}`,
103
- ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : []),
104
- ];
105
- return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
106
- }
107
- /** Stops and removes every container of a revision project (or of one service of the main project). */
108
- async function removeRevision(ctx, dir, project, service) {
109
- const ids = await containerIds(ctx, project, service);
110
- if (ids.length) {
111
- await ctx.docker(['stop', ...ids]).catch(() => { });
112
- await ctx.docker(['rm', '-f', ...ids]);
113
- }
114
- if (!service)
115
- await fs.rm(path.join(dir, 'rollout', `${project}.yml`), { force: true });
116
- }
117
- async function ensureSharedResources(ctx, project, services, full) {
118
- const network = `${project}_default`;
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
- ]));
130
- const created = new Set();
131
- for (const service of services) {
132
- for (const mount of full.services[service].volumes || []) {
133
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
134
- const def = source ? full.volumes?.[source] : undefined;
135
- if (!source || !def || def.external || def.name || created.has(source))
136
- continue;
137
- created.add(source);
138
- const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
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
- ]);
150
- }
151
- }
152
- }
153
- /** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
154
- async function publishedHostPorts(ctx) {
155
- const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
156
- return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap((m) => {
157
- const lo = Number(m[1]);
158
- const hi = Number(m[2] || m[1]);
159
- return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
160
- }));
161
- }
162
- async function allocatePort(ctx, known, exclude) {
163
- const [lo, hi] = parsePortRange(ctx.rolloutPorts);
164
- const busy = await publishedHostPorts(ctx);
165
- for (const port of [...known, ...exclude])
166
- busy.add(port);
167
- // Start after the newest port in use so a just-released port is not reused straight away.
168
- const last = Math.max(lo - 1, ...known.filter((p) => p >= lo && p <= hi));
169
- const size = hi - lo + 1;
170
- for (let i = 0; i < size; i++) {
171
- const port = lo + ((last + 1 - lo + i) % size);
172
- if (!busy.has(port))
173
- return port;
174
- }
175
- throw new Error(`no free host port left in the rollout range ${lo}-${hi} (TUG_ROLLOUT_PORTS)`);
176
- }
177
- const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
178
- function servingState(rev, 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
- };
187
- }
188
- /** True when containers of `services` still run in the main project from before they became rolling. */
189
- export async function hasLegacyContainers(ctx, project, services) {
190
- for (const service of services)
191
- if ((await containerIds(ctx, project, service)).length)
192
- return true;
193
- return false;
194
- }
195
- export function isRollingService(desired, service) {
196
- return isRolling(desired.compose.services[service]);
197
- }
198
- export async function reconcileRolling(ctx, input) {
199
- const { desired, dir, services, full, deps } = input;
200
- const project = desired.project;
201
- const state = await loadRolloutState(dir);
202
- const result = { serviceStates: {}, errors: {} };
203
- await ensureDir(path.join(dir, 'rollout'));
204
- // Drained revisions go first so their host ports are free for this rollout.
205
- await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
206
- await ensureSharedResources(ctx, project, services, full);
207
- for (const service of services) {
208
- const rollout = (state.services[service] ||= { draining: [] });
209
- const spec = desired.compose.services[service];
210
- const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
211
- const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
212
- const hash = serviceRevisionHash(desired, full.services[service], service, input.secretVersions[service]);
213
- const revProject = revisionProject(project, service, hash);
214
- const file = path.join(dir, 'rollout', `${revProject}.yml`);
215
- const image = desired.services[service].image;
216
- // A service that used to be recreated still runs in the main project: it is the revision to replace.
217
- if (!rollout.active && declaredPort && (await containerIds(ctx, project, service)).length) {
218
- rollout.active = { hash: 'legacy', project, port: declaredPort, image, legacy: true };
219
- }
220
- if (rollout.candidate && rollout.candidate.hash !== hash) {
221
- await removeRevision(ctx, dir, rollout.candidate.project);
222
- delete rollout.candidate;
223
- }
224
- const writeCompose = (port) => fs.writeFile(file, dumpYaml(rollingCompose(full, project, service, port)));
225
- const up = () => ctx.docker([...composeBase(revProject, dir, file), 'up', '-d'], { cwd: dir });
226
- if (rollout.active && rollout.active.hash === hash) {
227
- // Steady state: make sure the revision is still up and healthy; nothing restarts when it is.
228
- const active = { ...rollout.active, image };
229
- try {
230
- await writeCompose(active.port);
231
- await up();
232
- await deps.waitForHealth(revProject, service, file);
233
- delete rollout.failed;
234
- result.serviceStates[service] = servingState(active);
235
- }
236
- catch (e) {
237
- result.errors[service] = errMessage(e);
238
- result.serviceStates[service] = {
239
- rolloutState: 'health_checking',
240
- image,
241
- rolloutRevision: hash,
242
- };
243
- }
244
- continue;
245
- }
246
- const key = `${desired.revision || desired.version}:${hash}`;
247
- const reject = (error) => {
248
- result.errors[service] = error;
249
- result.serviceStates[service] = rollout.active
250
- ? servingState(rollout.active, { rolloutError: error })
251
- : { rolloutState: 'health_checking', image, rolloutRevision: hash };
252
- };
253
- if (rollout.failed?.key === key) {
254
- reject(rollout.failed.error);
255
- continue;
256
- }
257
- try {
258
- const candidate = rollout.candidate ?? {
259
- hash,
260
- project: revProject,
261
- port: await allocatePort(ctx, knownPorts(rollout), new Set()),
262
- image,
263
- };
264
- rollout.candidate = candidate;
265
- await saveRolloutState(dir, state);
266
- await deps.emit(service, 'pulling');
267
- await deps.ensureArtifact(service);
268
- const exclude = new Set();
269
- for (let attempt = 0;; attempt++) {
270
- await writeCompose(candidate.port);
271
- if (!desired.services[service].artifact)
272
- await ctx.docker([...composeBase(revProject, dir, file), 'pull', service], { cwd: dir });
273
- await deps.emit(service, 'starting', `starting new revision on host port ${candidate.port}`);
274
- try {
275
- await up();
276
- break;
277
- }
278
- catch (e) {
279
- if (attempt >= 4 || !PORT_CONFLICT.test(errMessage(e)))
280
- throw e;
281
- exclude.add(candidate.port);
282
- candidate.port = await allocatePort(ctx, knownPorts(rollout), exclude);
283
- await saveRolloutState(dir, state);
284
- }
285
- }
286
- await deps.emit(service, 'health_checking');
287
- await deps.waitForHealth(revProject, service, file);
288
- // Takeover: the next status report carries the new port and control re-renders the ingress.
289
- if (rollout.active)
290
- rollout.draining.push({
291
- service,
292
- project: rollout.active.project,
293
- legacy: rollout.active.legacy,
294
- drainUntil: Date.now() + drainMs,
295
- });
296
- rollout.active = { ...candidate };
297
- delete rollout.candidate;
298
- delete rollout.failed;
299
- await saveRolloutState(dir, state);
300
- result.serviceStates[service] = servingState(rollout.active);
301
- await deps.emit(service, 'serving', `new revision serving on host port ${rollout.active.port}`);
302
- }
303
- catch (e) {
304
- const error = errMessage(e);
305
- if (rollout.candidate)
306
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
307
- delete rollout.candidate;
308
- rollout.failed = { key, error };
309
- await saveRolloutState(dir, state);
310
- reject(error);
311
- await deps.emit(service, 'rollout_failed', `new revision rejected, previous revision keeps serving: ${error}`);
312
- }
313
- }
314
- await saveRolloutState(dir, state);
315
- return result;
316
- }
317
- function knownPorts(rollout) {
318
- return [rollout.active, rollout.candidate]
319
- .filter((r) => Boolean(r && !r.legacy))
320
- .map((r) => r.port);
321
- }
322
- /** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
323
- async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
324
- for (const [service, rollout] of Object.entries(state.services)) {
325
- if (!rolling.has(service)) {
326
- // No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
327
- const stillRuns = desired.compose.services[service] &&
328
- (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) ||
329
- !desired.services[service]?.placement?.nodes?.length);
330
- if (rollout.candidate) {
331
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
332
- delete rollout.candidate;
333
- }
334
- if (rollout.active && stillRuns) {
335
- rollout.draining.push({
336
- service,
337
- project: rollout.active.project,
338
- legacy: rollout.active.legacy,
339
- drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN),
340
- });
341
- delete rollout.active;
342
- }
343
- else if (rollout.active) {
344
- await removeRevision(ctx, dir, rollout.active.project, rollout.active.legacy ? service : undefined).catch(() => { });
345
- delete rollout.active;
346
- }
347
- }
348
- const keep = [];
349
- for (const old of rollout.draining) {
350
- if (old.drainUntil > Date.now()) {
351
- keep.push(old);
352
- continue;
353
- }
354
- try {
355
- await removeRevision(ctx, dir, old.project, old.legacy ? old.service : undefined);
356
- await deps?.emit(service, 'drained', `previous revision ${old.project} removed after drain`);
357
- }
358
- catch {
359
- keep.push(old);
360
- }
361
- }
362
- rollout.draining = keep;
363
- if (!rollout.active && !rollout.candidate && !rollout.draining.length && !rollout.failed)
364
- delete state.services[service];
365
- }
366
- }
367
- /** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
368
- export async function retireAll(ctx, dir, desired) {
369
- if (!(await fs.stat(stateFile(dir)).then(() => true, () => false)))
370
- return;
371
- const state = await loadRolloutState(dir);
372
- await retireRevisions(ctx, dir, state, desired, new Set());
373
- await saveRolloutState(dir, state);
374
- }
375
- /** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
376
- export async function activeRollingStates(dir, services) {
377
- const state = await loadRolloutState(dir);
378
- const out = {};
379
- for (const service of services) {
380
- const active = state.services[service]?.active;
381
- if (active)
382
- out[service] = servingState(active);
383
- }
384
- return out;
385
- }