@wpelevator/tug 0.1.0 → 0.2.1
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 +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- 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.js +17 -2
- 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.js +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
package/dist/compose.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import { isDuration } from "./util.js";
|
|
4
5
|
export async function readCompose(file = 'docker-compose.yml') {
|
|
5
6
|
return yaml.load(await fs.readFile(file, 'utf8'));
|
|
6
7
|
}
|
|
@@ -56,6 +57,51 @@ function validateIngress(name, svc, seen, errors) {
|
|
|
56
57
|
if (tls === 'custom' && (!ingress.cert || !ingress.key))
|
|
57
58
|
errors.push(`${name}: ingress.tls custom requires cert and key (names of project secrets holding the PEM certificate and private key)`);
|
|
58
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
|
+
}
|
|
59
105
|
export function validateCompose(compose, nodes = []) {
|
|
60
106
|
if (!compose?.services || typeof compose.services !== 'object')
|
|
61
107
|
throw new Error('compose file must define services');
|
|
@@ -73,19 +119,21 @@ export function validateCompose(compose, nodes = []) {
|
|
|
73
119
|
if (lp.replicas && selected.length && lp.replicas !== selected.length)
|
|
74
120
|
errors.push(`${name}: replicas must match placement node count in v1`);
|
|
75
121
|
validateIngress(name, svc, ingressHosts, errors);
|
|
122
|
+
validateRollout(name, svc, errors);
|
|
76
123
|
}
|
|
124
|
+
validateProjectExtension(compose, errors);
|
|
77
125
|
if (errors.length)
|
|
78
126
|
throw new Error(errors.join('; '));
|
|
79
127
|
}
|
|
80
|
-
export function buildDesiredState({ project, version, compose, registry, defaultNode = null }) {
|
|
128
|
+
export function buildDesiredState({ project, version, compose, registry, revision, defaultNode = null }) {
|
|
81
129
|
const services = {};
|
|
82
130
|
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
83
131
|
const lp = src['x-tug'] || {};
|
|
84
132
|
const placement = lp.placement || (defaultNode ? { nodes: [defaultNode] } : {});
|
|
85
|
-
const image = src.build ? `${registry}
|
|
133
|
+
const image = src.build ? `${registry ? `${registry}/` : "tug-build/"}${project}/${name}:${revision || version}` : src.image;
|
|
86
134
|
services[name] = { image, build: src.build || null, placement, replicas: lp.replicas || placement.nodes?.length || 1, health: lp.health || null };
|
|
87
135
|
}
|
|
88
|
-
return { project, version, services, compose };
|
|
136
|
+
return { project, version, ...(revision ? { revision } : {}), services, compose };
|
|
89
137
|
}
|
|
90
138
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
91
139
|
export function crossNodeHosts(project, endpoints, local) {
|
|
@@ -98,25 +146,53 @@ export function crossNodeHosts(project, endpoints, local) {
|
|
|
98
146
|
}
|
|
99
147
|
return hosts;
|
|
100
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
|
+
}
|
|
101
167
|
function extraHostsList(value) {
|
|
102
168
|
if (!value)
|
|
103
169
|
return [];
|
|
104
170
|
return Array.isArray(value) ? value.map(String) : Object.entries(value).map(([k, v]) => `${k}:${v}`);
|
|
105
171
|
}
|
|
106
|
-
|
|
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()) {
|
|
107
178
|
const out = { services: {}, volumes: {} };
|
|
108
179
|
const outVolumes = out.volumes;
|
|
109
180
|
const usedVolumes = new Set();
|
|
181
|
+
const placed = new Set();
|
|
110
182
|
for (const [name, src] of Object.entries(desiredState.compose.services || {})) {
|
|
111
|
-
const
|
|
112
|
-
const nodes = lp.placement?.nodes || [];
|
|
183
|
+
const nodes = desiredState.services[name].placement?.nodes || [];
|
|
113
184
|
if (nodes.length && !nodes.includes(nodeName))
|
|
114
185
|
continue;
|
|
186
|
+
placed.add(name);
|
|
187
|
+
if (exclude.has(name))
|
|
188
|
+
continue;
|
|
115
189
|
const svc = { ...src, image: desiredState.services[name].image };
|
|
116
190
|
delete svc.build;
|
|
117
191
|
delete svc['x-tug'];
|
|
118
192
|
delete svc.deploy;
|
|
119
193
|
delete svc.container_name;
|
|
194
|
+
if (desiredState.services[name].artifact)
|
|
195
|
+
svc.pull_policy = 'never';
|
|
120
196
|
if (Object.keys(secrets).length)
|
|
121
197
|
svc.env_file = Array.from(new Set([...(Array.isArray(svc.env_file) ? svc.env_file : svc.env_file ? [svc.env_file] : []), '.env']));
|
|
122
198
|
for (const vol of svc.volumes || []) {
|
|
@@ -124,9 +200,11 @@ export function composeForNode(desiredState, nodeName, secrets = {}, endpoints =
|
|
|
124
200
|
if (source && desiredState.compose?.volumes?.[source])
|
|
125
201
|
usedVolumes.add(source);
|
|
126
202
|
}
|
|
203
|
+
if (exclude.size)
|
|
204
|
+
dropDependsOn(svc, exclude);
|
|
127
205
|
out.services[name] = svc;
|
|
128
206
|
}
|
|
129
|
-
const hosts = crossNodeHosts(desiredState.project, endpoints,
|
|
207
|
+
const hosts = crossNodeHosts(desiredState.project, endpoints, placed);
|
|
130
208
|
if (hosts.length) {
|
|
131
209
|
for (const svc of Object.values(out.services)) {
|
|
132
210
|
const existing = extraHostsList(svc.extra_hosts);
|
package/dist/control.d.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from 'express';
|
|
3
|
+
import { saveImageArchive, inspectImage, validateImageArchive } from './artifacts.ts';
|
|
3
4
|
import https from 'node:https';
|
|
5
|
+
import { openDb } from './db.ts';
|
|
4
6
|
import type { NodeRow } from './db.ts';
|
|
7
|
+
import { buildAndPush, run as runCommand } from './build.ts';
|
|
5
8
|
declare module 'express-serve-static-core' {
|
|
6
9
|
interface Request {
|
|
7
10
|
node?: NodeRow;
|
|
8
11
|
}
|
|
9
12
|
}
|
|
10
|
-
export
|
|
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; /** Minimum time between event prunes triggered by agent reports; default one hour. */
|
|
23
|
+
eventPruneIntervalMs?: number;
|
|
24
|
+
}): Promise<import("express-serve-static-core").Express>;
|
|
11
25
|
export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;
|