@wpelevator/tug 0.2.1 → 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.
Files changed (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. package/dist/rollout.js +0 -319
@@ -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
- /** Default runner: `docker <args>` via the shared `run()` helper. */
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
  }
package/dist/secrets.js CHANGED
@@ -10,7 +10,9 @@ export async function loadOrCreateMasterKey(file) {
10
10
  await fs.writeFile(file, key, { mode: 0o600 });
11
11
  return key;
12
12
  }
13
- function keyBytes(key) { return crypto.createHash('sha256').update(key).digest(); }
13
+ function keyBytes(key) {
14
+ return crypto.createHash('sha256').update(key).digest();
15
+ }
14
16
  export function encrypt(value, masterKey) {
15
17
  const iv = crypto.randomBytes(12);
16
18
  const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(masterKey), iv);
@@ -7,6 +7,7 @@ services:
7
7
  PORT: 8080
8
8
  TUG_DATA_DIR: /data
9
9
  TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
+ TUG_MESH: {{MESH}}
10
11
  TUG_REGISTRY: {{REGISTRY_HOST}}
11
12
  TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
12
13
  TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
@@ -1,6 +1,9 @@
1
1
  set -eu
2
- if docker version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
3
- echo 'Docker Engine and Compose are already available.' >&2
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 docker-compose-plugin >&2
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 docker-compose-plugin >&2
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/dist/tokens.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Request, RequestHandler } from 'express';
2
2
  import type { Db } from './db.ts';
3
3
  /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
4
4
  export declare const ROLES: readonly ["read", "deploy", "admin"];
5
- export type Role = typeof ROLES[number];
5
+ export type Role = (typeof ROLES)[number];
6
6
  export type RouteRole = Role | 'public';
7
7
  /** Name recorded in the audit trail for the root operator token. */
8
8
  export declare const OPERATOR_ACTOR = "operator";
package/dist/tokens.js CHANGED
@@ -17,6 +17,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
17
17
  * credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
18
18
  */
