@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/mesh.js
CHANGED
|
@@ -17,7 +17,11 @@ export const isWgKey = (value) => typeof value === 'string' && WG_KEY_RE.test(va
|
|
|
17
17
|
const PKCS8_X25519_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
18
18
|
/** Public key for a base64 private key (X25519, as `wg pubkey` computes it). */
|
|
19
19
|
export function publicKeyFromPrivate(privateKey) {
|
|
20
|
-
const key = crypto.createPrivateKey({
|
|
20
|
+
const key = crypto.createPrivateKey({
|
|
21
|
+
key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]),
|
|
22
|
+
format: 'der',
|
|
23
|
+
type: 'pkcs8',
|
|
24
|
+
});
|
|
21
25
|
const jwk = crypto.createPublicKey(key).export({ format: 'jwk' });
|
|
22
26
|
return Buffer.from(jwk.x, 'base64url').toString('base64');
|
|
23
27
|
}
|
|
@@ -35,13 +39,17 @@ export async function ensureWgKeys(stateDir) {
|
|
|
35
39
|
if (isWgKey(privateKey))
|
|
36
40
|
return { privateKey, publicKey: publicKeyFromPrivate(privateKey) };
|
|
37
41
|
}
|
|
38
|
-
catch {
|
|
42
|
+
catch {
|
|
43
|
+
/* first boot */
|
|
44
|
+
}
|
|
39
45
|
return rotateWgKeys(stateDir);
|
|
40
46
|
}
|
|
41
47
|
export async function rotateWgKeys(stateDir) {
|
|
42
48
|
const pair = generateWgKeyPair();
|
|
43
49
|
await ensureDir(wgDir(stateDir));
|
|
44
|
-
await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, {
|
|
50
|
+
await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, {
|
|
51
|
+
mode: 0o600,
|
|
52
|
+
});
|
|
45
53
|
return pair;
|
|
46
54
|
}
|
|
47
55
|
/** The public key control last accepted, so the agent only re-reports when it changed. */
|
|
@@ -60,7 +68,7 @@ export async function writeReportedKey(stateDir, publicKey) {
|
|
|
60
68
|
// ---- Overlay addressing (control) --------------------------------------------------------------
|
|
61
69
|
export function ipToInt(ip) {
|
|
62
70
|
const parts = ip.split('.').map(Number);
|
|
63
|
-
if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255))
|
|
71
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
|
|
64
72
|
throw new Error(`invalid IPv4 address ${ip}`);
|
|
65
73
|
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
66
74
|
}
|
|
@@ -68,7 +76,11 @@ export const intToIp = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n &
|
|
|
68
76
|
export function parseCidr(cidr) {
|
|
69
77
|
const [ip, bits, extra] = cidr.split('/');
|
|
70
78
|
const prefix = Number(bits);
|
|
71
|
-
if (extra !== undefined ||
|
|
79
|
+
if (extra !== undefined ||
|
|
80
|
+
bits === undefined ||
|
|
81
|
+
!Number.isInteger(prefix) ||
|
|
82
|
+
prefix < 8 ||
|
|
83
|
+
prefix > 29)
|
|
72
84
|
throw new Error(`invalid mesh CIDR ${cidr} (IPv4, prefix /8 to /29)`);
|
|
73
85
|
const size = 2 ** (32 - prefix);
|
|
74
86
|
const base = (ipToInt(ip) & ~(size - 1)) >>> 0;
|
|
@@ -114,28 +126,30 @@ export function parseWgDump(dump) {
|
|
|
114
126
|
return {
|
|
115
127
|
publicKey: h[1] || '',
|
|
116
128
|
listenPort: Number(h[2]) || 0,
|
|
117
|
-
peers: rest.map(line => {
|
|
129
|
+
peers: rest.map((line) => {
|
|
118
130
|
const f = line.split('\t');
|
|
119
131
|
return {
|
|
120
132
|
publicKey: f[0],
|
|
121
133
|
endpoint: f[2] && f[2] !== '(none)' ? f[2] : undefined,
|
|
122
134
|
allowedIps: f[3] && f[3] !== '(none)' ? f[3].split(',') : [],
|
|
123
|
-
keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0
|
|
135
|
+
keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0,
|
|
124
136
|
};
|
|
125
|
-
})
|
|
137
|
+
}),
|
|
126
138
|
};
|
|
127
139
|
}
|
|
128
140
|
/** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
|
|
129
141
|
export function diffPeers(desired, applied) {
|
|
130
|
-
const have = new Map(applied.map(p => [p.publicKey, p]));
|
|
131
|
-
const want = new Map(desired.map(p => [p.publicKey, p]));
|
|
142
|
+
const have = new Map(applied.map((p) => [p.publicKey, p]));
|
|
143
|
+
const want = new Map(desired.map((p) => [p.publicKey, p]));
|
|
132
144
|
const sameSet = (a, b) => a.length === b.length && [...a].sort().every((v, i) => v === [...b].sort()[i]);
|
|
133
145
|
const diff = { add: [], remove: [], update: [] };
|
|
134
146
|
for (const [key, peer] of want) {
|
|
135
147
|
const cur = have.get(key);
|
|
136
148
|
if (!cur)
|
|
137
149
|
diff.add.push(key);
|
|
138
|
-
else if (!sameSet(peer.allowedIps, cur.allowedIps) ||
|
|
150
|
+
else if (!sameSet(peer.allowedIps, cur.allowedIps) ||
|
|
151
|
+
peer.persistentKeepalive !== cur.keepalive ||
|
|
152
|
+
(peer.endpoint && peer.endpoint !== cur.endpoint))
|
|
139
153
|
diff.update.push(key);
|
|
140
154
|
}
|
|
141
155
|
for (const key of have.keys())
|
|
@@ -172,11 +186,16 @@ export async function reconcileMesh(host, stateDir, network) {
|
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
const diff = diffPeers(network.peers, applied.peers);
|
|
175
|
-
const synced = created ||
|
|
189
|
+
const synced = created ||
|
|
190
|
+
applied.publicKey !== publicKey ||
|
|
191
|
+
applied.listenPort !== network.listenPort ||
|
|
192
|
+
diff.add.length + diff.remove.length + diff.update.length > 0;
|
|
176
193
|
if (synced) {
|
|
177
194
|
const file = path.join(wgDir(stateDir), `${iface}.conf`);
|
|
178
195
|
await ensureDir(wgDir(stateDir));
|
|
179
|
-
await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), {
|
|
196
|
+
await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), {
|
|
197
|
+
mode: 0o600,
|
|
198
|
+
});
|
|
180
199
|
await host('wg', ['syncconf', iface, file]);
|
|
181
200
|
}
|
|
182
201
|
return { created, synced, diff };
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface Placement {
|
|
2
2
|
nodes?: string[];
|
|
3
|
+
labels?: string[];
|
|
4
|
+
global?: boolean;
|
|
3
5
|
}
|
|
4
6
|
export interface HealthCheck {
|
|
5
7
|
type: 'docker' | 'tcp' | 'http';
|
|
@@ -33,6 +35,7 @@ export interface RolloutSpec {
|
|
|
33
35
|
export interface ServiceExtension {
|
|
34
36
|
placement?: Placement;
|
|
35
37
|
replicas?: number;
|
|
38
|
+
global?: boolean;
|
|
36
39
|
health?: HealthCheck;
|
|
37
40
|
ingress?: IngressSpec;
|
|
38
41
|
rollout?: RolloutSpec;
|
|
@@ -48,6 +51,10 @@ export interface VolumeExtension {
|
|
|
48
51
|
driver?: string;
|
|
49
52
|
persistent?: boolean;
|
|
50
53
|
placement?: Placement;
|
|
54
|
+
/** NFS exporter address; control fills in the exporter's overlay IP (or node address) at deploy time. */
|
|
55
|
+
address?: string;
|
|
56
|
+
/** Directory exported by the NFS server on the exporter node (default `/srv/tug/<volume>`). */
|
|
57
|
+
export?: string;
|
|
51
58
|
}
|
|
52
59
|
export type BuildConfig = string | {
|
|
53
60
|
context?: string;
|
|
@@ -141,12 +148,14 @@ export interface Command {
|
|
|
141
148
|
created_at?: string;
|
|
142
149
|
updated_at?: string;
|
|
143
150
|
}
|
|
151
|
+
/** One diagnostic result; `warning` flags something to look at that does not fail the check. */
|
|
144
152
|
export interface DiagnosticCheck {
|
|
145
153
|
name: string;
|
|
146
154
|
ok: boolean;
|
|
147
155
|
stdout?: string;
|
|
148
156
|
stderr?: string;
|
|
149
157
|
error?: string;
|
|
158
|
+
warning?: string;
|
|
150
159
|
}
|
|
151
160
|
export type CommandResultPayload = {
|
|
152
161
|
checks: DiagnosticCheck[];
|
|
@@ -172,6 +181,138 @@ export interface CommandResult {
|
|
|
172
181
|
state: 'succeeded' | 'failed';
|
|
173
182
|
result: CommandResultPayload;
|
|
174
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Messages sent by control over the agent-initiated WebSocket. The stream is authenticated during the HTTP
|
|
186
|
+
* upgrade, so credentials never appear in the URL or a message. A wake is only a hint: the agent fetches the
|
|
187
|
+
* authoritative state from `/agent/v1/work` and continues periodic polling if notifications are lost.
|
|
188
|
+
*/
|
|
189
|
+
export type StreamMessage = {
|
|
190
|
+
type: 'ready';
|
|
191
|
+
nodeId: string;
|
|
192
|
+
cursor: number;
|
|
193
|
+
} | {
|
|
194
|
+
type: 'work-available';
|
|
195
|
+
cursor: number;
|
|
196
|
+
project?: string;
|
|
197
|
+
};
|
|
198
|
+
/** Live operations always resolve their project/service placement on control before reaching an agent. */
|
|
199
|
+
export type LiveRequest = {
|
|
200
|
+
operation: 'exec';
|
|
201
|
+
project: string;
|
|
202
|
+
service: string;
|
|
203
|
+
node?: string;
|
|
204
|
+
command: string[];
|
|
205
|
+
interactive: boolean;
|
|
206
|
+
tty: boolean;
|
|
207
|
+
columns?: number;
|
|
208
|
+
rows?: number;
|
|
209
|
+
} | {
|
|
210
|
+
operation: 'logs';
|
|
211
|
+
project: string;
|
|
212
|
+
service: string;
|
|
213
|
+
node?: string;
|
|
214
|
+
follow: true;
|
|
215
|
+
tail: number;
|
|
216
|
+
} | {
|
|
217
|
+
operation: 'ps';
|
|
218
|
+
project: string;
|
|
219
|
+
node?: string;
|
|
220
|
+
} | {
|
|
221
|
+
operation: 'inspect';
|
|
222
|
+
project: string;
|
|
223
|
+
service: string;
|
|
224
|
+
node?: string;
|
|
225
|
+
};
|
|
226
|
+
export declare const LIVE_MAX_FRAME_BYTES: number;
|
|
227
|
+
export declare const LIVE_MAX_BUFFERED_BYTES: number;
|
|
228
|
+
export declare const LIVE_MAX_SESSIONS = 64;
|
|
229
|
+
export declare const LIVE_START_TIMEOUT_MS = 10000;
|
|
230
|
+
export type LiveInput = {
|
|
231
|
+
v: 1;
|
|
232
|
+
type: 'live.stdin';
|
|
233
|
+
data: string;
|
|
234
|
+
} | {
|
|
235
|
+
v: 1;
|
|
236
|
+
type: 'live.stdin-end';
|
|
237
|
+
} | {
|
|
238
|
+
v: 1;
|
|
239
|
+
type: 'live.resize';
|
|
240
|
+
columns: number;
|
|
241
|
+
rows: number;
|
|
242
|
+
} | {
|
|
243
|
+
v: 1;
|
|
244
|
+
type: 'live.cancel';
|
|
245
|
+
};
|
|
246
|
+
export type OperatorLiveFrame = {
|
|
247
|
+
v: 1;
|
|
248
|
+
type: 'live.open';
|
|
249
|
+
request: LiveRequest;
|
|
250
|
+
} | LiveInput;
|
|
251
|
+
export type AgentLiveCommand = {
|
|
252
|
+
v: 1;
|
|
253
|
+
type: 'live.open';
|
|
254
|
+
sessionId: string;
|
|
255
|
+
request: LiveRequest;
|
|
256
|
+
} | (LiveInput & {
|
|
257
|
+
sessionId: string;
|
|
258
|
+
});
|
|
259
|
+
export type AgentLiveFrame = {
|
|
260
|
+
v: 1;
|
|
261
|
+
type: 'live.started';
|
|
262
|
+
sessionId: string;
|
|
263
|
+
} | {
|
|
264
|
+
v: 1;
|
|
265
|
+
type: 'live.output';
|
|
266
|
+
sessionId: string;
|
|
267
|
+
stream: 'stdout' | 'stderr';
|
|
268
|
+
data: string;
|
|
269
|
+
} | {
|
|
270
|
+
v: 1;
|
|
271
|
+
type: 'live.result';
|
|
272
|
+
sessionId: string;
|
|
273
|
+
result: unknown;
|
|
274
|
+
} | {
|
|
275
|
+
v: 1;
|
|
276
|
+
type: 'live.exit';
|
|
277
|
+
sessionId: string;
|
|
278
|
+
code: number | null;
|
|
279
|
+
signal: string | null;
|
|
280
|
+
cancelled: boolean;
|
|
281
|
+
error?: string;
|
|
282
|
+
};
|
|
283
|
+
export type OperatorLiveMessage = {
|
|
284
|
+
v: 1;
|
|
285
|
+
type: 'live.started';
|
|
286
|
+
sessionId: string;
|
|
287
|
+
node: string;
|
|
288
|
+
} | {
|
|
289
|
+
v: 1;
|
|
290
|
+
type: 'live.output';
|
|
291
|
+
sessionId: string;
|
|
292
|
+
stream: 'stdout' | 'stderr';
|
|
293
|
+
data: string;
|
|
294
|
+
} | {
|
|
295
|
+
v: 1;
|
|
296
|
+
type: 'live.result';
|
|
297
|
+
sessionId: string;
|
|
298
|
+
value: unknown;
|
|
299
|
+
} | {
|
|
300
|
+
v: 1;
|
|
301
|
+
type: 'live.exit';
|
|
302
|
+
sessionId: string;
|
|
303
|
+
code: number;
|
|
304
|
+
} | {
|
|
305
|
+
v: 1;
|
|
306
|
+
type: 'live.error';
|
|
307
|
+
sessionId?: string;
|
|
308
|
+
message: string;
|
|
309
|
+
};
|
|
310
|
+
/** Response from the long-poll fallback used when a WebSocket cannot be established. */
|
|
311
|
+
export type WakeResponse = {
|
|
312
|
+
type: 'work-available' | 'timeout';
|
|
313
|
+
cursor: number;
|
|
314
|
+
project?: string;
|
|
315
|
+
};
|
|
175
316
|
export interface WorkResponse {
|
|
176
317
|
serverTime: string;
|
|
177
318
|
nodeId: string;
|
|
@@ -184,6 +325,18 @@ export interface WorkResponse {
|
|
|
184
325
|
serviceEndpoints?: ServiceEndpoints;
|
|
185
326
|
/** Edge proxy config; present only for nodes marked as ingress nodes. */
|
|
186
327
|
ingress?: IngressWork;
|
|
328
|
+
/** Host addresses for published ports that name no host IP. */
|
|
329
|
+
ports?: PortBinding;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Where a node's published ports listen when a `ports` entry names no host IP. Entries with an
|
|
333
|
+
* explicit host IP (including `0.0.0.0`) are never changed.
|
|
334
|
+
*/
|
|
335
|
+
export interface PortBinding {
|
|
336
|
+
/** The node's overlay IP with the mesh, otherwise its `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
|
|
337
|
+
address: string | null;
|
|
338
|
+
/** Per project, services whose ingress port only the managed Caddy on this node dials; that port binds to `127.0.0.1`. */
|
|
339
|
+
loopback: Record<string, string[]>;
|
|
187
340
|
}
|
|
188
341
|
export interface WgPeer {
|
|
189
342
|
node: string;
|
|
@@ -285,6 +438,7 @@ export interface NodeInfo {
|
|
|
285
438
|
address: string | null;
|
|
286
439
|
public_address: string | null;
|
|
287
440
|
overlay_ip?: string | null;
|
|
441
|
+
labels?: string[];
|
|
288
442
|
status: string;
|
|
289
443
|
created_at: string;
|
|
290
444
|
updated_at: string;
|
|
@@ -303,6 +457,8 @@ export interface EnrollRequest {
|
|
|
303
457
|
publicAddress?: string;
|
|
304
458
|
/** Base64 WireGuard public key; the private key never leaves the node. */
|
|
305
459
|
wgPublicKey?: string;
|
|
460
|
+
/** Node labels (tags) used for label-based placement. */
|
|
461
|
+
labels?: string[];
|
|
306
462
|
}
|
|
307
463
|
/** Response of `POST /agent/v1/enroll`. */
|
|
308
464
|
export interface EnrollResponse extends RegisterResponse {
|
|
@@ -323,4 +479,6 @@ export interface JoinKey {
|
|
|
323
479
|
enrollToken: string;
|
|
324
480
|
/** PEM CA certificate to trust for a private/self-signed control plane. */
|
|
325
481
|
ca?: string;
|
|
482
|
+
/** Node labels to register at enrollment. */
|
|
483
|
+
labels?: string[];
|
|
326
484
|
}
|
package/dist/protocol.js
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
// control.ts, agent.ts, compose.ts and cli.ts all import from here.
|
|
4
4
|
// ---- Deployments -------------------------------------------------------------------------------
|
|
5
5
|
/** Deployment states during which a project is considered busy. */
|
|
6
|
-
export const ACTIVE_DEPLOYMENT_STATES = [
|
|
6
|
+
export const ACTIVE_DEPLOYMENT_STATES = [
|
|
7
|
+
'uploading',
|
|
8
|
+
'building',
|
|
9
|
+
'pending_dependencies',
|
|
10
|
+
'rolling_out',
|
|
11
|
+
];
|
|
7
12
|
/** Deployment states a CLI wait loop stops on. */
|
|
8
|
-
export const TERMINAL_DEPLOYMENT_STATES = [
|
|
13
|
+
export const TERMINAL_DEPLOYMENT_STATES = [
|
|
14
|
+
'succeeded',
|
|
15
|
+
'failed',
|
|
16
|
+
'build_failed',
|
|
17
|
+
'cancelled',
|
|
18
|
+
];
|
|
19
|
+
export const LIVE_MAX_FRAME_BYTES = 256 * 1024;
|
|
20
|
+
export const LIVE_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
21
|
+
export const LIVE_MAX_SESSIONS = 64;
|
|
22
|
+
export const LIVE_START_TIMEOUT_MS = 10_000;
|
package/dist/rollout.js
CHANGED
|
@@ -27,7 +27,7 @@ export function revisionProject(project, service, hash) {
|
|
|
27
27
|
}
|
|
28
28
|
/** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
|
|
29
29
|
export function remapPublishedPort(ports, port) {
|
|
30
|
-
return ports.map(entry => {
|
|
30
|
+
return ports.map((entry) => {
|
|
31
31
|
if (typeof entry === 'number')
|
|
32
32
|
return entry;
|
|
33
33
|
if (typeof entry === 'object')
|
|
@@ -44,7 +44,11 @@ export function remapPublishedPort(ports, port) {
|
|
|
44
44
|
export function serviceRevisionHash(desired, rendered, service, secretVersions = {}) {
|
|
45
45
|
const artifact = desired.services[service]?.artifact;
|
|
46
46
|
const definition = { ...rendered, image: artifact?.imageId ?? rendered.image };
|
|
47
|
-
return crypto
|
|
47
|
+
return crypto
|
|
48
|
+
.createHash('sha256')
|
|
49
|
+
.update(JSON.stringify({ definition, secretVersions }))
|
|
50
|
+
.digest('hex')
|
|
51
|
+
.slice(0, 16);
|
|
48
52
|
}
|
|
49
53
|
/** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
|
|
50
54
|
export function rollingCompose(full, project, service, port) {
|
|
@@ -52,7 +56,10 @@ export function rollingCompose(full, project, service, port) {
|
|
|
52
56
|
delete svc.depends_on;
|
|
53
57
|
if (port !== null && svc.ports)
|
|
54
58
|
svc.ports = remapPublishedPort(svc.ports, port);
|
|
55
|
-
const out = {
|
|
59
|
+
const out = {
|
|
60
|
+
services: { [service]: svc },
|
|
61
|
+
networks: { default: { name: `${project}_default`, external: true } },
|
|
62
|
+
};
|
|
56
63
|
const volumes = {};
|
|
57
64
|
for (const mount of svc.volumes || []) {
|
|
58
65
|
const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
|
|
@@ -60,7 +67,8 @@ export function rollingCompose(full, project, service, port) {
|
|
|
60
67
|
if (!source || !def)
|
|
61
68
|
continue;
|
|
62
69
|
// Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
|
|
63
|
-
volumes[source] =
|
|
70
|
+
volumes[source] =
|
|
71
|
+
def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
|
|
64
72
|
}
|
|
65
73
|
if (Object.keys(volumes).length)
|
|
66
74
|
out.volumes = volumes;
|
|
@@ -89,7 +97,11 @@ export function composeBase(project, dir, file) {
|
|
|
89
97
|
return ['compose', '-p', project, '--project-directory', dir, '-f', file];
|
|
90
98
|
}
|
|
91
99
|
async function containerIds(ctx, project, service) {
|
|
92
|
-
const filters = [
|
|
100
|
+
const filters = [
|
|
101
|
+
'--filter',
|
|
102
|
+
`label=${LABEL_PROJECT}=${project}`,
|
|
103
|
+
...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : []),
|
|
104
|
+
];
|
|
93
105
|
return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
|
|
94
106
|
}
|
|
95
107
|
/** Stops and removes every container of a revision project (or of one service of the main project). */
|
|
@@ -104,7 +116,17 @@ async function removeRevision(ctx, dir, project, service) {
|
|
|
104
116
|
}
|
|
105
117
|
async function ensureSharedResources(ctx, project, services, full) {
|
|
106
118
|
const network = `${project}_default`;
|
|
107
|
-
await ctx
|
|
119
|
+
await ctx
|
|
120
|
+
.docker(['network', 'inspect', network])
|
|
121
|
+
.catch(() => ctx.docker([
|
|
122
|
+
'network',
|
|
123
|
+
'create',
|
|
124
|
+
'--label',
|
|
125
|
+
`${LABEL_PROJECT}=${project}`,
|
|
126
|
+
'--label',
|
|
127
|
+
'com.docker.compose.network=default',
|
|
128
|
+
network,
|
|
129
|
+
]));
|
|
108
130
|
const created = new Set();
|
|
109
131
|
for (const service of services) {
|
|
110
132
|
for (const mount of full.services[service].volumes || []) {
|
|
@@ -114,14 +136,24 @@ async function ensureSharedResources(ctx, project, services, full) {
|
|
|
114
136
|
continue;
|
|
115
137
|
created.add(source);
|
|
116
138
|
const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
|
|
117
|
-
await ctx.docker([
|
|
139
|
+
await ctx.docker([
|
|
140
|
+
'volume',
|
|
141
|
+
'create',
|
|
142
|
+
...(def.driver ? ['--driver', String(def.driver)] : []),
|
|
143
|
+
...opts,
|
|
144
|
+
'--label',
|
|
145
|
+
`${LABEL_PROJECT}=${project}`,
|
|
146
|
+
'--label',
|
|
147
|
+
`com.docker.compose.volume=${source}`,
|
|
148
|
+
`${project}_${source}`,
|
|
149
|
+
]);
|
|
118
150
|
}
|
|
119
151
|
}
|
|
120
152
|
}
|
|
121
153
|
/** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
|
|
122
154
|
async function publishedHostPorts(ctx) {
|
|
123
155
|
const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
|
|
124
|
-
return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap(m => {
|
|
156
|
+
return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap((m) => {
|
|
125
157
|
const lo = Number(m[1]);
|
|
126
158
|
const hi = Number(m[2] || m[1]);
|
|
127
159
|
return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
|
|
@@ -133,7 +165,7 @@ async function allocatePort(ctx, known, exclude) {
|
|
|
133
165
|
for (const port of [...known, ...exclude])
|
|
134
166
|
busy.add(port);
|
|
135
167
|
// Start after the newest port in use so a just-released port is not reused straight away.
|
|
136
|
-
const last = Math.max(lo - 1, ...known.filter(p => p >= lo && p <= hi));
|
|
168
|
+
const last = Math.max(lo - 1, ...known.filter((p) => p >= lo && p <= hi));
|
|
137
169
|
const size = hi - lo + 1;
|
|
138
170
|
for (let i = 0; i < size; i++) {
|
|
139
171
|
const port = lo + ((last + 1 - lo + i) % size);
|
|
@@ -144,7 +176,14 @@ async function allocatePort(ctx, known, exclude) {
|
|
|
144
176
|
}
|
|
145
177
|
const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
|
|
146
178
|
function servingState(rev, extra = {}) {
|
|
147
|
-
return {
|
|
179
|
+
return {
|
|
180
|
+
rolloutState: 'serving',
|
|
181
|
+
image: rev.image,
|
|
182
|
+
healthyAt: now(),
|
|
183
|
+
...(rev.legacy ? {} : { port: rev.port }),
|
|
184
|
+
rolloutRevision: rev.hash,
|
|
185
|
+
...extra,
|
|
186
|
+
};
|
|
148
187
|
}
|
|
149
188
|
/** True when containers of `services` still run in the main project from before they became rolling. */
|
|
150
189
|
export async function hasLegacyContainers(ctx, project, services) {
|
|
@@ -153,7 +192,9 @@ export async function hasLegacyContainers(ctx, project, services) {
|
|
|
153
192
|
return true;
|
|
154
193
|
return false;
|
|
155
194
|
}
|
|
156
|
-
export function isRollingService(desired, service) {
|
|
195
|
+
export function isRollingService(desired, service) {
|
|
196
|
+
return isRolling(desired.compose.services[service]);
|
|
197
|
+
}
|
|
157
198
|
export async function reconcileRolling(ctx, input) {
|
|
158
199
|
const { desired, dir, services, full, deps } = input;
|
|
159
200
|
const project = desired.project;
|
|
@@ -164,7 +205,7 @@ export async function reconcileRolling(ctx, input) {
|
|
|
164
205
|
await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
|
|
165
206
|
await ensureSharedResources(ctx, project, services, full);
|
|
166
207
|
for (const service of services) {
|
|
167
|
-
const rollout = state.services[service] ||= { draining: [] };
|
|
208
|
+
const rollout = (state.services[service] ||= { draining: [] });
|
|
168
209
|
const spec = desired.compose.services[service];
|
|
169
210
|
const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
|
|
170
211
|
const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
|
|
@@ -194,21 +235,32 @@ export async function reconcileRolling(ctx, input) {
|
|
|
194
235
|
}
|
|
195
236
|
catch (e) {
|
|
196
237
|
result.errors[service] = errMessage(e);
|
|
197
|
-
result.serviceStates[service] = {
|
|
238
|
+
result.serviceStates[service] = {
|
|
239
|
+
rolloutState: 'health_checking',
|
|
240
|
+
image,
|
|
241
|
+
rolloutRevision: hash,
|
|
242
|
+
};
|
|
198
243
|
}
|
|
199
244
|
continue;
|
|
200
245
|
}
|
|
201
246
|
const key = `${desired.revision || desired.version}:${hash}`;
|
|
202
247
|
const reject = (error) => {
|
|
203
248
|
result.errors[service] = error;
|
|
204
|
-
result.serviceStates[service] = rollout.active
|
|
249
|
+
result.serviceStates[service] = rollout.active
|
|
250
|
+
? servingState(rollout.active, { rolloutError: error })
|
|
251
|
+
: { rolloutState: 'health_checking', image, rolloutRevision: hash };
|
|
205
252
|
};
|
|
206
253
|
if (rollout.failed?.key === key) {
|
|
207
254
|
reject(rollout.failed.error);
|
|
208
255
|
continue;
|
|
209
256
|
}
|
|
210
257
|
try {
|
|
211
|
-
const candidate = rollout.candidate ?? {
|
|
258
|
+
const candidate = rollout.candidate ?? {
|
|
259
|
+
hash,
|
|
260
|
+
project: revProject,
|
|
261
|
+
port: await allocatePort(ctx, knownPorts(rollout), new Set()),
|
|
262
|
+
image,
|
|
263
|
+
};
|
|
212
264
|
rollout.candidate = candidate;
|
|
213
265
|
await saveRolloutState(dir, state);
|
|
214
266
|
await deps.emit(service, 'pulling');
|
|
@@ -235,7 +287,12 @@ export async function reconcileRolling(ctx, input) {
|
|
|
235
287
|
await deps.waitForHealth(revProject, service, file);
|
|
236
288
|
// Takeover: the next status report carries the new port and control re-renders the ingress.
|
|
237
289
|
if (rollout.active)
|
|
238
|
-
rollout.draining.push({
|
|
290
|
+
rollout.draining.push({
|
|
291
|
+
service,
|
|
292
|
+
project: rollout.active.project,
|
|
293
|
+
legacy: rollout.active.legacy,
|
|
294
|
+
drainUntil: Date.now() + drainMs,
|
|
295
|
+
});
|
|
239
296
|
rollout.active = { ...candidate };
|
|
240
297
|
delete rollout.candidate;
|
|
241
298
|
delete rollout.failed;
|
|
@@ -258,20 +315,29 @@ export async function reconcileRolling(ctx, input) {
|
|
|
258
315
|
return result;
|
|
259
316
|
}
|
|
260
317
|
function knownPorts(rollout) {
|
|
261
|
-
return [rollout.active, rollout.candidate]
|
|
318
|
+
return [rollout.active, rollout.candidate]
|
|
319
|
+
.filter((r) => Boolean(r && !r.legacy))
|
|
320
|
+
.map((r) => r.port);
|
|
262
321
|
}
|
|
263
322
|
/** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
|
|
264
323
|
async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
|
|
265
324
|
for (const [service, rollout] of Object.entries(state.services)) {
|
|
266
325
|
if (!rolling.has(service)) {
|
|
267
326
|
// No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
|
|
268
|
-
const stillRuns = desired.compose.services[service] &&
|
|
327
|
+
const stillRuns = desired.compose.services[service] &&
|
|
328
|
+
(desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) ||
|
|
329
|
+
!desired.services[service]?.placement?.nodes?.length);
|
|
269
330
|
if (rollout.candidate) {
|
|
270
331
|
await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
|
|
271
332
|
delete rollout.candidate;
|
|
272
333
|
}
|
|
273
334
|
if (rollout.active && stillRuns) {
|
|
274
|
-
rollout.draining.push({
|
|
335
|
+
rollout.draining.push({
|
|
336
|
+
service,
|
|
337
|
+
project: rollout.active.project,
|
|
338
|
+
legacy: rollout.active.legacy,
|
|
339
|
+
drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN),
|
|
340
|
+
});
|
|
275
341
|
delete rollout.active;
|
|
276
342
|
}
|
|
277
343
|
else if (rollout.active) {
|
package/dist/secrets.js
CHANGED
|
@@ -10,7 +10,9 @@ export async function loadOrCreateMasterKey(file) {
|
|
|
10
10
|
await fs.writeFile(file, key, { mode: 0o600 });
|
|
11
11
|
return key;
|
|
12
12
|
}
|
|
13
|
-
function keyBytes(key) {
|
|
13
|
+
function keyBytes(key) {
|
|
14
|
+
return crypto.createHash('sha256').update(key).digest();
|
|
15
|
+
}
|
|
14
16
|
export function encrypt(value, masterKey) {
|
|
15
17
|
const iv = crypto.randomBytes(12);
|
|
16
18
|
const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(masterKey), iv);
|
package/dist/tokens.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Request, RequestHandler } from 'express';
|
|
|
2
2
|
import type { Db } from './db.ts';
|
|
3
3
|
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
4
4
|
export declare const ROLES: readonly ["read", "deploy", "admin"];
|
|
5
|
-
export type Role = typeof ROLES[number];
|
|
5
|
+
export type Role = (typeof ROLES)[number];
|
|
6
6
|
export type RouteRole = Role | 'public';
|
|
7
7
|
/** Name recorded in the audit trail for the root operator token. */
|
|
8
8
|
export declare const OPERATOR_ACTOR = "operator";
|