@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
package/dist/compose.d.ts CHANGED
@@ -1,13 +1,39 @@
1
1
  import type { ComposeFile, ComposeService, DesiredState, ServiceEndpoint } from './protocol.ts';
2
- export declare function readCompose(file?: string): Promise<ComposeFile>;
3
2
  export declare function composeHash(compose: ComposeFile): string;
3
+ type PortEntry = NonNullable<ComposeService['ports']>[number];
4
4
  /**
5
5
  * The host port a service publishes for `port`, matching either side of a `ports` entry (a
6
6
  * container port resolves to its published host port). Null when the port is not published.
7
7
  */
8
8
  export declare function publishedPort(svc: ComposeService, port: number): number | null;
9
+ /** True when a `ports` entry names its host IP: `127.0.0.1:8080:80`, `[::1]:8080:80`, `0.0.0.0::80` or long-syntax `host_ip`. */
10
+ export declare function hasHostIp(entry: PortEntry): boolean;
11
+ /**
12
+ * Binds a `ports` entry that names no host IP to `ip`, keeping ranges and the protocol suffix
13
+ * (`8080:80/udp` becomes `10.0.0.2:8080:80/udp`, a container-only `80` becomes `10.0.0.2::80`).
14
+ * Entries that already name a host IP, including `0.0.0.0`, are returned as written.
15
+ */
16
+ export declare function bindPort(entry: PortEntry, ip: string): PortEntry;
17
+ /** `bindPort` for every entry; entries publishing `loopbackPort` bind to `127.0.0.1` instead of `ip`. */
18
+ export declare function bindPorts(ports: PortEntry[], ip: string, loopbackPort?: number): PortEntry[];
19
+ /**
20
+ * Services of `state` whose ingress port only the managed Caddy on `node` dials, so it can listen on
21
+ * `127.0.0.1`: `node` is the only ingress node, the service runs there, and every service of the
22
+ * project runs only on nodes where this one runs too, so no node reaches it through a service name.
23
+ * A service with no placement nodes runs on every node in `nodes`.
24
+ */
25
+ export declare function loopbackIngressServices(state: DesiredState, node: string, ingressNodes: readonly string[], nodes: readonly string[]): string[];
26
+ /** Where `composeForNode` binds published ports that name no host IP. */
27
+ export interface PortBind {
28
+ address: string;
29
+ loopback?: ReadonlySet<string>;
30
+ }
31
+ /** Services among `names` with at least one `ports` entry that `bind` puts on its address (not on loopback, not written by the operator). */
32
+ export declare function addressBoundServices(compose: ComposeFile, names: Iterable<string>, loopback?: ReadonlySet<string>): string[];
9
33
  /** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
10
34
  export declare function isRolling(svc: ComposeService | undefined): boolean;
35
+ /** Service keys the agent can create a container from; anything else is rejected with its name. */
36
+ export declare const SUPPORTED_SERVICE_KEYS: readonly string[];
11
37
  export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
12
38
  export interface BuildDesiredStateOptions {
13
39
  project: string;
@@ -16,18 +42,18 @@ export interface BuildDesiredStateOptions {
16
42
  registry: string;
17
43
  revision?: string;
18
44
  defaultNode?: string | null;
45
+ nodes?: Array<{
46
+ name: string;
47
+ labels?: string[];
48
+ }>;
19
49
  }
20
- export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode }: BuildDesiredStateOptions): DesiredState;
50
+ export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
21
51
  /** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
22
52
  export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
23
- /** Removes `depends_on` references to `names` (services that live in another Compose project). */
24
- export declare function dropDependsOn(svc: ComposeService, names: ReadonlySet<string>): void;
25
53
  /**
26
- * The Compose file for one node. `exclude` names services placed here that are managed outside the
27
- * node's main project (rolling services run in their own revision projects); they still count as
28
- * local, so they get no cross-node `extra_hosts` entry, and `depends_on` references to them are dropped.
54
+ * Control secrets a service uses: the sentinels in its definition. Their versions decide whether the
55
+ * service is recreated, so a secret change touches exactly the services that reference it.
29
56
  */