19
19
  export const ROUTE_ROLES = {
20
+ 'GET /api/v1/live': 'deploy',
20
21
  'GET /api/v1/status': 'read',
21
22
  'GET /api/v1/events': 'read',
22
23
  'GET /api/v1/projects/:project': 'read',
@@ -27,6 +28,7 @@ export const ROUTE_ROLES = {
27
28
  'POST /api/v1/deployments': 'deploy',
28
29
  'POST /api/v1/deployments/:id/cancel': 'deploy',
29
30
  'POST /api/v1/rollback': 'deploy',
31
+ 'POST /api/v1/scale': 'deploy',
30
32
  'POST /api/v1/restart': 'deploy',
31
33
  'POST /api/v1/logs': 'deploy',
32
34
  'GET /api/v1/commands/:id': 'deploy',
@@ -40,6 +42,7 @@ export const ROUTE_ROLES = {
40
42
  'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
41
43
  'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
42
44
  'POST /api/v1/nodes/:name/ingress': 'admin',
45
+ 'DELETE /api/v1/nodes/:name': 'admin',
43
46
  'POST /api/v1/control/backups/run': 'admin',
44
47
  'POST /api/v1/control/restore': 'admin',
45
48
  'GET /api/v1/diagnostics': 'admin',
@@ -47,7 +50,7 @@ export const ROUTE_ROLES = {
47
50
  'POST /api/v1/tokens': 'admin',
48
51
  'GET /api/v1/tokens': 'admin',
49
52
  'DELETE /api/v1/tokens/:name': 'admin',
50
- 'POST /api/v1/registry/auth': 'public'
53
+ 'POST /api/v1/registry/auth': 'public',
51
54
  };
52
55
  const statusError = (status, message) => Object.assign(new Error(message), { status });
53
56
  export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
@@ -64,7 +67,10 @@ export function parseTokenRequest(body) {
64
67
  throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
65
68
  let scope = null;
66
69
  if (projects !== undefined && projects !== null) {
67
- if (!Array.isArray(projects) || !projects.length || projects.length > 100 || projects.some(p => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
70
+ if (!Array.isArray(projects) ||
71
+ !projects.length ||
72
+ projects.length > 100 ||
73
+ projects.some((p) => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
68
74
  throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
69
75
  if (role === 'admin')
70
76
  throw statusError(400, 'admin tokens manage the whole control plane and cannot be limited to projects');
@@ -72,13 +78,25 @@ export function parseTokenRequest(body) {
72
78
  }
73
79
  let expiresAt = null;
74
80
  if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
75
- if (typeof expiresInSeconds !== 'number' || !Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 10 * 365 * 86400)
81
+ if (typeof expiresInSeconds !== 'number' ||
82
+ !Number.isSafeInteger(expiresInSeconds) ||
83
+ expiresInSeconds < 1 ||
84
+ expiresInSeconds > 10 * 365 * 86400)
76
85
  throw statusError(400, 'expiresInSeconds must be a positive integer of at most ten years');
77
86
  expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
78
87
  }
79
88
  return { name, role, projects: scope, expiresAt };
80
89
  }
81
- const info = (row) => ({ name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null, createdAt: row.created_at, createdBy: row.created_by, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, revokedAt: row.revoked_at });
90
+ const info = (row) => ({
91
+ name: row.name,
92
+ role: row.role,
93
+ projects: row.projects_json ? JSON.parse(row.projects_json) : null,
94
+ createdAt: row.created_at,
95
+ createdBy: row.created_by,
96
+ lastUsedAt: row.last_used_at,
97
+ expiresAt: row.expires_at,
98
+ revokedAt: row.revoked_at,
99
+ });
82
100
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
83
101
  export async function createToken(db, request, createdBy) {
84
102
  const value = token(TOKEN_PREFIX);
@@ -90,7 +108,10 @@ export async function createToken(db, request, createdBy) {
90
108
  throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
91
109
  throw e;
92
110
  }
93
- return { ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))), token: value };
111
+ return {
112
+ ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
113
+ token: value,
114
+ };
94
115
  }
95
116
  export async function listTokens(db) {
96
117
  return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
@@ -117,10 +138,14 @@ export async function authenticate(db, raw) {
117
138
  // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
118
139
  if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
119
140
  void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
120
- return { name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null };
141
+ return {
142
+ name: row.name,
143
+ role: row.role,
144
+ projects: row.projects_json ? JSON.parse(row.projects_json) : null,
145
+ };
121
146
  }
122
- export const bodyProject = req => String(req.body?.project || 'site');
123
- export const queryProject = req => String(req.query.project || 'site');
147
+ export const bodyProject = (req) => String(req.body?.project || 'site');
148
+ export const queryProject = (req) => String(req.query.project || 'site');
124
149
  const FORBIDDEN_PROJECT = 'forbidden: this token may not access that project';
125
150
  /** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
126
151
  export function assertProject(actor, project) {
@@ -146,7 +171,8 @@ export function createGuard(db) {
146
171
  res.status(403).json({ error: 'forbidden: this action requires the admin role' });
147
172
  return;
148
173
  }
149
- if (actor.projects && (project === undefined || project === null || !actor.projects.includes(project))) {
174
+ if (actor.projects &&
175
+ (project === undefined || project === null || !actor.projects.includes(project))) {
150
176
  res.status(403).json({ error: FORBIDDEN_PROJECT });
151
177
  return;
152
178
  }
package/dist/util.d.ts CHANGED
@@ -13,3 +13,10 @@ export declare const errMessage: (e: unknown) => string;
13
13
  /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
14
14
  export declare function parseDuration(value: string | number, fallback?: number): number;
15
15
  export declare const isDuration: (value: unknown) => boolean;
16
+ /**
17
+ * True for an IP address that is routable on the internet: anything outside loopback, private
18
+ * (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
19
+ * unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
20
+ * interface. Hostnames and other non-IP strings return false.
21
+ */
22
+ export declare function isPublicAddress(address: string): boolean;