@wpelevator/tug 0.2.0 → 0.3.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 +39 -4
- package/dist/agent.d.ts +63 -2
- package/dist/agent.js +828 -77
- 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 +396 -85
- package/dist/compose.d.ts +46 -7
- package/dist/compose.js +173 -37
- package/dist/control.d.ts +4 -1
- package/dist/control.js +1103 -148
- package/dist/db.d.ts +7 -0
- package/dist/db.js +13 -1
- package/dist/ingress.d.ts +10 -2
- package/dist/ingress.js +57 -16
- package/dist/live.d.ts +80 -0
- package/dist/live.js +340 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/rollout.js +85 -19
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- 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/compose.d.ts
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import type { ComposeFile, ComposeService, DesiredState, ServiceEndpoint } from './protocol.ts';
|
|
2
2
|
export declare function readCompose(file?: string): Promise<ComposeFile>;
|
|
3
3
|
export declare function composeHash(compose: ComposeFile): string;
|
|
4
|
+
type PortEntry = NonNullable<ComposeService['ports']>[number];
|
|
4
5
|
/**
|
|
5
6
|
* The host port a service publishes for `port`, matching either side of a `ports` entry (a
|
|
6
7
|
* container port resolves to its published host port). Null when the port is not published.
|
|
7
8
|
*/
|
|
8
9
|
export declare function publishedPort(svc: ComposeService, port: number): number | null;
|
|
10
|
+
/** 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`. */
|
|
11
|
+
export declare function hasHostIp(entry: PortEntry): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Binds a `ports` entry that names no host IP to `ip`, keeping ranges and the protocol suffix
|
|
14
|
+
* (`8080:80/udp` becomes `10.0.0.2:8080:80/udp`, a container-only `80` becomes `10.0.0.2::80`).
|
|
15
|
+
* Entries that already name a host IP, including `0.0.0.0`, are returned as written.
|
|
16
|
+
*/
|
|
17
|
+
export declare function bindPort(entry: PortEntry, ip: string): PortEntry;
|
|
18
|
+
/** `bindPort` for every entry; entries publishing `loopbackPort` bind to `127.0.0.1` instead of `ip`. */
|
|
19
|
+
export declare function bindPorts(ports: PortEntry[], ip: string, loopbackPort?: number): PortEntry[];
|
|
20
|
+
/**
|
|
21
|
+
* Services of `state` whose ingress port only the managed Caddy on `node` dials, so it can listen on
|
|
22
|
+
* `127.0.0.1`: `node` is the only ingress node, the service runs there, and every service of the
|
|
23
|
+
* project runs only on nodes where this one runs too, so no node reaches it through a service name.
|
|
24
|
+
* A service with no placement nodes runs on every node in `nodes`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function loopbackIngressServices(state: DesiredState, node: string, ingressNodes: readonly string[], nodes: readonly string[]): string[];
|
|
27
|
+
/** Where `composeForNode` binds published ports that name no host IP. */
|
|
28
|
+
export interface PortBind {
|
|
29
|
+
address: string;
|
|
30
|
+
loopback?: ReadonlySet<string>;
|
|
31
|
+
}
|
|
32
|
+
/** Services among `names` with at least one `ports` entry that `bind` puts on its address (not on loopback, not written by the operator). */
|
|
33
|
+
export declare function addressBoundServices(compose: ComposeFile, names: Iterable<string>, loopback?: ReadonlySet<string>): string[];
|
|
9
34
|
/** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
|
|
10
35
|
export declare function isRolling(svc: ComposeService | undefined): boolean;
|
|
11
36
|
export declare function validateCompose(compose: ComposeFile | undefined, nodes?: string[]): asserts compose is ComposeFile;
|
|
@@ -16,18 +41,32 @@ export interface BuildDesiredStateOptions {
|
|
|
16
41
|
registry: string;
|
|
17
42
|
revision?: string;
|
|
18
43
|
defaultNode?: string | null;
|
|
44
|
+
nodes?: Array<{
|
|
45
|
+
name: string;
|
|
46
|
+
labels?: string[];
|
|
47
|
+
}>;
|
|
19
48
|
}
|
|
20
|
-
export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode }: BuildDesiredStateOptions): DesiredState;
|
|
49
|
+
export declare function buildDesiredState({ project, version, compose, registry, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
|
|
21
50
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
22
51
|
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
23
52
|
/** Removes `depends_on` references to `names` (services that live in another Compose project). */
|
|
24
53
|
export declare function dropDependsOn(svc: ComposeService, names: ReadonlySet<string>): void;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
export interface NodeComposeOptions {
|
|
55
|
+
secrets?: Record<string, string>;
|
|
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;
|
|
31
69
|
export declare function serviceSecretNames(compose: ComposeFile | undefined, serviceNames: Iterable<string>): Set<string>;
|
|
32
70
|
export declare function serviceSecretVersions(compose: ComposeFile | undefined, serviceNames: Iterable<string>, versions?: Record<string, number>): Record<string, Record<string, number>>;
|
|
33
71
|
export declare function dumpYaml(obj: unknown): string;
|
|
72
|
+
export {};
|
package/dist/compose.js
CHANGED
|
@@ -8,28 +8,99 @@ export async function readCompose(file = 'docker-compose.yml') {
|
|
|
8
8
|
export function composeHash(compose) {
|
|
9
9
|
return crypto.createHash('sha1').update(JSON.stringify(compose)).digest('hex').slice(0, 12);
|
|
10
10
|
}
|
|
11
|
+
/** The host port one `ports` entry publishes for `port` (either side of the mapping), or null. */
|
|
12
|
+
function entryPublishedPort(entry, port) {
|
|
13
|
+
if (typeof entry === 'number')
|
|
14
|
+
return null;
|
|
15
|
+
if (typeof entry === 'object') {
|
|
16
|
+
const published = Number(entry.published);
|
|
17
|
+
return (Number(entry.target) === port || published === port) && published > 0
|
|
18
|
+
? published
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
const spec = String(entry).split('/')[0].split(':');
|
|
22
|
+
if (spec.length < 2)
|
|
23
|
+
return null;
|
|
24
|
+
const container = Number(spec[spec.length - 1]);
|
|
25
|
+
const host = Number(spec[spec.length - 2]);
|
|
26
|
+
return (container === port || host === port) && host > 0 ? host : null;
|
|
27
|
+
}
|
|
11
28
|
/**
|
|
12
29
|
* The host port a service publishes for `port`, matching either side of a `ports` entry (a
|
|
13
30
|
* container port resolves to its published host port). Null when the port is not published.
|
|
14
31
|
*/
|
|
15
32
|
export function publishedPort(svc, port) {
|
|
16
33
|
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;
|
|
34
|
+
const published = entryPublishedPort(entry, port);
|
|
35
|
+
if (published != null)
|
|
36
|
+
return published;
|
|
30
37
|
}
|
|
31
38
|
return null;
|
|
32
39
|
}
|
|
40
|
+
/** 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`. */
|
|
41
|
+
export function hasHostIp(entry) {
|
|
42
|
+
if (typeof entry === 'number')
|
|
43
|
+
return false;
|
|
44
|
+
if (typeof entry === 'object')
|
|
45
|
+
return entry.host_ip !== undefined && entry.host_ip !== null && entry.host_ip !== '';
|
|
46
|
+
const spec = String(entry).split('/')[0];
|
|
47
|
+
return spec.startsWith('[') || spec.split(':').length > 2;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Binds a `ports` entry that names no host IP to `ip`, keeping ranges and the protocol suffix
|
|
51
|
+
* (`8080:80/udp` becomes `10.0.0.2:8080:80/udp`, a container-only `80` becomes `10.0.0.2::80`).
|
|
52
|
+
* Entries that already name a host IP, including `0.0.0.0`, are returned as written.
|
|
53
|
+
*/
|
|
54
|
+
export function bindPort(entry, ip) {
|
|
55
|
+
if (hasHostIp(entry))
|
|
56
|
+
return entry;
|
|
57
|
+
if (typeof entry === 'object')
|
|
58
|
+
return { ...entry, host_ip: ip };
|
|
59
|
+
const host = ip.includes(':') ? `[${ip}]` : ip;
|
|
60
|
+
const [spec, ...protocol] = String(entry).split('/');
|
|
61
|
+
const bound = spec.includes(':') ? `${host}:${spec}` : `${host}::${spec}`;
|
|
62
|
+
return [bound, ...protocol].join('/');
|
|
63
|
+
}
|
|
64
|
+
/** `bindPort` for every entry; entries publishing `loopbackPort` bind to `127.0.0.1` instead of `ip`. */
|
|
65
|
+
export function bindPorts(ports, ip, loopbackPort) {
|
|
66
|
+
return ports.map((entry) => bindPort(entry, loopbackPort !== undefined && entryPublishedPort(entry, loopbackPort) != null
|
|
67
|
+
? '127.0.0.1'
|
|
68
|
+
: ip));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Services of `state` whose ingress port only the managed Caddy on `node` dials, so it can listen on
|
|
72
|
+
* `127.0.0.1`: `node` is the only ingress node, the service runs there, and every service of the
|
|
73
|
+
* project runs only on nodes where this one runs too, so no node reaches it through a service name.
|
|
74
|
+
* A service with no placement nodes runs on every node in `nodes`.
|
|
75
|
+
*/
|
|
76
|
+
export function loopbackIngressServices(state, node, ingressNodes, nodes) {
|
|
77
|
+
if (ingressNodes.length !== 1 || ingressNodes[0] !== node)
|
|
78
|
+
return [];
|
|
79
|
+
const placed = (service) => {
|
|
80
|
+
const list = state.services[service]?.placement?.nodes || [];
|
|
81
|
+
return list.length ? list : nodes;
|
|
82
|
+
};
|
|
83
|
+
return Object.keys(state.services)
|
|
84
|
+
.filter((service) => {
|
|
85
|
+
if (!state.compose.services?.[service]?.['x-tug']?.ingress)
|
|
86
|
+
return false;
|
|
87
|
+
const here = new Set(placed(service));
|
|
88
|
+
return (here.has(node) &&
|
|
89
|
+
Object.keys(state.services).every((other) => placed(other).every((n) => here.has(n))));
|
|
90
|
+
})
|
|
91
|
+
.sort();
|
|
92
|
+
}
|
|
93
|
+
/** Services among `names` with at least one `ports` entry that `bind` puts on its address (not on loopback, not written by the operator). */
|
|
94
|
+
export function addressBoundServices(compose, names, loopback = new Set()) {
|
|
95
|
+
return [...names]
|
|
96
|
+
.filter((name) => {
|
|
97
|
+
const svc = compose.services[name];
|
|
98
|
+
const ingressPort = loopback.has(name) ? svc?.['x-tug']?.ingress?.port : undefined;
|
|
99
|
+
return (svc?.ports || []).some((entry) => !hasHostIp(entry) &&
|
|
100
|
+
(ingressPort === undefined || entryPublishedPort(entry, ingressPort) == null));
|
|
101
|
+
})
|
|
102
|
+
.sort();
|
|
103
|
+
}
|
|
33
104
|
const HOSTNAME_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
34
105
|
function validateIngress(name, svc, seen, errors) {
|
|
35
106
|
const ingress = svc['x-tug']?.ingress;
|
|
@@ -58,7 +129,9 @@ function validateIngress(name, svc, seen, errors) {
|
|
|
58
129
|
errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
|
|
59
130
|
}
|
|
60
131
|
/** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
|
|
61
|
-
export function isRolling(svc) {
|
|
132
|
+
export function isRolling(svc) {
|
|
133
|
+
return svc?.['x-tug']?.rollout?.strategy === 'rolling';
|
|
134
|
+
}
|
|
62
135
|
function validateRollout(name, svc, errors) {
|
|
63
136
|
const rollout = svc['x-tug']?.rollout;
|
|
64
137
|
if (rollout === undefined)
|
|
@@ -113,10 +186,17 @@ export function validateCompose(compose, nodes = []) {
|
|
|
113
186
|
if (svc.deploy)
|
|
114
187
|
errors.push(`${name}: swarm deploy semantics are not supported`);
|
|
115
188
|
const lp = svc['x-tug'] || {};
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
189
|
+
const placement = lp.placement || {};
|
|
190
|
+
const selected = placement.nodes || [];
|
|
191
|
+
const hasLabels = Array.isArray(placement.labels) && placement.labels.length > 0;
|
|
192
|
+
const isGlobal = lp.global === true || placement.global === true;
|
|
193
|
+
if (nodes.length > 1 && selected.length === 0 && !hasLabels && !isGlobal)
|
|
194
|
+
errors.push(`${name}: multi-node deployments require x-tug.placement.nodes or placement.labels`);
|
|
195
|
+
if (lp.replicas &&
|
|
196
|
+
selected.length &&
|
|
197
|
+
!hasLabels &&
|
|
198
|
+
!isGlobal &&
|
|
199
|
+
lp.replicas !== selected.length)
|
|
120
200
|
errors.push(`${name}: replicas must match placement node count in v1`);
|
|
121
201
|
validateIngress(name, svc, ingressHosts, errors);
|
|
122
202
|
validateRollout(name, svc, errors);
|
|
@@ -125,13 +205,34 @@ export function validateCompose(compose, nodes = []) {
|
|
|
125
205
|
if (errors.length)
|
|
126
206
|
throw new Error(errors.join('; '));
|
|
127
207
|
}
|
|
128
|
-
export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null }) {
|
|
208
|
+
export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null, nodes, }) {
|
|
129
209
|
const services = {};
|
|
130
210
|
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
131
211
|
const lp = src['x-tug'] || {};
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
212
|
+
let placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
|
|
213
|
+
const global = lp.global === true || placement.global === true;
|
|
214
|
+
const labels = placement.labels || [];
|
|
215
|
+
if (nodes && (!placement.nodes || !placement.nodes.length) && (labels.length || global)) {
|
|
216
|
+
const matched = nodes
|
|
217
|
+
.filter((node) => labels.every((label) => (node.labels || []).includes(label)))
|
|
218
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
219
|
+
let selected = matched;
|
|
220
|
+
if (!global) {
|
|
221
|
+
const replicas = lp.replicas || matched.length;
|
|
222
|
+
selected = matched.slice(0, Math.min(replicas, matched.length));
|
|
223
|
+
}
|
|
224
|
+
placement = { ...placement, nodes: selected.map((node) => node.name) };
|
|
225
|
+
}
|
|
226
|
+
const image = src.build
|
|
227
|
+
? `${registry ? `${registry}/` : 'tug-build/'}${project}/${name}:${revision || version}`
|
|
228
|
+
: src.image;
|
|
229
|
+
services[name] = {
|
|
230
|
+
image,
|
|
231
|
+
build: src.build || null,
|
|
232
|
+
placement,
|
|
233
|
+
replicas: lp.replicas || placement.nodes?.length || 1,
|
|
234
|
+
health: lp.health || null,
|
|
235
|
+
};
|
|
135
236
|
}
|
|
136
237
|
return { project, version, ...(revision ? { revision } : {}), services, compose };
|
|
137
238
|
}
|
|
@@ -150,7 +251,7 @@ export function crossNodeHosts(project, endpoints, local) {
|
|
|
150
251
|
export function dropDependsOn(svc, names) {
|
|
151
252
|
const deps = svc.depends_on;
|
|
152
253
|
if (Array.isArray(deps)) {
|
|
153
|
-
const kept = deps.filter(d => !names.has(String(d)));
|
|
254
|
+
const kept = deps.filter((d) => !names.has(String(d)));
|
|
154
255
|
if (kept.length)
|
|
155
256
|
svc.depends_on = kept;
|
|
156
257
|
else
|
|
@@ -167,15 +268,16 @@ export function dropDependsOn(svc, names) {
|
|
|
167
268
|
function extraHostsList(value) {
|
|
168
269
|
if (!value)
|
|
169
270
|
return [];
|
|
170
|
-
return Array.isArray(value)
|
|
271
|
+
return Array.isArray(value)
|
|
272
|
+
? value.map(String)
|
|
273
|
+
: Object.entries(value).map(([k, v]) => `${k}:${v}`);
|
|
171
274
|
}
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const out = { services: {}, volumes: {} };
|
|
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
|
+
};
|
|
179
281
|
const outVolumes = out.volumes;
|
|
180
282
|
const usedVolumes = new Set();
|
|
181
283
|
const placed = new Set();
|
|
@@ -194,7 +296,10 @@ export function composeForNode(desiredState, nodeName, secrets = {}, endpoints =
|
|
|
194
296
|
if (desiredState.services[name].artifact)
|
|
195
297
|
svc.pull_policy = 'never';
|
|
196
298
|
if (Object.keys(secrets).length)
|
|
197
|
-
svc.env_file = Array.from(new Set([
|
|
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
|
+
]));
|
|
198
303
|
for (const vol of svc.volumes || []) {
|
|
199
304
|
const source = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
|
|
200
305
|
if (source && desiredState.compose?.volumes?.[source])
|
|
@@ -202,19 +307,48 @@ export function composeForNode(desiredState, nodeName, secrets = {}, endpoints =
|
|
|
202
307
|
}
|
|
203
308
|
if (exclude.size)
|
|
204
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);
|
|
205
312
|
out.services[name] = svc;
|
|
206
313
|
}
|
|
207
314
|
const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
|
|
208
315
|
if (hosts.length) {
|
|
209
316
|
for (const svc of Object.values(out.services)) {
|
|
210
317
|
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]))];
|
|
318
|
+
const taken = new Set(existing.map((h) => h.split(':')[0]));
|
|
319
|
+
svc.extra_hosts = [...existing, ...hosts.filter((h) => !taken.has(h.split(':')[0]))];
|
|
213
320
|
}
|
|
214
321
|
}
|
|
215
322
|
for (const name of usedVolumes) {
|
|
216
323
|
const vol = desiredState.compose.volumes?.[name] || {};
|
|
217
|
-
const
|
|
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
|
+
}
|
|
218
352
|
if (!nodes.length || nodes.includes(nodeName))
|
|
219
353
|
outVolumes[name] = vol;
|
|
220
354
|
}
|
|
@@ -253,8 +387,10 @@ export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
|
253
387
|
const out = {};
|
|
254
388
|
for (const service of serviceNames) {
|
|
255
389
|
const names = serviceSecretNames(compose, [service]);
|
|
256
|
-
out[service] = Object.fromEntries([...names].filter(name => versions[name] != null).map(name => [name, versions[name]]));
|
|
390
|
+
out[service] = Object.fromEntries([...names].filter((name) => versions[name] != null).map((name) => [name, versions[name]]));
|
|
257
391
|
}
|
|
258
392
|
return out;
|
|
259
393
|
}
|
|
260
|
-
export function dumpYaml(obj) {
|
|
394
|
+
export function dumpYaml(obj) {
|
|
395
|
+
return yaml.dump(obj, { noRefs: true, lineWidth: 120 });
|
|
396
|
+
}
|
package/dist/control.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
|
|
4
4
|
import https from 'node:https';
|
|
5
|
+
import http from 'node:http';
|
|
5
6
|
import { openDb } from './db.ts';
|
|
6
7
|
import type { NodeRow } from './db.ts';
|
|
7
8
|
import { buildAndPush, run as runCommand } from './build.ts';
|
|
@@ -20,5 +21,7 @@ export interface ControlImageOperations {
|
|
|
20
21
|
export declare function createApp(options?: {
|
|
21
22
|
images?: Partial<ControlImageOperations>;
|
|
22
23
|
openDatabase?: typeof openDb;
|
|
24
|
+
/** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
|
|
25
|
+
/** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
|
|
23
26
|
}): Promise<import("express-serve-static-core").Express>;
|
|
24
|
-
export declare function listen(app: express.Express, port?: number): Promise<
|
|
27
|
+
export declare function listen(app: express.Express, port?: number): Promise<http.Server | https.Server>;
|