@wpelevator/tug 0.4.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 +0 -2
- package/dist/agent.js +8 -30
- 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 +2 -33
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +2 -4
- package/dist/control.d.ts +4 -2
- package/dist/control.js +342 -366
- 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.js +15 -3
- package/dist/protocol.d.ts +5 -5
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- 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/README.md
CHANGED
|
@@ -6,15 +6,15 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata.
|
|
10
|
-
- Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with
|
|
11
|
-
- Agents pull desired state from control,
|
|
9
|
+
- Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
|
|
10
|
+
- Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with one agent token per node. Agents only dial out, so nodes behind NAT work.
|
|
11
|
+
- Agents pull desired state from control, reconcile containers through the Docker Engine API, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
|
|
12
12
|
- `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
|
|
13
|
-
- Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control
|
|
13
|
+
- Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control. Published images are pinned to registry digests at deployment creation.
|
|
14
14
|
- Secrets encrypted at rest, released only to the nodes whose services reference them, with targeted service recreation when only a secret changes.
|
|
15
15
|
- Deployment convergence, per-project operation locks, cancellation, rollback, per-service rollout events and log fetching through the agents.
|
|
16
16
|
- Opt-in zero-downtime rolling rollouts for ingress-fronted services (`x-tug.rollout`) and automatic rollback of failed deployments (`x-tug.rollback: auto`). See [Compose extensions](docs/compose.md#rolling-rollouts).
|
|
17
|
-
-
|
|
17
|
+
- Private published images use encrypted project credentials for digest resolution and Engine API pulls.
|
|
18
18
|
- Optional WireGuard mesh between nodes, health-aware cross-node service names and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
|
|
19
19
|
- Private-by-default published ports: generated Compose binds them to the overlay IP, loopback or the node address, never every interface unless you write `0.0.0.0`. See [Networking](docs/networking.md#published-ports-private-by-default).
|
|
20
20
|
- Control-plane backup and restore, and `tug doctor` diagnostics.
|
|
@@ -31,7 +31,6 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
|
|
|
31
31
|
| Control API (role-checked) |
|
|
32
32
|
| SQLite: desired states, secrets |
|
|
33
33
|
| image builds, saved images |
|
|
34
|
-
| registry (optional) |
|
|
35
34
|
+-------------------------------------+
|
|
36
35
|
^ ^
|
|
37
36
|
| agents dial out: work, status,
|
|
@@ -72,7 +71,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
|
|
|
72
71
|
|
|
73
72
|
- [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
|
|
74
73
|
- [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
|
|
75
|
-
- [Control plane](docs/control-plane.md): install, TLS,
|
|
74
|
+
- [Control plane](docs/control-plane.md): install, TLS, image delivery, backup and restore.
|
|
76
75
|
- [Backups](docs/backups.md): interim WordPress backup sidecar with restic, per-node and NFS variants, and a restore drill until `x-tug.backup` (M11).
|
|
77
76
|
- [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
|
|
78
77
|
- [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
|
|
@@ -80,7 +79,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
|
|
|
80
79
|
- [CLI reference](docs/cli.md): every `tug` command and option.
|
|
81
80
|
- [Configuration](docs/configuration.md): CLI, control and agent environment variables.
|
|
82
81
|
- [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
|
|
83
|
-
- [Testing](docs/testing.md): test layers,
|
|
82
|
+
- [Testing](docs/testing.md): test layers, rollout metrics, mocked test backlog, and the [Caddy/PHP and static-file benchmark](docs/testing.md#caddy-proxy-overhead-benchmark).
|
|
84
83
|
- [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
|
|
85
84
|
- [Releases](docs/releases.md): changesets, npm and Docker image publishing.
|
|
86
85
|
- [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
|
package/dist/agent.d.ts
CHANGED
|
@@ -93,8 +93,6 @@ interface Creds {
|
|
|
93
93
|
agentToken: string;
|
|
94
94
|
nodeName?: string;
|
|
95
95
|
controlUrl?: string;
|
|
96
|
-
registry?: string | null;
|
|
97
|
-
registryToken?: string | null;
|
|
98
96
|
}
|
|
99
97
|
export declare function loadOrEnroll(): Promise<Creds>;
|
|
100
98
|
export declare function main(): Promise<void>;
|
package/dist/agent.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import { imageLocation } from "./registry.js";
|
|
3
4
|
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
5
|
import { Readable } from 'node:stream';
|
|
5
6
|
import { pipeline } from 'node:stream/promises';
|
|
6
7
|
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
7
8
|
import path from 'node:path';
|
|
8
|
-
import {
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import net from 'node:net';
|
|
10
11
|
import dns from 'node:dns/promises';
|
|
11
12
|
import { WebSocket } from 'ws';
|
|
@@ -837,7 +838,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
837
838
|
const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
|
|
838
839
|
const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
|
|
839
840
|
const db = await agentDb(ctx.stateDir);
|
|
840
|
-
const previousRow =
|
|
841
|
+
const previousRow = db.get('SELECT version, revision FROM applied_state WHERE project = ?', desiredState.project);
|
|
841
842
|
const rolling = serviceNames.filter((service) => isRolling(desiredState.compose.services[service]));
|
|
842
843
|
const rollingSet = new Set(rolling);
|
|
843
844
|
const mainServices = serviceNames.filter((service) => !rollingSet.has(service));
|
|
@@ -882,7 +883,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
882
883
|
.digest('hex')
|
|
883
884
|
.slice(0, 16);
|
|
884
885
|
const knownEvents = mainServices.length
|
|
885
|
-
?
|
|
886
|
+
? db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project)
|
|
886
887
|
: undefined;
|
|
887
888
|
const announce = knownEvents?.fingerprint !== eventFingerprint;
|
|
888
889
|
const wasFailed = !announce && knownEvents?.phase === 'failed';
|
|
@@ -894,7 +895,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
894
895
|
if (artifact)
|
|
895
896
|
await ensureArtifact(ctx, artifact);
|
|
896
897
|
},
|
|
897
|
-
pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image),
|
|
898
|
+
pullImage: (service) => ctx.docker.pullImage(plan.specs[service].body.Image, sec.registryAuth?.[imageLocation(plan.specs[service].body.Image).host]),
|
|
898
899
|
emit,
|
|
899
900
|
};
|
|
900
901
|
let failing;
|
|
@@ -938,13 +939,13 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
938
939
|
},
|
|
939
940
|
});
|
|
940
941
|
if (mainServices.length && (announce || wasFailed))
|
|
941
|
-
|
|
942
|
+
markEvents('serving');
|
|
942
943
|
}
|
|
943
944
|
catch (e) {
|
|
944
945
|
if (mainServices.length && !wasFailed) {
|
|
945
946
|
// Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
|
|
946
947
|
await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
|
|
947
|
-
|
|
948
|
+
markEvents('failed');
|
|
948
949
|
}
|
|
949
950
|
// Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
|
|
950
951
|
if (!rolling.length)
|
|
@@ -974,7 +975,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
974
975
|
for (const service of serviceNames)
|
|
975
976
|
serviceStates[service].rolloutState = 'serving';
|
|
976
977
|
}
|
|
977
|
-
|
|
978
|
+
db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
|
|
978
979
|
VALUES(?,?,?,?,?,?)
|
|
979
980
|
ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
|
|
980
981
|
service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
|
|
@@ -1122,19 +1123,6 @@ async function readJoinKey() {
|
|
|
1122
1123
|
}
|
|
1123
1124
|
return null;
|
|
1124
1125
|
}
|
|
1125
|
-
function dockerLogin(registry, user, password) {
|
|
1126
|
-
return new Promise((resolve, reject) => {
|
|
1127
|
-
const p = spawn('docker', ['login', registry, '-u', user, '--password-stdin'], {
|
|
1128
|
-
stdio: ['pipe', 'ignore', 'pipe'],
|
|
1129
|
-
});
|
|
1130
|
-
let err = '';
|
|
1131
|
-
p.stderr.on('data', (d) => (err += d));
|
|
1132
|
-
p.on('error', reject);
|
|
1133
|
-
p.on('close', (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
|
|
1134
|
-
p.stdin.write(password);
|
|
1135
|
-
p.stdin.end();
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
1126
|
async function enroll(joinKey) {
|
|
1139
1127
|
let decoded;
|
|
1140
1128
|
try {
|
|
@@ -1184,8 +1172,6 @@ async function enroll(joinKey) {
|
|
|
1184
1172
|
agentToken: out.agentToken,
|
|
1185
1173
|
nodeName: out.node?.name || NODE_NAME,
|
|
1186
1174
|
controlUrl,
|
|
1187
|
-
registry: out.registry || null,
|
|
1188
|
-
registryToken: out.registryToken || null,
|
|
1189
1175
|
};
|
|
1190
1176
|
await saveCreds(creds);
|
|
1191
1177
|
if (wgPublicKey)
|
|
@@ -1209,14 +1195,6 @@ export async function loadOrEnroll() {
|
|
|
1209
1195
|
NODE_NAME = creds.nodeName || NODE_NAME;
|
|
1210
1196
|
if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
|
|
1211
1197
|
CONTROL_URL = creds.controlUrl;
|
|
1212
|
-
if (APPLY && creds.registry && creds.registryToken) {
|
|
1213
|
-
try {
|
|
1214
|
-
await dockerLogin(creds.registry, NODE_NAME, creds.registryToken);
|
|
1215
|
-
}
|
|
1216
|
-
catch (e) {
|
|
1217
|
-
console.error(`registry login failed (pulls from ${creds.registry} may fail): ${errMessage(e)}`);
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
1198
|
return creds;
|
|
1221
1199
|
}
|
|
1222
1200
|
export async function main() {
|
package/dist/build.d.ts
CHANGED
|
@@ -12,11 +12,10 @@ export interface BuildSpec {
|
|
|
12
12
|
}
|
|
13
13
|
export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult>;
|
|
14
14
|
export declare function unpackContext(tarball: string, dest: string): Promise<void>;
|
|
15
|
-
export interface
|
|
15
|
+
export interface BuildImageOptions {
|
|
16
16
|
contextDir: string;
|
|
17
17
|
service: string;
|
|
18
18
|
buildSpec: string | BuildSpec;
|
|
19
19
|
imageRef: string;
|
|
20
|
-
push?: boolean;
|
|
21
20
|
}
|
|
22
|
-
export declare function
|
|
21
|
+
export declare function buildImage({ contextDir, service, buildSpec, imageRef, }: BuildImageOptions): Promise<void>;
|
package/dist/build.js
CHANGED
|
@@ -33,7 +33,7 @@ export async function unpackContext(tarball, dest) {
|
|
|
33
33
|
});
|
|
34
34
|
await tar.x({ file: tarball, cwd: dest, strict: true });
|
|
35
35
|
}
|
|
36
|
-
export async function
|
|
36
|
+
export async function buildImage({ contextDir, service, buildSpec, imageRef, }) {
|
|
37
37
|
const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
|
|
38
38
|
const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile
|
|
39
39
|
? path.resolve(contextDir, buildPath, buildSpec.dockerfile)
|
|
@@ -48,6 +48,4 @@ export async function buildAndPush({ contextDir, service, buildSpec, imageRef, p
|
|
|
48
48
|
if (!(await exists(fullContext)))
|
|
49
49
|
throw new Error(`build context not found for ${service}: ${buildPath}`);
|
|
50
50
|
await run('docker', ['build', ...dockerfile, '-t', imageRef, fullContext]);
|
|
51
|
-
if (push)
|
|
52
|
-
await run('docker', ['push', imageRef]);
|
|
53
51
|
}
|
package/dist/cli-build.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildImage } from './build.ts';
|
|
2
2
|
import { saveImageArchive } from './artifacts.ts';
|
|
3
3
|
import type { ComposeFile, LocalImageUpload } from './protocol.ts';
|
|
4
4
|
export interface LocalBuildDependencies {
|
|
5
|
-
build: typeof
|
|
5
|
+
build: typeof buildImage;
|
|
6
6
|
save: typeof saveImageArchive;
|
|
7
7
|
nonce: () => string;
|
|
8
8
|
remove: (imageRef: string) => Promise<void>;
|
package/dist/cli-build.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import * as tar from 'tar';
|
|
5
|
-
import {
|
|
5
|
+
import { buildImage, run } from "./build.js";
|
|
6
6
|
import { saveImageArchive } from "./artifacts.js";
|
|
7
7
|
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
|
|
8
8
|
export function sourceArchiveFilter(entry, configPaths, ignores) {
|
|
@@ -18,7 +18,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
|
18
18
|
const configPaths = configFiles
|
|
19
19
|
.map((file) => path.relative(contextDir, file).split(path.sep).join('/'))
|
|
20
20
|
.filter((file) => file !== '..' && !file.startsWith('../') && !path.isAbsolute(file));
|
|
21
|
-
const ignores = ['node_modules', 'data'
|
|
21
|
+
const ignores = ['node_modules', 'data'];
|
|
22
22
|
try {
|
|
23
23
|
ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
|
|
24
24
|
.split(/\r?\n/)
|
|
@@ -38,7 +38,7 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
40
40
|
export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
|
|
41
|
-
build:
|
|
41
|
+
build: buildImage,
|
|
42
42
|
save: saveImageArchive,
|
|
43
43
|
nonce: randomUUID,
|
|
44
44
|
remove: async (imageRef) => {
|
|
@@ -58,7 +58,6 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
|
|
|
58
58
|
service,
|
|
59
59
|
buildSpec: spec.build,
|
|
60
60
|
imageRef,
|
|
61
|
-
push: false,
|
|
62
61
|
});
|
|
63
62
|
const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
|
|
64
63
|
images.push({ service, imageRef, ...archive, field });
|
package/dist/cli.js
CHANGED
|
@@ -201,12 +201,6 @@ program
|
|
|
201
201
|
});
|
|
202
202
|
console.log(JSON.stringify(r, null, 2));
|
|
203
203
|
});
|
|
204
|
-
program
|
|
205
|
-
.command('node-rotate-registry-token <name>')
|
|
206
|
-
.description('issue a new registry pull token for a node, invalidating the old one')
|
|
207
|
-
.action(async (name) => {
|
|
208
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/rotate-registry-token`, {}), null, 2));
|
|
209
|
-
});
|
|
210
204
|
program
|
|
211
205
|
.command('node-rotate-wg-key <name>')
|
|
212
206
|
.description('generate a new WireGuard keypair on the node; peers pick up the new public key on their next poll')
|
|
@@ -234,12 +228,6 @@ program
|
|
|
234
228
|
enabled: !o.disable,
|
|
235
229
|
}), null, 2));
|
|
236
230
|
});
|
|
237
|
-
program
|
|
238
|
-
.command('node-revoke-registry-token <name>')
|
|
239
|
-
.description("revoke a node's registry pull token, blocking future pulls until rotated")
|
|
240
|
-
.action(async (name) => {
|
|
241
|
-
console.log(JSON.stringify(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/revoke-registry-token`, {}), null, 2));
|
|
242
|
-
});
|
|
243
231
|
program
|
|
244
232
|
.command('scale <service> <replicas>')
|
|
245
233
|
.description('scale a service to N replicas across matching nodes')
|
|
@@ -263,9 +251,6 @@ program
|
|
|
263
251
|
.option('--token <token>', 'operator token')
|
|
264
252
|
.option('--port <port>', 'published API port', '8080')
|
|
265
253
|
.option('--mesh', 'enable the WireGuard mesh on control (TUG_MESH=true); install nodes with --mesh too')
|
|
266
|
-
.option('--registry <host>', 'registry host used in desired images; bring your own registry, Tug does not provision one')
|
|
267
|
-
.option('--registry-auth-path <path>', 'container path to an htpasswd file tug-control should manage credentials in, if your registry uses one')
|
|
268
|
-
.option('--registry-container <name>', "docker container name to restart when a node's registry credentials change, if your registry uses an htpasswd file")
|
|
269
254
|
.action(async (sshTarget, o) => {
|
|
270
255
|
const operatorToken = o.token || process.env.TUG_TOKEN || token('tug_op');
|
|
271
256
|
if (o.installDocker)
|
|
@@ -274,16 +259,12 @@ program
|
|
|
274
259
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
275
260
|
if (o.buildLocalImage)
|
|
276
261
|
await transferTugImage(sshTarget, image);
|
|
277
|
-
const registryHost = o.registry || '';
|
|
278
262
|
const yamlValue = (value) => JSON.stringify(value);
|
|
279
263
|
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
280
264
|
IMAGE: yamlValue(image),
|
|
281
265
|
OPERATOR_TOKEN: yamlValue(operatorToken),
|
|
282
|
-
REGISTRY_HOST: yamlValue(registryHost),
|
|
283
266
|
PORT: o.port,
|
|
284
267
|
MESH: yamlValue(o.mesh ? 'true' : 'false'),
|
|
285
|
-
REGISTRY_AUTH_PATH: yamlValue(o.registryAuthPath || ''),
|
|
286
|
-
REGISTRY_CONTAINER: yamlValue(o.registryContainer || ''),
|
|
287
268
|
});
|
|
288
269
|
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
289
270
|
COMPOSE: compose,
|
|
@@ -295,7 +276,6 @@ program
|
|
|
295
276
|
sshTarget,
|
|
296
277
|
operatorToken,
|
|
297
278
|
url: `http://${remoteHost(sshTarget)}:${o.port}`,
|
|
298
|
-
registry: registryHost,
|
|
299
279
|
}, null, 2));
|
|
300
280
|
});
|
|
301
281
|
program
|
|
@@ -306,7 +286,6 @@ program
|
|
|
306
286
|
.option('--public-address <addr>')
|
|
307
287
|
.option('--label <label>', 'node label (repeatable)', collect, [])
|
|
308
288
|
.option('--agent-control-url <url>', 'control URL reachable by the agent (default: resolved operator control URL)')
|
|
309
|
-
.option('--registry <host>', 'registry host for docker login')
|
|
310
289
|
.option('--http-only-dev', 'allow plaintext HTTP control URL for local/private development')
|
|
311
290
|
.option('--mesh', 'join the WireGuard mesh (agent runs with host networking and NET_ADMIN; control needs TUG_MESH=true)')
|
|
312
291
|
.option('--wg-port <port>', 'WireGuard UDP port checked by preflight', '51820')
|
|
@@ -338,17 +317,13 @@ program
|
|
|
338
317
|
NODE_NAME: o.name,
|
|
339
318
|
AGENT_TOKEN: registered.agentToken,
|
|
340
319
|
})) + (o.mesh ? 'TUG_MESH=true\n' : '');
|
|
341
|
-
const dockerLogin = o.registry
|
|
342
|
-
? `printf %s ${sh(registered.registryToken)} | docker login ${sh(o.registry)} --username ${sh(o.name)} --password-stdin`
|
|
343
|
-
: 'true';
|
|
344
320
|
const remote = await loadTemplate(templatesDir, 'node-install.sh', {
|
|
345
321
|
ENV: env,
|
|
346
|
-
DOCKER_LOGIN: dockerLogin,
|
|
347
322
|
IMAGE_SH: sh(image),
|
|
348
323
|
RUN_FLAGS: `${o.mesh ? MESH_RUN_FLAGS : HOST_NETWORK_FLAG} `,
|
|
349
324
|
});
|
|
350
325
|
runLocal('ssh', ['--', sshTarget, remote]);
|
|
351
|
-
console.log(JSON.stringify({ ok: true, node: registered.node
|
|
326
|
+
console.log(JSON.stringify({ ok: true, node: registered.node }, null, 2));
|
|
352
327
|
});
|
|
353
328
|
program
|
|
354
329
|
.command('node-enroll')
|
|
@@ -463,7 +438,6 @@ program
|
|
|
463
438
|
.option('-f, --file <file>', 'compose file', 'docker-compose.yml')
|
|
464
439
|
.option('--version <version>', 'display version (default: git-<Compose hash>)')
|
|
465
440
|
.option('--build-local', 'build application images locally and upload them without source')
|
|
466
|
-
.option('--no-build-upload', 'deploy published images without uploading source; conflicts with --build-local')
|
|
467
441
|
.option('--no-wait', 'return after desired state is created')
|
|
468
442
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
469
443
|
.action(async (o) => {
|
|
@@ -488,8 +462,7 @@ program
|
|
|
488
462
|
await fs.rm(directory, { recursive: true, force: true });
|
|
489
463
|
}
|
|
490
464
|
}
|
|
491
|
-
else if (
|
|
492
|
-
Object.values(compose.services).some((service) => service.build)) {
|
|
465
|
+
else if (Object.values(compose.services).some((service) => service.build)) {
|
|
493
466
|
const tarball = await makeContext();
|
|
494
467
|
try {
|
|
495
468
|
const form = new FormData();
|
|
@@ -705,10 +678,6 @@ program.hook('preAction', async (_root, command) => {
|
|
|
705
678
|
if (options[key] !== undefined)
|
|
706
679
|
integerOption(String(options[key]), '--' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), min, max);
|
|
707
680
|
}
|
|
708
|
-
if (options.buildLocal && options.buildUpload === false)
|
|
709
|
-
throw new Error('--build-local and --no-build-upload cannot be combined');
|
|
710
|
-
if ((options.registryAuthPath || options.registryContainer) && !options.registry)
|
|
711
|
-
throw new Error('--registry-auth-path and --registry-container require --registry');
|
|
712
681
|
if (command.name() === 'control-restore' && !options.confirm)
|
|
713
682
|
throw new Error('control-restore requires --confirm');
|
|
714
683
|
const positional = command.name() === 'exec' ? command.args.slice(0, 1) : command.args;
|
package/dist/compose.d.ts
CHANGED
|
@@ -39,7 +39,6 @@ export interface BuildDesiredStateOptions {
|
|
|
39
39
|
project: string;
|
|
40
40
|
version: string;
|
|
41
41
|
compose: ComposeFile;
|
|
42
|
-
registry: string;
|
|
43
42
|
revision?: string;
|
|
44
43
|
defaultNode?: string | null;
|
|
45
44
|
nodes?: Array<{
|
|
@@ -47,7 +46,7 @@ export interface BuildDesiredStateOptions {
|
|
|
47
46
|
labels?: string[];
|
|
48
47
|
}>;
|
|
49
48
|
}
|
|
50
|
-
export declare function buildDesiredState({ project, version, compose,
|
|
49
|
+
export declare function buildDesiredState({ project, version, compose, revision, defaultNode, nodes, }: BuildDesiredStateOptions): DesiredState;
|
|
51
50
|
/** `extra_hosts` entries (`name:ip`) for services that run on other nodes, from health-aware endpoints. */
|
|
52
51
|
export declare function crossNodeHosts(project: string, endpoints: Record<string, ServiceEndpoint[]>, local: Set<string>): string[];
|
|
53
52
|
/**
|
package/dist/compose.js
CHANGED
|
@@ -305,7 +305,7 @@ export function validateCompose(compose, nodes = []) {
|
|
|
305
305
|
if (errors.length)
|
|
306
306
|
throw new Error(errors.join('; '));
|
|
307
307
|
}
|
|
308
|
-
export function buildDesiredState({ project, version, compose,
|
|
308
|
+
export function buildDesiredState({ project, version, compose, revision, defaultNode = null, nodes, }) {
|
|
309
309
|
const services = {};
|
|
310
310
|
for (const [name, src] of Object.entries(compose.services || {})) {
|
|
311
311
|
const lp = src['x-tug'] || {};
|
|
@@ -323,9 +323,7 @@ export function buildDesiredState({ project, version, compose, registry, revisio
|
|
|
323
323
|
}
|
|
324
324
|
placement = { ...placement, nodes: selected.map((node) => node.name) };
|
|
325
325
|
}
|
|
326
|
-
const image = src.build
|
|
327
|
-
? `${registry ? `${registry}/` : 'tug-build/'}${project}/${name}:${revision || version}`
|
|
328
|
-
: src.image;
|
|
326
|
+
const image = src.build ? `tug-build/${project}/${name}:${revision || version}` : src.image;
|
|
329
327
|
services[name] = {
|
|
330
328
|
image,
|
|
331
329
|
build: src.build || null,
|
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;
|