30
- export declare function composeForNode(desiredState: DesiredState, nodeName: string, secrets?: Record<string, string>, endpoints?: Record<string, ServiceEndpoint[]>, exclude?: ReadonlySet<string>): ComposeFile;
31
57
  export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
32
58
  export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
33
- export declare function dumpYaml(obj: unknown): string;
59
+ export {};
package/dist/compose.js CHANGED
@@ -1,35 +1,102 @@
1
- import fs from 'node:fs/promises';
2
- import yaml from 'js-yaml';
3
1
  import crypto from 'node:crypto';
4
2
  import { isDuration } from "./util.js";
5
- export async function readCompose(file = 'docker-compose.yml') {
6
- return yaml.load(await fs.readFile(file, 'utf8'));
7
- }
3
+ import { sentinelNames } from "./normalize.js";
8
4
  export function composeHash(compose) {
9
5
  return crypto.createHash('sha1').update(JSON.stringify(compose)).digest('hex').slice(0, 12);
10
6
  }
7
+ /** The host port one `ports` entry publishes for `port` (either side of the mapping), or null. */
8
+ function entryPublishedPort(entry, port) {
9
+ if (typeof entry === 'number')
10
+ return null;
11
+ if (typeof entry === 'object') {
12
+ const published = Number(entry.published);
13
+ return (Number(entry.target) === port || published === port) && published > 0
14
+ ? published
15
+ : null;
16
+ }
17
+ const spec = String(entry).split('/')[0].split(':');
18
+ if (spec.length < 2)
19
+ return null;
20
+ const container = Number(spec[spec.length - 1]);
21
+ const host = Number(spec[spec.length - 2]);
22
+ return (container === port || host === port) && host > 0 ? host : null;
23
+ }
11
24
  /**
12
25
  * The host port a service publishes for `port`, matching either side of a `ports` entry (a
13
26
  * container port resolves to its published host port). Null when the port is not published.
14
27
  */
15
28
  export function publishedPort(svc, port) {
16
29
  for (const entry of svc.ports || []) {
17
- if (typeof entry === 'object') {
18
- const published = Number(entry.published);
19
- if ((Number(entry.target) === port || published === port) && published > 0)
20
- return published;
21
- continue;
22
- }
23
- const spec = String(entry).split('/')[0].split(':');
24
- if (spec.length < 2)
25
- continue;
26
- const container = Number(spec[spec.length - 1]);
27
- const host = Number(spec[spec.length - 2]);
28
- if ((container === port || host === port) && host > 0)
29
- return host;
30
+ const published = entryPublishedPort(entry, port);
31
+ if (published != null)
32
+ return published;
30
33
  }
31
34
  return null;
32
35
  }
