@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
|
@@ -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;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Turns the desired state for one node into Engine API container definitions. Pure: no Docker, no
|
|
2
|
+
// filesystem. The config hash decides whether a running container already matches its definition.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { bindPorts, crossNodeHosts } from "./compose.js";
|
|
5
|
+
import { sentinelNames, substituteSentinels } from "./normalize.js";
|
|
6
|
+
export const LABEL_PROJECT = 'tug.project';
|
|
7
|
+
export const LABEL_SERVICE = 'tug.service';
|
|
8
|
+
export const LABEL_REVISION = 'tug.revision';
|
|
9
|
+
export const LABEL_HASH = 'tug.config-hash';
|
|
10
|
+
/** The project network every container of the project joins; service names are its aliases. */
|
|
11
|
+
export const networkName = (project) => `tug_${project}`;
|
|
12
|
+
/** Docker volume name for a Compose volume; `external` volumes keep their own name. */
|
|
13
|
+
export const volumeName = (project, volume) => `tug_${project}_${volume}`;
|
|
14
|
+
export const containerName = (project, service, hash) => `tug-${project}-${service}-${hash.slice(0, 8)}`;
|
|
15
|
+
const SECOND_NS = 1_000_000_000;
|
|
16
|
+
const UNITS = {
|
|
17
|
+
ns: 1e-6,
|
|
18
|
+
us: 1e-3,
|
|
19
|
+
µs: 1e-3,
|
|
20
|
+
ms: 1,
|
|
21
|
+
s: 1000,
|
|
22
|
+
m: 60000,
|
|
23
|
+
h: 3600000,
|
|
24
|
+
};
|
|
25
|
+
/** Compose durations (`30s`, `1m30s`, `500ms`) or a number of seconds, in milliseconds. */
|
|
26
|
+
export function composeDurationMs(value) {
|
|
27
|
+
if (typeof value === 'number')
|
|
28
|
+
return value * 1000;
|
|
29
|
+
if (typeof value !== 'string' || !value)
|
|
30
|
+
return undefined;
|
|
31
|
+
let total = 0;
|
|
32
|
+
let rest = value;
|
|
33
|
+
while (rest) {
|
|
34
|
+
const m = rest.match(/^(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/);
|
|
35
|
+
if (!m)
|
|
36
|
+
return undefined;
|
|
37
|
+
total += Number(m[1]) * UNITS[m[2]];
|
|
38
|
+
rest = rest.slice(m[0].length);
|
|
39
|
+
}
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
42
|
+
const toNs = (value) => {
|
|
43
|
+
const ms = composeDurationMs(value);
|
|
44
|
+
return ms === undefined ? undefined : Math.round(ms * 1e6);
|
|
45
|
+
};
|
|
46
|
+
/** Compose sizes: bytes as a number, or `64m` / `1g` style strings. */
|
|
47
|
+
export function sizeBytes(value) {
|
|
48
|
+
if (typeof value === 'number')
|
|
49
|
+
return value;
|
|
50
|
+
if (typeof value !== 'string')
|
|
51
|
+
return undefined;
|
|
52
|
+
const m = value
|
|
53
|
+
.trim()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/);
|
|
56
|
+
if (!m)
|
|
57
|
+
return undefined;
|
|
58
|
+
const power = { '': 0, k: 1, m: 2, g: 3, t: 4 }[m[2]];
|
|
59
|
+
return Math.round(Number(m[1]) * 1024 ** power);
|
|
60
|
+
}
|
|
61
|
+
const asList = (value) => Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)];
|
|
62
|
+
function extraHosts(value) {
|
|
63
|
+
if (!value)
|
|
64
|
+
return [];
|
|
65
|
+
return Array.isArray(value)
|
|
66
|
+
? value.map((entry) => String(entry).replace('=', ':'))
|
|
67
|
+
: Object.entries(value).map(([name, ip]) => `${name}:${ip}`);
|
|
68
|
+
}
|
|
69
|
+
function restartPolicy(value) {
|
|
70
|
+
if (typeof value !== 'string' || !value)
|
|
71
|
+
return undefined;
|
|
72
|
+
const [name, max] = value.split(':');
|
|
73
|
+
return name === 'on-failure' && max
|
|
74
|
+
? { Name: name, MaximumRetryCount: Number(max) }
|
|
75
|
+
: { Name: name === 'no' ? '' : name };
|
|
76
|
+
}
|
|
77
|
+
function healthcheck(value) {
|
|
78
|
+
if (!value || typeof value !== 'object')
|
|
79
|
+
return undefined;
|
|
80
|
+
const hc = value;
|
|
81
|
+
if (hc.disable === true)
|
|
82
|
+
return { Test: ['NONE'] };
|
|
83
|
+
const test = typeof hc.test === 'string' ? ['CMD-SHELL', hc.test] : hc.test;
|
|
84
|
+
return {
|
|
85
|
+
...(test ? { Test: test } : {}),
|
|
86
|
+
...(toNs(hc.interval) !== undefined ? { Interval: toNs(hc.interval) } : {}),
|
|
87
|
+
...(toNs(hc.timeout) !== undefined ? { Timeout: toNs(hc.timeout) } : {}),
|
|
88
|
+
...(toNs(hc.start_period) !== undefined ? { StartPeriod: toNs(hc.start_period) } : {}),
|
|
89
|
+
...(toNs(hc.start_interval) !== undefined ? { StartInterval: toNs(hc.start_interval) } : {}),
|
|
90
|
+
...(hc.retries !== undefined ? { Retries: Number(hc.retries) } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function portMaps(svc, ports) {
|
|
94
|
+
const exposed = {};
|
|
95
|
+
const bindings = {};
|
|
96
|
+
for (const entry of ports) {
|
|
97
|
+
const key = `${Number(entry.target)}/${entry.protocol || 'tcp'}`;
|
|
98
|
+
exposed[key] = {};
|
|
99
|
+
(bindings[key] ||= []).push({
|
|
100
|
+
HostIp: entry.host_ip || '',
|
|
101
|
+
HostPort: entry.published === undefined ? '' : String(entry.published),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
for (const port of asList(svc.expose))
|
|
105
|
+
exposed[`${port.split('/')[0]}/${port.split('/')[1] || 'tcp'}`] = {};
|
|
106
|
+
return { exposed, bindings };
|
|
107
|
+
}
|
|
108
|
+
/** Volume definitions this node needs, with NFS volumes mounted from the exporter's directory or address. */
|
|
109
|
+
function volumeSpecs(desired, node, used, nodeAddresses) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const key of [...used].sort()) {
|
|
112
|
+
const def = desired.compose.volumes?.[key] || {};
|
|
113
|
+
const xe = def['x-tug'] || {};
|
|
114
|
+
const labels = {
|
|
115
|
+
[LABEL_PROJECT]: desired.project,
|
|
116
|
+
'tug.volume': key,
|
|
117
|
+
...(def.labels || {}),
|
|
118
|
+
};
|
|
119
|
+
if (def.external) {
|
|
120
|
+
out.push({
|
|
121
|
+
name: String(def.name || key),
|
|
122
|
+
driver: 'local',
|
|
123
|
+
opts: {},
|
|
124
|
+
labels,
|
|
125
|
+
external: true,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const name = volumeName(desired.project, key);
|
|
130
|
+
const nodes = xe.placement?.nodes || [];
|
|
131
|
+
const isNfs = xe.type === 'nfs' || xe.driver === 'nfs' || (xe.type === 'shared' && xe.driver === 'nfs');
|
|
132
|
+
const plain = () => ({
|
|
133
|
+
name,
|
|
134
|
+
driver: String(def.driver || 'local'),
|
|
135
|
+
opts: def.driver_opts || {},
|
|
136
|
+
labels,
|
|
137
|
+
external: false,
|
|
138
|
+
});
|
|
139
|
+
if (isNfs) {
|
|
140
|
+
const exporter = nodes[0];
|
|
141
|
+
const exportPath = xe.export || `/srv/tug/${key}`;
|
|
142
|
+
if (!exporter)
|
|
143
|
+
out.push(plain());
|
|
144
|
+
else if (nodes.includes(node))
|
|
145
|
+
// The exporter mounts the exported directory directly; an NFS server on this node must export it.
|
|
146
|
+
out.push({
|
|
147
|
+
name,
|
|
148
|
+
driver: 'local',
|
|
149
|
+
opts: { type: 'none', o: 'bind', device: exportPath },
|
|
150
|
+
labels,
|
|
151
|
+
external: false,
|
|
152
|
+
});
|
|
153
|
+
else {
|
|
154
|
+
const addr = xe.address || nodeAddresses[exporter] || exporter;
|
|
155
|
+
out.push({
|
|
156
|
+
name,
|
|
157
|
+
driver: 'local',
|
|
158
|
+
opts: { type: 'nfs', o: `addr=${addr},rw,nolock,soft`, device: `:${exportPath}` },
|
|
159
|
+
labels,
|
|
160
|
+
external: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!nodes.length || nodes.includes(node))
|
|
166
|
+
out.push(plain());
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function walkStrings(value, fn) {
|
|
171
|
+
if (typeof value === 'string')
|
|
172
|
+
return fn(value);
|
|
173
|
+
if (Array.isArray(value))
|
|
174
|
+
return value.map((v) => walkStrings(v, fn));
|
|
175
|
+
if (value && typeof value === 'object')
|
|
176
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walkStrings(v, fn)]));
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
export function containerSpecs(desired, node, { secrets = {}, secretVersions = {}, endpoints = {}, nodeAddresses = {}, bind, } = {}) {
|
|
180
|
+
const { project } = desired;
|
|
181
|
+
const network = networkName(project);
|
|
182
|
+
const placed = new Set();
|
|
183
|
+
for (const service of Object.keys(desired.compose.services || {})) {
|
|
184
|
+
const nodes = desired.services[service].placement?.nodes || [];
|
|
185
|
+
if (!nodes.length || nodes.includes(node))
|
|
186
|
+
placed.add(service);
|
|
187
|
+
}
|
|
188
|
+
const hosts = crossNodeHosts(project, endpoints, placed);
|
|
189
|
+
const usedVolumes = new Set();
|
|
190
|
+
const specs = {};
|
|
191
|
+
for (const service of [...placed].sort()) {
|
|
192
|
+
const src = desired.compose.services[service];
|
|
193
|
+
const state = desired.services[service];
|
|
194
|
+
const identity = state.artifact?.imageId ?? state.image;
|
|
195
|
+
const image = state.artifact?.imageRef ?? state.image ?? '';
|
|
196
|
+
const ports = (bind && src.ports
|
|
197
|
+
? bindPorts(src.ports, bind.address, bind.loopback?.has(service) ? src['x-tug']?.ingress?.port : undefined)
|
|
198
|
+
: src.ports || []);
|
|
199
|
+
const { exposed, bindings } = portMaps(src, ports);
|
|
200
|
+
const binds = [];
|
|
201
|
+
const tmpfs = {};
|
|
202
|
+
for (const mount of src.volumes || []) {
|
|
203
|
+
if (typeof mount === 'string')
|
|
204
|
+
continue;
|
|
205
|
+
const m = mount;
|
|
206
|
+
const ro = m.read_only ? ':ro' : '';
|
|
207
|
+
if (m.type === 'volume') {
|
|
208
|
+
const key = String(m.source);
|
|
209
|
+
usedVolumes.add(key);
|
|
210
|
+
const def = desired.compose.volumes?.[key];
|
|
211
|
+
binds.push(`${def?.external ? (def.name ?? key) : volumeName(project, key)}:${m.target}${ro}`);
|
|
212
|
+
}
|
|
213
|
+
else if (m.type === 'bind')
|
|
214
|
+
binds.push(`${m.source}:${m.target}${ro}`);
|
|
215
|
+
else if (m.type === 'tmpfs') {
|
|
216
|
+
const size = m.tmpfs?.size;
|
|
217
|
+
tmpfs[String(m.target)] = size ? `size=${size}` : '';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const target of asList(src.tmpfs))
|
|
221
|
+
tmpfs[target] = tmpfs[target] ?? '';
|
|
222
|
+
const env = Object.entries(src.environment || {})
|
|
223
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
224
|
+
.map(([k, v]) => `${k}=${v}`);
|
|
225
|
+
const memory = sizeBytes(src.mem_limit);
|
|
226
|
+
const reservation = sizeBytes(src.mem_reservation);
|
|
227
|
+
const shm = sizeBytes(src.shm_size);
|
|
228
|
+
const ulimits = Object.entries(src.ulimits || {}).map(([Name, v]) => typeof v === 'number'
|
|
229
|
+
? { Name, Soft: v, Hard: v }
|
|
230
|
+
: {
|
|
231
|
+
Name,
|
|
232
|
+
Soft: Number(v.soft),
|
|
233
|
+
Hard: Number(v.hard),
|
|
234
|
+
});
|
|
235
|
+
const logging = src.logging;
|
|
236
|
+
const extra = [...extraHosts(src.extra_hosts)];
|
|
237
|
+
const taken = new Set(extra.map((h) => h.split(':')[0]));
|
|
238
|
+
for (const host of hosts)
|
|
239
|
+
if (!taken.has(host.split(':')[0]))
|
|
240
|
+
extra.push(host);
|
|
241
|
+
const stopTimeout = composeDurationMs(src.stop_grace_period);
|
|
242
|
+
const hostConfig = {
|
|
243
|
+
NetworkMode: network,
|
|
244
|
+
...(binds.length ? { Binds: binds } : {}),
|
|
245
|
+
...(Object.keys(tmpfs).length ? { Tmpfs: tmpfs } : {}),
|
|
246
|
+
...(Object.keys(bindings).length ? { PortBindings: bindings } : {}),
|
|
247
|
+
...(restartPolicy(src.restart) ? { RestartPolicy: restartPolicy(src.restart) } : {}),
|
|
248
|
+
...(memory !== undefined ? { Memory: memory } : {}),
|
|
249
|
+
...(reservation !== undefined ? { MemoryReservation: reservation } : {}),
|
|
250
|
+
...(src.cpus !== undefined ? { NanoCpus: Math.round(Number(src.cpus) * SECOND_NS) } : {}),
|
|
251
|
+
...(src.pids_limit !== undefined ? { PidsLimit: Number(src.pids_limit) } : {}),
|
|
252
|
+
...(shm !== undefined ? { ShmSize: shm } : {}),
|
|
253
|
+
...(src.init !== undefined ? { Init: Boolean(src.init) } : {}),
|
|
254
|
+
...(src.read_only ? { ReadonlyRootfs: true } : {}),
|
|
255
|
+
...(extra.length ? { ExtraHosts: extra } : {}),
|
|
256
|
+
...(asList(src.cap_add).length ? { CapAdd: asList(src.cap_add) } : {}),
|
|
257
|
+
...(asList(src.cap_drop).length ? { CapDrop: asList(src.cap_drop) } : {}),
|
|
258
|
+
...(ulimits.length ? { Ulimits: ulimits } : {}),
|
|
259
|
+
...(logging?.driver
|
|
260
|
+
? { LogConfig: { Type: logging.driver, Config: logging.options || {} } }
|
|
261
|
+
: {}),
|
|
262
|
+
};
|
|
263
|
+
const hc = healthcheck(src.healthcheck);
|
|
264
|
+
const definition = {
|
|
265
|
+
Image: image,
|
|
266
|
+
...(env.length ? { Env: env } : {}),
|
|
267
|
+
...(src.command != null ? { Cmd: asList(src.command) } : {}),
|
|
268
|
+
...(src.entrypoint != null ? { Entrypoint: asList(src.entrypoint) } : {}),
|
|
269
|
+
...(src.user ? { User: String(src.user) } : {}),
|
|
270
|
+
...(src.working_dir ? { WorkingDir: String(src.working_dir) } : {}),
|
|
271
|
+
...(src.hostname ? { Hostname: String(src.hostname) } : {}),
|
|
272
|
+
Labels: Object.fromEntries(Object.entries(src.labels || {}).map(([k, v]) => [
|
|
273
|
+
k,
|
|
274
|
+
String(v),
|
|
275
|
+
])),
|
|
276
|
+
...(Object.keys(exposed).length ? { ExposedPorts: exposed } : {}),
|
|
277
|
+
...(src.stop_signal ? { StopSignal: String(src.stop_signal) } : {}),
|
|
278
|
+
...(stopTimeout !== undefined ? { StopTimeout: Math.ceil(stopTimeout / 1000) } : {}),
|
|
279
|
+
...(hc ? { Healthcheck: hc } : {}),
|
|
280
|
+
HostConfig: hostConfig,
|
|
281
|
+
NetworkingConfig: { EndpointsConfig: { [network]: { Aliases: [service] } } },
|
|
282
|
+
};
|
|
283
|
+
const used = [...new Set(sentinelNames(definition))].sort();
|
|
284
|
+
const versions = Object.fromEntries(used
|
|
285
|
+
.filter((name) => secretVersions[name] != null)
|
|
286
|
+
.map((name) => [name, secretVersions[name]]));
|
|
287
|
+
const hash = crypto
|
|
288
|
+
.createHash('sha256')
|
|
289
|
+
.update(JSON.stringify({ definition: { ...definition, Image: identity }, versions }))
|
|
290
|
+
.digest('hex')
|
|
291
|
+
.slice(0, 16);
|
|
292
|
+
const body = walkStrings(definition, (s) => substituteSentinels(s, secrets));
|
|
293
|
+
body.Labels = {
|
|
294
|
+
...body.Labels,
|
|
295
|
+
[LABEL_PROJECT]: project,
|
|
296
|
+
[LABEL_SERVICE]: service,
|
|
297
|
+
[LABEL_HASH]: hash,
|
|
298
|
+
...(desired.revision || desired.version
|
|
299
|
+
? { [LABEL_REVISION]: desired.revision || desired.version }
|
|
300
|
+
: {}),
|
|
301
|
+
};
|
|
302
|
+
const dependsOn = {};
|
|
303
|
+
const deps = src.depends_on;
|
|
304
|
+
if (Array.isArray(deps)) {
|
|
305
|
+
for (const dep of deps)
|
|
306
|
+
if (placed.has(String(dep)))
|
|
307
|
+
dependsOn[String(dep)] = 'service_started';
|
|
308
|
+
}
|
|
309
|
+
else if (deps && typeof deps === 'object') {
|
|
310
|
+
for (const [dep, spec] of Object.entries(deps))
|
|
311
|
+
if (placed.has(dep))
|
|
312
|
+
dependsOn[dep] =
|
|
313
|
+
spec?.condition === 'service_healthy'
|
|
314
|
+
? 'service_healthy'
|
|
315
|
+
: 'service_started';
|
|
316
|
+
}
|
|
317
|
+
specs[service] = {
|
|
318
|
+
service,
|
|
319
|
+
name: containerName(project, service, hash),
|
|
320
|
+
hash,
|
|
321
|
+
body,
|
|
322
|
+
dependsOn,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { network, volumes: volumeSpecs(desired, node, usedVolumes, nodeAddresses), specs };
|
|
326
|
+
}
|
|
327
|
+
/** Start order: dependencies first, ties by service name; a cycle is an error. */
|
|
328
|
+
export function startOrder(specs) {
|
|
329
|
+
const order = [];
|
|
330
|
+
const state = new Map();
|
|
331
|
+
const visit = (service, path) => {
|
|
332
|
+
if (state.get(service) === 'done')
|
|
333
|
+
return;
|
|
334
|
+
if (state.get(service) === 'visiting')
|
|
335
|
+
throw new Error(`depends_on cycle: ${[...path, service].join(' -> ')}`);
|
|
336
|
+
state.set(service, 'visiting');
|
|
337
|
+
for (const dep of Object.keys(specs[service].dependsOn).sort())
|
|
338
|
+
visit(dep, [...path, service]);
|
|
339
|
+
state.set(service, 'done');
|
|
340
|
+
order.push(service);
|
|
341
|
+
};
|
|
342
|
+
for (const service of Object.keys(specs).sort())
|
|
343
|
+
visit(service, []);
|
|
344
|
+
return order;
|
|
345
|
+
}
|
|
346
|
+
/** `body` with the host side of its (single) published port replaced; used for rolling revisions. */
|
|
347
|
+
export function withHostPort(body, port) {
|
|
348
|
+
const bindings = (body.HostConfig?.PortBindings || {});
|
|
349
|
+
return {
|
|
350
|
+
...body,
|
|
351
|
+
HostConfig: {
|
|
352
|
+
...body.HostConfig,
|
|
353
|
+
PortBindings: Object.fromEntries(Object.entries(bindings).map(([key, list]) => [
|
|
354
|
+
key,
|
|
355
|
+
list.map((b) => ({ ...b, HostPort: String(port) })),
|
|
356
|
+
])),
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
package/dist/control.d.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import { resolveImage } from './registry.ts';
|
|
3
4
|
import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
|
|
4
5
|
import https from 'node:https';
|
|
5
6
|
import http from 'node:http';
|
|
6
7
|
import { openDb } from './db.ts';
|
|
7
8
|
import type { NodeRow } from './db.ts';
|
|
8
|
-
import {
|
|
9
|
+
import { buildImage, run as runCommand } from './build.ts';
|
|
9
10
|
declare module 'express-serve-static-core' {
|
|
10
11
|
interface Request {
|
|
11
12
|
node?: NodeRow;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
export interface ControlImageOperations {
|
|
15
|
-
|
|
16
|
+
resolve: typeof resolveImage;
|
|
17
|
+
build: typeof buildImage;
|
|
16
18
|
save: typeof saveImageArchive;
|
|
17
19
|
inspect: typeof inspectImage;
|
|
18
20
|
validate: typeof validateImageArchive;
|