@wpelevator/tug 0.10.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 (53) hide show
  1. package/README.md +93 -34
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.js +1 -1
  4. package/dist/backup-check.d.ts +1 -1
  5. package/dist/backup-config.js +32 -24
  6. package/dist/backup-destination.d.ts +1 -1
  7. package/dist/backup-destination.js +1 -1
  8. package/dist/backup-restore.js +65 -65
  9. package/dist/backup-scheduler.js +1 -1
  10. package/dist/cli-build.js +1 -1
  11. package/dist/cli-config.d.ts +6 -1
  12. package/dist/cli-config.js +18 -44
  13. package/dist/cli-install.d.ts +4 -2
  14. package/dist/cli-install.js +12 -7
  15. package/dist/cli-live.js +4 -6
  16. package/dist/cli-onboarding.d.ts +31 -0
  17. package/dist/cli-onboarding.js +66 -0
  18. package/dist/cli-prompt.d.ts +9 -0
  19. package/dist/cli-prompt.js +60 -0
  20. package/dist/cli-secrets.d.ts +12 -0
  21. package/dist/cli-secrets.js +71 -0
  22. package/dist/cli-status.d.ts +11 -0
  23. package/dist/cli-status.js +37 -7
  24. package/dist/cli.js +62 -981
  25. package/dist/commands/backup.d.ts +2 -0
  26. package/dist/commands/backup.js +109 -0
  27. package/dist/commands/context.d.ts +54 -0
  28. package/dist/commands/context.js +96 -0
  29. package/dist/commands/control.d.ts +2 -0
  30. package/dist/commands/control.js +110 -0
  31. package/dist/commands/credential.d.ts +2 -0
  32. package/dist/commands/credential.js +36 -0
  33. package/dist/commands/deploy.d.ts +2 -0
  34. package/dist/commands/deploy.js +139 -0
  35. package/dist/commands/host.d.ts +18 -0
  36. package/dist/commands/host.js +99 -0
  37. package/dist/commands/init.d.ts +11 -0
  38. package/dist/commands/init.js +40 -0
  39. package/dist/commands/node.d.ts +28 -0
  40. package/dist/commands/node.js +320 -0
  41. package/dist/commands/observe.d.ts +2 -0
  42. package/dist/commands/observe.js +71 -0
  43. package/dist/commands/restore.d.ts +2 -0
  44. package/dist/commands/restore.js +85 -0
  45. package/dist/commands/secret.d.ts +2 -0
  46. package/dist/commands/secret.js +26 -0
  47. package/dist/commands/service.d.ts +2 -0
  48. package/dist/commands/service.js +62 -0
  49. package/dist/commands/volume.d.ts +2 -0
  50. package/dist/commands/volume.js +13 -0
  51. package/dist/control.js +6 -6
  52. package/dist/diagnostics.js +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerBackup(program: Command): void;