36
+ /** True when a `ports` entry names its host IP: `127.0.0.1:8080:80`, `[::1]:8080:80`, `0.0.0.0::80` or long-syntax `host_ip`. */
37
+ export function hasHostIp(entry) {
38
+ if (typeof entry === 'number')
39
+ return false;
40
+ if (typeof entry === 'object')
41
+ return entry.host_ip !== undefined && entry.host_ip !== null && entry.host_ip !== '';
42
+ const spec = String(entry).split('/')[0];
43
+ return spec.startsWith('[') || spec.split(':').length > 2;
44
+ }
45
+ /**
46
+ * Binds a `ports` entry that names no host IP to `ip`, keeping ranges and the protocol suffix
47
+ * (`8080:80/udp` becomes `10.0.0.2:8080:80/udp`, a container-only `80` becomes `10.0.0.2::80`).
48
+ * Entries that already name a host IP, including `0.0.0.0`, are returned as written.
49
+ */
50
+ export function bindPort(entry, ip) {
51
+ if (hasHostIp(entry))
52
+ return entry;
53
+ if (typeof entry === 'object')
54
+ return { ...entry, host_ip: ip };
55
+ const host = ip.includes(':') ? `[${ip}]` : ip;
56
+ const [spec, ...protocol] = String(entry).split('/');
57
+ const bound = spec.includes(':') ? `${host}:${spec}` : `${host}::${spec}`;
58
+ return [bound, ...protocol].join('/');
59
+ }
60
+ /** `bindPort` for every entry; entries publishing `loopbackPort` bind to `127.0.0.1` instead of `ip`. */
61
+ export function bindPorts(ports, ip, loopbackPort) {
62
+ return ports.map((entry) => bindPort(entry, loopbackPort !== undefined && entryPublishedPort(entry, loopbackPort) != null
63
+ ? '127.0.0.1'
64
+ : ip));
65
+ }
66
+ /**
67
+ * Services of `state` whose ingress port only the managed Caddy on `node` dials, so it can listen on
68
+ * `127.0.0.1`: `node` is the only ingress node, the service runs there, and every service of the
69
+ * project runs only on nodes where this one runs too, so no node reaches it through a service name.
70
+ * A service with no placement nodes runs on every node in `nodes`.
71
+ */
72
+ export function loopbackIngressServices(state, node, ingressNodes, nodes) {
73
+ if (ingressNodes.length !== 1 || ingressNodes[0] !== node)
74
+ return [];
75
+ const placed = (service) => {
76
+ const list = state.services[service]?.placement?.nodes || [];
77
+ return list.length ? list : nodes;
78
+ };
79
+ return Object.keys(state.services)
80
+ .filter((service) => {
81
+ if (!state.compose.services?.[service]?.['x-tug']?.ingress)
82
+ return false;
83
+ const here = new Set(placed(service));
84
+ return (here.has(node) &&
85
+ Object.keys(state.services).every((other) => placed(other).every((n) => here.has(n))));
86
+ })
87
+ .sort();
88
+ }
89
+ /** Services among `names` with at least one `ports` entry that `bind` puts on its address (not on loopback, not written by the operator). */
90
+ export function addressBoundServices(compose, names, loopback = new Set()) {
91
+ return [...names]
92
+ .filter((name) => {
93
+ const svc = compose.services[name];
94
+ const ingressPort = loopback.has(name) ? svc?.['x-tug']?.ingress?.port : undefined;
95
+ return (svc?.ports || []).some((entry) => !hasHostIp(entry) &&
96
+ (ingressPort === undefined || entryPublishedPort(entry, ingressPort) == null));
97
+ })
98
+ .sort();
99
+ }
33
100
  const HOSTNAME_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
