@wpelevator/tug 0.0.0 → 0.2.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 +63 -0
- package/dist/agent.d.ts +40 -0
- package/dist/agent.js +547 -0
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.js +44 -0
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +422 -0
- package/dist/compose.d.ts +33 -0
- package/dist/compose.js +260 -0
- package/dist/control.d.ts +24 -0
- package/dist/control.js +1105 -0
- package/dist/db.d.ts +117 -0
- package/dist/db.js +51 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +20 -0
- package/dist/ingress.d.ts +50 -0
- package/dist/ingress.js +180 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +22 -0
- package/dist/mesh.d.ts +70 -0
- package/dist/mesh.js +183 -0
- package/dist/protocol.d.ts +326 -0
- package/dist/protocol.js +8 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/runners.d.ts +12 -0
- package/dist/runners.js +10 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +29 -0
- package/dist/templates/agent.env +6 -0
- package/dist/templates/control-compose.yml +18 -0
- package/dist/templates/control-install.sh +7 -0
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +15 -0
- package/dist/util.js +35 -0
- package/package.json +62 -3
package/dist/compose.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { isDuration } from "./util.js";
|
|
5
|
+
export async function readCompose(file = 'docker-compose.yml') {
|
|
6
|
+
return yaml.load(await fs.readFile(file, 'utf8'));
|
|
7
|
+
}
|
|
8
|
+
export function composeHash(compose) {
|
|
9
|
+
return crypto.createHash('sha1').update(JSON.stringify(compose)).digest('hex').slice(0, 12);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The host port a service publishes for `port`, matching either side of a `ports` entry (a
|
|
13
|
+
* container port resolves to its published host port). Null when the port is not published.
|
|
14
|
+
*/
|
|
15
|
+
export function publishedPort(svc, port) {
|
|
16
|
+
for (const entry of svc.ports || []) {
|
|
17
|
+
if (typeof entry === 'object') {
|
|
18
|
+
const published = Number(entry.published);
|
|
19
|
+
if ((Number(entry.target) === port || published === port) && published > 0)
|
|
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;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const HOSTNAME_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
34
|
+
function validateIngress(name, svc, seen, errors) {
|
|
35
|
+
const ingress = svc['x-tug']?.ingress;
|
|
36
|
+
if (ingress === undefined)
|
|
37
|
+
return;
|
|
38
|
+
if (!ingress || typeof ingress !== 'object') {
|
|
39
|
+
errors.push(`${name}: x-tug.ingress must be an object`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const host = typeof ingress.host === 'string' ? ingress.host.toLowerCase() : '';
|
|
43
|
+
if (!HOSTNAME_RE.test(host))
|
|
44
|
+
errors.push(`${name}: ingress.host must be a fully qualified hostname`);
|
|
45
|
+
else if (seen.has(host))
|
|
46
|
+
errors.push(`${name}: ingress.host ${host} is already used by ${seen.get(host)}`);
|
|
47
|
+
else
|
|
48
|
+
seen.set(host, name);
|
|
49
|
+
const port = Number(ingress.port);
|
|
50
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
51
|
+
errors.push(`${name}: ingress.port must be a port number`);
|
|
52
|
+
else if (publishedPort(svc, port) == null)
|
|
53
|
+
errors.push(`${name}: ingress.port ${port} must be published in ports (e.g. "8080:${port}"); the edge reaches the service on its published host port`);
|
|
54
|
+
const tls = ingress.tls ?? 'auto';
|
|
55
|
+
if (!['auto', 'custom', 'off'].includes(tls))
|
|
56
|
+
errors.push(`${name}: ingress.tls must be auto, custom or off`);
|
|
57
|
+
if (tls === 'custom' && (!ingress.cert || !ingress.key))
|
|
58
|
+
errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
|
|
59
|
+
}
|
|
60
|
+
/** True when the service opted in to `x-tug.rollout.strategy: rolling`. */
|
|
61
|
+
export function isRolling(svc) { return svc?.['x-tug']?.rollout?.strategy === 'rolling'; }
|
|
62
|
+
function validateRollout(name, svc, errors) {
|
|
63
|
+
const rollout = svc['x-tug']?.rollout;
|
|
64
|
+
if (rollout === undefined)
|
|
65
|
+
return;
|
|
66
|
+
if (!rollout || typeof rollout !== 'object' || Array.isArray(rollout)) {
|
|
67
|
+
errors.push(`${name}: x-tug.rollout must be an object`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const key of Object.keys(rollout))
|
|
71
|
+
if (key !== 'strategy' && key !== 'drain')
|
|
72
|
+
errors.push(`${name}: x-tug.rollout.${key} is not supported (supported: strategy, drain)`);
|
|
73
|
+
const strategy = rollout.strategy ?? 'recreate';
|
|
74
|
+
if (!['recreate', 'rolling'].includes(strategy)) {
|
|
75
|
+
errors.push(`${name}: rollout.strategy must be recreate or rolling`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (rollout.drain !== undefined && !isDuration(rollout.drain))
|
|
79
|
+
errors.push(`${name}: rollout.drain must be a duration such as 30s, 500ms or 1m`);
|
|
80
|
+
if (strategy !== 'rolling')
|
|
81
|
+
return;
|
|
82
|
+
const lp = svc['x-tug'];
|
|
83
|
+
if (!lp.ingress)
|
|
84
|
+
errors.push(`${name}: rollout.strategy rolling requires x-tug.ingress (the edge is what switches traffic between revisions)`);
|
|
85
|
+
if (!lp.health)
|
|
86
|
+
errors.push(`${name}: rollout.strategy rolling requires x-tug.health (a new revision only takes over once it is healthy)`);
|
|
87
|
+
const ports = svc.ports || [];
|
|
88
|
+
if (ports.length !== 1)
|
|
89
|
+
errors.push(`${name}: rollout.strategy rolling requires exactly one entry in ports (the ingress port); each revision gets its own host port`);
|
|
90
|
+
}
|
|
91
|
+
function validateProjectExtension(compose, errors) {
|
|
92
|
+
const ext = compose['x-tug'];
|
|
93
|
+
if (ext === undefined)
|
|
94
|
+
return;
|
|
95
|
+
if (!ext || typeof ext !== 'object' || Array.isArray(ext)) {
|
|
96
|
+
errors.push('x-tug must be an object');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(ext))
|
|
100
|
+
if (key !== 'rollback')
|
|
101
|
+
errors.push(`x-tug.${key} is not supported (supported: rollback)`);
|
|
102
|
+
if (ext.rollback !== undefined && !['auto', 'manual'].includes(ext.rollback))
|
|
103
|
+
errors.push('x-tug.rollback must be auto or manual');
|
|
104
|
+
}
|
|
105
|
+
export function validateCompose(compose, nodes = []) {
|
|
106
|
+
if (!compose?.services || typeof compose.services !== 'object')
|
|
107
|
+
throw new Error('compose file must define services');
|
|
108
|
+
const errors = [];
|
|
109
|
+
const ingressHosts = new Map();
|
|
110
|
+
for (const [name, svc] of Object.entries(compose.services)) {
|
|
111
|
+
if (svc.container_name)
|
|
112
|
+
errors.push(`${name}: container_name is not supported`);
|
|
113
|
+
if (svc.deploy)
|
|
114
|
+
errors.push(`${name}: swarm deploy semantics are not supported`);
|
|
115
|
+
const lp = svc['x-tug'] || {};
|
|
116
|
+
const selected = lp.placement?.nodes || [];
|
|
117
|
+
if (nodes.length > 1 && selected.length === 0)
|
|
118
|
+
errors.push(`${name}: multi-node deployments require x-tug.placement.nodes`);
|
|
119
|
+
if (lp.replicas && selected.length && lp.replicas !== selected.length)
|
|
120
|
+
errors.push(`${name}: replicas must match placement node count in v1`);
|
|
121
|
+
validateIngress(name, svc, ingressHosts, errors);
|
|
122
|
+
validateRollout(name, svc, errors);
|
|
123
|
+
}
|
|
124
|
+
validateProjectExtension(compose, errors);
|
|
125
|
+
if (errors.length)
|
|
126
|
+
throw new Error(errors.join('; '));
|
|
127
|
+
}
|
|
128
|
+
export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null }) {
|
|
129
|
+
const services = {};
|
|
130
|
+
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
131
|
+
const lp = src['x-tug'] || {};
|
|
132
|
+
const placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
|
|
133
|
+
const image = src.build ? `${registry ? `${registry}/` : "tug-build/"}${project}/${name}:${revision || version}` : src.image;
|
|
134
|
+
services[name] = { image, build: src.build || null, placement, replicas: lp.replicas || placement.nodes?.length || 1, health: lp.health || null };
|
|
135
|
+
}
|
|
136
|
+
return { project, version, ...(revision ? { revision } : {}), services, compose };
|
|
137
|
+
}
|
|
138
|
+
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
139
|
+
export function crossNodeHosts(project, endpoints, local) {
|
|
140
|
+
const hosts = [];
|
|
141
|
+
for (const [service, list] of Object.entries(endpoints).sort(([a], [b]) => a.localeCompare(b))) {
|
|
142
|
+
if (local.has(service))
|
|
143
|
+
continue;
|
|
144
|
+
for (const ep of list)
|
|
145
|
+
hosts.push(`${service}:${ep.address}`, `${service}.${project}.tug:${ep.address}`);
|
|
146
|
+
}
|
|
147
|
+
return hosts;
|
|
148
|
+
}
|
|
149
|
+
/** Removes `depends_on` references to `names` (services that live in another Compose project). */
|
|
150
|
+
export function dropDependsOn(svc, names) {
|
|
151
|
+
const deps = svc.depends_on;
|
|
152
|
+
if (Array.isArray(deps)) {
|
|
153
|
+
const kept = deps.filter(d => !names.has(String(d)));
|
|
154
|
+
if (kept.length)
|
|
155
|
+
svc.depends_on = kept;
|
|
156
|
+
else
|
|
157
|
+
delete svc.depends_on;
|
|
158
|
+
}
|
|
159
|
+
else if (deps && typeof deps === 'object') {
|
|
160
|
+
const kept = Object.fromEntries(Object.entries(deps).filter(([dep]) => !names.has(dep)));
|
|
161
|
+
if (Object.keys(kept).length)
|
|
162
|
+
svc.depends_on = kept;
|
|
163
|
+
else
|
|
164
|
+
delete svc.depends_on;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function extraHostsList(value) {
|
|
168
|
+
if (!value)
|
|
169
|
+
return [];
|
|
170
|
+
return Array.isArray(value) ? value.map(String) : Object.entries(value).map(([k, v]) => `${k}:${v}`);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The Compose file for one node. `exclude` names services placed here that are managed outside the
|
|
174
|
+
* node's main project (rolling services run in their own revision projects); they still count as
|
|
175
|
+
* local, so they get no cross-node `extra_hosts` entry, and `depends_on` references to them are dropped.
|
|
176
|
+
*/
|
|
177
|
+
export function composeForNode(desiredState, nodeName, secrets = {}, endpoints = {}, exclude = new Set()) {
|
|
178
|
+
const out = { services: {}, volumes: {} };
|
|
179
|
+
const outVolumes = out.volumes;
|
|
180
|
+
const usedVolumes = new Set();
|
|
181
|
+
const placed = new Set();
|
|
182
|
+
for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
|
|
183
|
+
const nodes = desiredState.services[name].placement?.nodes || [];
|
|
184
|
+
if (nodes.length && !nodes.includes(nodeName))
|
|
185
|
+
continue;
|
|
186
|
+
placed.add(name);
|
|
187
|
+
if (exclude.has(name))
|
|
188
|
+
continue;
|
|
189
|
+
const svc = { ...src, image: desiredState.services[name].image };
|
|
190
|
+
delete svc.build;
|
|
191
|
+
delete svc['x-tug'];
|
|
192
|
+
delete svc.deploy;
|
|
193
|
+
delete svc.container_name;
|
|
194
|
+
if (desiredState.services[name].artifact)
|
|
195
|
+
svc.pull_policy = 'never';
|
|
196
|
+
if (Object.keys(secrets).length)
|
|
197
|
+
svc.env_file = Array.from(new Set([...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []), '.env']));
|
|
198
|
+
for (const vol of svc.volumes || []) {
|
|
199
|
+
const source = typeof vol === 'string' ? vol.split(':')[0] : vol.source;
|
|
200
|
+
if (source && desiredState.compose?.volumes?.[source])
|
|
201
|
+
usedVolumes.add(source);
|
|
202
|
+
}
|
|
203
|
+
if (exclude.size)
|
|
204
|
+
dropDependsOn(svc, exclude);
|
|
205
|
+
out.services[name] = svc;
|
|
206
|
+
}
|
|
207
|
+
const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
|
|
208
|
+
if (hosts.length) {
|
|
209
|
+
for (const svc of Object.values(out.services)) {
|
|
210
|
+
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]))];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const name of usedVolumes) {
|
|
216
|
+
const vol = desiredState.compose.volumes?.[name] || {};
|
|
217
|
+
const nodes = vol['x-tug']?.placement?.nodes || [];
|
|
218
|
+
if (!nodes.length || nodes.includes(nodeName))
|
|
219
|
+
outVolumes[name] = vol;
|
|
220
|
+
}
|
|
221
|
+
if (!Object.keys(outVolumes).length)
|
|
222
|
+
delete out.volumes;
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
export function serviceSecretNames(compose, serviceNames) {
|
|
226
|
+
const allowed = new Set();
|
|
227
|
+
for (const serviceName of serviceNames) {
|
|
228
|
+
const env = compose?.services?.[serviceName]?.environment || {};
|
|
229
|
+
if (Array.isArray(env)) {
|
|
230
|
+
for (const entry of env) {
|
|
231
|
+
const [key, value = ''] = String(entry).split('=');
|
|
232
|
+
if (key)
|
|
233
|
+
allowed.add(key);
|
|
234
|
+
for (const match of String(value).matchAll(/\$\{?([A-Z_][A-Z0-9_]*)[^}]*\}?/g))
|
|
235
|
+
allowed.add(match[1]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
for (const [key, value] of Object.entries(env)) {
|
|
240
|
+
if (typeof value === 'string' && value.match(/^\$\{?[A-Z_][A-Z0-9_]*(?::-[^}]*)?\}?$/)) {
|
|
241
|
+
const m = value.match(/^\$\{?([A-Z_][A-Z0-9_]*)/);
|
|
242
|
+
if (m)
|
|
243
|
+
allowed.add(m[1]);
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
allowed.add(key);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return allowed;
|
|
251
|
+
}
|
|
252
|
+
export function serviceSecretVersions(compose, serviceNames, versions = {}) {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const service of serviceNames) {
|
|
255
|
+
const names = serviceSecretNames(compose, [service]);
|
|
256
|
+
out[service] = Object.fromEntries([...names].filter(name => versions[name] != null).map(name => [name, versions[name]]));
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
export function dumpYaml(obj) { return yaml.dump(obj, { noRefs: true, lineWidth: 120 }); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { openDb } from './db.ts';
|
|
6
|
+
import type { NodeRow } from './db.ts';
|
|
7
|
+
import { buildAndPush, run as runCommand } from './build.ts';
|
|
8
|
+
declare module 'express-serve-static-core' {
|
|
9
|
+
interface Request {
|
|
10
|
+
node?: NodeRow;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export interface ControlImageOperations {
|
|
14
|
+
build: typeof buildAndPush;
|
|
15
|
+
save: typeof saveImageArchive;
|
|
16
|
+
inspect: typeof inspectImage;
|
|
17
|
+
validate: typeof validateImageArchive;
|
|
18
|
+
run: typeof runCommand;
|
|
19
|
+
}
|
|
20
|
+
export declare function createApp(options?: {
|
|
21
|
+
images?: Partial<ControlImageOperations>;
|
|
22
|
+
openDatabase?: typeof openDb;
|
|
23
|
+
}): Promise<import("express-serve-static-core").Express>;
|
|
24
|
+
export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;
|