@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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// The agent's container reconciler. Every service is one Docker container labelled with the hash of
|
|
2
|
+
// its resolved definition (`tug.config-hash`); a container whose hash no longer matches is replaced.
|
|
3
|
+
// `recreate` (the default) stops the old container and starts the new one. `rolling` starts the new
|
|
4
|
+
// container beside the old one on a free host port, health-checks it, lets the ingress switch, and
|
|
5
|
+
// removes the old one after a drain delay. State of rolling services lives in
|
|
6
|
+
// `<project dir>/rollout.json` so an agent restart resumes mid-rollout.
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { isRolling } from "./compose.js";
|
|
10
|
+
import { LABEL_HASH, LABEL_PROJECT, LABEL_SERVICE, startOrder, withHostPort, } from "./container-spec.js";
|
|
11
|
+
import { isNotFound } from "./docker-api.js";
|
|
12
|
+
import { ensureDir, errMessage, now, parseDuration } from "./util.js";
|
|
13
|
+
export const DEFAULT_DRAIN = '30s';
|
|
14
|
+
export const DEFAULT_PORT_RANGE = '20000-20999';
|
|
15
|
+
export function parsePortRange(value) {
|
|
16
|
+
const m = (value || DEFAULT_PORT_RANGE).trim().match(/^(\d+)-(\d+)$/);
|
|
17
|
+
const lo = Number(m?.[1]);
|
|
18
|
+
const hi = Number(m?.[2]);
|
|
19
|
+
if (!m || lo < 1024 || hi > 65535 || lo > hi)
|
|
20
|
+
throw new Error(`invalid rollout port range ${JSON.stringify(value)} (expected LOW-HIGH within 1024-65535, e.g. ${DEFAULT_PORT_RANGE})`);
|
|
21
|
+
return [lo, hi];
|
|
22
|
+
}
|
|
23
|
+
// ---- Containers --------------------------------------------------------------------------------
|
|
24
|
+
const containerNameOf = (c) => (c.Names?.[0] || c.Id).replace(/^\//, '');
|
|
25
|
+
/** Containers of a project (or one service of it), running or not. */
|
|
26
|
+
export async function projectContainers(docker, project, service) {
|
|
27
|
+
return docker.listContainers({
|
|
28
|
+
label: [`${LABEL_PROJECT}=${project}`, ...(service ? [`${LABEL_SERVICE}=${service}`] : [])],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function removeContainers(docker, containers) {
|
|
32
|
+
for (const c of containers) {
|
|
33
|
+
if (c.State === 'running')
|
|
34
|
+
await docker.stopContainer(c.Id).catch(() => { });
|
|
35
|
+
await docker.removeContainer(c.Id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function removeByName(docker, name) {
|
|
39
|
+
await docker.stopContainer(name).catch(() => { });
|
|
40
|
+
await docker.removeContainer(name);
|
|
41
|
+
}
|
|
42
|
+
/** Creates the project network and its volumes; volumes are never removed by reconciliation. */
|
|
43
|
+
export async function ensureProjectResources(docker, project, plan) {
|
|
44
|
+
if (!(await docker.inspectNetwork(plan.network)))
|
|
45
|
+
await docker.createNetwork(plan.network, { [LABEL_PROJECT]: project });
|
|
46
|
+
for (const volume of plan.volumes) {
|
|
47
|
+
if (await docker.inspectVolume(volume.name))
|
|
48
|
+
continue;
|
|
49
|
+
if (volume.external)
|
|
50
|
+
throw new Error(`external volume ${volume.name} does not exist on this node`);
|
|
51
|
+
await docker.createVolume(volume.name, {
|
|
52
|
+
driver: volume.driver,
|
|
53
|
+
opts: volume.opts,
|
|
54
|
+
labels: volume.labels,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Makes sure a service's container exists for `spec`, running, replacing any container with another hash. */
|
|
59
|
+
export async function ensureContainer(docker, spec, existing) {
|
|
60
|
+
const match = existing.find((c) => c.Labels?.[LABEL_HASH] === spec.hash);
|
|
61
|
+
await removeContainers(docker, existing.filter((c) => c !== match));
|
|
62
|
+
if (match) {
|
|
63
|
+
if (match.State !== 'running')
|
|
64
|
+
await docker.startContainer(match.Id);
|
|
65
|
+
return match.Id;
|
|
66
|
+
}
|
|
67
|
+
const id = await docker.createContainer(spec.name, spec.body);
|
|
68
|
+
await docker.startContainer(id);
|
|
69
|
+
return id;
|
|
70
|
+
}
|
|
71
|
+
/** Removes containers of services that are not placed here any more (never volumes). */
|
|
72
|
+
export async function sweepOrphans(docker, project, keep) {
|
|
73
|
+
const stray = (await projectContainers(docker, project)).filter((c) => !keep.has(c.Labels?.[LABEL_SERVICE] ?? ''));
|
|
74
|
+
await removeContainers(docker, stray);
|
|
75
|
+
}
|
|
76
|
+
const stateFile = (dir) => path.join(dir, 'rollout.json');
|
|
77
|
+
export async function loadRolloutState(dir) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(await fs.readFile(stateFile(dir), 'utf8'));
|
|
80
|
+
for (const s of Object.values(parsed.services))
|
|
81
|
+
s.draining ||= [];
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { services: {} };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function saveRolloutState(dir, state) {
|
|
89
|
+
const tmp = `${stateFile(dir)}.tmp`;
|
|
90
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
91
|
+
await fs.rename(tmp, stateFile(dir));
|
|
92
|
+
}
|
|
93
|
+
/** Host ports already published by containers on this node. */
|
|
94
|
+
async function publishedHostPorts(docker) {
|
|
95
|
+
return new Set((await docker.listContainers({}, false)).flatMap((c) => (c.Ports || []).flatMap((p) => (p.PublicPort ? [p.PublicPort] : []))));
|
|
96
|
+
}
|
|
97
|
+
async function allocatePort(docker, range, known, exclude) {
|
|
98
|
+
const [lo, hi] = parsePortRange(range);
|
|
99
|
+
const busy = await publishedHostPorts(docker);
|
|
100
|
+
for (const port of [...known, ...exclude])
|
|
101
|
+
busy.add(port);
|
|
102
|
+
// Start after the newest port in use so a just-released port is not reused straight away.
|
|
103
|
+
const last = Math.max(lo - 1, ...known.filter((p) => p >= lo && p <= hi));
|
|
104
|
+
const size = hi - lo + 1;
|
|
105
|
+
for (let i = 0; i < size; i++) {
|
|
106
|
+
const port = lo + ((last + 1 - lo + i) % size);
|
|
107
|
+
if (!busy.has(port))
|
|
108
|
+
return port;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`no free host port left in the rollout range ${lo}-${hi} (TUG_ROLLOUT_PORTS)`);
|
|
111
|
+
}
|
|
112
|
+
const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
|
|
113
|
+
function knownPorts(rollout) {
|
|
114
|
+
return [rollout.active, rollout.candidate]
|
|
115
|
+
.filter((r) => Boolean(r))
|
|
116
|
+
.map((r) => r.port);
|
|
117
|
+
}
|
|
118
|
+
/** Waits for the dependencies a service started with `service_healthy` on. */
|
|
119
|
+
async function waitForDependencies(docker, spec, containers, deps) {
|
|
120
|
+
for (const [dep, condition] of Object.entries(spec.dependsOn)) {
|
|
121
|
+
const id = containers.get(dep);
|
|
122
|
+
if (condition === 'service_healthy' && id)
|
|
123
|
+
await deps.waitForHealth(id, dep, true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Recreate-strategy services: pull, then create/start each in dependency order. `onStarting` and
|
|
128
|
+
* `onHealth` let the agent report per-service state; a failure throws with the service named in
|
|
129
|
+
* `failing`.
|
|
130
|
+
*/
|
|
131
|
+
export async function reconcileRecreate(docker, input, report) {
|
|
132
|
+
const { plan, deps, services, desired } = input;
|
|
133
|
+
const only = Object.fromEntries(services.map((s) => [s, plan.specs[s]]));
|
|
134
|
+
for (const service of services)
|
|
135
|
+
await report.pulling(service);
|
|
136
|
+
for (const service of services) {
|
|
137
|
+
if (desired.services[service].artifact)
|
|
138
|
+
await deps.ensureArtifact(service);
|
|
139
|
+
else if (input.pull || !(await docker.inspectImage(plan.specs[service].body.Image)))
|
|
140
|
+
await deps.pullImage(service);
|
|
141
|
+
}
|
|
142
|
+
const ids = new Map();
|
|
143
|
+
const order = startOrder(Object.fromEntries(Object.entries(only).map(([name, spec]) => [
|
|
144
|
+
name,
|
|
145
|
+
{
|
|
146
|
+
...spec,
|
|
147
|
+
dependsOn: Object.fromEntries(Object.entries(spec.dependsOn).filter(([d]) => d in only)),
|
|
148
|
+
},
|
|
149
|
+
])));
|
|
150
|
+
for (const service of order) {
|
|
151
|
+
const spec = plan.specs[service];
|
|
152
|
+
await report.starting(service);
|
|
153
|
+
report.failing(service);
|
|
154
|
+
await waitForDependencies(docker, spec, ids, deps);
|
|
155
|
+
ids.set(service, await ensureContainer(docker, spec, await projectContainers(docker, desired.project, service)));
|
|
156
|
+
report.failing(undefined);
|
|
157
|
+
}
|
|
158
|
+
for (const service of order) {
|
|
159
|
+
await report.checking(service);
|
|
160
|
+
report.failing(service);
|
|
161
|
+
await deps.waitForHealth(ids.get(service), service);
|
|
162
|
+
report.failing(undefined);
|
|
163
|
+
await report.serving(service);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function servingState(rev, extra = {}) {
|
|
167
|
+
return {
|
|
168
|
+
rolloutState: 'serving',
|
|
169
|
+
image: rev.image,
|
|
170
|
+
healthyAt: now(),
|
|
171
|
+
port: rev.port,
|
|
172
|
+
rolloutRevision: rev.hash,
|
|
173
|
+
...extra,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export const isRollingService = (desired, service) => isRolling(desired.compose.services[service]);
|
|
177
|
+
/** The host port a container publishes for the container port `target`. */
|
|
178
|
+
function publishedOf(c, target) {
|
|
179
|
+
const ports = (c.Ports || []).filter((p) => p.PublicPort);
|
|
180
|
+
return (ports.find((p) => p.PrivatePort === target) ?? ports[0])?.PublicPort;
|
|
181
|
+
}
|
|
182
|
+
export async function reconcileRolling(docker, input) {
|
|
183
|
+
const { desired, dir, services, plan, deps } = input;
|
|
184
|
+
const project = desired.project;
|
|
185
|
+
const state = await loadRolloutState(dir);
|
|
186
|
+
const result = { serviceStates: {}, errors: {} };
|
|
187
|
+
await ensureDir(dir);
|
|
188
|
+
// Drained revisions go first so their host ports are free for this rollout.
|
|
189
|
+
await retireRevisions(docker, dir, state, new Set(services), deps);
|
|
190
|
+
for (const service of services) {
|
|
191
|
+
const rollout = (state.services[service] ||= { draining: [] });
|
|
192
|
+
const spec = plan.specs[service];
|
|
193
|
+
const ext = desired.compose.services[service];
|
|
194
|
+
const ingressPort = ext['x-tug'].ingress.port;
|
|
195
|
+
const drainMs = parseDuration(ext['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
|
|
196
|
+
const image = desired.services[service].image;
|
|
197
|
+
// Adopt a container that predates the rollout state (a service that used to be recreated, or a lost state file).
|
|
198
|
+
const present = await projectContainers(docker, project, service);
|
|
199
|
+
if (!rollout.active) {
|
|
200
|
+
const adopt = present.find((c) => c.State === 'running');
|
|
201
|
+
const port = adopt && publishedOf(adopt, ingressPort);
|
|
202
|
+
if (adopt && port)
|
|
203
|
+
rollout.active = {
|
|
204
|
+
hash: adopt.Labels?.[LABEL_HASH] ?? 'unknown',
|
|
205
|
+
container: containerNameOf(adopt),
|
|
206
|
+
port,
|
|
207
|
+
image,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (rollout.candidate && rollout.candidate.hash !== spec.hash) {
|
|
211
|
+
await removeByName(docker, rollout.candidate.container).catch(() => { });
|
|
212
|
+
delete rollout.candidate;
|
|
213
|
+
}
|
|
214
|
+
if (rollout.active && rollout.active.hash === spec.hash) {
|
|
215
|
+
// Steady state: make sure the revision is still up and healthy; nothing restarts when it is.
|
|
216
|
+
const active = { ...rollout.active, image };
|
|
217
|
+
try {
|
|
218
|
+
const running = present.find((c) => containerNameOf(c) === active.container);
|
|
219
|
+
if (!running)
|
|
220
|
+
await docker.startContainer(await docker.createContainer(active.container, withHostPort(spec.body, active.port)));
|
|
221
|
+
else if (running.State !== 'running')
|
|
222
|
+
await docker.startContainer(running.Id);
|
|
223
|
+
await deps.waitForHealth(active.container, service);
|
|
224
|
+
delete rollout.failed;
|
|
225
|
+
result.serviceStates[service] = servingState(active);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
result.errors[service] = errMessage(e);
|
|
229
|
+
result.serviceStates[service] = {
|
|
230
|
+
rolloutState: 'health_checking',
|
|
231
|
+
image,
|
|
232
|
+
rolloutRevision: spec.hash,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const key = `${desired.revision || desired.version}:${spec.hash}`;
|
|
238
|
+
const reject = (error) => {
|
|
239
|
+
result.errors[service] = error;
|
|
240
|
+
result.serviceStates[service] = rollout.active
|
|
241
|
+
? servingState(rollout.active, { rolloutError: error })
|
|
242
|
+
: { rolloutState: 'health_checking', image, rolloutRevision: spec.hash };
|
|
243
|
+
};
|
|
244
|
+
if (rollout.failed?.key === key) {
|
|
245
|
+
reject(rollout.failed.error);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const candidate = rollout.candidate ?? {
|
|
250
|
+
hash: spec.hash,
|
|
251
|
+
container: spec.name,
|
|
252
|
+
port: await allocatePort(docker, input.rolloutPorts, knownPorts(rollout), new Set()),
|
|
253
|
+
image,
|
|
254
|
+
};
|
|
255
|
+
rollout.candidate = candidate;
|
|
256
|
+
await saveRolloutState(dir, state);
|
|
257
|
+
await deps.emit(service, 'pulling');
|
|
258
|
+
if (desired.services[service].artifact)
|
|
259
|
+
await deps.ensureArtifact(service);
|
|
260
|
+
else
|
|
261
|
+
await deps.pullImage(service);
|
|
262
|
+
const exclude = new Set();
|
|
263
|
+
for (let attempt = 0;; attempt++) {
|
|
264
|
+
await deps.emit(service, 'starting', `starting new revision on host port ${candidate.port}`);
|
|
265
|
+
try {
|
|
266
|
+
await removeByName(docker, candidate.container);
|
|
267
|
+
const id = await docker.createContainer(candidate.container, withHostPort(spec.body, candidate.port));
|
|
268
|
+
await docker.startContainer(id);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
if (attempt >= 4 || !PORT_CONFLICT.test(errMessage(e)))
|
|
273
|
+
throw e;
|
|
274
|
+
exclude.add(candidate.port);
|
|
275
|
+
candidate.port = await allocatePort(docker, input.rolloutPorts, knownPorts(rollout), exclude);
|
|
276
|
+
await saveRolloutState(dir, state);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await deps.emit(service, 'health_checking');
|
|
280
|
+
await deps.waitForHealth(candidate.container, service);
|
|
281
|
+
// Takeover: the next status report carries the new port and control re-renders the ingress.
|
|
282
|
+
if (rollout.active)
|
|
283
|
+
rollout.draining.push({
|
|
284
|
+
service,
|
|
285
|
+
container: rollout.active.container,
|
|
286
|
+
drainUntil: Date.now() + drainMs,
|
|
287
|
+
});
|
|
288
|
+
rollout.active = { ...candidate };
|
|
289
|
+
delete rollout.candidate;
|
|
290
|
+
delete rollout.failed;
|
|
291
|
+
await saveRolloutState(dir, state);
|
|
292
|
+
result.serviceStates[service] = servingState(rollout.active);
|
|
293
|
+
await deps.emit(service, 'serving', `new revision serving on host port ${rollout.active.port}`);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
const error = errMessage(e);
|
|
297
|
+
if (rollout.candidate)
|
|
298
|
+
await removeByName(docker, rollout.candidate.container).catch(() => { });
|
|
299
|
+
delete rollout.candidate;
|
|
300
|
+
rollout.failed = { key, error };
|
|
301
|
+
await saveRolloutState(dir, state);
|
|
302
|
+
reject(error);
|
|
303
|
+
await deps.emit(service, 'rollout_failed', `new revision rejected, previous revision keeps serving: ${error}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Containers of a rolling service that are no revision of it (a failed adoption, a lost state file).
|
|
307
|
+
for (const service of services) {
|
|
308
|
+
const rollout = state.services[service];
|
|
309
|
+
const known = new Set([
|
|
310
|
+
rollout?.active?.container,
|
|
311
|
+
rollout?.candidate?.container,
|
|
312
|
+
...(rollout?.draining || []).map((d) => d.container),
|
|
313
|
+
].filter(Boolean));
|
|
314
|
+
const stray = (await projectContainers(docker, project, service)).filter((c) => !known.has(containerNameOf(c)));
|
|
315
|
+
await removeContainers(docker, stray).catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
await saveRolloutState(dir, state);
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
/** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
|
|
321
|
+
async function retireRevisions(docker, dir, state, rolling, deps) {
|
|
322
|
+
for (const [service, rollout] of Object.entries(state.services)) {
|
|
323
|
+
if (!rolling.has(service)) {
|
|
324
|
+
// No longer rolling here: the recreate path (or the orphan sweep) removes the containers.
|
|
325
|
+
delete state.services[service];
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const keep = [];
|
|
329
|
+
for (const old of rollout.draining) {
|
|
330
|
+
if (old.drainUntil > Date.now()) {
|
|
331
|
+
keep.push(old);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await removeByName(docker, old.container);
|
|
336
|
+
await deps?.emit(service, 'drained', `previous revision ${old.container} removed after drain`);
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
if (!isNotFound(e))
|
|
340
|
+
keep.push(old);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
rollout.draining = keep;
|
|
344
|
+
if (!rollout.active && !rollout.candidate && !rollout.draining.length && !rollout.failed)
|
|
345
|
+
delete state.services[service];
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Drops rolling state of a project that has no rolling service on this node any more. */
|
|
349
|
+
export async function clearRolloutState(dir) {
|
|
350
|
+
await fs.rm(stateFile(dir), { force: true });
|
|
351
|
+
}
|
|
352
|
+
/** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
|
|
353
|
+
export async function activeRollingStates(dir, services) {
|
|
354
|
+
const state = await loadRolloutState(dir);
|
|
355
|
+
const out = {};
|
|
356
|
+
for (const service of services) {
|
|
357
|
+
const active = state.services[service]?.active;
|
|
358
|
+
if (active)
|
|
359
|
+
out[service] = servingState(active);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
package/dist/runners.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import type { RunResult } from './build.ts';
|
|
2
|
-
export type DockerRunner = (args: string[], opts?: {
|
|
3
|
-
cwd?: string;
|
|
4
|
-
}) => Promise<RunResult>;
|
|
5
2
|
export type HttpClient = typeof fetch;
|
|
6
|
-
/**
|
|
7
|
-
export declare function createDockerRunner(): DockerRunner;
|
|
8
|
-
/** Runs a host binary (`wg`, `ip`); rejects on a non-zero exit like the docker runner. */
|
|
3
|
+
/** Runs a host binary (`wg`, `ip`); rejects on a non-zero exit. */
|
|
9
4
|
export type HostRunner = (cmd: string, args: string[], opts?: {
|
|
10
5
|
cwd?: string;
|
|
11
6
|
}) => Promise<RunResult>;
|
package/dist/runners.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
// Injectable seams for the agent's side effects so rollout/health/secret logic can be unit-tested
|
|
2
|
-
// without a real Docker daemon or control plane.
|
|
2
|
+
// without a real Docker daemon or control plane. Docker itself is the `DockerApi` in `docker-api.ts`.
|
|
3
3
|
import { run } from "./build.js";
|
|
4
|
-
/** Default runner: `docker <args>` via the shared `run()` helper. */
|
|
5
|
-
export function createDockerRunner() {
|
|
6
|
-
return (args, opts) => run('docker', args, opts);
|
|
7
|
-
}
|
|
8
4
|
export function createHostRunner() {
|
|
9
5
|
return (cmd, args, opts) => run(cmd, args, opts);
|
|
10
6
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
set -eu
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
# Nodes need only the Docker Engine. The control host also runs its own install with Docker Compose,
|
|
3
|
+
# which the caller asks for with TUG_INSTALL_COMPOSE=1.
|
|
4
|
+
want_compose="${TUG_INSTALL_COMPOSE:-0}"
|
|
5
|
+
if docker version >/dev/null 2>&1 && { [ "$want_compose" != 1 ] || docker compose version >/dev/null 2>&1; }; then
|
|
6
|
+
echo 'Docker Engine is already available.' >&2
|
|
4
7
|
exit 0
|
|
5
8
|
fi
|
|
6
9
|
os_release="${TUG_OS_RELEASE:-/etc/os-release}"
|
|
@@ -61,9 +64,10 @@ Signed-By: /etc/apt/keyrings/docker.asc
|
|
|
61
64
|
REPO
|
|
62
65
|
apt-get update >&2
|
|
63
66
|
if [ "$engine_present" = true ]; then
|
|
64
|
-
apt-get install -y --no-upgrade --no-remove docker-compose-plugin >&2
|
|
67
|
+
[ "$want_compose" != 1 ] || apt-get install -y --no-upgrade --no-remove docker-compose-plugin >&2
|
|
65
68
|
else
|
|
66
|
-
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
|
|
69
|
+
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin >&2
|
|
70
|
+
[ "$want_compose" != 1 ] || apt-get install -y docker-compose-plugin >&2
|
|
67
71
|
fi
|
|
68
72
|
else
|
|
69
73
|
if [ "$engine_present" = false ]; then
|
|
@@ -75,11 +79,12 @@ else
|
|
|
75
79
|
command -v curl >/dev/null 2>&1 || dnf install -y curl >&2
|
|
76
80
|
curl -fsSL "https://download.docker.com/linux/$repo_os/docker-ce.repo" -o /etc/yum.repos.d/docker-ce.repo
|
|
77
81
|
if [ "$engine_present" = true ]; then
|
|
78
|
-
dnf install -y docker-compose-plugin >&2
|
|
82
|
+
[ "$want_compose" != 1 ] || dnf install -y docker-compose-plugin >&2
|
|
79
83
|
else
|
|
80
|
-
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
|
|
84
|
+
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin >&2
|
|
85
|
+
[ "$want_compose" != 1 ] || dnf install -y docker-compose-plugin >&2
|
|
81
86
|
fi
|
|
82
87
|
fi
|
|
83
88
|
[ "$engine_present" = true ] || systemctl enable --now docker >&2
|
|
84
89
|
docker version >/dev/null
|
|
85
|
-
docker compose version >/dev/null
|
|
90
|
+
[ "$want_compose" != 1 ] || docker compose version >/dev/null
|
package/package.json
CHANGED
package/dist/rollout.d.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import type { AgentContext } from './agent.ts';
|
|
2
|
-
import type { ComposeFile, ComposeService, DesiredState, ServiceState } from './protocol.ts';
|
|
3
|
-
export declare const DEFAULT_DRAIN = "30s";
|
|
4
|
-
export declare const DEFAULT_PORT_RANGE = "20000-20999";
|
|
5
|
-
export declare function parsePortRange(value: string | undefined): [number, number];
|
|
6
|
-
/** Compose project names allow lowercase letters, digits, dashes and underscores. */
|
|
7
|
-
export declare function revisionProject(project: string, service: string, hash: string): string;
|
|
8
|
-
/** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
|
|
9
|
-
export declare function remapPublishedPort(ports: NonNullable<ComposeService['ports']>, port: number): NonNullable<ComposeService['ports']>;
|
|
10
|
-
/** Identity of one revision of a service: its rendered definition and the versions of the secrets it uses. */
|
|
11
|
-
export declare function serviceRevisionHash(desired: DesiredState, rendered: ComposeService, service: string, secretVersions?: Record<string, number>): string;
|
|
12
|
-
/** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
|
|
13
|
-
export declare function rollingCompose(full: ComposeFile, project: string, service: string, port: number | null): ComposeFile;
|
|
14
|
-
interface Revision {
|
|
15
|
-
hash: string;
|
|
16
|
-
project: string;
|
|
17
|
-
port: number;
|
|
18
|
-
image?: string;
|
|
19
|
-
legacy?: boolean;
|
|
20
|
-
}
|
|
21
|
-
interface Draining {
|
|
22
|
-
service: string;
|
|
23
|
-
project: string;
|
|
24
|
-
legacy?: boolean;
|
|
25
|
-
drainUntil: number;
|
|
26
|
-
}
|
|
27
|
-
interface ServiceRollout {
|
|
28
|
-
active?: Revision;
|
|
29
|
-
candidate?: Revision;
|
|
30
|
-
draining: Draining[];
|
|
31
|
-
failed?: {
|
|
32
|
-
key: string;
|
|
33
|
-
error: string;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
export interface RolloutState {
|
|
37
|
-
services: Record<string, ServiceRollout>;
|
|
38
|
-
}
|
|
39
|
-
export declare function loadRolloutState(dir: string): Promise<RolloutState>;
|
|
40
|
-
/** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
|
|
41
|
-
export declare function composeBase(project: string, dir: string, file: string): string[];
|
|
42
|
-
export interface RolloutDeps {
|
|
43
|
-
waitForHealth: (project: string, service: string, composeFile: string) => Promise<void>;
|
|
44
|
-
ensureArtifact: (service: string) => Promise<void>;
|
|
45
|
-
emit: (service: string, event: string, message?: string) => Promise<unknown>;
|
|
46
|
-
}
|
|
47
|
-
export interface RollingInput {
|
|
48
|
-
desired: DesiredState;
|
|
49
|
-
/** The project's directory; revision Compose files go in `<dir>/rollout/`. */
|
|
50
|
-
dir: string;
|
|
51
|
-
/** Rolling services placed on this node. */
|
|
52
|
-
services: string[];
|
|
53
|
-
/** `composeForNode` output with no exclusions (so `extra_hosts`, `.env` and volumes are all present). */
|
|
54
|
-
full: ComposeFile;
|
|
55
|
-
secretVersions: Record<string, Record<string, number>>;
|
|
56
|
-
deps: RolloutDeps;
|
|
57
|
-
}
|
|
58
|
-
export interface RollingResult {
|
|
59
|
-
/** Reported state of every rolling service; the serving revision keeps `serving` while a newer one is rejected. */
|
|
60
|
-
serviceStates: Record<string, ServiceState>;
|
|
61
|
-
/** Services whose newest revision failed (or whose current revision is unhealthy), with the reason. */
|
|
62
|
-
errors: Record<string, string>;
|
|
63
|
-
}
|
|
64
|
-
/** True when containers of `services` still run in the main project from before they became rolling. */
|
|
65
|
-
export declare function hasLegacyContainers(ctx: AgentContext, project: string, services: string[]): Promise<boolean>;
|
|
66
|
-
export declare function isRollingService(desired: DesiredState, service: string): boolean;
|
|
67
|
-
export declare function reconcileRolling(ctx: AgentContext, input: RollingInput): Promise<RollingResult>;
|
|
68
|
-
/** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
|
|
69
|
-
export declare function retireAll(ctx: AgentContext, dir: string, desired: DesiredState): Promise<void>;
|
|
70
|
-
/** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
|
|
71
|
-
export declare function activeRollingStates(dir: string, services: string[]): Promise<Record<string, ServiceState>>;
|
|
72
|
-
export {};
|