34
101
  function validateIngress(name, svc, seen, errors) {
35
102
  const ingress = svc['x-tug']?.ingress;
@@ -58,7 +125,9 @@ function validateIngress(name, svc, seen, errors) {
58
125
  errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
59
126
  }
60
127
  /** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
61
- export function isRolling(svc) { return svc?.['x-tug']?.rollout?.strategy === 'rolling'; }
128
+ export function isRolling(svc) {
129
+ return svc?.['x-tug']?.rollout?.strategy === 'rolling';
130
+ }
62
131
  function validateRollout(name, svc, errors) {
63
132
  const rollout = svc['x-tug']?.rollout;
64
133
  if (rollout === undefined)
@@ -102,36 +171,168 @@ function validateProjectExtension(compose, errors) {
102
171
  if (ext.rollback !== undefined && !['auto', 'manual'].includes(ext.rollback))
103
172
  errors.push('x-tug.rollback must be auto or manual');
104
173
  }
174
+ /** Service keys the agent can create a container from; anything else is rejected with its name. */
175
+ export const SUPPORTED_SERVICE_KEYS = [
176
+ 'image',
177
+ 'build',
178
+ 'command',
179
+ 'entrypoint',
180
+ 'environment',
181
+ 'ports',
182
+ 'expose',
183
+ 'volumes',
184
+ 'tmpfs',
185
+ 'healthcheck',
186
+ 'restart',
187
+ 'labels',
188
+ 'depends_on',
189
+ 'extra_hosts',
190
+ 'user',
191
+ 'working_dir',
192
+ 'hostname',
193
+ 'init',
194
+ 'read_only',
195
+ 'shm_size',
196
+ 'mem_limit',
197
+ 'mem_reservation',
198
+ 'cpus',
199
+ 'pids_limit',
200
+ 'cap_add',
201
+ 'cap_drop',
202
+ 'ulimits',
203
+ 'logging',
204
+ 'stop_grace_period',
205
+ 'stop_signal',
206
+ 'x-tug',
207
+ ];
208
+ /** Keys Compose adds or that Tug ignores on purpose: the default network, and `pull_policy` because Tug decides when images change. */
209
+ const IGNORED_SERVICE_KEYS = ['networks', 'pull_policy'];
210
+ const SUPPORTED_TOP_LEVEL = ['services', 'volumes', 'networks', 'name', 'x-tug'];
211
+ const SUPPORTED_VOLUME_KEYS = ['name', 'driver', 'driver_opts', 'labels', 'external', 'x-tug'];
212
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
213
+ const isExtension = (key) => key.startsWith('x-');
214
+ function validateSubset(name, svc, compose, errors) {
215
+ for (const key of Object.keys(svc)) {
216
+ if (SUPPORTED_SERVICE_KEYS.includes(key) || IGNORED_SERVICE_KEYS.includes(key))
217
+ continue;
218
+ if (isExtension(key))
219
+ continue;
220
+ errors.push(key === 'deploy'
221
+ ? `${name}: deploy (swarm) is not supported; use x-tug placement`
222
+ : `${name}: ${key} is not supported`);
223
+ }
224
+ if (svc.networks !== undefined && svc.networks !== null) {
225
+ const nets = svc.networks;
226
+ const names = Array.isArray(nets) ? nets.map(String) : isObject(nets) ? Object.keys(nets) : [];
227
+ if (names.some((n) => n !== 'default'))
228
+ errors.push(`${name}: networks other than the default network are not supported`);
229
+ }
230
+ if (!svc.image && !svc.build)
231
+ errors.push(`${name}: image or build is required`);
232
+ for (const mount of svc.volumes || []) {
233
+ if (typeof mount === 'string') {
234
+ errors.push(`${name}: volumes must be normalized by docker compose config (got "${mount}")`);
235
+ continue;
236
+ }
237
+ const type = String(mount.type ?? '');
238
+ if (type === 'bind') {
239
+ if (!String(mount.source ?? '').startsWith('/'))
240
+ errors.push(`${name}: bind mount source ${JSON.stringify(mount.source)} must be an absolute path on the node`);
241
+ }
242
+ else if (type === 'volume') {
243
+ const source = String(mount.source ?? '');
244
+ if (!source)
245
+ errors.push(`${name}: anonymous volumes are not supported; name the volume`);
246
+ else if (!compose.volumes?.[source])
247
+ errors.push(`${name}: volume ${source} is not defined in top-level volumes`);
248
+ }
249
+ else if (type !== 'tmpfs')
250
+ errors.push(`${name}: volume type ${JSON.stringify(type)} is not supported`);
251
+ }
252
+ const deps = svc.depends_on;
253
+ if (isObject(deps))
254
+ for (const [dep, spec] of Object.entries(deps)) {
255
+ const condition = isObject(spec) ? String(spec.condition ?? 'service_started') : '';
256
+ if (!compose.services[dep])
257
+ errors.push(`${name}: depends_on ${dep} is not a service`);
258
+ else if (!['service_started', 'service_healthy'].includes(condition))
259
+ errors.push(`${name}: depends_on condition ${JSON.stringify(condition)} is not supported`);
260
+ }
261
+ }
262
+ function validateTopLevel(compose, errors) {
263
+ for (const key of Object.keys(compose))
264
+ if (!SUPPORTED_TOP_LEVEL.includes(key) && !isExtension(key))
265
+ errors.push(`${key} is not supported at the top level of the Compose file`);
266
+ const networks = compose.networks;
267
+ if (isObject(networks)) {
268
+ for (const [key, def] of Object.entries(networks))
269
+ if (key !== 'default' || (isObject(def) && def.external))
270
+ errors.push(`networks.${key}: custom networks are not supported`);
271
+ }
272
+ for (const [key, def] of Object.entries(compose.volumes || {})) {
273
+ if (!isObject(def))
274
+ continue;
275
+ for (const field of Object.keys(def))
276
+ if (!SUPPORTED_VOLUME_KEYS.includes(field) && !isExtension(field))
277
+ errors.push(`volumes.${key}: ${field} is not supported`);
278
+ }
279
+ }
105
280
  export function validateCompose(compose, nodes = []) {
106
281
  if (!compose?.services || typeof compose.services !== 'object')
107
282
  throw new Error('compose file must define services');
108
283
  const errors = [];
109
284
  const ingressHosts = new Map();
110
285
  for (const [name, svc] of Object.entries(compose.services)) {
111
- if (svc.container_name)
112
- errors.push(`${name}: container_name is not supported`);
113
- if (svc.deploy)
114
- errors.push(`${name}: swarm deploy semantics are not supported`);
286
+ validateSubset(name, svc, compose, errors);
115
287
  const lp = svc['x-tug'] || {};
116
- const selected = lp.placement?.nodes || [];
117
- if (nodes.length > 1 && selected.length === 0)
118
- errors.push(`${name}: multi-node deployments require x-tug.placement.nodes`);
119
- if (lp.replicas && selected.length && lp.replicas !== selected.length)
288
+ const placement = lp.placement || {};
289
+ const selected = placement.nodes || [];
290
+ const hasLabels = Array.isArray(placement.labels) && placement.labels.length > 0;
291
+ const isGlobal = lp.global === true || placement.global === true;
292
+ if (nodes.length > 1 && selected.length === 0 && !hasLabels && !isGlobal)
293
+ errors.push(`${name}: multi-node deployments require x-tug.placement.nodes or placement.labels`);
294
+ if (lp.replicas &&
295
+ selected.length &&
296
+ !hasLabels &&
297
+ !isGlobal &&
298
+ lp.replicas !== selected.length)
120
299
  errors.push(`${name}: replicas must match placement node count in v1`);
121
300
  validateIngress(name, svc, ingressHosts, errors);
122
301
  validateRollout(name, svc, errors);
123
302
  }
303
+ validateTopLevel(compose, errors);
124
304
  validateProjectExtension(compose, errors);
125
305
  if (errors.length)
126
306
  throw new Error(errors.join('; '));
127
307
  }
128
- export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null }) {
308
+ export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null, nodes, }) {
129
309
  const services = {};
130
310
  for (const [name, src] of Object.entries(compose.services || {})) {
131
311
  const lp = src['x-tug'] || {};
132
- const placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
133
- const image = src.build ? `${registry ? `${registry}/` : "tug-build/"}${project}/${name}:${revision || version}` : src.image;
134
- services[name] = { image, build: src.build || null, placement, replicas: lp.replicas || placement.nodes?.length || 1, health: lp.health || null };
312
+ let placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
313
+ const global = lp.global === true || placement.global === true;
314
+ const labels = placement.labels || [];
315
+ if (nodes && (!placement.nodes || !placement.nodes.length) && (labels.length || global)) {
316
+ const matched = nodes
317
+ .filter((node) => labels.every((label) => (node.labels || []).includes(label)))
318
+ .sort((a, b) => a.name.localeCompare(b.name));
319
+ let selected = matched;
320
+ if (!global) {
321
+ const replicas = lp.replicas || matched.length;
322
+ selected = matched.slice(0, Math.min(replicas, matched.length));
323
+ }
324
+ placement = { ...placement, nodes: selected.map((node) => node.name) };
325
+ }
326
+ const image = src.build
327
+ ? `${registry ? `${registry}/` : 'tug-build/'}${project}/${name}:${revision || version}`
328
+ : src.image;
329
+ services[name] = {
330
+ image,
331
+ build: src.build || null,
332
+ placement,
333
+ replicas: lp.replicas || placement.nodes?.length || 1,
334
+ health: lp.health || null,
335
+ };
135
336
  }
136
337
  return { project, version, ...(revision ? { revision } : {}), services, compose };
137
338
  }
