@wpelevator/tug 0.0.0 → 0.2.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 (49) hide show
  1. package/README.md +63 -0
  2. package/dist/agent.d.ts +40 -0
  3. package/dist/agent.js +547 -0
  4. package/dist/archive.d.ts +2 -0
  5. package/dist/archive.js +20 -0
  6. package/dist/artifacts.d.ts +23 -0
  7. package/dist/artifacts.js +238 -0
  8. package/dist/build.d.ts +22 -0
  9. package/dist/build.js +44 -0
  10. package/dist/cli-build.d.ts +15 -0
  11. package/dist/cli-build.js +50 -0
  12. package/dist/cli-config.d.ts +20 -0
  13. package/dist/cli-config.js +47 -0
  14. package/dist/cli-install.d.ts +7 -0
  15. package/dist/cli-install.js +31 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +422 -0
  18. package/dist/compose.d.ts +33 -0
  19. package/dist/compose.js +260 -0
  20. package/dist/control.d.ts +24 -0
  21. package/dist/control.js +1105 -0
  22. package/dist/db.d.ts +117 -0
  23. package/dist/db.js +51 -0
  24. package/dist/image.d.ts +9 -0
  25. package/dist/image.js +20 -0
  26. package/dist/ingress.d.ts +50 -0
  27. package/dist/ingress.js +180 -0
  28. package/dist/main.d.ts +2 -0
  29. package/dist/main.js +22 -0
  30. package/dist/mesh.d.ts +70 -0
  31. package/dist/mesh.js +183 -0
  32. package/dist/protocol.d.ts +326 -0
  33. package/dist/protocol.js +8 -0
  34. package/dist/rollout.d.ts +72 -0
  35. package/dist/rollout.js +319 -0
  36. package/dist/runners.d.ts +12 -0
  37. package/dist/runners.js +10 -0
  38. package/dist/secrets.d.ts +3 -0
  39. package/dist/secrets.js +29 -0
  40. package/dist/templates/agent.env +6 -0
  41. package/dist/templates/control-compose.yml +18 -0
  42. package/dist/templates/control-install.sh +7 -0
  43. package/dist/templates/install-docker.sh +85 -0
  44. package/dist/templates/node-install.sh +8 -0
  45. package/dist/tokens.d.ts +91 -0
  46. package/dist/tokens.js +165 -0
  47. package/dist/util.d.ts +15 -0
  48. package/dist/util.js +35 -0
  49. package/package.json +62 -3
