@wpelevator/tug 0.9.0 → 0.11.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 +100 -35
- package/dist/agent-credential.js +3 -3
- package/dist/agent.d.ts +5 -7
- package/dist/agent.js +45 -67
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +32 -24
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-job.js +17 -5
- package/dist/backup-restore.js +69 -69
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +2 -0
- package/dist/cli-build.js +15 -2
- package/dist/cli-config.d.ts +10 -2
- package/dist/cli-config.js +28 -49
- package/dist/cli-install.d.ts +10 -5
- package/dist/cli-install.js +27 -24
- 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 +40 -0
- package/dist/cli-status.js +91 -0
- package/dist/cli.js +63 -989
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +54 -0
- package/dist/commands/context.js +96 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +110 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +139 -0
- package/dist/commands/host.d.ts +18 -0
- package/dist/commands/host.js +99 -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 +320 -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/container-spec.d.ts +5 -0
- package/dist/container-spec.js +11 -2
- package/dist/control-credential.d.ts +2 -0
- package/dist/control-credential.js +4 -4
- package/dist/control.d.ts +1 -1
- package/dist/control.js +96 -143
- package/dist/credentials.js +1 -1
- package/dist/db.d.ts +0 -10
- package/dist/db.js +1 -2
- package/dist/diagnostics.js +1 -1
- package/dist/ingress.d.ts +1 -2
- package/dist/ingress.js +1 -1
- package/dist/main.js +1 -1
- package/dist/node-address.d.ts +2 -0
- package/dist/node-address.js +6 -0
- package/dist/pki.d.ts +1 -1
- package/dist/pki.js +2 -2
- package/dist/protocol.d.ts +2 -34
- package/dist/reconciler.js +40 -7
- package/dist/runners.d.ts +0 -6
- package/dist/runners.js +1 -6
- package/dist/templates/control-compose.yml +1 -2
- package/package.json +1 -1
- package/dist/mesh.d.ts +0 -74
- package/dist/mesh.js +0 -219
package/dist/backup-restore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { id } from "./util.js";
|
|
2
|
-
import {
|
|
2
|
+
import { scratchVolumeName } from "./container-spec.js";
|
|
3
3
|
const MINUTE = 60_000;
|
|
4
4
|
const LOCK_TTL_MS = 15 * MINUTE;
|
|
5
5
|
const HOLD_TIMEOUT_MS = 5 * MINUTE;
|
|
@@ -77,6 +77,8 @@ export function createRestoreService(deps) {
|
|
|
77
77
|
}
|
|
78
78
|
/** Validates a request and describes what a restore would do; nothing is changed. */
|
|
79
79
|
function plan(request) {
|
|
80
|
+
// The most recent deployment may have moved the target since the last scheduler tick.
|
|
81
|
+
backups.sync();
|
|
80
82
|
const desired = deployed(request.project);
|
|
81
83
|
if (!desired)
|
|
82
84
|
return { error: `project ${request.project} has no successful deployment`, status: 409 };
|
|
@@ -115,9 +117,7 @@ export function createRestoreService(deps) {
|
|
|
115
117
|
snapshotId: snapshot.snapshotId,
|
|
116
118
|
snapshotTime: snapshot.time,
|
|
117
119
|
mode,
|
|
118
|
-
...(mode === 'scratch'
|
|
119
|
-
? { scratchVolume: volumeName(request.project, `restore-${id('r').slice(-6)}`) }
|
|
120
|
-
: {}),
|
|
120
|
+
...(mode === 'scratch' ? { scratchVolume: scratchVolumeName(request.project, id('r')) } : {}),
|
|
121
121
|
writers,
|
|
122
122
|
safetyBackup: mode === 'in-place' && request.safetyBackup !== false,
|
|
123
123
|
};
|
|
@@ -197,7 +197,7 @@ export function createRestoreService(deps) {
|
|
|
197
197
|
release(restore);
|
|
198
198
|
move(restore, 'failed', { error });
|
|
199
199
|
}
|
|
200
|
-
event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore
|
|
200
|
+
event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore release' : ''}`, { error, held: dataChanged });
|
|
201
201
|
}
|
|
202
202
|
function queueRestoreJob(restore) {
|
|
203
203
|
const queued = backups.queueRestore(restore.project_id, restore.target, {
|
|
@@ -210,72 +210,72 @@ export function createRestoreService(deps) {
|
|
|
210
210
|
move(restore, 'restoring', { commandId: queued.commandId });
|
|
211
211
|
db.run('UPDATE restore_runs SET node_id=? WHERE id=?', queued.nodeId, restore.id);
|
|
212
212
|
}
|
|
213
|
+
function advanceHolding(restore, writers, age) {
|
|
214
|
+
if (!writersReport(restore, writers, 'held')) {
|
|
215
|
+
if (age > HOLD_TIMEOUT_MS)
|
|
216
|
+
fail(restore, 'the writers did not stop in time', false);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!restore.safety_backup)
|
|
220
|
+
return queueRestoreJob(restore);
|
|
221
|
+
const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
|
|
222
|
+
if (!safety.runs.length)
|
|
223
|
+
return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
|
|
224
|
+
move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
|
|
225
|
+
}
|
|
226
|
+
function advanceSafetyBackup(restore) {
|
|
227
|
+
const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
|
|
228
|
+
if (!run || run.state === 'queued' || run.state === 'running')
|
|
229
|
+
return;
|
|
230
|
+
if (run.state === 'succeeded')
|
|
231
|
+
return queueRestoreJob(restore);
|
|
232
|
+
fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
|
|
233
|
+
}
|
|
234
|
+
function advanceRestoring(restore, writers) {
|
|
235
|
+
const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
|
|
236
|
+
if (!command || ['pending', 'running'].includes(command.state))
|
|
237
|
+
return;
|
|
238
|
+
if (command.state !== 'succeeded') {
|
|
239
|
+
const error = JSON.parse(command.result_json ?? '{}').error ??
|
|
240
|
+
`the restore command ${command.state}`;
|
|
241
|
+
return fail(restore, error, restore.mode === 'in-place');
|
|
242
|
+
}
|
|
243
|
+
if (restore.mode === 'in-place')
|
|
244
|
+
release(restore);
|
|
245
|
+
// The data is back; the restore is done when the services that were stopped are serving again.
|
|
246
|
+
if (restore.mode === 'in-place' && writers.length)
|
|
247
|
+
move(restore, 'verifying');
|
|
248
|
+
else
|
|
249
|
+
complete(restore);
|
|
250
|
+
}
|
|
251
|
+
function advanceVerifying(restore, writers, age) {
|
|
252
|
+
if (writersReport(restore, writers, 'serving'))
|
|
253
|
+
return complete(restore);
|
|
254
|
+
if (age > VERIFY_TIMEOUT_MS)
|
|
255
|
+
complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
|
|
256
|
+
}
|
|
257
|
+
function advanceRestore(restore) {
|
|
258
|
+
const age = clock().getTime() -
|
|
259
|
+
new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
|
|
260
|
+
// Keep the project locked while a restore is unfinished, across a control restart.
|
|
261
|
+
if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
|
|
262
|
+
lock(restore);
|
|
263
|
+
const writers = JSON.parse(restore.writers_json);
|
|
264
|
+
switch (restore.state) {
|
|
265
|
+
case 'holding':
|
|
266
|
+
return advanceHolding(restore, writers, age);
|
|
267
|
+
case 'safety_backup':
|
|
268
|
+
return advanceSafetyBackup(restore);
|
|
269
|
+
case 'restoring':
|
|
270
|
+
return advanceRestoring(restore, writers);
|
|
271
|
+
case 'verifying':
|
|
272
|
+
return advanceVerifying(restore, writers, age);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
213
275
|
/** Advances every restore that is waiting on something; safe to call often. */
|
|
214
276
|
function advance() {
|
|
215
|
-
for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`))
|
|
216
|
-
|
|
217
|
-
new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
|
|
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:
|
|
277
|
-
}
|
|
278
|
-
}
|
|
277
|
+
for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`))
|
|
278
|
+
advanceRestore(restore);
|
|
279
279
|
}
|
|
280
280
|
function complete(restore, warning) {
|
|
281
281
|
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);
|
|
@@ -397,6 +397,8 @@ export function createBackupService(deps) {
|
|
|
397
397
|
}
|
|
398
398
|
/** Queues the `backup.restore` job for a target on its node. */
|
|
399
399
|
function queueRestore(project, target, restore, runId) {
|
|
400
|
+
// Placement can change after planning; never queue a restore using a stale schedule.
|
|
401
|
+
sync();
|
|
400
402
|
const schedule = db.get('SELECT * FROM backup_schedules WHERE project_id=? AND target=? AND enabled=1', project, target);
|
|
401
403
|
const current = deployed(project)[0];
|
|
402
404
|
if (!schedule || !current)
|
package/dist/cli-build.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ export interface LocalBuildDependencies {
|
|
|
11
11
|
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. The tracked config names are excluded at the context root only, so an app's own nested `tug.json` is kept. */
|
|
12
12
|
export declare function sourceArchiveFilter(entry: string, configPaths: string[], ignores: string[]): boolean;
|
|
13
13
|
export declare function createSourceArchive(contextDir: string, archive: string, configFiles: string[]): Promise<void>;
|
|
14
|
+
/** Build from the same filtered, validated source snapshot used for remote builds. */
|
|
15
|
+
export declare function createLocalBuildSnapshot(contextDir: string, directory: string, configFiles: string[]): Promise<string>;
|
|
14
16
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
15
17
|
export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies): Promise<LocalImageUpload[]>;
|
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 { buildImage, run } from "./build.js";
|
|
5
|
+
import { buildImage, run, unpackContext } from "./build.js";
|
|
6
6
|
import { saveImageArchive } from "./artifacts.js";
|
|
7
7
|
import { CONFIG_NAMES } from "./cli-config.js";
|
|
8
8
|
/** At any depth: the git directory and every file that can hold a token (the untracked config overlays). */
|
|
@@ -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. */
|
|
@@ -122,6 +122,19 @@ export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
|
122
122
|
filter: (entry) => sourceArchiveFilter(entry, configPaths, ignores),
|
|
123
123
|
}, ['.']);
|
|
124
124
|
}
|
|
125
|
+
/** Build from the same filtered, validated source snapshot used for remote builds. */
|
|
126
|
+
export async function createLocalBuildSnapshot(contextDir, directory, configFiles) {
|
|
127
|
+
const archive = path.join(directory, 'source.tgz');
|
|
128
|
+
const snapshot = path.join(directory, 'source');
|
|
129
|
+
try {
|
|
130
|
+
await createSourceArchive(contextDir, archive, configFiles);
|
|
131
|
+
await unpackContext(archive, snapshot);
|
|
132
|
+
return snapshot;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await fs.rm(archive, { force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
125
138
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
126
139
|
export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
|
|
127
140
|
build: buildImage,
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Credential } from './pki.ts';
|
|
2
|
-
export type SettingType = 'string' | 'boolean' | '
|
|
2
|
+
export type SettingType = 'string' | 'boolean' | 'list' | 'path';
|
|
3
3
|
export interface Setting {
|
|
4
4
|
type: SettingType;
|
|
5
5
|
/** The flag or positional argument the key stands for; the key is the camelCase of the long flag. */
|
|
@@ -28,6 +28,9 @@ export interface ResolvedCliConfig {
|
|
|
28
28
|
localFile: string;
|
|
29
29
|
/** Where connections go: the credential's public URL unless overridden (an override changes the connect target only). */
|
|
30
30
|
controlUrl: string;
|
|
31
|
+
/** Shared config address for installation, before connection-only flags, environment and credential defaults. */
|
|
32
|
+
/** The control URL the operator asked for (flag, environment or config), without credential or default fallbacks. */
|
|
33
|
+
requestedControlUrl?: string;
|
|
31
34
|
credential?: Credential;
|
|
32
35
|
credentialFile?: string;
|
|
33
36
|
project: string;
|
|
@@ -41,13 +44,18 @@ export declare function localConfigPath(file: string): string;
|
|
|
41
44
|
export declare function resolveCliConfig(options: ConnectionOptions, env: NodeJS.ProcessEnv, cwd: string, creating?: boolean): Promise<ResolvedCliConfig>;
|
|
42
45
|
/** `nodeDefaults` under the node's own keys; a list in the node replaces the default list. */
|
|
43
46
|
export declare function nodeSettings(config: ResolvedCliConfig, name?: string): SettingValues;
|
|
44
|
-
interface
|
|
47
|
+
interface Named {
|
|
45
48
|
name(): string;
|
|
49
|
+
parent?: Named | null;
|
|
50
|
+
}
|
|
51
|
+
interface OptionSource extends Named {
|
|
46
52
|
options: readonly {
|
|
47
53
|
attributeName(): string;
|
|
48
54
|
}[];
|
|
49
55
|
getOptionValueSource(key: string): string | undefined;
|
|
50
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;
|
|
51
59
|
/**
|
|
52
60
|
* Use config values as defaults for the options of a command: a key applies to a command that lists it
|
|
53
61
|
* and has an option of that name, and never over a value given on the command line.
|
package/dist/cli-config.js
CHANGED
|
@@ -8,73 +8,46 @@ import { errMessage } from "./util.js";
|
|
|
8
8
|
export const COMMAND_SETTINGS = {
|
|
9
9
|
file: { type: 'path', flag: '--file', commands: ['deploy'] },
|
|
10
10
|
buildLocal: { type: 'boolean', flag: '--build-local', commands: ['deploy'] },
|
|
11
|
-
wait: {
|
|
11
|
+
wait: {
|
|
12
|
+
type: 'boolean',
|
|
13
|
+
flag: '--no-wait',
|
|
14
|
+
commands: ['deploy', 'rollback', 'backup run', 'node add', 'node replace', 'node repair'],
|
|
15
|
+
},
|
|
12
16
|
};
|
|
13
17
|
/** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
|
|
14
18
|
export const NODE_SETTINGS = {
|
|
15
19
|
ssh: {
|
|
16
20
|
type: 'string',
|
|
17
21
|
flag: '<ssh-target>',
|
|
18
|
-
commands: ['node
|
|
22
|
+
commands: ['node add', 'node replace', 'node repair'],
|
|
19
23
|
},
|
|
20
|
-
address: {
|
|
21
|
-
publicAddress: {
|
|
24
|
+
address: {
|
|
22
25
|
type: 'string',
|
|
23
|
-
flag: '--
|
|
24
|
-
commands: ['node
|
|
26
|
+
flag: '--address',
|
|
27
|
+
commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
|
|
25
28
|
},
|
|
26
29
|
label: {
|
|
27
30
|
type: 'list',
|
|
28
31
|
flag: '--label',
|
|
29
|
-
commands: ['node
|
|
30
|
-
},
|
|
31
|
-
mesh: {
|
|
32
|
-
type: 'boolean',
|
|
33
|
-
flag: '--mesh',
|
|
34
|
-
commands: [
|
|
35
|
-
'node-install',
|
|
36
|
-
'node-replace-identity',
|
|
37
|
-
'node-repair-identity',
|
|
38
|
-
'node-prepare-enrollment',
|
|
39
|
-
],
|
|
40
|
-
},
|
|
41
|
-
wgPort: {
|
|
42
|
-
type: 'port',
|
|
43
|
-
flag: '--wg-port',
|
|
44
|
-
commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
|
|
32
|
+
commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
|
|
45
33
|
},
|
|
46
34
|
installDocker: {
|
|
47
35
|
type: 'boolean',
|
|
48
36
|
flag: '--install-docker',
|
|
49
|
-
commands: [
|
|
50
|
-
'node-install',
|
|
51
|
-
'node-replace-identity',
|
|
52
|
-
'node-repair-identity',
|
|
53
|
-
'node-prepare-enrollment',
|
|
54
|
-
],
|
|
37
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
|
|
55
38
|
},
|
|
56
39
|
image: {
|
|
57
40
|
type: 'string',
|
|
58
41
|
flag: '--image',
|
|
59
|
-
commands: [
|
|
60
|
-
'node-install',
|
|
61
|
-
'node-replace-identity',
|
|
62
|
-
'node-repair-identity',
|
|
63
|
-
'node-prepare-enrollment',
|
|
64
|
-
],
|
|
42
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
|
|
65
43
|
},
|
|
66
44
|
agentControlUrl: {
|
|
67
45
|
type: 'string',
|
|
68
46
|
flag: '--agent-control-url',
|
|
69
|
-
commands: [
|
|
70
|
-
'node-install',
|
|
71
|
-
'node-replace-identity',
|
|
72
|
-
'node-repair-identity',
|
|
73
|
-
'node-prepare-enrollment',
|
|
74
|
-
],
|
|
47
|
+
commands: ['node add', 'node replace', 'node repair', 'node enroll'],
|
|
75
48
|
},
|
|
76
49
|
};
|
|
77
|
-
const NODE_SPECIFIC_KEYS = ['ssh', 'address'
|
|
50
|
+
const NODE_SPECIFIC_KEYS = ['ssh', 'address'];
|
|
78
51
|
const SETTINGS = { ...COMMAND_SETTINGS, ...NODE_SETTINGS };
|
|
79
52
|
/** Default file names, in lookup order; more than one present is an error. */
|
|
80
53
|
export const CONFIG_NAMES = ['tug.yml', 'tug.yaml', 'tug.json', '.tug.json'];
|
|
@@ -118,16 +91,14 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
|
118
91
|
if (!creating)
|
|
119
92
|
throw new Error(`cannot use credential ${credentialFile ?? 'from TUG_CREDENTIAL'}: ${errMessage(e)}`, { cause: e });
|
|
120
93
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
config.controlUrl ||
|
|
124
|
-
credential?.url ||
|
|
125
|
-
'https://localhost:8443';
|
|
94
|
+
const requestedControlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl;
|
|
95
|
+
const controlUrl = requestedControlUrl || credential?.url || 'https://localhost:8443';
|
|
126
96
|
return {
|
|
127
97
|
file,
|
|
128
98
|
fileExists: Boolean(base),
|
|
129
99
|
localFile,
|
|
130
100
|
controlUrl: normalizeControlUrl(controlUrl),
|
|
101
|
+
requestedControlUrl: requestedControlUrl ? normalizeControlUrl(requestedControlUrl) : undefined,
|
|
131
102
|
credential,
|
|
132
103
|
credentialFile,
|
|
133
104
|
project: options.project || env.TUG_PROJECT || config.project || 'site',
|
|
@@ -140,6 +111,16 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
|
140
111
|
export function nodeSettings(config, name) {
|
|
141
112
|
return { ...config.nodeDefaults, ...(name ? config.nodes[name] : undefined) };
|
|
142
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
|
+
}
|
|
143
124
|
/**
|
|
144
125
|
* Use config values as defaults for the options of a command: a key applies to a command that lists it
|
|
145
126
|
* and has an option of that name, and never over a value given on the command line.
|
|
@@ -147,7 +128,7 @@ export function nodeSettings(config, name) {
|
|
|
147
128
|
export function applyConfigDefaults(command, values, settings) {
|
|
148
129
|
const attributes = new Set(command.options.map((option) => option.attributeName()));
|
|
149
130
|
for (const [key, value] of Object.entries(settings)) {
|
|
150
|
-
if (SETTINGS[key]?.commands.includes(command
|
|
131
|
+
if (SETTINGS[key]?.commands.includes(commandPath(command)) &&
|
|
151
132
|
attributes.has(key) &&
|
|
152
133
|
command.getOptionValueSource(key) !== 'cli')
|
|
153
134
|
values[key] = value;
|
|
@@ -214,8 +195,6 @@ function settingValue(setting, value, where, dir) {
|
|
|
214
195
|
if (typeof value !== 'boolean')
|
|
215
196
|
throw new Error(`${where} must be true or false`);
|
|
216
197
|
return value;
|
|
217
|
-
case 'port':
|
|
218
|
-
return String(integerOption(String(value), where, 1, 65535));
|
|
219
198
|
case 'list':
|
|
220
199
|
return (Array.isArray(value) ? value : [value]).map((item, index) => text(item, Array.isArray(value) ? `${where}[${index}]` : where));
|
|
221
200
|
}
|
package/dist/cli-install.d.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
2
2
|
export declare const HOST_NETWORK_FLAG = "--network host";
|
|
3
|
-
export declare const MESH_RUN_FLAGS = "--network host --cap-add NET_ADMIN";
|
|
4
3
|
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
5
4
|
export declare function dockerPlatform(uname: string): string;
|
|
6
5
|
/** Build for the host the image will run on, not the machine that builds it (an arm64 laptop and an amd64 VPS differ). */
|
|
7
6
|
export declare function localImageBuildArgs(imageTag: string, uname: string): string[];
|
|
8
7
|
export declare function shellQuote(value: unknown): string;
|
|
9
|
-
export declare function dockerRunSnippet(image: string, joinKey: string,
|
|
8
|
+
export declare function dockerRunSnippet(image: string, joinKey: string, address?: string): string;
|
|
10
9
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
11
10
|
export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
|
|
12
|
-
/** Name of the container `control
|
|
11
|
+
/** Name of the container `control setup` creates (Compose project `tug-control`, service `control`). */
|
|
13
12
|
export declare const CONTROL_CONTAINER = "tug-control-control-1";
|
|
14
13
|
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
15
14
|
export declare function controlPortCheck(port: number): string;
|
|
16
|
-
/**
|
|
17
|
-
export declare function
|
|
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;
|
|
17
|
+
/**
|
|
18
|
+
* The default control URL for `control setup`: the SSH target's host and the published port. Accepts
|
|
19
|
+
* `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
|
|
20
|
+
* so pass `--control-url` when the alias is not a name or address clients can reach.
|
|
21
|
+
*/
|
|
22
|
+
export declare function defaultControlUrl(sshTarget: string, port: number | string): string;
|
package/dist/cli-install.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as yaml from 'js-yaml';
|
|
2
2
|
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
3
3
|
export const HOST_NETWORK_FLAG = '--network host';
|
|
4
|
-
export const MESH_RUN_FLAGS = `${HOST_NETWORK_FLAG} --cap-add NET_ADMIN`;
|
|
5
4
|
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
6
5
|
export function dockerPlatform(uname) {
|
|
7
6
|
const arch = uname.trim();
|
|
@@ -18,8 +17,8 @@ export function localImageBuildArgs(imageTag, uname) {
|
|
|
18
17
|
export function shellQuote(value) {
|
|
19
18
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
20
19
|
}
|
|
21
|
-
export function dockerRunSnippet(image, joinKey,
|
|
22
|
-
return `docker run -d --name tug-agent --restart unless-stopped ${
|
|
20
|
+
export function dockerRunSnippet(image, joinKey, address) {
|
|
21
|
+
return `docker run -d --name tug-agent --restart unless-stopped ${HOST_NETWORK_FLAG} -v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${address ? `-e TUG_NODE_ADDRESS=${shellQuote(address)} ` : ''}${shellQuote(image)} agent`;
|
|
23
22
|
}
|
|
24
23
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
25
24
|
export function enrollmentCloudInit(bootstrap, command) {
|
|
@@ -31,7 +30,7 @@ export function enrollmentCloudInit(bootstrap, command) {
|
|
|
31
30
|
runcmd: [['sh', '-eu', '-c', `sh /opt/tug/install-docker.sh && ${command}`]],
|
|
32
31
|
}, { lineWidth: -1, noRefs: true }));
|
|
33
32
|
}
|
|
34
|
-
/** Name of the container `control
|
|
33
|
+
/** Name of the container `control setup` creates (Compose project `tug-control`, service `control`). */
|
|
35
34
|
export const CONTROL_CONTAINER = 'tug-control-control-1';
|
|
36
35
|
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
37
36
|
export function controlPortCheck(port) {
|
|
@@ -42,24 +41,28 @@ ss -ltn | awk '{print $4}' | grep -qE '[.:]${port}$' || exit 0
|
|
|
42
41
|
[ -n "$(docker ps -q --filter 'name=^/${CONTROL_CONTAINER}$' --filter 'publish=${port}' 2>/dev/null)" ]
|
|
43
42
|
`;
|
|
44
43
|
}
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
44
|
+
/** The host of an SSH target: `host`, `user@host`, `ssh://user@host:2222` or bracketed IPv6. */
|
|
45
|
+
export function sshHost(sshTarget) {
|
|
46
|
+
let host = sshTarget
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^ssh:\/\//, '')
|
|
49
|
+
.replace(/\/.*$/, '');
|
|
50
|
+
host = host.replace(/^[^@]*@/, '');
|
|
51
|
+
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
|
52
|
+
if (bracketed)
|
|
53
|
+
host = bracketed[1];
|
|
54
|
+
else if (host.split(':').length === 2)
|
|
55
|
+
host = host.split(':')[0];
|
|
56
|
+
if (!host || /[\s'"]/.test(host))
|
|
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);
|
|
67
|
+
return `https://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
65
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
|
+
}
|