@@ -146,115 +347,22 @@ export function crossNodeHosts(project, endpoints, local) {
146
347
  }
147
348
  return hosts;
148
349
  }
149
- /** Removes `depends_on` references to `names` (services that live in another Compose project). */
150
- export function dropDependsOn(svc, names) {
151
- const deps = svc.depends_on;
152
- if (Array.isArray(deps)) {
153
- const kept = deps.filter(d => !names.has(String(d)));
154
- if (kept.length)
155
- svc.depends_on = kept;
156
- else
157
- delete svc.depends_on;
158
- }
159
- else if (deps && typeof deps === 'object') {
160
- const kept = Object.fromEntries(Object.entries(deps).filter(([dep]) => !names.has(dep)));
161
- if (Object.keys(kept).length)
162
- svc.depends_on = kept;
163
- else
164
- delete svc.depends_on;
165
- }
166
- }
167
- function extraHostsList(value) {
168
- if (!value)
169
- return [];
170
- return Array.isArray(value) ? value.map(String) : Object.entries(value).map(([k, v]) => `${k}:${v}`);
171
- }
172
350
  /**
173
- * The Compose file for one node. `exclude` names services placed here that are managed outside the
174
- * node's main project (rolling services run in their own revision projects); they still count as
175
- * local, so they get no cross-node `extra_hosts` entry, and `depends_on` references to them are dropped.
351
+ * Control secrets a service uses: the sentinels in its definition. Their versions decide whether the
352
+ * service is recreated, so a secret change touches exactly the services that reference it.
176
353
  */
