@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.
Files changed (78) hide show
  1. package/README.md +100 -35
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.d.ts +5 -7
  4. package/dist/agent.js +45 -67
  5. package/dist/backup-check.d.ts +1 -1
  6. package/dist/backup-config.js +32 -24
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/backup-job.js +17 -5
  10. package/dist/backup-restore.js +69 -69
  11. package/dist/backup-scheduler.js +3 -1
  12. package/dist/cli-build.d.ts +2 -0
  13. package/dist/cli-build.js +15 -2
  14. package/dist/cli-config.d.ts +10 -2
  15. package/dist/cli-config.js +28 -49
  16. package/dist/cli-install.d.ts +10 -5
  17. package/dist/cli-install.js +27 -24
  18. package/dist/cli-live.js +4 -6
  19. package/dist/cli-onboarding.d.ts +31 -0
  20. package/dist/cli-onboarding.js +66 -0
  21. package/dist/cli-prompt.d.ts +9 -0
  22. package/dist/cli-prompt.js +60 -0
  23. package/dist/cli-secrets.d.ts +12 -0
  24. package/dist/cli-secrets.js +71 -0
  25. package/dist/cli-status.d.ts +40 -0
  26. package/dist/cli-status.js +91 -0
  27. package/dist/cli.js +63 -989
  28. package/dist/commands/backup.d.ts +2 -0
  29. package/dist/commands/backup.js +109 -0
  30. package/dist/commands/context.d.ts +54 -0
  31. package/dist/commands/context.js +96 -0
  32. package/dist/commands/control.d.ts +2 -0
  33. package/dist/commands/control.js +110 -0
  34. package/dist/commands/credential.d.ts +2 -0
  35. package/dist/commands/credential.js +36 -0
  36. package/dist/commands/deploy.d.ts +2 -0
  37. package/dist/commands/deploy.js +139 -0
  38. package/dist/commands/host.d.ts +18 -0
  39. package/dist/commands/host.js +99 -0
  40. package/dist/commands/init.d.ts +11 -0
  41. package/dist/commands/init.js +40 -0
  42. package/dist/commands/node.d.ts +28 -0
  43. package/dist/commands/node.js +320 -0
  44. package/dist/commands/observe.d.ts +2 -0
  45. package/dist/commands/observe.js +71 -0
  46. package/dist/commands/restore.d.ts +2 -0
  47. package/dist/commands/restore.js +85 -0
  48. package/dist/commands/secret.d.ts +2 -0
  49. package/dist/commands/secret.js +26 -0
  50. package/dist/commands/service.d.ts +2 -0
  51. package/dist/commands/service.js +62 -0
  52. package/dist/commands/volume.d.ts +2 -0
  53. package/dist/commands/volume.js +13 -0
  54. package/dist/container-spec.d.ts +5 -0
  55. package/dist/container-spec.js +11 -2
  56. package/dist/control-credential.d.ts +2 -0
  57. package/dist/control-credential.js +4 -4
  58. package/dist/control.d.ts +1 -1
  59. package/dist/control.js +96 -143
  60. package/dist/credentials.js +1 -1
  61. package/dist/db.d.ts +0 -10
  62. package/dist/db.js +1 -2
  63. package/dist/diagnostics.js +1 -1
  64. package/dist/ingress.d.ts +1 -2
  65. package/dist/ingress.js +1 -1
  66. package/dist/main.js +1 -1
  67. package/dist/node-address.d.ts +2 -0
  68. package/dist/node-address.js +6 -0
  69. package/dist/pki.d.ts +1 -1
  70. package/dist/pki.js +2 -2
  71. package/dist/protocol.d.ts +2 -34
  72. package/dist/reconciler.js +40 -7
  73. package/dist/runners.d.ts +0 -6
  74. package/dist/runners.js +1 -6
  75. package/dist/templates/control-compose.yml +1 -2
  76. package/package.json +1 -1
  77. package/dist/mesh.d.ts +0 -74
  78. package/dist/mesh.js +0 -219
