@wpelevator/tug 0.10.0 → 0.12.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 +172 -75
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +39 -3
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +33 -25
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.d.ts +8 -1
- package/dist/backup-restore.js +135 -71
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +7 -2
- package/dist/cli-config.js +25 -46
- package/dist/cli-install.d.ts +4 -2
- package/dist/cli-install.js +12 -7
- package/dist/cli-live.js +4 -6
- package/dist/cli-onboarding.d.ts +31 -0
- package/dist/cli-onboarding.js +66 -0
- package/dist/cli-prompt.d.ts +9 -0
- package/dist/cli-prompt.js +60 -0
- package/dist/cli-secrets.d.ts +12 -0
- package/dist/cli-secrets.js +71 -0
- package/dist/cli-status.d.ts +11 -0
- package/dist/cli-status.js +37 -7
- package/dist/cli.js +66 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.js +111 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +111 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +159 -0
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +13 -0
- package/dist/commands/host.d.ts +24 -0
- package/dist/commands/host.js +122 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +40 -0
- package/dist/commands/node.d.ts +28 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/observe.d.ts +2 -0
- package/dist/commands/observe.js +71 -0
- package/dist/commands/restore.d.ts +2 -0
- package/dist/commands/restore.js +85 -0
- package/dist/commands/secret.d.ts +2 -0
- package/dist/commands/secret.js +26 -0
- package/dist/commands/service.d.ts +2 -0
- package/dist/commands/service.js +62 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +13 -0
- package/dist/control.js +145 -14
- package/dist/credentials.js +2 -0
- package/dist/diagnostics.js +1 -1
- package/dist/docker-api.d.ts +1 -0
- package/dist/live.js +1 -0
- package/package.json +1 -1
package/dist/backup-restore.js
CHANGED
|
@@ -8,8 +8,36 @@ const VERIFY_TIMEOUT_MS = 5 * MINUTE;
|
|
|
8
8
|
/** Restore states in which the project must stay locked. */
|
|
9
9
|
const LOCKED_STATES = ['locking', 'holding', 'safety_backup', 'restoring', 'interrupted'];
|
|
10
10
|
const TERMINAL = ['succeeded', 'failed', 'released'];
|
|
11
|
+
const HOST_KEY = /host|server|addr|endpoint|url|uri|dsn/i;
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
+
* True when an environment entry points at `service` over the network: a host-like variable that is exactly the
|
|
14
|
+
* service name (`WORDPRESS_DB_HOST=db`), or a value that names it as a host (`db:3306`, `mysql://u:p@db/app`,
|
|
15
|
+
* `mysql:host=db;dbname=app`). A service reached across nodes has no `depends_on` to find it by.
|
|
16
|
+
*/
|
|
17
|
+
export function environmentReaches(service, key, value) {
|
|
18
|
+
const name = service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
if (value === service)
|
|
20
|
+
return HOST_KEY.test(key);
|
|
21
|
+
return [
|
|
22
|
+
new RegExp(`^${name}:\\d+$`),
|
|
23
|
+
new RegExp(`(?:://|@)(?:[^/@\\s]*@)?${name}(?::\\d+)?(?:[/?\\s]|$)`),
|
|
24
|
+
new RegExp(`(?:host|server)=${name}(?:[;:,\\s]|$)`, 'i'),
|
|
25
|
+
].some((pattern) => pattern.test(value));
|
|
26
|
+
}
|
|
27
|
+
function environmentOf(service) {
|
|
28
|
+
const env = service.environment;
|
|
29
|
+
if (Array.isArray(env))
|
|
30
|
+
return env.map(String).map((entry) => {
|
|
31
|
+
const at = entry.indexOf('=');
|
|
32
|
+
return at < 0 ? [entry, ''] : [entry.slice(0, at), entry.slice(at + 1)];
|
|
33
|
+
});
|
|
34
|
+
return env && typeof env === 'object'
|
|
35
|
+
? Object.entries(env).map(([k, v]) => [k, String(v ?? '')])
|
|
36
|
+
: [];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The services that write to a target. For a service they are the services that depend on it, transitively,
|
|
40
|
+
* plus the services whose environment names it as a host, which is how a service on another node reaches it (only for the target itself, not for what depends on the writers);
|
|
13
41
|
* for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
|
|
14
42
|
* because the per-node copies drop cross-node dependencies.
|
|
15
43
|
*/
|
|
@@ -34,7 +62,12 @@ export function discoverWriters(compose, target) {
|
|
|
34
62
|
while (queue.length) {
|
|
35
63
|
const current = queue.pop();
|
|
36
64
|
for (const service of Object.keys(compose.services))
|
|
37
|
-
if (!writers.has(service) &&
|
|
65
|
+
if (!writers.has(service) &&
|
|
66
|
+
service !== name &&
|
|
67
|
+
(dependsOn(service).includes(current) ||
|
|
68
|
+
(kind === 'service' &&
|
|
69
|
+
current === name &&
|
|
70
|
+
environmentOf(compose.services[service]).some(([k, v]) => environmentReaches(name, k, v))))) {
|
|
38
71
|
writers.add(service);
|
|
39
72
|
queue.push(service);
|
|
40
73
|
}
|
|
@@ -106,7 +139,7 @@ export function createRestoreService(deps) {
|
|
|
106
139
|
return { error: `writer ${writer} is not a service of ${request.project}`, status: 400 };
|
|
107
140
|
if (!writers.length && !request.noWriters && !request.writers)
|
|
108
141
|
return {
|
|
109
|
-
error: `no services were found that write to ${request.target}; pass --writers to name them or --no-writers to restore without holding any`,
|
|
142
|
+
error: `no services were found that write to ${request.target} (looked at depends_on, volume mounts and host names in environment variables); pass --writers to name them or --no-writers to restore without holding any`,
|
|
110
143
|
status: 409,
|
|
111
144
|
};
|
|
112
145
|
}
|
|
@@ -136,8 +169,16 @@ export function createRestoreService(deps) {
|
|
|
136
169
|
function unlock(restore) {
|
|
137
170
|
db.run('DELETE FROM locks WHERE name=? AND owner=?', `project:${restore.project_id}`, `restore:${restore.id}`);
|
|
138
171
|
}
|
|
139
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Drops a restore's holds and lock. Holds a deploy placed for a move (no `restore_id`) are dropped with them,
|
|
174
|
+
* unless `keepMoveHolds`: a restore that failed before changing data must not start the writers against the
|
|
175
|
+
* empty database it was meant to fill.
|
|
176
|
+
*/
|
|
177
|
+
function release(restore, keepMoveHolds = false) {
|
|
140
178
|
db.run('DELETE FROM service_holds WHERE restore_id=?', restore.id);
|
|
179
|
+
if (!keepMoveHolds)
|
|
180
|
+
for (const service of JSON.parse(restore.writers_json))
|
|
181
|
+
db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service);
|
|
141
182
|
unlock(restore);
|
|
142
183
|
deps.wake(restore.project_id);
|
|
143
184
|
}
|
|
@@ -166,8 +207,12 @@ export function createRestoreService(deps) {
|
|
|
166
207
|
else
|
|
167
208
|
move(restore, 'holding');
|
|
168
209
|
if (restore.mode === 'in-place')
|
|
169
|
-
for (const service of restorePlan.writers)
|
|
210
|
+
for (const service of restorePlan.writers) {
|
|
211
|
+
// A hold a deploy placed for a move stays as it is; this restore drops it when it succeeds.
|
|
212
|
+
if (db.get('SELECT 1 FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service))
|
|
213
|
+
continue;
|
|
170
214
|
db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', restore.project_id, service, `restore ${restoreId}`, restoreId, ts);
|
|
215
|
+
}
|
|
171
216
|
deps.wake(restore.project_id);
|
|
172
217
|
advance();
|
|
173
218
|
return {
|
|
@@ -189,15 +234,32 @@ export function createRestoreService(deps) {
|
|
|
189
234
|
});
|
|
190
235
|
});
|
|
191
236
|
};
|
|
237
|
+
/** Says which writers never reported `held` and why: the node's own apply error, or that it has not reported. */
|
|
238
|
+
const holdDiagnosis = (restore, writers) => {
|
|
239
|
+
const desired = deployed(restore.project_id);
|
|
240
|
+
const problems = [];
|
|
241
|
+
for (const service of writers)
|
|
242
|
+
for (const node of desired?.services[service]?.placement.nodes ?? []) {
|
|
243
|
+
const row = db.get('SELECT s.* FROM node_statuses s JOIN nodes n ON n.id=s.node_id WHERE n.name=? AND s.project_id=?', node, restore.project_id);
|
|
244
|
+
const status = row ? JSON.parse(row.status_json) : undefined;
|
|
245
|
+
const state = status?.serviceStates?.[service]?.rolloutState;
|
|
246
|
+
if (state === 'held')
|
|
247
|
+
continue;
|
|
248
|
+
problems.push(!status
|
|
249
|
+
? `${service} on ${node}: the node has not reported`
|
|
250
|
+
: `${service} on ${node}: ${state ?? 'no state'}${status.error ? `; the node's apply failed with: ${status.error}` : ''}`);
|
|
251
|
+
}
|
|
252
|
+
return problems.join('; ');
|
|
253
|
+
};
|
|
192
254
|
/** Fails a restore. Before anything changed, holds and the lock are dropped; after, they stay until released. */
|
|
193
255
|
function fail(restore, error, dataChanged) {
|
|
194
256
|
if (dataChanged)
|
|
195
257
|
move(restore, 'interrupted', { error });
|
|
196
258
|
else {
|
|
197
|
-
release(restore);
|
|
259
|
+
release(restore, true);
|
|
198
260
|
move(restore, 'failed', { error });
|
|
199
261
|
}
|
|
200
|
-
event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore
|
|
262
|
+
event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore release' : ''}`, { error, held: dataChanged });
|
|
201
263
|
}
|
|
202
264
|
function queueRestoreJob(restore) {
|
|
203
265
|
const queued = backups.queueRestore(restore.project_id, restore.target, {
|
|
@@ -210,72 +272,74 @@ export function createRestoreService(deps) {
|
|
|
210
272
|
move(restore, 'restoring', { commandId: queued.commandId });
|
|
211
273
|
db.run('UPDATE restore_runs SET node_id=? WHERE id=?', queued.nodeId, restore.id);
|
|
212
274
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// Keep the project locked while a restore is unfinished, across a control restart.
|
|
219
|
-
if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
|
|
220
|
-
lock(restore);
|
|
221
|
-
const writers = JSON.parse(restore.writers_json);
|
|
222
|
-
switch (restore.state) {
|
|
223
|
-
case 'holding': {
|
|
224
|
-
if (!writersReport(restore, writers, 'held')) {
|
|
225
|
-
if (age > HOLD_TIMEOUT_MS)
|
|
226
|
-
fail(restore, 'the writers did not stop in time', false);
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
if (!restore.safety_backup) {
|
|
230
|
-
queueRestoreJob(restore);
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
|
|
234
|
-
if (!safety.runs.length)
|
|
235
|
-
return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
|
|
236
|
-
move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
|
-
case 'safety_backup': {
|
|
240
|
-
const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
|
|
241
|
-
if (!run || run.state === 'queued' || run.state === 'running')
|
|
242
|
-
break;
|
|
243
|
-
if (run.state === 'succeeded')
|
|
244
|
-
queueRestoreJob(restore);
|
|
245
|
-
else
|
|
246
|
-
fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
case 'restoring': {
|
|
250
|
-
const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
|
|
251
|
-
if (!command || ['pending', 'running'].includes(command.state))
|
|
252
|
-
break;
|
|
253
|
-
if (command.state === 'succeeded') {
|
|
254
|
-
if (restore.mode === 'in-place')
|
|
255
|
-
release(restore);
|
|
256
|
-
// The data is back; the restore is done when the services that were stopped are serving again.
|
|
257
|
-
if (restore.mode === 'in-place' && writers.length)
|
|
258
|
-
move(restore, 'verifying');
|
|
259
|
-
else
|
|
260
|
-
complete(restore);
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
const error = JSON.parse(command.result_json ?? '{}').error ??
|
|
264
|
-
`the restore command ${command.state}`;
|
|
265
|
-
fail(restore, error, restore.mode === 'in-place');
|
|
266
|
-
}
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
case 'verifying': {
|
|
270
|
-
if (writersReport(restore, writers, 'serving'))
|
|
271
|
-
complete(restore);
|
|
272
|
-
else if (age > VERIFY_TIMEOUT_MS)
|
|
273
|
-
complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
default:
|
|
275
|
+
function advanceHolding(restore, writers, age) {
|
|
276
|
+
if (!writersReport(restore, writers, 'held')) {
|
|
277
|
+
if (age > HOLD_TIMEOUT_MS) {
|
|
278
|
+
const why = holdDiagnosis(restore, writers);
|
|
279
|
+
fail(restore, `the writers did not stop in time${why ? ` (${why})` : ''}`, false);
|
|
277
280
|
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!restore.safety_backup)
|
|
284
|
+
return queueRestoreJob(restore);
|
|
285
|
+
const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
|
|
286
|
+
if (!safety.runs.length)
|
|
287
|
+
return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
|
|
288
|
+
move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
|
|
289
|
+
}
|
|
290
|
+
function advanceSafetyBackup(restore) {
|
|
291
|
+
const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
|
|
292
|
+
if (!run || run.state === 'queued' || run.state === 'running')
|
|
293
|
+
return;
|
|
294
|
+
if (run.state === 'succeeded')
|
|
295
|
+
return queueRestoreJob(restore);
|
|
296
|
+
fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
|
|
297
|
+
}
|
|
298
|
+
function advanceRestoring(restore, writers) {
|
|
299
|
+
const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
|
|
300
|
+
if (!command || ['pending', 'running'].includes(command.state))
|
|
301
|
+
return;
|
|
302
|
+
if (command.state !== 'succeeded') {
|
|
303
|
+
const error = JSON.parse(command.result_json ?? '{}').error ??
|
|
304
|
+
`the restore command ${command.state}`;
|
|
305
|
+
return fail(restore, error, restore.mode === 'in-place');
|
|
278
306
|
}
|
|
307
|
+
if (restore.mode === 'in-place')
|
|
308
|
+
release(restore);
|
|
309
|
+
// The data is back; the restore is done when the services that were stopped are serving again.
|
|
310
|
+
if (restore.mode === 'in-place' && writers.length)
|
|
311
|
+
move(restore, 'verifying');
|
|
312
|
+
else
|
|
313
|
+
complete(restore);
|
|
314
|
+
}
|
|
315
|
+
function advanceVerifying(restore, writers, age) {
|
|
316
|
+
if (writersReport(restore, writers, 'serving'))
|
|
317
|
+
return complete(restore);
|
|
318
|
+
if (age > VERIFY_TIMEOUT_MS)
|
|
319
|
+
complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
|
|
320
|
+
}
|
|
321
|
+
function advanceRestore(restore) {
|
|
322
|
+
const age = clock().getTime() -
|
|
323
|
+
new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
|
|
324
|
+
// Keep the project locked while a restore is unfinished, across a control restart.
|
|
325
|
+
if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
|
|
326
|
+
lock(restore);
|
|
327
|
+
const writers = JSON.parse(restore.writers_json);
|
|
328
|
+
switch (restore.state) {
|
|
329
|
+
case 'holding':
|
|
330
|
+
return advanceHolding(restore, writers, age);
|
|
331
|
+
case 'safety_backup':
|
|
332
|
+
return advanceSafetyBackup(restore);
|
|
333
|
+
case 'restoring':
|
|
334
|
+
return advanceRestoring(restore, writers);
|
|
335
|
+
case 'verifying':
|
|
336
|
+
return advanceVerifying(restore, writers, age);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Advances every restore that is waiting on something; safe to call often. */
|
|
340
|
+
function advance() {
|
|
341
|
+
for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`))
|
|
342
|
+
advanceRestore(restore);
|
|
279
343
|
}
|
|
280
344
|
function complete(restore, warning) {
|
|
281
345
|
move(restore, 'succeeded', warning ? { error: warning } : {});
|
package/dist/backup-scheduler.js
CHANGED
|
@@ -189,7 +189,7 @@ export function createBackupService(deps) {
|
|
|
189
189
|
actor,
|
|
190
190
|
});
|
|
191
191
|
if (runId)
|
|
192
|
-
event('warn', 'backup.no_destination', schedule.project_id, `backup of ${schedule.target} skipped: no backup destination is configured; run tug backup
|
|
192
|
+
event('warn', 'backup.no_destination', schedule.project_id, `backup of ${schedule.target} skipped: no backup destination is configured; run tug backup destination set`, {
|
|
193
193
|
runId,
|
|
194
194
|
target: schedule.target,
|
|
195
195
|
}, null, actor);
|
package/dist/cli-build.js
CHANGED
|
@@ -29,7 +29,7 @@ export function sourceArchiveFilter(entry, configPaths, ignores) {
|
|
|
29
29
|
return false;
|
|
30
30
|
return !compileIgnore(ignores)(relative);
|
|
31
31
|
}
|
|
32
|
-
/** Credential documents
|
|
32
|
+
/** Credential documents, such as the copy `control setup --credential-out` writes (conventionally `*.credential.json`). */
|
|
33
33
|
const CREDENTIAL_FILE = /\.credential\.json$/;
|
|
34
34
|
const ignoreCache = new WeakMap();
|
|
35
35
|
/** Translates one `.dockerignore` pattern: `*`, `?` and `[...]` classes stay within a path segment, `**` crosses them. */
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -44,13 +44,18 @@ export declare function localConfigPath(file: string): string;
|
|
|
44
44
|
export declare function resolveCliConfig(options: ConnectionOptions, env: NodeJS.ProcessEnv, cwd: string, creating?: boolean): Promise<ResolvedCliConfig>;
|
|
45
45
|
/** `nodeDefaults` under the node's own keys; a list in the node replaces the default list. */
|
|
46
46
|
export declare function nodeSettings(config: ResolvedCliConfig, name?: string): SettingValues;
|
|
47
|
-
interface
|
|
47
|
+
interface Named {
|
|
48
48
|
name(): string;
|
|
49
|
+
parent?: Named | null;
|
|
50
|
+
}
|
|
51
|
+
interface OptionSource extends Named {
|
|
49
52
|
options: readonly {
|
|
50
53
|
attributeName(): string;
|
|
51
54
|
}[];
|
|
52
55
|
getOptionValueSource(key: string): string | undefined;
|
|
53
56
|
}
|
|
57
|
+
/** The space-joined command names from the first subject down, such as `node add`; `setup` is `control setup`. */
|
|
58
|
+
export declare function commandPath(command: Named): string;
|
|
54
59
|
/**
|
|
55
60
|
* Use config values as defaults for the options of a command: a key applies to a command that lists it
|
|
56
61
|
* and has an option of that name, and never over a value given on the command line.
|
|
@@ -61,7 +66,7 @@ export declare function applyConfigDefaults(command: OptionSource, values: objec
|
|
|
61
66
|
* overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
|
|
62
67
|
* connection flags passed to `init` are saved to the overlay instead.
|
|
63
68
|
*/
|
|
64
|
-
export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions): Promise<string[]>;
|
|
69
|
+
export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions, credentialUrl?: string): Promise<string[]>;
|
|
65
70
|
export declare function normalizeControlUrl(value: string): string;
|
|
66
71
|
export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
|
|
67
72
|
export {};
|
package/dist/cli-config.js
CHANGED
|
@@ -11,14 +11,7 @@ export const COMMAND_SETTINGS = {
|
|
|
11
11
|
wait: {
|
|
12
12
|
type: 'boolean',
|
|
13
13
|
flag: '--no-wait',
|
|
14
|
-
commands: [
|
|
15
|
-
'deploy',
|
|
16
|
-
'rollback',
|
|
17
|
-
'backup-run',
|
|
18
|
-
'node-install',
|
|
19
|
-
'node-replace-identity',
|
|
20
|
-
'node-repair-identity',
|
|
21
|
-
],
|
|
14
|
+
commands: ['deploy', 'rollback', 'backup run', 'node add', 'node replace', 'node repair'],
|
|
22
15
|
},
|
|
23
16
|
};
|
|
24
17
|
/** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
|
|
@@ -26,61 +19,32 @@ export const NODE_SETTINGS = {
|
|
|
26
19
|
ssh: {
|
|
27
20
|
type: 'string',
|
|
28
21
|
flag: '<ssh-target>',
|
|
29
|
-
commands: ['node
|
|
22
|
+
commands: ['node add', 'node replace', 'node repair'],
|
|
30
23
|
},
|
|
31
24
|
address: {
|
|
32
25
|
type: 'string',
|
|
33
26
|
flag: '--address',
|
|
34
|
-
commands: [
|
|
35
|
-
'node-install',
|
|
36
|
-
'node-replace-identity',
|
|
37
|
-
'node-repair-identity',
|
|
38
|
-
'node-register',
|
|
39
|
-
'node-prepare-enrollment',
|
|
40
|
-
],
|
|
27
|
+
commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
|
|
41
28
|
},
|
|
42
29
|
label: {
|
|
43
30
|
type: 'list',
|
|
44
31
|
flag: '--label',
|
|
45
|
-
commands: [
|
|
46
|
-
'node-install',
|
|
47
|
-
'node-replace-identity',
|
|
48
|
-
'node-repair-identity',
|
|
49
|
-
'node-register',
|
|
50
|
-
'node-prepare-enrollment',
|
|
51
|
-
],
|
|
32
|
+
commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
|
|
52
33
|
},
|
|
53
34
|
installDocker: {
|
|
54
35
|
type: 'boolean',
|
|
55
36
|
flag: '--install-docker',
|
|
56
|
-
commands: [
|
|
57
|
-
'node-install',
|
|
58
|
-
'node-replace-identity',
|
|
59
|
-
'node-repair-identity',
|
|
60
|
-
'node-prepare-enrollment',
|
|
61
|
-
'control-install',
|
|
62
|
-
],
|
|
37
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
|
|
63
38
|
},
|
|
64
39
|
image: {
|
|
65
40
|
type: 'string',
|
|
66
41
|
flag: '--image',
|
|
67
|
-
commands: [
|
|
68
|
-
'node-install',
|
|
69
|
-
'node-replace-identity',
|
|
70
|
-
'node-repair-identity',
|
|
71
|
-
'node-prepare-enrollment',
|
|
72
|
-
'control-install',
|
|
73
|
-
],
|
|
42
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
|
|
74
43
|
},
|
|
75
44
|
agentControlUrl: {
|
|
76
45
|
type: 'string',
|
|
77
46
|
flag: '--agent-control-url',
|
|
78
|
-
commands: [
|
|
79
|
-
'node-install',
|
|
80
|
-
'node-replace-identity',
|
|
81
|
-
'node-repair-identity',
|
|
82
|
-
'node-prepare-enrollment',
|
|
83
|
-
],
|
|
47
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll'],
|
|
84
48
|
},
|
|
85
49
|
};
|
|
86
50
|
const NODE_SPECIFIC_KEYS = ['ssh', 'address'];
|
|
@@ -147,6 +111,16 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
|
147
111
|
export function nodeSettings(config, name) {
|
|
148
112
|
return { ...config.nodeDefaults, ...(name ? config.nodes[name] : undefined) };
|
|
149
113
|
}
|
|
114
|
+
/** Top-level shortcuts that run a grouped command, so config keys and validation treat them alike. */
|
|
115
|
+
const SHORTCUTS = { setup: 'control setup' };
|
|
116
|
+
/** The space-joined command names from the first subject down, such as `node add`; `setup` is `control setup`. */
|
|
117
|
+
export function commandPath(command) {
|
|
118
|
+
const names = [command.name()];
|
|
119
|
+
for (let parent = command.parent; parent?.parent; parent = parent.parent)
|
|
120
|
+
names.unshift(parent.name());
|
|
121
|
+
const path = names.join(' ');
|
|
122
|
+
return SHORTCUTS[path] ?? path;
|
|
123
|
+
}
|
|
150
124
|
/**
|
|
151
125
|
* Use config values as defaults for the options of a command: a key applies to a command that lists it
|
|
152
126
|
* and has an option of that name, and never over a value given on the command line.
|
|
@@ -154,7 +128,7 @@ export function nodeSettings(config, name) {
|
|
|
154
128
|
export function applyConfigDefaults(command, values, settings) {
|
|
155
129
|
const attributes = new Set(command.options.map((option) => option.attributeName()));
|
|
156
130
|
for (const [key, value] of Object.entries(settings)) {
|
|
157
|
-
if (SETTINGS[key]?.commands.includes(command
|
|
131
|
+
if (SETTINGS[key]?.commands.includes(commandPath(command)) &&
|
|
158
132
|
attributes.has(key) &&
|
|
159
133
|
command.getOptionValueSource(key) !== 'cli')
|
|
160
134
|
values[key] = value;
|
|
@@ -298,11 +272,16 @@ function serialize(file, data, notice) {
|
|
|
298
272
|
* overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
|
|
299
273
|
* connection flags passed to `init` are saved to the overlay instead.
|
|
300
274
|
*/
|
|
301
|
-
export async function writeInitConfig(config, credentialFile, explicit) {
|
|
275
|
+
export async function writeInitConfig(config, credentialFile, explicit, credentialUrl) {
|
|
302
276
|
const written = [];
|
|
303
277
|
if (!config.fileExists) {
|
|
304
278
|
await fs.mkdir(path.dirname(config.file), { recursive: true });
|
|
305
|
-
await fs.writeFile(config.file, serialize(config.file, {
|
|
279
|
+
await fs.writeFile(config.file, serialize(config.file, {
|
|
280
|
+
controlUrl: explicit.controlUrl
|
|
281
|
+
? config.controlUrl
|
|
282
|
+
: (credentialUrl ?? config.controlUrl),
|
|
283
|
+
project: config.project,
|
|
284
|
+
}, `Safe to commit: keep credentials out of this file (use TUG_CREDENTIAL_FILE or ${path.basename(config.localFile)}).`));
|
|
306
285
|
written.push(config.file);
|
|
307
286
|
}
|
|
308
287
|
let local = {};
|
package/dist/cli-install.d.ts
CHANGED
|
@@ -8,12 +8,14 @@ export declare function shellQuote(value: unknown): string;
|
|
|
8
8
|
export declare function dockerRunSnippet(image: string, joinKey: string, address?: string): string;
|
|
9
9
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
10
10
|
export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
|
|
11
|
-
/** Name of the container `control
|
|
11
|
+
/** Name of the container `control setup` creates (Compose project `tug-control`, service `control`). */
|
|
12
12
|
export declare const CONTROL_CONTAINER = "tug-control-control-1";
|
|
13
13
|
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
14
14
|
export declare function controlPortCheck(port: number): string;
|
|
15
|
+
/** The host of an SSH target: `host`, `user@host`, `ssh://user@host:2222` or bracketed IPv6. */
|
|
16
|
+
export declare function sshHost(sshTarget: string): string;
|
|
15
17
|
/**
|
|
16
|
-
* The default control URL for `control
|
|
18
|
+
* The default control URL for `control setup`: the SSH target's host and the published port. Accepts
|
|
17
19
|
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
18
20
|
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
19
21
|
*/
|
package/dist/cli-install.js
CHANGED
|
@@ -30,7 +30,7 @@ export function enrollmentCloudInit(bootstrap, command) {
|
|
|
30
30
|
runcmd: [['sh', '-eu', '-c', `sh /opt/tug/install-docker.sh && ${command}`]],
|
|
31
31
|
}, { lineWidth: -1, noRefs: true }));
|
|
32
32
|
}
|
|
33
|
-
/** Name of the container `control
|
|
33
|
+
/** Name of the container `control setup` creates (Compose project `tug-control`, service `control`). */
|
|
34
34
|
export const CONTROL_CONTAINER = 'tug-control-control-1';
|
|
35
35
|
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
36
36
|
export function controlPortCheck(port) {
|
|
@@ -41,12 +41,8 @@ ss -ltn | awk '{print $4}' | grep -qE '[.:]${port}$' || exit 0
|
|
|
41
41
|
[ -n "$(docker ps -q --filter 'name=^/${CONTROL_CONTAINER}$' --filter 'publish=${port}' 2>/dev/null)" ]
|
|
42
42
|
`;
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
47
|
-
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
48
|
-
*/
|
|
49
|
-
export function defaultControlUrl(sshTarget, port) {
|
|
44
|
+
/** The host of an SSH target: `host`, `user@host`, `ssh://user@host:2222` or bracketed IPv6. */
|
|
45
|
+
export function sshHost(sshTarget) {
|
|
50
46
|
let host = sshTarget
|
|
51
47
|
.trim()
|
|
52
48
|
.replace(/^ssh:\/\//, '')
|
|
@@ -59,5 +55,14 @@ export function defaultControlUrl(sshTarget, port) {
|
|
|
59
55
|
host = host.split(':')[0];
|
|
60
56
|
if (!host || /[\s'"]/.test(host))
|
|
61
57
|
throw new Error(`cannot derive a host from SSH target ${sshTarget}`);
|
|
58
|
+
return host;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The default control URL for `control setup`: the SSH target's host and the published port. Accepts
|
|
62
|
+
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
63
|
+
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
64
|
+
*/
|
|
65
|
+
export function defaultControlUrl(sshTarget, port) {
|
|
66
|
+
const host = sshHost(sshTarget);
|
|
62
67
|
return `https://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
63
68
|
}
|
package/dist/cli-live.js
CHANGED
|
@@ -232,12 +232,10 @@ export async function runCliLive(request, transport, io = {}) {
|
|
|
232
232
|
finally {
|
|
233
233
|
try {
|
|
234
234
|
if (session) {
|
|
235
|
-
if (controller.signal.aborted || failure)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
catch { }
|
|
240
|
-
}
|
|
235
|
+
if (controller.signal.aborted || failure)
|
|
236
|
+
await Promise.resolve()
|
|
237
|
+
.then(() => session.send({ type: 'cancel' }))
|
|
238
|
+
.catch(() => { });
|
|
241
239
|
await session.close();
|
|
242
240
|
}
|
|
243
241
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node name from an SSH target's host: the first DNS label (`app1` for `root@app1.example.com`). An IP address
|
|
3
|
+
* says nothing about the machine, so it yields no name and the operator has to choose one.
|
|
4
|
+
*/
|
|
5
|
+
export declare const isValidNodeName: (name: string) => boolean;
|
|
6
|
+
export declare function inferNodeName(sshTarget: string): string | undefined;
|
|
7
|
+
export interface AddressCandidate {
|
|
8
|
+
interface: string;
|
|
9
|
+
address: string;
|
|
10
|
+
/** RFC 1918 or carrier-grade NAT, the range overlay networks such as Tailscale use. */
|
|
11
|
+
private: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** The remote command that prints one line per global IPv4 address. */
|
|
14
|
+
export declare const LIST_ADDRESSES_COMMAND = "ip -o -4 addr show scope global 2>/dev/null || true";
|
|
15
|
+
export declare function isPrivateIPv4(address: string): boolean;
|
|
16
|
+
/** Parse `ip -o -4 addr show` output (`2: eth0 inet 10.0.0.7/24 brd ... scope global eth0`). */
|
|
17
|
+
export declare function parseAddressCandidates(output: string): AddressCandidate[];
|
|
18
|
+
/** Private addresses first, since a node's address carries cross-node traffic; otherwise the order the host reports. */
|
|
19
|
+
export declare function sortCandidates(candidates: AddressCandidate[]): AddressCandidate[];
|
|
20
|
+
export interface NodePlan {
|
|
21
|
+
name: string;
|
|
22
|
+
ssh: string;
|
|
23
|
+
installDocker?: boolean;
|
|
24
|
+
/** False when nothing asked the host, so whether Docker is present is unknown. */
|
|
25
|
+
probed: boolean;
|
|
26
|
+
address?: string;
|
|
27
|
+
ingress?: boolean;
|
|
28
|
+
image: string;
|
|
29
|
+
}
|
|
30
|
+
/** The plan `node add` shows before it changes anything; it never claims what it did not check. */
|
|
31
|
+
export declare function formatNodePlan(plan: NodePlan): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { sshHost } from "./cli-install.js";
|
|
3
|
+
const NODE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
4
|
+
/**
|
|
5
|
+
* A node name from an SSH target's host: the first DNS label (`app1` for `root@app1.example.com`). An IP address
|
|
6
|
+
* says nothing about the machine, so it yields no name and the operator has to choose one.
|
|
7
|
+
*/
|
|
8
|
+
export const isValidNodeName = (name) => NODE_NAME.test(name);
|
|
9
|
+
export function inferNodeName(sshTarget) {
|
|
10
|
+
let host;
|
|
11
|
+
try {
|
|
12
|
+
host = sshHost(sshTarget);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
if (isIP(host) !== 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
const label = host.split('.')[0];
|
|
20
|
+
return NODE_NAME.test(label) ? label : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** The remote command that prints one line per global IPv4 address. */
|
|
23
|
+
export const LIST_ADDRESSES_COMMAND = 'ip -o -4 addr show scope global 2>/dev/null || true';
|
|
24
|
+
export function isPrivateIPv4(address) {
|
|
25
|
+
const [a, b] = address.split('.').map(Number);
|
|
26
|
+
return (a === 10 ||
|
|
27
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
28
|
+
(a === 192 && b === 168) ||
|
|
29
|
+
(a === 100 && b >= 64 && b <= 127));
|
|
30
|
+
}
|
|
31
|
+
/** Parse `ip -o -4 addr show` output (`2: eth0 inet 10.0.0.7/24 brd ... scope global eth0`). */
|
|
32
|
+
export function parseAddressCandidates(output) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (const line of output.split('\n')) {
|
|
35
|
+
const match = /^\d+:\s+(\S+)\s+inet\s+(\d{1,3}(?:\.\d{1,3}){3})\//.exec(line.trim());
|
|
36
|
+
if (!match || isIP(match[2]) !== 4)
|
|
37
|
+
continue;
|
|
38
|
+
candidates.push({
|
|
39
|
+
interface: match[1],
|
|
40
|
+
address: match[2],
|
|
41
|
+
private: isPrivateIPv4(match[2]),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return candidates;
|
|
45
|
+
}
|
|
46
|
+
/** Private addresses first, since a node's address carries cross-node traffic; otherwise the order the host reports. */
|
|
47
|
+
export function sortCandidates(candidates) {
|
|
48
|
+
return [...candidates].sort((a, b) => Number(b.private) - Number(a.private));
|
|
49
|
+
}
|
|
50
|
+
/** The plan `node add` shows before it changes anything; it never claims what it did not check. */
|
|
51
|
+
export function formatNodePlan(plan) {
|
|
52
|
+
const docker = plan.installDocker
|
|
53
|
+
? 'install'
|
|
54
|
+
: plan.probed
|
|
55
|
+
? 'already installed'
|
|
56
|
+
: 'not checked; must already be installed';
|
|
57
|
+
return [
|
|
58
|
+
'Node plan',
|
|
59
|
+
` name: ${plan.name}`,
|
|
60
|
+
` ssh: ${plan.ssh}`,
|
|
61
|
+
` docker: ${docker}`,
|
|
62
|
+
` address: ${plan.address ?? 'not set'}`,
|
|
63
|
+
` ingress: ${plan.ingress ? 'enabled (Caddy on ports 80 and 443)' : 'no'}`,
|
|
64
|
+
` image: ${plan.image}`,
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** True when both ends are a terminal and the run is not CI, so asking a question can be answered. */
|
|
2
|
+
export declare function canPrompt(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
+
export type Prompter = (question: string, hidden?: boolean) => Promise<string>;
|
|
4
|
+
/** Ask a line of text on the terminal; with `hidden` the typed characters are not echoed. */
|
|
5
|
+
export declare const ask: Prompter;
|
|
6
|
+
/** A yes/no question; an empty answer takes `fallback`. */
|
|
7
|
+
export declare function confirm(question: string, fallback?: boolean, prompt?: Prompter): Promise<boolean>;
|
|
8
|
+
/** Pick one of `options` by number; an empty answer takes the option at `fallback`. */
|
|
9
|
+
export declare function choose(question: string, options: readonly string[], fallback?: number, prompt?: Prompter): Promise<number>;
|