177
- export function composeForNode(desiredState, nodeName, secrets = {}, endpoints = {}, exclude = new Set()) {
178
- const out = { services: {}, volumes: {} };
179
- const outVolumes = out.volumes;
180
- const usedVolumes = new Set();
181
- const placed = new Set();
182
- for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
183
- const nodes = desiredState.services[name].placement?.nodes || [];
184
- if (nodes.length && !nodes.includes(nodeName))
185
- continue;
186
- placed.add(name);
187
- if (exclude.has(name))
188
- continue;
189
- const svc = { ...src, image: desiredState.services[name].image };
190
- delete svc.build;
191
- delete svc['x-tug'];
192
- delete svc.deploy;
193
- delete svc.container_name;
194
- if (desiredState.services[name].artifact)
195
- svc.pull_policy = 'never';
196
- if (Object.keys(secrets).length)
197
- svc.env_file = Array.from(new Set([...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []), '.env']));
198
- for (const vol of svc.volumes || []) {
199
- const source = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
200
- if (source && desiredState.compose?.volumes?.[source])
201
- usedVolumes.add(source);
202
- }
203
- if (exclude.size)
204
- dropDependsOn(svc, exclude);
205
- out.services[name] = svc;
206
- }
207
- const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
208
- if (hosts.length) {
209
- for (const svc of Object.values(out.services)) {
210
- const existing = extraHostsList(svc.extra_hosts);
211
- const taken = new Set(existing.map(h => h.split(':')[0]));
212
- svc.extra_hosts = [...existing, ...hosts.filter(h => !taken.has(h.split(':')[0]))];
213
- }
214
- }
215
- for (const name of usedVolumes) {
216
- const vol = desiredState.compose.volumes?.[name] || {};
217
- const nodes = vol['x-tug']?.placement?.nodes || [];
218
- if (!nodes.length || nodes.includes(nodeName))
219
- outVolumes[name] = vol;
220
- }
221
- if (!Object.keys(outVolumes).length)
222
- delete out.volumes;
223
- return out;
224
- }
225
354
  export function serviceSecretNames(compose, serviceNames) {
226
- const allowed = new Set();
227
- for (const serviceName of serviceNames) {
228
- const env = compose?.services?.[serviceName]?.environment || {};
229
- if (Array.isArray(env)) {
230
- for (const entry of env) {
231
- const [key, value = ''] = String(entry).split('=');
232
- if (key)
233
- allowed.add(key);
234
- for (const match of String(value).matchAll(/\$\{?([A-Z_][A-Z0-9_]*)[^}]*\}?/g))
235
- allowed.add(match[1]);
236
- }
237
- }
238
- else {
239
- for (const [key, value] of Object.entries(env)) {
240
- if (typeof value === 'string' && value.match(/^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?$/)) {
241
- const m = value.match(/^\$\{?([A-Z_][A-Z0-9_]*)/);
242
- if (m)
243
- allowed.add(m[1]);
244
- }
245
- else
246
- allowed.add(key);
247
- }
248
- }
249
- }
250
- return allowed;
355
+ const names = new Set();
356
+ for (const service of serviceNames)
357
+ for (const name of sentinelNames(compose?.services?.[service]))
358
+ names.add(name);
359
+ return names;
251
360
  }