@@ -0,0 +1,85 @@
1
+ import { collect, loadCfg, request } from "./context.js";
2
+ export function registerRestore(program) {
3
+ const restore = program
4
+ .command('restore')
5
+ .description('restore services and volumes from backups');
6
+ restore
7
+ .command('run <target>')
8
+ .description('restore a service or volume from a backup. Without --confirm it only prints the plan. An in-place restore locks the project, stops the services that write to the target, takes a safety backup, restores, then starts them again')
9
+ .option('--from <id>', 'backup run id, restic snapshot id, or latest', 'latest')
10
+ .option('--scratch', 'restore a volume into a new volume next to the live one, touching nothing live')
11
+ .option('--no-safety-backup', 'skip the backup of the current data taken before an in-place restore')
12
+ .option('--writers <service>', 'service to stop during the restore, instead of discovering them (repeatable)', collect, [])
13
+ .option('--no-writers', 'restore without stopping any service')
14
+ .option('--confirm', 'perform the restore')
15
+ .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
16
+ .action(async (target, o) => {
17
+ const project = (await loadCfg()).project || 'site';
18
+ const explicit = Array.isArray(o.writers) && o.writers.length > 0 ? o.writers : undefined;
19
+ const result = await request('POST', '/api/v1/restores', {
20
+ project,
21
+ target,
22
+ from: o.from,
23
+ scratch: o.scratch === true,
24
+ safetyBackup: o.safetyBackup,
25
+ writers: explicit,
26
+ noWriters: o.writers === false,
27
+ confirm: o.confirm === true,
28
+ });
29
+ const plan = result.plan;
30
+ console.log(`target: ${plan.target} on ${plan.node}`);
31
+ console.log(`snapshot: ${plan.snapshotId}${plan.snapshotTime ? ` (${plan.snapshotTime})` : ''}`);
32
+ console.log(`mode: ${plan.mode}${plan.scratchVolume ? ` into new volume ${plan.scratchVolume}` : ''}`);
33
+ if (plan.mode === 'in-place') {
34
+ console.log(`stops: ${plan.writers.length ? plan.writers.join(', ') : 'nothing'}`);
35
+ console.log(`safety: ${plan.safetyBackup ? 'a backup of the current data is taken first' : 'none (--no-safety-backup)'}`);
36
+ }
37
+ if (!result.confirmed) {
38
+ console.log('\nNothing was changed. Run again with --confirm to restore.');
39
+ return;
40
+ }
41
+ const deadline = Date.now() + Number(o.timeout) * 1000;
42
+ for (;;) {
43
+ await new Promise((resolve) => setTimeout(resolve, 2000));
44
+ const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
45
+ const record = restores.find((item) => item.id === result.restoreId);
46
+ if (record?.state === 'succeeded') {
47
+ if (record.error)
48
+ console.error(`warning: ${record.error}`);
49
+ console.log(`restore ${record.id} completed${record.scratch_volume ? `; files are in volume ${record.scratch_volume}` : ''}`);
50
+ return;
51
+ }
52
+ if (record && ['failed', 'interrupted', 'released'].includes(record.state)) {
53
+ console.error(`restore ${record.id} ${record.state}: ${record.error ?? ''}`);
54
+ if (record.state === 'interrupted')
55
+ console.error(`the services stay stopped; run tug restore resume ${record.id} to try again or tug restore release ${record.id} to start them (data may be partly restored)`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ if (Date.now() > deadline) {
60
+ console.error(`timed out waiting; restore ${result.restoreId} continues on control (tug restore list)`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ }
65
+ });
66
+ restore
67
+ .command('list')
68
+ .description('show recent restores and their state')
69
+ .action(async () => {
70
+ const project = (await loadCfg()).project || 'site';
71
+ const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
72
+ if (!restores.length)
73
+ console.log('no restores');
74
+ for (const r of restores)
75
+ console.log(`${r.started_at}\t${r.id}\t${r.target}\t${r.mode}\t${r.state}\t${r.snapshot_id.slice(0, 8)}${r.error ? `\t${r.error}` : ''}`);
76
+ });
77
+ restore
78
+ .command('resume <id>')
79
+ .description('run an interrupted restore again; the stopped services stay stopped meanwhile')
80
+ .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/resume`))));
81
+ restore
82
+ .command('release <id>')
83
+ .description('start the services an interrupted restore stopped and unlock the project; the data may be partly restored')
84
+ .action(async (id) => console.log(JSON.stringify(await request('POST', `/api/v1/restores/${encodeURIComponent(id)}/release`))));
85
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSecret(program: Command): void;
@@ -0,0 +1,26 @@
1
+ import { print, projectOf, request } from "./context.js";
2
+ export function registerSecret(program) {
3
+ const secret = program
4
+ .command('secret')
5
+ .description('manage the project secrets that Compose files reference');
6
+ secret
7
+ .command('set <name>')
8
+ .description('set a secret, reading the value from stdin unless --value is given')
9
+ .option('--value <value>')
10
+ .action(async (name, o) => {
11
+ const value = o.value ??
12
+ (await new Promise((resolve) => {
13
+ process.stdout.write(`${name}: `);
14
+ process.stdin.once('data', (d) => resolve(String(d).trim()));
15
+ }));
16
+ print(await request('POST', '/api/v1/secrets', { project: await projectOf(o), name, value }));
17
+ });
18
+ secret
19
+ .command('list')
20
+ .description('list secret names and versions, never values')
21
+ .action(async (o) => print(await request('GET', `/api/v1/secrets?project=${encodeURIComponent(await projectOf(o))}`)));
22
+ secret
23
+ .command('remove <name>')
24
+ .description('delete a secret')
25
+ .action(async (name, o) => print(await request('DELETE', `/api/v1/secrets/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}`)));
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerService(program: Command): void;
@@ -0,0 +1,62 @@
1
+ import { projectOf, request, runLive, waitForCommand } from "./context.js";
2
+ export function registerService(program) {
3
+ program
4
+ .command('exec <service> [command...]')
5
+ .description('execute a command on a service container (default: sh)')
6
+ .option('-i, --interactive', 'forward stdin to the remote command')
7
+ .option('-t, --tty', 'allocate a terminal (requires a local terminal and agent TTY support)')
8
+ .option('--node <name>', 'select the node when a service has multiple placements')
9
+ .action(async (service, command, o) => {
10
+ await runLive('exec', { service, command, ...o });
11
+ });
12
+ program
13
+ .command('run <service> [command...]')
14
+ .description("run a one-off command in a new container from the service: same image, environment, secrets, network and mounts, removed afterwards (for example: tug run web -- wp core update-db). Exits with the command's exit code")
15
+ .option('--node <name>', 'select the node when a service has multiple placements')
16
+ .action(async (service, command, o) => {
17
+ if (!command.length)
18
+ throw new Error('run needs a command, such as: tug run web -- wp core update-db');
19
+ await runLive('exec', { service, command, node: o.node, oneOff: true });
20
+ });
21
+ program
22
+ .command('ps')
23
+ .description('print per-container state as JSON')
24
+ .option('--node <name>', 'select one node')
25
+ .action(async (o) => {
26
+ await runLive('ps', o);
27
+ });
28
+ program
29
+ .command('inspect <service>')
30
+ .description('print container details as JSON')
31
+ .option('--node <name>', 'select one node')
32
+ .action(async (service, o) => {
33
+ await runLive('inspect', { service, ...o });
34
+ });
35
+ program
36
+ .command('logs <service>')
37
+ .description('fetch service container logs from the node that runs the service')
38
+ .option('--tail <n>', 'number of lines', '200')
39
+ .option('-f, --follow', 'follow live logs')
40
+ .option('--node <name>', 'select a node for followed logs')
41
+ .action(async (svc, o) => {
42
+ if (o.follow) {
43
+ await runLive('logs', { service: svc, node: o.node, tail: o.tail });
44
+ return;
45
+ }
46
+ if (o.node)
47
+ throw new Error('--node requires --follow for logs');
48
+ const created = await request('POST', '/api/v1/logs', {
49
+ project: await projectOf(o),
50
+ service: svc,
51
+ tail: Number(o.tail),
52
+ });
53
+ const cmd = await waitForCommand(created.commandId, 60);
54
+ const result = JSON.parse(cmd.result_json || '{}');
55
+ if (result.stdout)
56
+ process.stdout.write(result.stdout);
57
+ if (result.stderr)
58
+ process.stderr.write(result.stderr);
59
+ if (result.error)
60
+ throw new Error(result.error);
61
+ });
62
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerVolume(program: Command): void;
@@ -0,0 +1,13 @@
1
+ import { print, projectOf, request } from "./context.js";
2
+ export function registerVolume(program) {
3
+ const volume = program.command('volume').description("list and remove a project's volumes");
4
+ volume
5
+ .command('list')
6
+ .description('list the volumes of the project and where they live')
7
+ .action(async (o) => print(await request('GET', `/api/v1/volumes?project=${encodeURIComponent(await projectOf(o))}`)));
8
+ volume
9
+ .command('remove <name>')
10
+ .description('remove a volume from the project')
11
+ .option('--delete-data', 'delete underlying Docker volume data')
12
+ .action(async (name, o) => print(await request('DELETE', `/api/v1/volumes/${encodeURIComponent(name)}?project=${encodeURIComponent(await projectOf(o))}&deleteData=${Boolean(o.deleteData)}`)));
13
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CreateContainerBody } from './docker-api.ts';
2
+ import type { VolumeInspect } from './docker-api.ts';
2
3
  import type { DesiredState, ServiceEndpoint } from './protocol.ts';
3
4
  import type { PortBind } from './compose.ts';
4
5
  export declare const LABEL_PROJECT = "tug.project";
@@ -7,10 +8,14 @@ export declare const LABEL_JOB = "tug.job";
7
8
  export declare const LABEL_SERVICE = "tug.service";
8
9
  export declare const LABEL_REVISION = "tug.revision";
9
10
  export declare const LABEL_HASH = "tug.config-hash";
11
+ /** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
12
+ export declare function assertVolumeOwnership(volume: VolumeInspect, project: string, name: string): void;
10
13
  /** The project network every container of the project joins; service names are its aliases. */
11
14
  export declare const networkName: (project: string) => string;
12
15
  /** Docker volume name for a Compose volume; `external` volumes keep their own name. */
13
16
  export declare const volumeName: (project: string, volume: string) => string;
17
+ /** Scratch volumes occupy a separate namespace from project-managed volumes. */
18
+ export declare const scratchVolumeName: (project: string, restore: string) => string;
14
19
  export declare const containerName: (project: string, service: string, hash: string) => string;
15
20
  export interface VolumeSpec {
16
21
  /** Docker volume name. */
@@ -9,10 +9,17 @@ export const LABEL_JOB = 'tug.job';
9
9
  export const LABEL_SERVICE = 'tug.service';
10
10
  export const LABEL_REVISION = 'tug.revision';
11
11
  export const LABEL_HASH = 'tug.config-hash';
12
+ /** Never treat a Docker name alone as proof of ownership; validate its labels before reuse. */
13
+ export function assertVolumeOwnership(volume, project, name) {
14
+ if (volume.Labels?.[LABEL_PROJECT] !== project || volume.Labels?.['tug.volume'] !== name)
15
+ throw new Error(`volume ${volume.Name} is not owned by project ${project} as volume ${name}`);
16
+ }
12
17
  /** The project network every container of the project joins; service names are its aliases. */
13
18
  export const networkName = (project) => `tug_${project}`;
14
19
  /** Docker volume name for a Compose volume; `external` volumes keep their own name. */
15
- export const volumeName = (project, volume) => `tug_${project}_${volume}`;
20
+ export const volumeName = (project, volume) => `tug_v${project.length}_${project}_${volume}`;
21
+ /** Scratch volumes occupy a separate namespace from project-managed volumes. */
22
+ export const scratchVolumeName = (project, restore) => `tug_r${project.length}_${project}_${restore}`;
16
23
  export const containerName = (project, service, hash) => `tug-${project}-${service}-${hash.slice(0, 8)}`;
17
24
  const SECOND_NS = 1_000_000_000;
18
25
  const UNITS = {
@@ -113,10 +120,12 @@ function volumeSpecs(desired, node, used) {
113
120
  for (const key of [...used].sort()) {
114
121
  const def = desired.compose.volumes?.[key] || {};
115
122
  const xe = def['x-tug'] || {};
123
+ const customLabels = { ...(def.labels || {}) };
124
+ delete customLabels['tug.restore'];
116
125
  const labels = {
126
+ ...customLabels,
117
127
  [LABEL_PROJECT]: desired.project,
118
128
  'tug.volume': key,
119
- ...(def.labels || {}),
120
129
  };
121
130
  if (def.external) {
122
131
  out.push({
@@ -2,6 +2,8 @@ export interface IssueArgs {
2
2
  name: string;
3
3
  role: string;
4
4
  replace: boolean;
5
+ /** The control URL to write into the credential; defaults to `TUG_CONTROL_URL`. */
6
+ controlUrl?: string;
5
7
  }
6
8
  export declare function parseIssueArgs(argv: string[]): IssueArgs;
7
9
  /**
@@ -25,15 +25,15 @@ export function parseIssueArgs(argv) {
25
25
  */
26
26
  export async function issueHostCredential(args) {
27
27
  const dataDir = process.env.TUG_DATA_DIR || './data';
28
- const advertiseUrl = process.env.TUG_ADVERTISE_URL;
29
- if (!advertiseUrl)
30
- throw new Error('TUG_ADVERTISE_URL is required');
28
+ const controlUrl = args.controlUrl ?? process.env.TUG_CONTROL_URL;
29
+ if (!controlUrl)
30
+ throw new Error('TUG_CONTROL_URL is required');
31
31
  const db = await openDb(path.join(dataDir, 'tug.db'));
32
32
  try {
33
33
  const fresh = !db.get('SELECT 1 FROM operator_credentials') && !db.get('SELECT 1 FROM nodes');
34
34
  const pki = await Pki.load(path.join(dataDir, 'pki'), fresh);
35
35
  const request = parseCredentialRequest({ name: args.name, role: args.role });
36
- const created = await createCredential(db, pki, new URL(advertiseUrl).origin, request, 'host', {
36
+ const created = await createCredential(db, pki, new URL(controlUrl).origin, request, 'host', {
37
37
  replace: args.replace,
38
38
  });
39
39
  if (args.replace)
package/dist/control.d.ts CHANGED
@@ -27,7 +27,7 @@ export declare function createApp(options?: {
27
27
  openDatabase?: typeof openDb;
28
28
  /** Minimum time between event prunes triggered by agent reports; default one hour. */ eventPruneIntervalMs?: number;
29
29
  /** Override the live open/agent startup timeout in milliseconds. */ liveStartTimeoutMs?: number;
30
- /** Credential-free HTTPS origin clients use to reach control; defaults to `TUG_ADVERTISE_URL`. */ advertiseUrl?: string;
30
+ /** Credential-free HTTPS origin clients connect to by default; defaults to `TUG_CONTROL_URL`. */ controlUrl?: string;
31
31
  /**
32
32
  * Replaces certificate-based identity resolution. Only for in-process tests that skip TLS; production never sets it.
33
33
  * It returns the principal URI and key hash a connection would have presented.