@@ -0,0 +1,319 @@
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.createHash('sha256').update(JSON.stringify({ definition, secretVersions })).digest('hex').slice(0, 16);
48
+ }
49
+ /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
50
+ export function rollingCompose(full, project, service, port) {
51
+ const svc = { ...full.services[service] };
52
+ delete svc.depends_on;
53
+ if (port !== null && svc.ports)
54
+ svc.ports = remapPublishedPort(svc.ports, port);
55
+ const out = { services: { [service]: svc }, networks: { default: { name: `${project}_default`, external: true } } };
56
+ const volumes = {};
57
+ for (const mount of svc.volumes || []) {
58
+ const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
59
+ const def = source ? full.volumes?.[source] : undefined;
60
+ if (!source || !def)
61
+ continue;
62
+ // Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
63
+ volumes[source] = def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
64
+ }
65
+ if (Object.keys(volumes).length)
66
+ out.volumes = volumes;
67
+ return out;
68
+ }
69
+ const stateFile = (dir) => path.join(dir, 'rollout.json');
70
+ export async function loadRolloutState(dir) {
71
+ try {
72
+ const parsed = JSON.parse(await fs.readFile(stateFile(dir), 'utf8'));
73
+ for (const s of Object.values(parsed.services))
74
+ s.draining ||= [];
75
+ return parsed;
76
+ }
77
+ catch {
78
+ return { services: {} };
79
+ }
80
+ }
81
+ async function saveRolloutState(dir, state) {
82
+ const tmp = `${stateFile(dir)}.tmp`;
83
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
84
+ await fs.rename(tmp, stateFile(dir));
85
+ }
86
+ // ---- Docker helpers ----------------------------------------------------------------------------
87
+ /** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
88
+ export function composeBase(project, dir, file) {
89
+ return ['compose', '-p', project, '--project-directory', dir, '-f', file];
90
+ }
91
+ async function containerIds(ctx, project, service) {
92
+ const filters = ['--filter', `label=${LABEL_PROJECT}=${project}`, ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : [])];
93
+ return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
94
+ }
95
+ /** Stops and removes every container of a revision project (or of one service of the main project). */
96
+ async function removeRevision(ctx, dir, project, service) {
97
+ const ids = await containerIds(ctx, project, service);
98
+ if (ids.length) {
99
+ await ctx.docker(['stop', ...ids]).catch(() => { });
100
+ await ctx.docker(['rm', '-f', ...ids]);
101
+ }
102
+ if (!service)
103
+ await fs.rm(path.join(dir, 'rollout', `${project}.yml`), { force: true });
104
+ }
105
+ async function ensureSharedResources(ctx, project, services, full) {
106
+ const network = `${project}_default`;
107
+ await ctx.docker(['network', 'inspect', network]).catch(() => ctx.docker(['network', 'create', '--label', `${LABEL_PROJECT}=${project}`, '--label', 'com.docker.compose.network=default', network]));
108
+ const created = new Set();
109
+ for (const service of services) {
110
+ for (const mount of full.services[service].volumes || []) {
111
+ const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
112
+ const def = source ? full.volumes?.[source] : undefined;
113
+ if (!source || !def || def.external || def.name || created.has(source))
114
+ continue;
115
+ created.add(source);
116
+ const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
117
+ await ctx.docker(['volume', 'create', ...(def.driver ? ['--driver', String(def.driver)] : []), ...opts, '--label', `${LABEL_PROJECT}=${project}`, '--label', `com.docker.compose.volume=${source}`, `${project}_${source}`]);
118
+ }
119
+ }
120
+ }
121
+ /** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
122
+ async function publishedHostPorts(ctx) {
123
+ const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
124
+ return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap(m => {
125
+ const lo = Number(m[1]);
126
+ const hi = Number(m[2] || m[1]);
127
+ return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
128
+ }));
129
+ }
130
+ async function allocatePort(ctx, known, exclude) {
131
+ const [lo, hi] = parsePortRange(ctx.rolloutPorts);
132
+ const busy = await publishedHostPorts(ctx);
133
+ for (const port of [...known, ...exclude])
134
+ busy.add(port);
135
+ // Start after the newest port in use so a just-released port is not reused straight away.
136
+ const last = Math.max(lo - 1, ...known.filter(p => p >= lo && p <= hi));
137
+ const size = hi - lo + 1;
138
+ for (let i = 0; i < size; i++) {
139
+ const port = lo + ((last + 1 - lo + i) % size);
140
+ if (!busy.has(port))
141
+ return port;
142
+ }
143
+ throw new Error(`no free host port left in the rollout range ${lo}-${hi} (TUG_ROLLOUT_PORTS)`);
144
+ }
145
+ const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
146
+ function servingState(rev, extra = {}) {
147
+ return { rolloutState: 'serving', image: rev.image, healthyAt: now(), ...(rev.legacy ? {} : { port: rev.port }), rolloutRevision: rev.hash, ...extra };
148
+ }
149
+ /** True when containers of `services` still run in the main project from before they became rolling. */
150
+ export async function hasLegacyContainers(ctx, project, services) {
151
+ for (const service of services)
152
+ if ((await containerIds(ctx, project, service)).length)
153
+ return true;
154
+ return false;
155
+ }
156
+ export function isRollingService(desired, service) { return isRolling(desired.compose.services[service]); }
157
+ export async function reconcileRolling(ctx, input) {
158
+ const { desired, dir, services, full, deps } = input;
159
+ const project = desired.project;
160
+ const state = await loadRolloutState(dir);
161
+ const result = { serviceStates: {}, errors: {} };
162
+ await ensureDir(path.join(dir, 'rollout'));
163
+ // Drained revisions go first so their host ports are free for this rollout.
164
+ await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
165
+ await ensureSharedResources(ctx, project, services, full);
166
+ for (const service of services) {
167
+ const rollout = state.services[service] ||= { draining: [] };
168
+ const spec = desired.compose.services[service];
169
+ const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
170
+ const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
171
+ const hash = serviceRevisionHash(desired, full.services[service], service, input.secretVersions[service]);
172
+ const revProject = revisionProject(project, service, hash);
173
+ const file = path.join(dir, 'rollout', `${revProject}.yml`);
174
+ const image = desired.services[service].image;
175
+ // A service that used to be recreated still runs in the main project: it is the revision to replace.
176
+ if (!rollout.active && declaredPort && (await containerIds(ctx, project, service)).length) {
177
+ rollout.active = { hash: 'legacy', project, port: declaredPort, image, legacy: true };
178
+ }
179
+ if (rollout.candidate && rollout.candidate.hash !== hash) {
180
+ await removeRevision(ctx, dir, rollout.candidate.project);
181
+ delete rollout.candidate;
182
+ }
183
+ const writeCompose = (port) => fs.writeFile(file, dumpYaml(rollingCompose(full, project, service, port)));
184
+ const up = () => ctx.docker([...composeBase(revProject, dir, file), 'up', '-d'], { cwd: dir });
185
+ if (rollout.active && rollout.active.hash === hash) {
186
+ // Steady state: make sure the revision is still up and healthy; nothing restarts when it is.
187
+ const active = { ...rollout.active, image };
188
+ try {
189
+ await writeCompose(active.port);
190
+ await up();
191
+ await deps.waitForHealth(revProject, service, file);
192
+ delete rollout.failed;
193
+ result.serviceStates[service] = servingState(active);
194
+ }
195
+ catch (e) {
196
+ result.errors[service] = errMessage(e);
197
+ result.serviceStates[service] = { rolloutState: 'health_checking', image, rolloutRevision: hash };
198
+ }
199
+ continue;
200
+ }
201
+ const key = `${desired.revision || desired.version}:${hash}`;
202
+ const reject = (error) => {
203
+ result.errors[service] = error;
204
+ result.serviceStates[service] = rollout.active ? servingState(rollout.active, { rolloutError: error }) : { rolloutState: 'health_checking', image, rolloutRevision: hash };
205
+ };
206
+ if (rollout.failed?.key === key) {
207
+ reject(rollout.failed.error);
208
+ continue;
209
+ }
210
+ try {
211
+ const candidate = rollout.candidate ?? { hash, project: revProject, port: await allocatePort(ctx, knownPorts(rollout), new Set()), image };
212
+ rollout.candidate = candidate;
213
+ await saveRolloutState(dir, state);
214
+ await deps.emit(service, 'pulling');
215
+ await deps.ensureArtifact(service);
216
+ const exclude = new Set();
217
+ for (let attempt = 0;; attempt++) {
218
+ await writeCompose(candidate.port);
219
+ if (!desired.services[service].artifact)
220
+ await ctx.docker([...composeBase(revProject, dir, file), 'pull', service], { cwd: dir });
221
+ await deps.emit(service, 'starting', `starting new revision on host port ${candidate.port}`);
222
+ try {
223
+ await up();
224
+ break;
225
+ }
226
+ catch (e) {
227
+ if (attempt >= 4 || !PORT_CONFLICT.test(errMessage(e)))
228
+ throw e;
229
+ exclude.add(candidate.port);
230
+ candidate.port = await allocatePort(ctx, knownPorts(rollout), exclude);
231
+ await saveRolloutState(dir, state);
232
+ }
233
+ }
234
+ await deps.emit(service, 'health_checking');
235
+ await deps.waitForHealth(revProject, service, file);
236
+ // Takeover: the next status report carries the new port and control re-renders the ingress.
237
+ if (rollout.active)
238
+ rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + drainMs });
239
+ rollout.active = { ...candidate };
240
+ delete rollout.candidate;
241
+ delete rollout.failed;
242
+ await saveRolloutState(dir, state);
243
+ result.serviceStates[service] = servingState(rollout.active);
244
+ await deps.emit(service, 'serving', `new revision serving on host port ${rollout.active.port}`);
245
+ }
246
+ catch (e) {
247
+ const error = errMessage(e);
248
+ if (rollout.candidate)
249
+ await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
250
+ delete rollout.candidate;
251
+ rollout.failed = { key, error };
252
+ await saveRolloutState(dir, state);
253
+ reject(error);
254
+ await deps.emit(service, 'rollout_failed', `new revision rejected, previous revision keeps serving: ${error}`);
255
+ }
256
+ }
257
+ await saveRolloutState(dir, state);
258
+ return result;
259
+ }
260
+ function knownPorts(rollout) {
261
+ return [rollout.active, rollout.candidate].filter((r) => Boolean(r && !r.legacy)).map(r => r.port);
262
+ }
263
+ /** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
264
+ async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
265
+ for (const [service, rollout] of Object.entries(state.services)) {
266
+ if (!rolling.has(service)) {
267
+ // No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
268
+ const stillRuns = desired.compose.services[service] && (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) || !desired.services[service]?.placement?.nodes?.length);
269
+ if (rollout.candidate) {
270
+ await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
271
+ delete rollout.candidate;
272
+ }
273
+ if (rollout.active && stillRuns) {
274
+ rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN) });
275
+ delete rollout.active;
276
+ }
277
+ else if (rollout.active) {
278
+ await removeRevision(ctx, dir, rollout.active.project, rollout.active.legacy ? service : undefined).catch(() => { });
279
+ delete rollout.active;
280
+ }
281
+ }
282
+ const keep = [];
283
+ for (const old of rollout.draining) {
284
+ if (old.drainUntil > Date.now()) {
285
+ keep.push(old);
286
+ continue;
287
+ }
288
+ try {
289
+ await removeRevision(ctx, dir, old.project, old.legacy ? old.service : undefined);
290
+ await deps?.emit(service, 'drained', `previous revision ${old.project} removed after drain`);
291
+ }
292
+ catch {
293
+ keep.push(old);
294
+ }
295
+ }
296
+ rollout.draining = keep;
297
+ if (!rollout.active && !rollout.candidate && !rollout.draining.length && !rollout.failed)
298
+ delete state.services[service];
299
+ }
300
+ }
301
+ /** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
302
+ export async function retireAll(ctx, dir, desired) {
303
+ if (!(await fs.stat(stateFile(dir)).then(() => true, () => false)))
304
+ return;
305
+ const state = await loadRolloutState(dir);
306
+ await retireRevisions(ctx, dir, state, desired, new Set());
307
+ await saveRolloutState(dir, state);
308
+ }
309
+ /** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
310
+ export async function activeRollingStates(dir, services) {
311
+ const state = await loadRolloutState(dir);
312
+ const out = {};
313
+ for (const service of services) {
314
+ const active = state.services[service]?.active;
315
+ if (active)
316
+ out[service] = servingState(active);
317
+ }
318
+ return out;
319
+ }
@@ -0,0 +1,12 @@
1
+ import type { RunResult } from './build.ts';
2
+ export type DockerRunner = (args: string[], opts?: {
3
+ cwd?: string;
4
+ }) => Promise<RunResult>;
5
+ 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. */
9
+ export type HostRunner = (cmd: string, args: string[], opts?: {
10
+ cwd?: string;
11
+ }) => Promise<RunResult>;
12
+ export declare function createHostRunner(): HostRunner;
@@ -0,0 +1,10 @@
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.
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
+ export function createHostRunner() {
9
+ return (cmd, args, opts) => run(cmd, args, opts);
10
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loadOrCreateMasterKey(file: string): Promise<string>;
2
+ export declare function encrypt(value: unknown, masterKey: string): string;
3
+ export declare function decrypt(payload: string, masterKey: string): string;
@@ -0,0 +1,29 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import { ensureDir, exists } from "./util.js";
4
+ import path from 'node:path';
5
+ export async function loadOrCreateMasterKey(file) {
6
+ await ensureDir(path.dirname(file));
7
+ if (await exists(file))
8
+ return (await fs.readFile(file, 'utf8')).trim();
9
+ const key = crypto.randomBytes(32).toString('base64');
10
+ await fs.writeFile(file, key, { mode: 0o600 });
11
+ return key;
12
+ }
13
+ function keyBytes(key) { return crypto.createHash('sha256').update(key).digest(); }
14
+ export function encrypt(value, masterKey) {
15
+ const iv = crypto.randomBytes(12);
16
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(masterKey), iv);
17
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
18
+ const tag = cipher.getAuthTag();
19
+ return Buffer.concat([iv, tag, encrypted]).toString('base64');
20
+ }
21
+ export function decrypt(payload, masterKey) {
22
+ const buf = Buffer.from(payload, 'base64');
23
+ const iv = buf.subarray(0, 12);
24
+ const tag = buf.subarray(12, 28);
25
+ const data = buf.subarray(28);
26
+ const decipher = crypto.createDecipheriv('aes-256-gcm', keyBytes(masterKey), iv);
27
+ decipher.setAuthTag(tag);
28
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
29
+ }
@@ -0,0 +1,6 @@
1
+ TUG_CONTROL_URL={{CONTROL_URL}}
2
+ TUG_NODE_NAME={{NODE_NAME}}
3
+ TUG_AGENT_TOKEN={{AGENT_TOKEN}}
4
+ TUG_STATE_DIR=/var/lib/tug
5
+ TUG_APPLY=true
6
+ TUG_POLL_INTERVAL_MS=5000
@@ -0,0 +1,18 @@
1
+ services:
2
+ control:
3
+ image: {{IMAGE}}
4
+ restart: unless-stopped
5
+ command: control
6
+ environment:
7
+ PORT: 8080
8
+ TUG_DATA_DIR: /data
9
+ TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
+ TUG_REGISTRY: {{REGISTRY_HOST}}
11
+ TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
12
+ TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
13
+ ports:
14
+ - "{{PORT}}:8080"
15
+ volumes:
16
+ - /var/lib/tug-control:/data
17
+ - /var/lib/tug-registry-auth:/registry-auth
18
+ - /var/run/docker.sock:/var/run/docker.sock
@@ -0,0 +1,7 @@
1
+ set -eu
2
+ mkdir -p /opt/tug-control /var/lib/tug-control
3
+ cat > /opt/tug-control/docker-compose.yml <<'YAML'
4
+ {{COMPOSE}}YAML
5
+ docker compose -f /opt/tug-control/docker-compose.yml up -d
6
+ install -m 0600 /dev/null /var/lib/tug-control/operator.token
7
+ printf %s {{OPERATOR_TOKEN_SH}} > /var/lib/tug-control/operator.token
@@ -0,0 +1,85 @@
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
4
+ exit 0
5
+ fi
6
+ os_release="${TUG_OS_RELEASE:-/etc/os-release}"
7
+ [ -r "$os_release" ] || { echo 'Docker bootstrap supports Debian, Ubuntu, Fedora and RHEL-compatible distributions with systemd.' >&2; exit 20; }
8
+ . "$os_release"
9
+ unsupported() { echo "Unsupported Docker bootstrap $1; install Docker Engine and Compose manually." >&2; exit 20; }
10
+ major="${VERSION_ID:-}"; major="${major%%.*}"
11
+ case "$ID" in
12
+ ubuntu|debian)
13
+ family=apt
14
+ case "$ID:${VERSION_CODENAME:-}" in
15
+ ubuntu:jammy|ubuntu:noble|ubuntu:resolute|ubuntu:stonking|debian:bookworm|debian:trixie) ;;
16
+ *) unsupported "release: $ID ${VERSION_CODENAME:-unknown}" ;;
17
+ esac
18
+ repo_os=$ID ;;
19
+ fedora)
20
+ family=dnf; repo_os=fedora
21
+ case "$major" in 42|43|44) ;; *) unsupported "release: $ID ${VERSION_ID:-unknown}" ;; esac ;;
22
+ rhel|centos|rocky|almalinux)
23
+ family=dnf
24
+ # Docker publishes one EL repository per RHEL major; Rocky, AlmaLinux and CentOS Stream use the centos one.
25
+ if [ "$ID" = rhel ]; then repo_os=rhel; else repo_os=centos; fi
26
+ case "$major" in 9|10) ;; *) unsupported "release: $ID ${VERSION_ID:-unknown}" ;; esac ;;
27
+ *) unsupported "OS: $ID" ;;
28
+ esac
29
+ # Print the selected package family and repository without touching the host (used by tests).
30
+ if [ "${TUG_BOOTSTRAP_DETECT:-}" = 1 ]; then echo "$family $repo_os"; exit 0; fi
31
+ [ "$(id -u)" = 0 ] || { echo 'Docker installation requires root; use a root SSH target.' >&2; exit 20; }
32
+ [ -d /run/systemd/system ] || { echo 'Docker bootstrap requires systemd.' >&2; exit 20; }
33
+ # Preserve existing Docker installations. Install only a missing Compose plugin.
34
+ if command -v docker >/dev/null 2>&1; then
35
+ docker version >/dev/null 2>&1 || { echo 'Docker exists but is not accessible; repair/start it before installing Tug.' >&2; exit 20; }
36
+ engine_present=true
37
+ else
38
+ engine_present=false
39
+ fi
40
+ conflict() { echo "Conflicting package $1 is installed; review Docker's installation instructions before replacing it." >&2; exit 20; }
41
+ if [ "$family" = apt ]; then
42
+ [ -n "${VERSION_CODENAME:-}" ] || { echo 'OS release must declare VERSION_CODENAME.' >&2; exit 20; }
43
+ export DEBIAN_FRONTEND=noninteractive
44
+ if [ "$engine_present" = false ]; then
45
+ for package in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do
46
+ if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then conflict "$package"; fi
47
+ done
48
+ fi
49
+ apt-get update >&2
50
+ apt-get install -y ca-certificates curl >&2
51
+ install -m 0755 -d /etc/apt/keyrings
52
+ curl -fsSL "https://download.docker.com/linux/$repo_os/gpg" -o /etc/apt/keyrings/docker.asc
53
+ chmod a+r /etc/apt/keyrings/docker.asc
54
+ cat > /etc/apt/sources.list.d/docker.sources <<REPO
55
+ Types: deb
56
+ URIs: https://download.docker.com/linux/$repo_os
57
+ Suites: $VERSION_CODENAME
58
+ Components: stable
59
+ Architectures: $(dpkg --print-architecture)
60
+ Signed-By: /etc/apt/keyrings/docker.asc
61
+ REPO
62
+ apt-get update >&2
63
+ if [ "$engine_present" = true ]; then
64
+ apt-get install -y --no-upgrade --no-remove docker-compose-plugin >&2
65
+ else
66
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >&2
67
+ fi
68
+ else
69
+ if [ "$engine_present" = false ]; then
70
+ for package in docker docker-client docker-client-latest docker-common docker-latest docker-logrotate docker-engine moby-engine podman-docker docker-compose; do
71
+ if rpm -q "$package" >/dev/null 2>&1; then conflict "$package"; fi
72
+ done
73
+ fi
74
+ # curl-minimal already provides curl on many EL images; installing curl over it conflicts.
75
+ command -v curl >/dev/null 2>&1 || dnf install -y curl >&2
76
+ curl -fsSL "https://download.docker.com/linux/$repo_os/docker-ce.repo" -o /etc/yum.repos.d/docker-ce.repo
77
+ if [ "$engine_present" = true ]; then
78
+ dnf install -y docker-compose-plugin >&2
79
+ else
80
+ dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >&2
81
+ fi
82
+ fi
83
+ [ "$engine_present" = true ] || systemctl enable --now docker >&2
84
+ docker version >/dev/null
85
+ docker compose version >/dev/null
@@ -0,0 +1,8 @@
1
+ set -eu
2
+ mkdir -p /etc/tug /var/lib/tug
3
+ cat > /etc/tug/agent.env <<'ENV'
4
+ {{ENV}}ENV
5
+ chmod 0600 /etc/tug/agent.env
6
+ {{DOCKER_LOGIN}}
7
+ docker rm -f tug-agent >/dev/null 2>&1 || true
8
+ 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
@@ -0,0 +1,91 @@
1
+ import type { Request, RequestHandler } from 'express';
2
+ import type { Db } from './db.ts';
3
+ /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
4
+ export declare const ROLES: readonly ["read", "deploy", "admin"];
5
+ export type Role = typeof ROLES[number];
6
+ export type RouteRole = Role | 'public';
7
+ /** Name recorded in the audit trail for the root operator token. */
8
+ export declare const OPERATOR_ACTOR = "operator";
9
+ /** Reserved actor for rollbacks control starts on its own. Token names cannot contain a colon, so no token can use it. */
10
+ export declare const AUTO_ROLLBACK_ACTOR = "system:auto-rollback";
11
+ export declare const TOKEN_PREFIX = "tug_tok";
12
+ export declare const TOKEN_NAME_PATTERN: RegExp;
13
+ export declare const PROJECT_NAME_PATTERN: RegExp;
14
+ /**
15
+ * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
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.
18
+ */
19
+ export declare const ROUTE_ROLES: Record<string, RouteRole>;
20
+ /** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
21
+ export interface Actor {
22
+ name: string;
23
+ role: Role;
24
+ projects: string[] | null;
25
+ }
26
+ export interface TokenRow {
27
+ id: string;
28
+ name: string;
29
+ token_hash: string;
30
+ role: Role;
31
+ projects_json: string | null;
32
+ created_at: string;
33
+ created_by: string;
34
+ last_used_at: string | null;
35
+ expires_at: string | null;
36
+ revoked_at: string | null;
37
+ }
38
+ export interface TokenInfo {
39
+ name: string;
40
+ role: Role;
41
+ projects: string[] | null;
42
+ createdAt: string;
43
+ createdBy: string;
44
+ lastUsedAt: string | null;
45
+ expiresAt: string | null;
46
+ revokedAt: string | null;
47
+ }
48
+ declare module 'express-serve-static-core' {
49
+ interface Request {
50
+ actor?: Actor;
51
+ }
52
+ }
53
+ export declare const isRole: (value: unknown) => value is Role;
54
+ export declare const hasRole: (actor: Actor, required: Role) => boolean;
55
+ /** Validates a token request body, throwing a 400 error describing the first problem. */
56
+ export declare function parseTokenRequest(body: unknown): {
57
+ name: string;
58
+ role: Role;
59
+ projects: string[] | null;
60
+ expiresAt: string | null;
61
+ };
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 & {
64
+ token: string;
65
+ }>;
66
+ export declare function listTokens(db: Db): Promise<TokenInfo[]>;
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>;
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>;
71
+ /**
72
+ * Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
73
+ * resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
74
+ * cannot probe which IDs or projects exist.
75
+ */
76
+ export type ProjectResolver = (req: Request) => string | null | undefined | Promise<string | null | undefined>;
77
+ export declare const bodyProject: ProjectResolver;
78
+ export declare const queryProject: ProjectResolver;
79
+ /** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
80
+ export declare function assertProject(actor: Actor, project: string): void;
81
+ /** Builds the request guard: authenticate (401), check the role (403), then check project scope (403). */
82
+ export declare function createGuard(db: Db): (required: Role, resolve?: ProjectResolver) => RequestHandler;
83
+ /** Appends a control-originated audit event naming the acting token. */
84
+ export declare function recordEvent(db: Db, event: {
85
+ actor: string;
86
+ type: string;
87
+ message: string;
88
+ project?: string | null;
89
+ deploymentId?: string | null;
90
+ metadata?: Record<string, unknown>;
91
+ }): Promise<void>;