@@ -0,0 +1,109 @@
1
+ import { backupCheckProblems } from "../backup-check.js";
2
+ import { collect, loadCfg, print, readStdin, request } from "./context.js";
3
+ const formatBytes = (bytes) => bytes === null
4
+ ? '-'
5
+ : bytes < 1024 ** 2
6
+ ? `${Math.ceil(bytes / 1024)} KiB`
7
+ : `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
8
+ const BACKUP_TERMINAL = ['succeeded', 'failed', 'missed', 'cancelled'];
9
+ export function registerBackup(program) {
10
+ const backup = program
11
+ .command('backup')
12
+ .description('run and inspect workload backups and set where they go');
13
+ backup
14
+ .command('run [targets...]')
15
+ .description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
16
+ .option('--no-wait', 'return once the backups are queued')
17
+ .option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
18
+ .action(async (targets, o) => {
19
+ const project = (await loadCfg()).project || 'site';
20
+ const queued = await request('POST', '/api/v1/backups/run', {
21
+ project,
22
+ targets: targets.length ? targets : undefined,
23
+ });
24
+ for (const message of queued.errors)
25
+ console.error(message);
26
+ for (const run of queued.runs)
27
+ console.log(`queued ${run.target} as ${run.runId}`);
28
+ let failed = queued.errors.length > 0;
29
+ if (o.wait) {
30
+ const deadline = Date.now() + Number(o.timeout) * 1000;
31
+ const pending = new Set(queued.runs.map((run) => run.runId));
32
+ while (pending.size && Date.now() < deadline) {
33
+ await new Promise((resolve) => setTimeout(resolve, 2000));
34
+ const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
35
+ for (const run of listing.runs)
36
+ if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
37
+ pending.delete(run.id);
38
+ const ok = run.state === 'succeeded';
39
+ failed ||= !ok;
40
+ console.log(ok
41
+ ? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
42
+ : `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
43
+ }
44
+ }
45
+ if (pending.size) {
46
+ console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
47
+ failed = true;
48
+ }
49
+ }
50
+ if (failed)
51
+ process.exitCode = 1;
52
+ });
53
+ backup
54
+ .command('list')
55
+ .description('show each backup target with its last success and next run, then recent runs')
56
+ .option('--limit <n>', 'number of recent runs to show', '20')
57
+ .option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
58
+ .action(async (o) => {
59
+ const project = (await loadCfg()).project || 'site';
60
+ const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
61
+ if (!listing.destinationConfigured)
62
+ console.error('no backup destination is configured; run tug backup destination set');
63
+ if (!listing.targets.length)
64
+ console.log('no backup targets; add x-tug.backup to a database service or volume');
65
+ for (const t of listing.targets)
66
+ console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
67
+ for (const r of listing.runs)
68
+ console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
69
+ if (o.check) {
70
+ const problems = backupCheckProblems(listing);
71
+ for (const problem of problems)
72
+ console.error(`PROBLEM ${problem}`);
73
+ if (problems.length)
74
+ process.exitCode = 1;
75
+ }
76
+ });
77
+ backup
78
+ .command('password')
79
+ .description("print the project's repository and its password for offline escrow; anyone holding the password and destination credentials can read the project's backups")
80
+ .action(async () => {
81
+ const project = (await loadCfg()).project || 'site';
82
+ print(await request('GET', `/api/v1/backup/repository-password?project=${encodeURIComponent(project)}`));
83
+ });
84
+ const destination = backup
85
+ .command('destination')
86
+ .description('set, show or remove where backups go');
87
+ destination
88
+ .command('set <url>')
89
+ .description('set where backups go, such as s3:https://s3.example.com/bucket/tug-backups; credentials are stored encrypted on control')
90
+ .option('--env <name>', 'credential variable for the backend, such as AWS_ACCESS_KEY_ID (repeatable); each value is read from the variable of that name in your environment, else from a line on stdin', collect, [])
91
+ .action(async (url, o) => {
92
+ const missing = o.env.filter((name) => process.env[name] === undefined);
93
+ const lines = missing.length
94
+ ? (await readStdin()).split(/\r?\n/).filter((line) => line.length > 0)
95
+ : [];
96
+ if (lines.length < missing.length)
97
+ throw new Error(`expected a value on stdin for ${missing.slice(lines.length).join(', ')}`);
98
+ const env = Object.fromEntries(o.env.map((name) => [name, process.env[name] ?? lines[missing.indexOf(name)]]));
99
+ console.log(JSON.stringify(await request('POST', '/api/v1/backup/destination', { url, env })));
100
+ });
101
+ destination
102
+ .command('show')
103
+ .description('show the backup destination and the names of its credential variables, never values')
104
+ .action(async () => print(await request('GET', '/api/v1/backup/destination')));
105
+ destination
106
+ .command('remove')
107
+ .description('remove the backup destination and its stored credentials; existing backups stay in the repository')
108
+ .action(async () => console.log(JSON.stringify(await request('DELETE', '/api/v1/backup/destination'))));
109
+ }
@@ -0,0 +1,54 @@
1
+ import type { SpawnSyncOptions } from 'node:child_process';
2
+ import type { ResolvedCliConfig } from '../cli-config.ts';
3
+ import type { LiveInput, LiveRequest } from '../cli-live.ts';
4
+ import type { DeploymentState } from '../protocol.ts';
5
+ export declare const templatesDir: string;
6
+ export interface DeploymentRecord {
7
+ id: string;
8
+ state: DeploymentState;
9
+ error_message?: string | null;
10
+ }
11
+ export interface CommandRecord {
12
+ state: string;
13
+ result_json?: string | null;
14
+ }
15
+ export interface DeployResponse {
16
+ deployment: DeploymentRecord;
17
+ desiredState?: unknown;
18
+ }
19
+ export interface CommonOpts {
20
+ project?: string;
21
+ }
22
+ export interface WaitOpts {
23
+ wait: boolean;
24
+ timeout: string;
25
+ }
26
+ export interface InstallOpts {
27
+ buildLocalImage?: boolean;
28
+ image?: string;
29
+ installDocker?: boolean;
30
+ }
31
+ export declare const CONFIG_HELP = "CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)";
32
+ type Config = ResolvedCliConfig;
33
+ /** Set by the root `preAction` hook before any command runs. */
34
+ export declare function setResolvedConfig(config: Config): void;
35
+ export declare function loadCfg(): Promise<Config>;
36
+ export declare const collect: (v: string, a: string[]) => string[];
37
+ export declare function readStdin(): Promise<string>;
38
+ /** The pinned mTLS transport for the resolved credential; an address override only changes the connect target. */
39
+ export declare function transportFor(c: Config): Promise<{
40
+ http: import("../agent-credential.ts").HttpClient;
41
+ base: string;
42
+ dispatcher: import("undici").Agent;
43
+ close: () => Promise<void>;
44
+ }>;
45
+ export declare function request<T = any>(method: string, route: string, body?: unknown, multipart?: boolean): Promise<T>;
46
+ /** The project a project-scoped command works on: `--project`, then the resolved config, then `site`. */
47
+ export declare function projectOf(o: CommonOpts): Promise<string>;
48
+ export declare const print: (value: unknown) => void;
49
+ export declare function runLocal(cmd: string, args: string[], opts?: SpawnSyncOptions): void;
50
+ export declare function runCapture(cmd: string, args: string[], opts?: SpawnSyncOptions): string;
51
+ export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number): Promise<DeploymentRecord>;
52
+ export declare function waitForCommand(commandId: string, timeoutSeconds?: number): Promise<CommandRecord>;
53
+ export declare function runLive(operation: LiveRequest['operation'], input: Omit<LiveInput, 'project'>): Promise<void>;
54
+ export {};
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createTransport } from "../agent-credential.js";
5
+ import { createCliLiveTransport } from "../cli-live-transport.js";
6
+ import { parseLiveRequest, runCliLive } from "../cli-live.js";
7
+ import { TERMINAL_DEPLOYMENT_STATES } from "../protocol.js";
8
+ // Templates live next to the commands' parent: src/templates in dev (`node src/cli.ts`), dist/templates in the
9
+ // built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
10
+ export const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
11
+ export const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
12
+ let resolvedConfig;
13
+ /** Set by the root `preAction` hook before any command runs. */
14
+ export function setResolvedConfig(config) {
15
+ resolvedConfig = config;
16
+ }
17
+ export async function loadCfg() {
18
+ return resolvedConfig;
19
+ }
20
+ export const collect = (v, a) => [...a, v];
21
+ export async function readStdin() {
22
+ const chunks = [];
23
+ for await (const chunk of process.stdin)
24
+ chunks.push(Buffer.from(chunk));
25
+ return Buffer.concat(chunks).toString('utf8');
26
+ }
27
+ /** The pinned mTLS transport for the resolved credential; an address override only changes the connect target. */
28
+ export async function transportFor(c) {
29
+ if (!c.credential)
30
+ throw new Error('no credential: run `tug init <credential-file>`, set TUG_CREDENTIAL_FILE or pass --credential');
31
+ return createTransport(c.credential, c.controlUrl);
32
+ }
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ export async function request(method, route, body, multipart = false) {
35
+ const c = await loadCfg();
36
+ const transport = await transportFor(c);
37
+ const headers = {};
38
+ if (!multipart)
39
+ headers['content-type'] = 'application/json';
40
+ try {
41
+ const res = await transport.http(`${transport.base}${route}`, {
42
+ method,
43
+ headers,
44
+ body: multipart ? body : body ? JSON.stringify(body) : undefined,
45
+ });
46
+ if (!res.ok)
47
+ throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
48
+ return (await res.json());
49
+ }
50
+ finally {
51
+ void transport.close();
52
+ }
53
+ }
54
+ /** The project a project-scoped command works on: `--project`, then the resolved config, then `site`. */
55
+ export async function projectOf(o) {
56
+ return o.project || (await loadCfg()).project || 'site';
57
+ }
58
+ export const print = (value) => console.log(JSON.stringify(value, null, 2));
59
+ export function runLocal(cmd, args, opts = {}) {
60
+ const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
61
+ if (result.status !== 0)
62
+ throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
63
+ }
64
+ export function runCapture(cmd, args, opts = {}) {
65
+ const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
66
+ if (result.status !== 0)
67
+ throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
68
+ return String(result.stdout);
69
+ }
70
+ export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
71
+ const deadline = Date.now() + timeoutSeconds * 1000;
72
+ while (Date.now() < deadline) {
73
+ const deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
74
+ if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
75
+ if (deployment.state !== 'succeeded')
76
+ throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
77
+ return deployment;
78
+ }
79
+ await new Promise((r) => setTimeout(r, 2000));
80
+ }
81
+ throw new Error(`timed out waiting for deployment ${deploymentId}`);
82
+ }
83
+ export async function waitForCommand(commandId, timeoutSeconds = 60) {
84
+ const deadline = Date.now() + timeoutSeconds * 1000;
85
+ while (Date.now() < deadline) {
86
+ const cmd = await request('GET', `/api/v1/commands/${commandId}`);
87
+ if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
88
+ return cmd;
89
+ await new Promise((r) => setTimeout(r, 1000));
90
+ }
91
+ throw new Error(`timed out waiting for command ${commandId}`);
92
+ }
93
+ export async function runLive(operation, input) {
94
+ const cfg = await loadCfg();
95
+ process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
96
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerControl(program: Command): void;
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { canPrompt, confirm } from "../cli-prompt.js";
3
+ import { CONTROL_CONTAINER, defaultControlUrl, shellQuote as sh } from "../cli-install.js";
4
+ import { resolveImage } from "../image.js";
5
+ import { decodeCredential, writeFileDurable } from "../pki.js";
6
+ import { loadTemplate } from "../util.js";
7
+ import { collect, loadCfg, print, request, runLocal, templatesDir } from "./context.js";
8
+ import { bootstrapDocker, hostHasDocker, preflightRemote, transferTugImage } from "./host.js";
9
+ import { importCredential } from "./init.js";
10
+ /** `control setup`, also registered as the top-level shortcut `setup`. */
11
+ function addSetup(parent, description) {
12
+ parent
13
+ .command('setup <sshTarget>')
14
+ .description(description)
15
+ .option('--extra-san <name>', 'additional DNS name or IP for the server certificate (repeatable)', collect, [])
16
+ .option('--image <tag>', "Tug image to run (default: ghcr.io/wpelevator/tug:<this CLI's version>)")
17
+ .option('--install-docker', 'install missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts (root required); in a terminal you are asked when they are missing')
18
+ .option('--build-local-image', 'build the image from the current checkout and copy it to the host over SSH (tags it tug:local unless --image is set); for development')
19
+ .option('--port <port>', 'published mTLS port', '8443')
20
+ .option('--credential-out <file>', 'also write the root credential to this 0600 file (for example to hand it to CI)')
21
+ .option('--no-import', 'do not import the root credential into the local config (requires --credential-out)')
22
+ .option('--recover-root', "replace root's key from the control host without the API (lost credential); other admins are unaffected")
23
+ .option('-y, --yes', 'do not ask for confirmation in a terminal')
24
+ .action(async (sshTarget, o, command) => {
25
+ const cfg = await loadCfg();
26
+ if (!o.import && !o.credentialOut)
27
+ throw new Error('--no-import needs --credential-out, or the root credential would be lost');
28
+ // The control URL clients use: --control-url, TUG_CONTROL_URL or the config's controlUrl, else the SSH target's host
29
+ // and the published port. Clients verify a name generated from the CA, so this is only an address.
30
+ const controlUrl = cfg.requestedControlUrl ?? defaultControlUrl(sshTarget, o.port);
31
+ console.error(`control URL: ${controlUrl} (${cfg.requestedControlUrl ? 'from --control-url, TUG_CONTROL_URL or the config file' : 'from the SSH target and --port'}); clients connect there by default`);
32
+ const interactive = canPrompt();
33
+ let missingDocker = false;
34
+ if (!o.installDocker && interactive && !hostHasDocker(sshTarget, true)) {
35
+ missingDocker = true;
36
+ o.installDocker = await confirm(`Docker with the Compose plugin is not installed on ${sshTarget}. Install it now (needs root)?`);
37
+ }
38
+ const image = resolveImage(o.image, o.buildLocalImage);
39
+ console.error([
40
+ 'Control plan',
41
+ ` host: ${sshTarget}`,
42
+ ` control URL: ${controlUrl}`,
43
+ ` mTLS port: ${o.port}`,
44
+ ` image: ${image}`,
45
+ ` docker: ${o.installDocker ? 'install Engine and Compose' : missingDocker ? 'not installed (the preflight will fail)' : 'must already be installed'}`,
46
+ ` credential: ${[o.import ? 'imported into the local config' : '', o.credentialOut ? `written to ${o.credentialOut}` : ''].filter(Boolean).join(' and ')}`,
47
+ ].join('\n'));
48
+ if (interactive && !o.yes && !(await confirm('Install the control plane?')))
49
+ throw new Error('cancelled; nothing was changed');
50
+ if (o.installDocker)
51
+ await bootstrapDocker(sshTarget, true);
52
+ preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
53
+ if (o.buildLocalImage)
54
+ await transferTugImage(sshTarget, image);
55
+ const yamlValue = (value) => JSON.stringify(value);
56
+ const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
57
+ IMAGE: yamlValue(image),
58
+ CONTROL_URL: yamlValue(new URL(controlUrl).origin),
59
+ EXTRA_SANS: yamlValue(o.extraSan.join(',')),
60
+ PORT: o.port,
61
+ });
62
+ const remote = await loadTemplate(templatesDir, 'control-install.sh', {
63
+ COMPOSE: compose,
64
+ CONTAINER_SH: sh(CONTROL_CONTAINER),
65
+ });
66
+ runLocal('ssh', ['--', sshTarget, remote]);
67
+ // Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to files.
68
+ const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
69
+ const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
70
+ if (out.status !== 0) {
71
+ if (!o.recoverRoot && /already used/.test(String(out.stderr)))
72
+ console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
73
+ else
74
+ throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
75
+ }
76
+ else {
77
+ const text = `${String(out.stdout).trim()}\n`;
78
+ decodeCredential(text);
79
+ if (o.credentialOut) {
80
+ await writeFileDurable(o.credentialOut, text, 0o600);
81
+ console.error(`root credential written to ${o.credentialOut}`);
82
+ }
83
+ if (o.import) {
84
+ const { controlUrl: connectUrl, project } = command.optsWithGlobals();
85
+ await importCredential(cfg, text, { controlUrl: connectUrl, project });
86
+ }
87
+ }
88
+ print({ ok: true, sshTarget, url: controlUrl });
89
+ });
90
+ }
91
+ export function registerControl(program) {
92
+ const description = 'install the control plane on a Docker-capable host over SSH and import the root credential into the local config; it listens for mTLS only, so front it with TCP passthrough rather than a terminating proxy';
93
+ const control = program
94
+ .command('control')
95
+ .description('install, back up and restore the control plane');
96
+ addSetup(control, description);
97
+ addSetup(program, 'shortcut for `tug control setup`');
98
+ control
99
+ .command('backup')
100
+ .description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
101
+ .action(async () => print(await request('POST', '/api/v1/control/backups/run', {})));
102
+ control
103
+ .command('restore <artifactRef>')
104
+ .description('restore a control-plane backup on the control-plane host; requires a control restart')
105
+ .option('--confirm')
106
+ .action(async (artifactRef, o) => print(await request('POST', '/api/v1/control/restore', {
107
+ artifactRef,
108
+ confirm: Boolean(o.confirm),
109
+ })));
110
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerCredential(program: Command): void;
@@ -0,0 +1,36 @@
1
+ import { Option } from 'commander';
2
+ import { ROLES } from "../credentials.js";
3
+ import { print, request } from "./context.js";
4
+ export function registerCredential(program) {
5
+ const credential = program
6
+ .command('credential')
7
+ .description('create, list and revoke operator credentials');
8
+ credential
9
+ .command('create <name>')
10
+ .description('create a named operator credential for CI or a teammate; it is printed once, control keeps no copy of its private key, and it expires after one year at most')
11
+ .addOption(new Option('--role <role>', 'read: status and listings; deploy: read plus deploy, rollback, cancel, restart, logs; admin: everything')
12
+ .choices(ROLES)
13
+ .makeOptionMandatory())
14
+ .addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
15
+ .default([], 'all projects')
16
+ .argParser((value, previous) => [...previous, value]))
17
+ .option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
18
+ .action(async (name, o) => {
19
+ const created = await request('POST', '/api/v1/credentials', {
20
+ name,
21
+ role: o.role,
22
+ projects: o.project.length ? o.project : undefined,
23
+ expiresInSeconds: o.expiresInDays ? Number(o.expiresInDays) * 86400 : undefined,
24
+ });
25
+ console.error('Store this credential now (for example as the TUG_CREDENTIAL secret in CI); it is not shown again. Replace one by creating a new name, verifying it, then revoking the old name.');
26
+ print(created);
27
+ });
28
+ credential
29
+ .command('list')
30
+ .description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
31
+ .action(async () => print(await request('GET', '/api/v1/credentials')));
32
+ credential
33
+ .command('revoke <name>')
34
+ .description('revoke an operator credential immediately; its name stays reserved for the audit trail')
35
+ .action(async (name) => print(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`)));
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerDeploy(program: Command): void;
@@ -0,0 +1,139 @@
1
+ import { openAsBlob } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createLocalBuildSnapshot, createSourceArchive, prepareLocalImages } from "../cli-build.js";
6
+ import { canPrompt } from "../cli-prompt.js";
7
+ import { findMissingSecrets, promptForSecrets } from "../cli-secrets.js";
8
+ import { composeHash } from "../compose.js";
9
+ import { normalizeCompose } from "../normalize.js";
10
+ import { loadCfg, print, projectOf, request, waitForCommand, waitForDeployment, } from "./context.js";
11
+ async function makeContext() {
12
+ const cfg = await loadCfg();
13
+ const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
14
+ try {
15
+ await createSourceArchive(process.cwd(), tmp, [
16
+ cfg.file,
17
+ cfg.localFile,
18
+ ...(cfg.credentialFile ? [cfg.credentialFile] : []),
19
+ ]);
20
+ }
21
+ catch (error) {
22
+ await fs.rm(tmp, { force: true });
23
+ throw error;
24
+ }
25
+ return tmp;
26
+ }
27
+ export function registerDeploy(program) {
28
+ const deploy = program
29
+ .command('deploy')
30
+ .description('deploy the Compose project, or cancel a deployment with `deploy cancel <id>`')
31
+ .option('-f, --file <file>', 'compose file', 'docker-compose.yml')
32
+ .option('--version <version>', 'display version (default: git-<Compose hash>)')
33
+ .option('--build-local', 'build application images locally and upload them without source')
34
+ .option('--no-wait', 'return after desired state is created')
35
+ .option('--timeout <seconds>', 'wait timeout in seconds', '300')
36
+ .action(async (o) => {
37
+ const cfg = await loadCfg();
38
+ const project = o.project || cfg.project || 'site';
39
+ // Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
40
+ const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
41
+ const secretNames = secrets.map((secret) => secret.name);
42
+ const missing = await findMissingSecrets(o.file, secretNames);
43
+ if (missing.length) {
44
+ if (!canPrompt())
45
+ throw new Error(`${o.file} needs values that are not set: ${missing.join(', ')}; create each with tug secret set <name> (or run tug deploy in a terminal to be prompted)`);
46
+ console.error(`Missing secrets for project ${project}: ${missing.join(', ')}`);
47
+ // Only names that do not exist yet are stored, so an existing secret is never overwritten.
48
+ for (const [name, value] of Object.entries(await promptForSecrets(missing))) {
49
+ await request('POST', '/api/v1/secrets', { project, name, value });
50
+ secretNames.push(name);
51
+ }
52
+ }
53
+ const compose = await normalizeCompose(o.file, secretNames, { project });
54
+ const version = o.version || `git-${composeHash(compose)}`;
55
+ let response;
56
+ if (o.buildLocal) {
57
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
58
+ try {
59
+ const form = new FormData();
60
+ const source = Object.values(compose.services).some((service) => service.build)
61
+ ? await createLocalBuildSnapshot(process.cwd(), directory, [
62
+ cfg.file,
63
+ cfg.localFile,
64
+ ...(cfg.credentialFile ? [cfg.credentialFile] : []),
65
+ ])
66
+ : process.cwd();
67
+ const localImages = await prepareLocalImages(compose, source, directory);
68
+ for (const { field } of localImages)
69
+ form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
70
+ form.append('payload', JSON.stringify({ project, version, compose, localImages }));
71
+ response = await request('POST', '/api/v1/deployments', form, true);
72
+ }
73
+ finally {
74
+ await fs.rm(directory, { recursive: true, force: true });
75
+ }
76
+ }
77
+ else if (Object.values(compose.services).some((service) => service.build)) {
78
+ const tarball = await makeContext();
79
+ try {
80
+ const form = new FormData();
81
+ form.append('payload', JSON.stringify({ project, version, compose }));
82
+ form.append('context', await openAsBlob(tarball), 'context.tgz');
83
+ response = await request('POST', '/api/v1/deployments', form, true);
84
+ }
85
+ finally {
86
+ await fs.rm(tarball, { force: true });
87
+ }
88
+ }
89
+ else
90
+ response = await request('POST', '/api/v1/deployments', {
91
+ project,
92
+ version,
93
+ compose,
94
+ });
95
+ if (o.wait)
96
+ response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
97
+ print(response);
98
+ });
99
+ deploy
100
+ .command('cancel <id>')
101
+ .description('cancel a deployment that has not finished')
102
+ .action(async (id) => print(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {})));
103
+ program
104
+ .command('rollback')
105
+ .description('activate the previous successful deployment')
106
+ .option('--force')
107
+ .option('--no-wait', 'return immediately after activating rollback')
108
+ .option('--timeout <seconds>', 'wait timeout', '300')
109
+ .action(async (o) => {
110
+ const response = await request('POST', '/api/v1/rollback', {
111
+ project: await projectOf(o),
112
+ force: Boolean(o.force),
113
+ });
114
+ if (o.wait)
115
+ response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
116
+ print(response);
117
+ });
118
+ program
119
+ .command('scale <service> <replicas>')
120
+ .description('scale a service to N replicas across matching nodes')
121
+ .action(async (service, replicas, o) => {
122
+ const project = await projectOf(o);
123
+ if (!/^[1-9]\d*$/.test(replicas))
124
+ throw new Error('replicas must be a positive integer');
125
+ print(await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) }));
126
+ });
127
+ program
128
+ .command('restart <service>')
129
+ .description('restart a service on every node that runs it')
130
+ .option('--timeout <seconds>', 'wait timeout', '60')
131
+ .action(async (svc, o) => {
132
+ const project = await projectOf(o);
133
+ const created = await request('POST', '/api/v1/restart', { project, service: svc });
134
+ const cmd = await waitForCommand(created.commandId, Number(o.timeout));
135
+ if (cmd.state === 'failed')
136
+ throw new Error(JSON.parse(cmd.result_json || '{}').error || 'restart failed');
137
+ print(cmd);
138
+ });
139
+ }
@@ -0,0 +1,18 @@
1
+ import type { AddressCandidate } from '../cli-onboarding.ts';
2
+ export declare function preflightRemote(target: string, { controlUrl, ports, minDiskMb, compose, }?: {
3
+ controlUrl?: string;
4
+ ports?: number[];
5
+ minDiskMb?: number;
6
+ /** Requires the Compose plugin; only the control host runs Compose, nodes need the Docker Engine alone. */
7
+ compose?: boolean;
8
+ }): void;
9
+ export declare function transferTugImage(target: string, imageTag: string): Promise<void>;
10
+ export declare function dockerBootstrap(): Promise<string>;
11
+ /** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
12
+ export declare function bootstrapDocker(target: string, compose?: boolean): Promise<void>;
13
+ /** Whether the host already has the Docker Engine (and the Compose plugin when `compose`). */
14
+ export declare function hostHasDocker(target: string, compose?: boolean): boolean;
15
+ /** The host's short hostname, or undefined when it cannot be read. */
16
+ export declare function remoteHostname(target: string): string | undefined;
17
+ /** The host's global IPv4 addresses, private ones first; empty when the host cannot say. */
18
+ export declare function discoverAddresses(target: string): AddressCandidate[];