@wpelevator/tug 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/agent.d.ts +9 -12
- package/dist/agent.js +232 -302
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +2 -2
- package/dist/cli-build.js +3 -4
- package/dist/cli.js +26 -41
- package/dist/compose.d.ts +7 -21
- package/dist/compose.js +120 -150
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +4 -2
- package/dist/control.js +350 -365
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +9 -6
- package/dist/db.js +40 -26
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +428 -0
- package/dist/ingress.d.ts +6 -6
- package/dist/ingress.js +56 -42
- package/dist/live.d.ts +10 -7
- package/dist/live.js +130 -194
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +5 -5
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/install-docker.sh +12 -7
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +24 -21
- package/package.json +1 -3
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -385
package/dist/build.d.ts
CHANGED
|
@@ -12,11 +12,10 @@ export interface BuildSpec {
|
|
|
12
12
|
}
|
|
13
13
|
export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult>;
|
|
14
14
|
export declare function unpackContext(tarball: string, dest: string): Promise<void>;
|
|
15
|
-
export interface
|
|
15
|
+
export interface BuildImageOptions {
|
|
16
16
|
contextDir: string;
|
|
17
17
|
service: string;
|
|
18
18
|
buildSpec: string | BuildSpec;
|
|
19
19
|
imageRef: string;
|
|
20
|
-
push?: boolean;
|
|
21
20
|
}
|
|
22
|
-
export declare function
|
|
21
|
+
export declare function buildImage({ contextDir, service, buildSpec, imageRef, }: BuildImageOptions): Promise<void>;
|
package/dist/build.js
CHANGED
|
@@ -33,7 +33,7 @@ export async function unpackContext(tarball, dest) {
|
|
|
33
33
|
});
|
|
34
34
|
await tar.x({ file: tarball, cwd: dest, strict: true });
|
|
35
35
|
}
|
|
36
|
-
export async function
|
|
36
|
+
export async function buildImage({ contextDir, service, buildSpec, imageRef, }) {
|
|
37
37
|
const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
|
|
38
38
|
const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile
|
|
39
39
|
? path.resolve(contextDir, buildPath, buildSpec.dockerfile)
|
|
@@ -48,6 +48,4 @@ export async function buildAndPush({ contextDir, service, buildSpec, imageRef, p
|
|
|
48
48
|
if (!(await exists(fullContext)))
|
|
49
49
|
throw new Error(`build context not found for ${service}: ${buildPath}`);
|
|
50
50
|
await run('docker', ['build', ...dockerfile, '-t', imageRef, fullContext]);
|
|
51
|
-
if (push)
|
|
52
|
-
await run('docker', ['push', imageRef]);
|
|
53
51
|
}
|
package/dist/cli-build.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildImage } from './build.ts';
|
|
2
2
|
import { saveImageArchive } from './artifacts.ts';
|
|
3
3
|
import type { ComposeFile, LocalImageUpload } from './protocol.ts';
|
|
4
4
|
export interface LocalBuildDependencies {
|
|
5
|
-
build: typeof
|
|
5
|
+
build: typeof buildImage;
|
|
6
6
|
save: typeof saveImageArchive;
|
|
7
7
|
nonce: () => string;
|
|
8
8
|
remove: (imageRef: string) => Promise<void>;
|
package/dist/cli-build.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import * as tar from 'tar';
|
|
5
|
-
import {
|
|
5
|
+
import { buildImage, run } from "./build.js";
|
|
6
6
|
import { saveImageArchive } from "./artifacts.js";
|
|
7
7
|
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
|
|
8
8
|
export function sourceArchiveFilter(entry, configPaths, ignores) {
|
|
@@ -18,7 +18,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
|
18
18
|
const configPaths = configFiles
|
|
19
19
|
.map((file) => path.relative(contextDir, file).split(path.sep).join('/'))
|
|
20
20
|
.filter((file) => file !== '..' && !file.startsWith('../') && !path.isAbsolute(file));
|
|
21
|
-
const ignores = ['node_modules', 'data'
|
|
21
|
+
const ignores = ['node_modules', 'data'];
|
|
22
22
|
try {
|
|
23
23
|
ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
|
|
24
24
|
.split(/\r?\n/)
|
|
@@ -38,7 +38,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
40
40
|
export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
|
|
41
|
-
build:
|
|
41
|
+
build: buildImage,
|
|
42
42
|
save: saveImageArchive,
|
|
43
43
|
nonce: randomUUID,
|
|
44
44
|
remove: async (imageRef) => {
|
|
@@ -58,7 +58,6 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
|
|
|
58
58
|
service,
|
|
59
59
|
buildSpec: spec.build,
|
|
60
60
|
imageRef,
|
|
61
|
-
push: false,
|
|
62
61
|
});
|
|
63
62
|
const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
|
|
64
63
|
images.push({ service, imageRef, ...archive, field });
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,8 @@ import fs from 'node:fs/promises';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import {
|
|
13
|
+
import { composeHash } from "./compose.js";
|
|
14
|
+
import { normalizeCompose } from "./normalize.js";
|
|
14
15
|
import { errMessage, token, loadTemplate } from "./util.js";
|
|
15
16
|
import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
|
|
16
17
|
import { packageVersion, resolveImage } from "./image.js";
|
|
@@ -74,14 +75,22 @@ function assertHttps(url, allowHttp) {
|
|
|
74
75
|
if (!allowHttp && !String(url).startsWith('https://'))
|
|
75
76
|
throw new Error(`${url} must use https:// outside local development (pass --http-only-dev to override)`);
|
|
76
77
|
}
|
|
77
|
-
function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, } = {}) {
|
|
78
|
+
function preflightRemote(target, { controlUrl, ports = [], udpPorts = [], minDiskMb = 1024, mesh = false, compose = false, } = {}) {
|
|
78
79
|
const checks = [
|
|
79
80
|
[
|
|
80
81
|
'docker.version',
|
|
81
82
|
'docker version >/dev/null',
|
|
82
83
|
'Install Docker Engine and ensure the SSH user can access it.',
|
|
83
84
|
],
|
|
84
|
-
|
|
85
|
+
...(compose
|
|
86
|
+
? [
|
|
87
|
+
[
|
|
88
|
+
'docker.compose.version',
|
|
89
|
+
'docker compose version >/dev/null',
|
|
90
|
+
'Install Docker Compose v2.',
|
|
91
|
+
],
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
85
94
|
[
|
|
86
95
|
'paths',
|
|
87
96
|
'mkdir -p /opt/tug-preflight /var/lib/tug-preflight /etc/tug && test -w /opt/tug-preflight && test -w /var/lib/tug-preflight && test -w /etc/tug',
|
|
@@ -147,8 +156,13 @@ async function transferTugImage(target, imageTag) {
|
|
|
147
156
|
async function dockerBootstrap() {
|
|
148
157
|
return fs.readFile(path.join(templatesDir, 'install-docker.sh'), 'utf8');
|
|
149
158
|
}
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
160
|
+
async function bootstrapDocker(target, compose = false) {
|
|
161
|
+
runLocal('ssh', [
|
|
162
|
+
'--',
|
|
163
|
+
target,
|
|
164
|
+
`${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
|
|
165
|
+
]);
|
|
152
166
|
}
|
|
153
167
|
async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
154
168
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
@@ -187,12 +201,6 @@ program
|
|
|
187
201
|
});
|
|
188
202
|
console.log(JSON.stringify(r, null, 2));
|
|
189
203
|
});
|
|
190
|
-
program
|
|
191
|
-
.command('node-rotate-registry-token <name>')
|
|
192
|
-
.description('issue a new registry pull token for a node, invalidating the old one')
|
|
193
|
-
.action(async (name) => {
|
|
194
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
195
|
-
});
|
|
196
204
|
program
|
|
197
205
|
.command('node-rotate-wg-key <name>')
|
|
198
206
|
.description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
|
|
@@ -220,12 +228,6 @@ program
|
|
|
220
228
|
enabled: !o.disable,
|
|
221
229
|
}), null, 2));
|
|
222
230
|
});
|
|
223
|
-
program
|
|
224
|
-
.command('node-revoke-registry-token <name>')
|
|
225
|
-
.description("revoke a node's registry pull token, blocking future pulls until rotated")
|
|
226
|
-
.action(async (name) => {
|
|
227
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
228
|
-
});
|
|
229
231
|
program
|
|
230
232
|
.command('scale <service> <replicas>')
|
|
231
233
|
.description('scale a service to N replicas across matching nodes')
|
|
@@ -249,27 +251,20 @@ program
|
|
|
249
251
|
.option('--token <token>', 'operator token')
|
|
250
252
|
.option('--port <port>', 'published API port', '8080')
|
|
251
253
|
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
252
|
-
.option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
253
|
-
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
254
|
-
.option('--registry-container <name>', "docker container name to restart when a node's registry credentials change, if your registry uses an htpasswd file")
|
|
255
254
|
.action(async (sshTarget, o) => {
|
|
256
255
|
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
257
256
|
if (o.installDocker)
|
|
258
|
-
await bootstrapDocker(sshTarget);
|
|
259
|
-
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
|
|
257
|
+
await bootstrapDocker(sshTarget, true);
|
|
258
|
+
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
|
|
260
259
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
261
260
|
if (o.buildLocalImage)
|
|
262
261
|
await transferTugImage(sshTarget, image);
|
|
263
|
-
const registryHost = o.registry || '';
|
|
264
262
|
const yamlValue = (value) => JSON.stringify(value);
|
|
265
263
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
266
264
|
IMAGE: yamlValue(image),
|
|
267
265
|
OPERATOR_TOKEN: yamlValue(operatorToken),
|
|
268
|
-
REGISTRY_HOST: yamlValue(registryHost),
|
|
269
266
|
PORT: o.port,
|
|
270
267
|
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
271
|
-
REGISTRY_AUTH_PATH: yamlValue(o.registryAuthPath || ''),
|
|
272
|
-
REGISTRY_CONTAINER: yamlValue(o.registryContainer || ''),
|
|
273
268
|
});
|
|
274
269
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
275
270
|
COMPOSE: compose,
|
|
@@ -281,7 +276,6 @@ program
|
|
|
281
276
|
sshTarget,
|
|
282
277
|
operatorToken,
|
|
283
278
|
url: `http://${remoteHost(sshTarget)}:${o.port}`,
|
|
284
|
-
registry: registryHost,
|
|
285
279
|
}, null, 2));
|
|
286
280
|
});
|
|
287
281
|
program
|
|
@@ -292,7 +286,6 @@ program
|
|
|
292
286
|
.option('--public-address <addr>')
|
|
293
287
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
294
288
|
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
295
|
-
.option('--registry <host>', 'registry host for docker login')
|
|
296
289
|
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
297
290
|
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
298
291
|
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
@@ -324,17 +317,13 @@ program
|
|
|
324
317
|
NODE_NAME: o.name,
|
|
325
318
|
AGENT_TOKEN: registered.agentToken,
|
|
326
319
|
})) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
327
|
-
const dockerLogin = o.registry
|
|
328
|
-
? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin`
|
|
329
|
-
: 'true';
|
|
330
320
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
331
321
|
ENV: env,
|
|
332
|
-
DOCKER_LOGIN: dockerLogin,
|
|
333
322
|
IMAGE_SH: sh(image),
|
|
334
323
|
RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
|
|
335
324
|
});
|
|
336
325
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
337
|
-
console.log(JSON.stringify({ ok: true, node: registered.node
|
|
326
|
+
console.log(JSON.stringify({ ok: true, node: registered.node }, null, 2));
|
|
338
327
|
});
|
|
339
328
|
program
|
|
340
329
|
.command('node-enroll')
|
|
@@ -449,13 +438,14 @@ program
|
|
|
449
438
|
.option('-f, --file <file>', 'compose file', 'docker-compose.yml')
|
|
450
439
|
.option('--version <version>', 'display version (default: git-<Compose hash>)')
|
|
451
440
|
.option('--build-local', 'build application images locally and upload them without source')
|
|
452
|
-
.option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local')
|
|
453
441
|
.option('--no-wait', 'return after desired state is created')
|
|
454
442
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
455
443
|
.action(async (o) => {
|
|
456
444
|
const cfg = await loadCfg();
|
|
457
|
-
const compose = await readCompose(o.file);
|
|
458
445
|
const project = o.project || cfg.project || 'site';
|
|
446
|
+
// Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
|
|
447
|
+
const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
|
|
448
|
+
const compose = await normalizeCompose(o.file, secrets.map((secret) => secret.name), { project });
|
|
459
449
|
const version = o.version || `git-${composeHash(compose)}`;
|
|
460
450
|
let response;
|
|
461
451
|
if (o.buildLocal) {
|
|
@@ -472,8 +462,7 @@ program
|
|
|
472
462
|
await fs.rm(directory, { recursive: true, force: true });
|
|
473
463
|
}
|
|
474
464
|
}
|
|
475
|
-
else if (
|
|
476
|
-
Object.values(compose.services).some((service) => service.build)) {
|
|
465
|
+
else if (Object.values(compose.services).some((service) => service.build)) {
|
|
477
466
|
const tarball = await makeContext();
|
|
478
467
|
try {
|
|
479
468
|
const form = new FormData();
|
|
@@ -689,10 +678,6 @@ program.hook('preAction', async (_root, command) => {
|
|
|
689
678
|
if (options[key] !== undefined)
|
|
690
679
|
integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), min, max);
|
|
691
680
|
}
|
|
692
|
-
if (options.buildLocal && options.buildUpload === false)
|
|
693
|
-
throw new Error('--build-local and --no-build-upload cannot be combined');
|
|
694
|
-
if ((options.registryAuthPath || options.registryContainer) && !options.registry)
|
|
695
|
-
throw new Error('--registry-auth-path and --registry-container require --registry');
|
|
696
681
|
if (command.name() === 'control-restore' && !options.confirm)
|
|
697
682
|
throw new Error('control-restore requires --confirm');
|
|
698
683
|
const positional = command.name() === 'exec' ? command.args.slice(0, 1) : command.args;
|
package/dist/compose.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
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;
|
|
4
3
|
type PortEntry = NonNullable<ComposeService['ports']>[number];
|
|
5
4
|
/**
|
|
@@ -33,12 +32,13 @@ export interface PortBind {
|
|
|
33
32
|
export declare function addressBoundServices(compose: ComposeFile, names: Iterable<string>, loopback?: ReadonlySet<string>): string[];
|
|
34
33
|
/** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
|
|
35
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[];
|
|
36
37
|
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
37
38
|
export interface BuildDesiredStateOptions {
|
|
38
39
|
project: string;
|
|
39
40
|
version: string;
|
|
40
41
|
compose: ComposeFile;
|
|
41
|
-
registry: string;
|
|
42
42
|
revision?: string;
|
|
43
43
|
defaultNode?: string | null;
|
|
44
44
|
nodes?: Array<{
|
|
@@ -46,27 +46,13 @@ export interface BuildDesiredStateOptions {
|
|
|
46
46
|
labels?: string[];
|
|
47
47
|
}>;
|
|
48
48
|
}
|
|
49
|
-
export declare function buildDesiredState({ project, version, compose,
|
|
49
|
+
export declare function buildDesiredState({ project, version, compose, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
|
|
50
50
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
51
51
|
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
endpoints?: Record<string, ServiceEndpoint[]>;
|
|
57
|
-
/**
|
|
58
|
-
* Services placed here that are managed outside the node's main project (rolling services run in
|
|
59
|
-
* their own revision projects); they still count as local, so they get no cross-node `extra_hosts`
|
|
60
|
-
* entry, and `depends_on` references to them are dropped.
|
|
61
|
-
*/
|
|
62
|
-
exclude?: ReadonlySet<string>;
|
|
63
|
-
nodeAddresses?: Record<string, string>;
|
|
64
|
-
/** Host IP for published ports that name none; without it `ports` stay as written. */
|
|
65
|
-
bind?: PortBind;
|
|
66
|
-
}
|
|
67
|
-
/** The Compose file for one node. */
|
|
68
|
-
export declare function composeForNode(desiredState: DesiredState, nodeName: string, { secrets, endpoints, exclude, nodeAddresses, bind, }?: NodeComposeOptions): ComposeFile;
|
|
52
|
+
/**
|
|
53
|
+
* Control secrets a service uses: the sentinels in its definition. Their versions decide whether the
|
|
54
|
+
* service is recreated, so a secret change touches exactly the services that reference it.
|
|
55
|
+
*/
|
|
69
56
|
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
70
57
|
export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
|
|
71
|
-
export declare function dumpYaml(obj: unknown): string;
|
|
72
58
|
export {};
|
package/dist/compose.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
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
|
}
|
|
@@ -175,16 +171,119 @@ function validateProjectExtension(compose, errors) {
|
|
|
175
171
|
if (ext.rollback !== undefined && !['auto', 'manual'].includes(ext.rollback))
|
|
176
172
|
errors.push('x-tug.rollback must be auto or manual');
|
|
177
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
|
+
}
|
|
178
280
|
export function validateCompose(compose, nodes = []) {
|
|
179
281
|
if (!compose?.services || typeof compose.services !== 'object')
|
|
180
282
|
throw new Error('compose file must define services');
|
|
181
283
|
const errors = [];
|
|
182
284
|
const ingressHosts = new Map();
|
|
183
285
|
for (const [name, svc] of Object.entries(compose.services)) {
|
|
184
|
-
|
|
185
|
-
errors.push(`${name}: container_name is not supported`);
|
|
186
|
-
if (svc.deploy)
|
|
187
|
-
errors.push(`${name}: swarm deploy semantics are not supported`);
|
|
286
|
+
validateSubset(name, svc, compose, errors);
|
|
188
287
|
const lp = svc['x-tug'] || {};
|
|
189
288
|
const placement = lp.placement || {};
|
|
190
289
|
const selected = placement.nodes || [];
|
|
@@ -201,11 +300,12 @@ export function validateCompose(compose, nodes = []) {
|
|
|
201
300
|
validateIngress(name, svc, ingressHosts, errors);
|
|
202
301
|
validateRollout(name, svc, errors);
|
|
203
302
|
}
|
|
303
|
+
validateTopLevel(compose, errors);
|
|
204
304
|
validateProjectExtension(compose, errors);
|
|
205
305
|
if (errors.length)
|
|
206
306
|
throw new Error(errors.join('; '));
|
|
207
307
|
}
|
|
208
|
-
export function buildDesiredState({ project, version, compose,
|
|
308
|
+
export function buildDesiredState({ project, version, compose, revision, defaultNode = null, nodes, }) {
|
|
209
309
|
const services = {};
|
|
210
310
|
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
211
311
|
const lp = src['x-tug'] || {};
|
|
@@ -223,9 +323,7 @@ export function buildDesiredState({ project, version, compose, registry, revisio
|
|
|
223
323
|
}
|
|
224
324
|
placement = { ...placement, nodes: selected.map((node) => node.name) };
|
|
225
325
|
}
|
|
226
|
-
const image = src.build
|
|
227
|
-
? `${registry ? `${registry}/` : 'tug-build/'}${project}/${name}:${revision || version}`
|
|
228
|
-
: src.image;
|
|
326
|
+
const image = src.build ? `tug-build/${project}/${name}:${revision || version}` : src.image;
|
|
229
327
|
services[name] = {
|
|
230
328
|
image,
|
|
231
329
|
build: src.build || null,
|
|
@@ -247,141 +345,16 @@ export function crossNodeHosts(project, endpoints, local) {
|
|
|
247
345
|
}
|
|
248
346
|
return hosts;
|
|
249
347
|
}
|
|
250
|
-
/**
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const kept = deps.filter((d) => !names.has(String(d)));
|
|
255
|
-
if (kept.length)
|
|
256
|
-
svc.depends_on = kept;
|
|
257
|
-
else
|
|
258
|
-
delete svc.depends_on;
|
|
259
|
-
}
|
|
260
|
-
else if (deps && typeof deps === 'object') {
|
|
261
|
-
const kept = Object.fromEntries(Object.entries(deps).filter(([dep]) => !names.has(dep)));
|
|
262
|
-
if (Object.keys(kept).length)
|
|
263
|
-
svc.depends_on = kept;
|
|
264
|
-
else
|
|
265
|
-
delete svc.depends_on;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
function extraHostsList(value) {
|
|
269
|
-
if (!value)
|
|
270
|
-
return [];
|
|
271
|
-
return Array.isArray(value)
|
|
272
|
-
? value.map(String)
|
|
273
|
-
: Object.entries(value).map(([k, v]) => `${k}:${v}`);
|
|
274
|
-
}
|
|
275
|
-
/** The Compose file for one node. */
|
|
276
|
-
export function composeForNode(desiredState, nodeName, { secrets = {}, endpoints = {}, exclude = new Set(), nodeAddresses = {}, bind, } = {}) {
|
|
277
|
-
const out = {
|
|
278
|
-
services: {},
|
|
279
|
-
volumes: {},
|
|
280
|
-
};
|
|
281
|
-
const outVolumes = out.volumes;
|
|
282
|
-
const usedVolumes = new Set();
|
|
283
|
-
const placed = new Set();
|
|
284
|
-
for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
|
|
285
|
-
const nodes = desiredState.services[name].placement?.nodes || [];
|
|
286
|
-
if (nodes.length && !nodes.includes(nodeName))
|
|
287
|
-
continue;
|
|
288
|
-
placed.add(name);
|
|
289
|
-
if (exclude.has(name))
|
|
290
|
-
continue;
|
|
291
|
-
const svc = { ...src, image: desiredState.services[name].image };
|
|
292
|
-
delete svc.build;
|
|
293
|
-
delete svc['x-tug'];
|
|
294
|
-
delete svc.deploy;
|
|
295
|
-
delete svc.container_name;
|
|
296
|
-
if (desiredState.services[name].artifact)
|
|
297
|
-
svc.pull_policy = 'never';
|
|
298
|
-
if (Object.keys(secrets).length)
|
|
299
|
-
svc.env_file = Array.from(new Set([
|
|
300
|
-
...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []),
|
|
301
|
-
'.env',
|
|
302
|
-
]));
|
|
303
|
-
for (const vol of svc.volumes || []) {
|
|
304
|
-
const source = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
|
|
305
|
-
if (source && desiredState.compose?.volumes?.[source])
|
|
306
|
-
usedVolumes.add(source);
|
|
307
|
-
}
|
|
308
|
-
if (exclude.size)
|
|
309
|
-
dropDependsOn(svc, exclude);
|
|
310
|
-
if (bind && svc.ports)
|
|
311
|
-
svc.ports = bindPorts(svc.ports, bind.address, bind.loopback?.has(name) ? src['x-tug']?.ingress?.port : undefined);
|
|
312
|
-
out.services[name] = svc;
|
|
313
|
-
}
|
|
314
|
-
const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
|
|
315
|
-
if (hosts.length) {
|
|
316
|
-
for (const svc of Object.values(out.services)) {
|
|
317
|
-
const existing = extraHostsList(svc.extra_hosts);
|
|
318
|
-
const taken = new Set(existing.map((h) => h.split(':')[0]));
|
|
319
|
-
svc.extra_hosts = [...existing, ...hosts.filter((h) => !taken.has(h.split(':')[0]))];
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
for (const name of usedVolumes) {
|
|
323
|
-
const vol = desiredState.compose.volumes?.[name] || {};
|
|
324
|
-
const xe = vol['x-tug'] || {};
|
|
325
|
-
const nodes = xe.placement?.nodes || [];
|
|
326
|
-
const isNfs = xe.type === 'nfs' || xe.driver === 'nfs' || (xe.type === 'shared' && xe.driver === 'nfs');
|
|
327
|
-
if (isNfs) {
|
|
328
|
-
const exporter = nodes[0];
|
|
329
|
-
const exportPath = xe.export || `/srv/tug/${name}`;
|
|
330
|
-
if (!exporter) {
|
|
331
|
-
// unplaced: nothing to export, plain local volume
|
|
332
|
-
const cleaned = { ...vol };
|
|
333
|
-
delete cleaned['x-tug'];
|
|
334
|
-
outVolumes[name] = cleaned;
|
|
335
|
-
}
|
|
336
|
-
else if (nodes.includes(nodeName)) {
|
|
337
|
-
// The exporter mounts the exported directory directly; an NFS server on this node must export it.
|
|
338
|
-
outVolumes[name] = {
|
|
339
|
-
driver: 'local',
|
|
340
|
-
driver_opts: { type: 'none', o: 'bind', device: exportPath },
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
const addr = xe.address || nodeAddresses[exporter] || exporter;
|
|
345
|
-
outVolumes[name] = {
|
|
346
|
-
driver: 'local',
|
|
347
|
-
driver_opts: { type: 'nfs', o: `addr=${addr},rw,nolock,soft`, device: `:${exportPath}` },
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
if (!nodes.length || nodes.includes(nodeName))
|
|
353
|
-
outVolumes[name] = vol;
|
|
354
|
-
}
|
|
355
|
-
if (!Object.keys(outVolumes).length)
|
|
356
|
-
delete out.volumes;
|
|
357
|
-
return out;
|
|
358
|
-
}
|
|
348
|
+
/**
|
|
349
|
+
* Control secrets a service uses: the sentinels in its definition. Their versions decide whether the
|
|
350
|
+
* service is recreated, so a secret change touches exactly the services that reference it.
|
|
351
|
+
*/
|
|
359
352
|
export function serviceSecretNames(compose, serviceNames) {
|
|
360
|
-
const
|
|
361
|
-
for (const
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const [key, value = ''] = String(entry).split('=');
|
|
366
|
-
if (key)
|
|
367
|
-
allowed.add(key);
|
|
368
|
-
for (const match of String(value).matchAll(/\$\{?([A-Z_][A-Z0-9_]*)[^}]*\}?/g))
|
|
369
|
-
allowed.add(match[1]);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
else {
|
|
373
|
-
for (const [key, value] of Object.entries(env)) {
|
|
374
|
-
if (typeof value === 'string' && value.match(/^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?$/)) {
|
|
375
|
-
const m = value.match(/^\$\{?([A-Z_][A-Z0-9_]*)/);
|
|
376
|
-
if (m)
|
|
377
|
-
allowed.add(m[1]);
|
|
378
|
-
}
|
|
379
|
-
else
|
|
380
|
-
allowed.add(key);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
return allowed;
|
|
353
|
+
const names = new Set();
|
|
354
|
+
for (const service of serviceNames)
|
|
355
|
+
for (const name of sentinelNames(compose?.services?.[service]))
|
|
356
|
+
names.add(name);
|
|
357
|
+
return names;
|
|
385
358
|
}
|
|
386
359
|
export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
387
360
|
const out = {};
|
|
@@ -391,6 +364,3 @@ export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
|
391
364
|
}
|
|
392
365
|
return out;
|
|
393
366
|
}
|
|
394
|
-
export function dumpYaml(obj) {
|
|
395
|
-
return yaml.dump(obj, { noRefs: true, lineWidth: 120 });
|
|
396
|
-
}
|