252
361
  export function serviceSecretVersions(compose, serviceNames, versions = {}) {
253
362
  const out = {};
254
363
  for (const service of serviceNames) {
255
364
  const names = serviceSecretNames(compose, [service]);
256
- out[service] = Object.fromEntries([...names].filter(name => versions[name] != null).map(name => [name, versions[name]]));
365
+ out[service] = Object.fromEntries([...names].filter((name) => versions[name] != null).map((name) => [name, versions[name]]));
257
366
  }
258
367
  return out;
259
368
  }
260
- export function dumpYaml(obj) { return yaml.dump(obj, { noRefs: true, lineWidth: 120 }); }
@@ -0,0 +1,56 @@
1
+ import type { CreateContainerBody } from './docker-api.ts';
2
+ import type { DesiredState, ServiceEndpoint } from './protocol.ts';
3
+ import type { PortBind } from './compose.ts';
4
+ export declare const LABEL_PROJECT = "tug.project";
5
+ export declare const LABEL_SERVICE = "tug.service";
6
+ export declare const LABEL_REVISION = "tug.revision";
7
+ export declare const LABEL_HASH = "tug.config-hash";
8
+ /** The project network every container of the project joins; service names are its aliases. */
9
+ export declare const networkName: (project: string) => string;
10
+ /** Docker volume name for a Compose volume; `external` volumes keep their own name. */
11
+ export declare const volumeName: (project: string, volume: string) => string;
12
+ export declare const containerName: (project: string, service: string, hash: string) => string;
13
+ export interface VolumeSpec {
14
+ /** Docker volume name. */
15
+ name: string;
16
+ driver: string;
17
+ opts: Record<string, string>;
18
+ labels: Record<string, string>;
19
+ /** Not created by Tug (`external: true`); it must already exist. */
20
+ external: boolean;
21
+ }
22
+ export interface ContainerSpec {
23
+ service: string;
24
+ name: string;
25
+ /** Identity of the definition: the body, the image identity and the versions of the secrets it uses. */
26
+ hash: string;
27
+ /** Create body with secrets substituted. Never log it. */
28
+ body: CreateContainerBody;
29
+ /** Services on this node that must be started (and healthy, for `service_healthy`) first. */
30
+ dependsOn: Record<string, 'service_started' | 'service_healthy'>;
31
+ }
32
+ export interface NodePlan {
33
+ network: string;
34
+ volumes: VolumeSpec[];
35
+ /** Keyed by service, only the services placed on this node. */
36
+ specs: Record<string, ContainerSpec>;
37
+ }
38
+ export interface ContainerSpecOptions {
39
+ /** Released secret values by name, substituted for sentinels. */
40
+ secrets?: Record<string, string>;
41
+ /** Secret versions by name; they are part of the hash instead of the values. */
42
+ secretVersions?: Record<string, number>;
43
+ endpoints?: Record<string, ServiceEndpoint[]>;
44
+ nodeAddresses?: Record<string, string>;
45
+ /** Host IP for published ports that name none; without it `ports` stay as written. */
46
+ bind?: PortBind;
47
+ }
48
+ /** Compose durations (`30s`, `1m30s`, `500ms`) or a number of seconds, in milliseconds. */
49
+ export declare function composeDurationMs(value: unknown): number | undefined;
50
+ /** Compose sizes: bytes as a number, or `64m` / `1g` style strings. */
51
+ export declare function sizeBytes(value: unknown): number | undefined;
52
+ export declare function containerSpecs(desired: DesiredState, node: string, { secrets, secretVersions, endpoints, nodeAddresses, bind, }?: ContainerSpecOptions): NodePlan;
53
+ /** Start order: dependencies first, ties by service name; a cycle is an error. */
54
+ export declare function startOrder(specs: Record<string, ContainerSpec>): string[];
55
+ /** `body` with the host side of its (single) published port replaced; used for rolling revisions. */
56
+ export declare function withHostPort(body: CreateContainerBody, port: number): CreateContainerBody;