@wpelevator/tug 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,17 @@
1
+ export declare const REGISTRY_AUTH_SECRET = "TUG_REGISTRY_AUTH";
2
+ export interface RegistryCredential {
3
+ username: string;
4
+ password: string;
5
+ }
6
+ export type RegistryCredentials = Record<string, RegistryCredential>;
7
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
8
+ export declare function parseRegistryCredentials(value?: string): RegistryCredentials;
9
+ export declare function imageLocation(image: string): {
10
+ host: string;
11
+ repository: string;
12
+ tag: string;
13
+ digest: string;
14
+ name: string;
15
+ };
16
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
17
+ export declare function resolveImage(image: string, credentials?: RegistryCredentials, request?: typeof fetch): Promise<string>;
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const REGISTRY_AUTH_SECRET = 'TUG_REGISTRY_AUTH';
3
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
4
+ export function parseRegistryCredentials(value) {
5
+ if (!value)
6
+ return {};
7
+ try {
8
+ const entries = JSON.parse(value);
9
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
10
+ throw new Error();
11
+ const credentials = {};
12
+ for (const [host, auth] of Object.entries(entries)) {
13
+ if (!/^[a-zA-Z0-9.:[\]-]+$/.test(host) ||
14
+ !auth ||
15
+ typeof auth !== 'object' ||
16
+ typeof auth.username !== 'string' ||
17
+ typeof auth.password !== 'string')
18
+ throw new Error();
19
+ credentials[host] = { username: auth.username, password: auth.password };
20
+ }
21
+ return credentials;
22
+ }
23
+ catch {
24
+ throw new Error(`${REGISTRY_AUTH_SECRET} must be a JSON object mapping registry hosts to username/password objects`);
25
+ }
26
+ }
27
+ export function imageLocation(image) {
28
+ const match = /^(?<name>[^@\s]+?)(?:@(?<digest>sha256:[a-f0-9]{64}))?$/.exec(image);
29
+ if (!match?.groups)
30
+ throw new Error('invalid image reference');
31
+ const { name, digest } = match.groups;
32
+ const slash = name.indexOf('/');
33
+ const qualified = slash >= 0 && /[.:]|^localhost$/.test(name.slice(0, slash));
34
+ let host = qualified ? name.slice(0, slash) : 'docker.io';
35
+ if (host === 'index.docker.io')
36
+ host = 'docker.io';
37
+ let repository = qualified ? name.slice(slash + 1) : name;
38
+ const colon = repository.lastIndexOf(':');
39
+ const tag = colon >= 0 ? repository.slice(colon + 1) : 'latest';
40
+ if (colon >= 0)
41
+ repository = repository.slice(0, colon);
42
+ if (host === 'docker.io' && !repository.includes('/'))
43
+ repository = `library/${repository}`;
44
+ if (!/^[a-z0-9]+(?:[._-]+[a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-]+[a-z0-9]+)*)*$/.test(repository) ||
45
+ !/^[\w][\w.-]{0,127}$/.test(tag) ||
46
+ !/^[a-zA-Z0-9.:[\]-]+$/.test(host))
47
+ throw new Error('invalid image reference');
48
+ return { host, repository, tag, digest, name };
49
+ }
50
+ const accept = [
51
+ 'application/vnd.oci.image.index.v1+json',
52
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
53
+ 'application/vnd.oci.image.manifest.v1+json',
54
+ 'application/vnd.docker.distribution.manifest.v2+json',
55
+ ].join(', ');
56
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
57
+ export async function resolveImage(image, credentials = {}, request = fetch) {
58
+ const location = imageLocation(image);
59
+ if (location.digest)
60
+ return image;
61
+ const { host, repository, tag, name } = location;
62
+ const endpoint = host === 'docker.io' ? 'registry-1.docker.io' : host;
63
+ const url = `https://${endpoint}/v2/${repository}/manifests/${tag}`;
64
+ const auth = credentials[host];
65
+ const basic = auth
66
+ ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`
67
+ : undefined;
68
+ const get = (authorization) => request(url, {
69
+ headers: { Accept: accept, ...(authorization ? { Authorization: authorization } : {}) },
70
+ redirect: 'error',
71
+ signal: AbortSignal.timeout(30_000),
72
+ });
73
+ try {
74
+ let response = await get();
75
+ if (response.status === 401) {
76
+ const challenge = response.headers.get('www-authenticate') || '';
77
+ await response.body?.cancel();
78
+ if (/^Basic\s/i.test(challenge) && basic)
79
+ response = await get(basic);
80
+ else if (/^Bearer\s/i.test(challenge)) {
81
+ const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
82
+ const tokenUrl = new URL(params.realm);
83
+ if (tokenUrl.protocol !== 'https:' || tokenUrl.username || tokenUrl.password)
84
+ throw new Error();
85
+ if (params.service)
86
+ tokenUrl.searchParams.set('service', params.service);
87
+ tokenUrl.searchParams.set('scope', `repository:${repository}:pull`);
88
+ const tokenResponse = await request(tokenUrl, {
89
+ headers: basic ? { Authorization: basic } : {},
90
+ redirect: 'error',
91
+ signal: AbortSignal.timeout(30_000),
92
+ });
93
+ if (!tokenResponse.ok) {
94
+ await tokenResponse.body?.cancel();
95
+ throw new Error();
96
+ }
97
+ const body = (await tokenResponse.json());
98
+ const token = body.token || body.access_token;
99
+ if (!token || typeof token !== 'string')
100
+ throw new Error();
101
+ response = await get(`Bearer ${token}`);
102
+ }
103
+ else
104
+ throw new Error();
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel();
108
+ throw new Error();
109
+ }
110
+ // Hash the exact bytes instead of trusting a registry's digest header. Bound manifest memory use.
111
+ const chunks = [];
112
+ let size = 0;
113
+ for await (const chunk of response.body) {
114
+ size += chunk.length;
115
+ if (size > 8 * 1024 * 1024)
116
+ throw new Error();
117
+ chunks.push(chunk);
118
+ }
119
+ const bytes = Buffer.concat(chunks);
120
+ const manifest = JSON.parse(bytes.toString());
121
+ if (manifest.schemaVersion !== 2 ||
122
+ !(Array.isArray(manifest.manifests) || (manifest.config && Array.isArray(manifest.layers))))
123
+ throw new Error();
124
+ const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
125
+ const reported = response.headers.get('docker-content-digest');
126
+ if (reported && reported !== digest)
127
+ throw new Error();
128
+ return `${name}@${digest}`;
129
+ }
130
+ catch {
131
+ throw new Error(`cannot resolve image ${image}; check registry availability and project ${REGISTRY_AUTH_SECRET} credentials`);
132
+ }
133
+ }
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
  }
@@ -8,12 +8,8 @@ services:
8
8
  TUG_DATA_DIR: /data
9
9
  TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
10
  TUG_MESH: {{MESH}}
11
- TUG_REGISTRY: {{REGISTRY_HOST}}
12
- TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
13
- TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
14
11
  ports:
15
12
  - "{{PORT}}:8080"
16
13
  volumes:
17
14
  - /var/lib/tug-control:/data
18
- - /var/lib/tug-registry-auth:/registry-auth
19
15
  - /var/run/docker.sock:/var/run/docker.sock
@@ -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
@@ -3,6 +3,5 @@ mkdir -p /etc/tug /var/lib/tug
3
3
  cat > /etc/tug/agent.env <<'ENV'
4
4
  {{ENV}}ENV
5
5
  chmod 0600 /etc/tug/agent.env
6
- {{DOCKER_LOGIN}}
7
6
  docker rm -f tug-agent >/dev/null 2>&1 || true
8
7
  docker run -d --name tug-agent --restart unless-stopped --env-file /etc/tug/agent.env {{RUN_FLAGS}}-v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/tug:/var/lib/tug {{IMAGE_SH}} agent
package/dist/tokens.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare const PROJECT_NAME_PATTERN: RegExp;
14
14
  /**
15
15
  * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
16
  * register an unlisted route, and a test compares this table with the live router. `public` routes do their own
17
- * credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
17
+ * credential check and are not behind the token guard. docs/access-control.md mirrors this table.
18
18
  */
19
19
  export declare const ROUTE_ROLES: Record<string, RouteRole>;
20
20
  /** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
@@ -60,14 +60,14 @@ export declare function parseTokenRequest(body: unknown): {
60
60
  expiresAt: string | null;
61
61
  };
62
62
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
63
- export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): Promise<TokenInfo & {
63
+ export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): TokenInfo & {
64
64
  token: string;
65
- }>;
66
- export declare function listTokens(db: Db): Promise<TokenInfo[]>;
65
+ };
66
+ export declare function listTokens(db: Db): TokenInfo[];
67
67
  /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
68
- export declare function revokeToken(db: Db, name: string): Promise<boolean>;
68
+ export declare function revokeToken(db: Db, name: string): boolean;
69
69
  /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
70
- export declare function authenticate(db: Db, raw: string): Promise<Actor | null>;
70
+ export declare function authenticate(db: Db, raw: string): Actor | null;
71
71
  /**
72
72
  * Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
73
73
  * resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
@@ -88,4 +88,4 @@ export declare function recordEvent(db: Db, event: {
88
88
  project?: string | null;
89
89
  deploymentId?: string | null;
90
90
  metadata?: Record<string, unknown>;
91
- }): Promise<void>;
91
+ }): void;