@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.
- package/README.md +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
- package/dist/rollout.d.ts +0 -72
- 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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
*
|
|
174
|
-
*
|
|
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
|
|
227
|
-
for (const
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
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;
|