@wpelevator/tug 0.3.0 → 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/dist/agent.d.ts +9 -10
- package/dist/agent.js +226 -274
- package/dist/cli.js +24 -8
- package/dist/compose.d.ts +6 -19
- package/dist/compose.js +118 -146
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.js +9 -0
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -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/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/templates/install-docker.sh +12 -7
- package/package.json +1 -1
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -385
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;
|
|
@@ -255,8 +269,8 @@ program
|
|
|
255
269
|
.action(async (sshTarget, o) => {
|
|
256
270
|
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
257
271
|
if (o.installDocker)
|
|
258
|
-
await bootstrapDocker(sshTarget);
|
|
259
|
-
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048 });
|
|
272
|
+
await bootstrapDocker(sshTarget, true);
|
|
273
|
+
preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
|
|
260
274
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
261
275
|
if (o.buildLocalImage)
|
|
262
276
|
await transferTugImage(sshTarget, image);
|
|
@@ -454,8 +468,10 @@ program
|
|
|
454
468
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
455
469
|
.action(async (o) => {
|
|
456
470
|
const cfg = await loadCfg();
|
|
457
|
-
const compose = await readCompose(o.file);
|
|
458
471
|
const project = o.project || cfg.project || 'site';
|
|
472
|
+
// Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
|
|
473
|
+
const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
|
|
474
|
+
const compose = await normalizeCompose(o.file, secrets.map((secret) => secret.name), { project });
|
|
459
475
|
const version = o.version || `git-${composeHash(compose)}`;
|
|
460
476
|
let response;
|
|
461
477
|
if (o.buildLocal) {
|
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,6 +32,8 @@ 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;
|
|
@@ -49,24 +50,10 @@ export interface BuildDesiredStateOptions {
|
|
|
49
50
|
export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
|
|
50
51
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
51
52
|
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;
|
|
53
|
+
/**
|
|
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.
|
|
56
|
+
*/
|
|
69
57
|
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
70
58
|
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
59
|
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,6 +300,7 @@ 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('; '));
|
|
@@ -247,141 +347,16 @@ export function crossNodeHosts(project, endpoints, local) {
|
|
|
247
347
|
}
|
|
248
348
|
return hosts;
|
|
249
349
|
}
|
|
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
|
-
}
|
|
350
|
+
/**
|
|
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.
|
|
353
|
+
*/
|
|
359
354
|
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;
|
|
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;
|
|
385
360
|
}
|
|
386
361
|
export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
387
362
|
const out = {};
|
|
@@ -391,6 +366,3 @@ export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
|
391
366
|
}
|
|
392
367
|
return out;
|
|
393
368
|
}
|
|
394
|
-
export function dumpYaml(obj) {
|
|
395
|
-
return yaml.dump(obj, { noRefs: true, lineWidth: 120 });
|
|
396
|
-
}
|
|
@@ -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;
|