@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.
- package/README.md +93 -34
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +1 -1
- 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-restore.js +65 -65
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +6 -1
- package/dist/cli-config.js +18 -44
- 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 +62 -981
- 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/control.js +6 -6
- package/dist/diagnostics.js +1 -1
- package/package.json +1 -1
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>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
/** True when both ends are a terminal and the run is not CI, so asking a question can be answered. */
|
|
4
|
+
export function canPrompt(env = process.env) {
|
|
5
|
+
return !!process.stdin.isTTY && !!process.stdout.isTTY && !env.CI;
|
|
6
|
+
}
|
|
7
|
+
/** Ask a line of text on the terminal; with `hidden` the typed characters are not echoed. */
|
|
8
|
+
export const ask = async (question, hidden = false) => {
|
|
9
|
+
const muted = { on: false };
|
|
10
|
+
const output = new Writable({
|
|
11
|
+
write(chunk, _encoding, callback) {
|
|
12
|
+
if (!muted.on)
|
|
13
|
+
process.stdout.write(chunk);
|
|
14
|
+
callback();
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
|
|
18
|
+
try {
|
|
19
|
+
return await new Promise((resolve, reject) => {
|
|
20
|
+
rl.once('close', () => reject(new Error('input closed before an answer was given')));
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
if (hidden)
|
|
23
|
+
process.stdout.write('\n');
|
|
24
|
+
resolve(answer);
|
|
25
|
+
});
|
|
26
|
+
muted.on = hidden;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
rl.removeAllListeners('close');
|
|
31
|
+
rl.close();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/** A yes/no question; an empty answer takes `fallback`. */
|
|
35
|
+
export async function confirm(question, fallback = true, prompt = ask) {
|
|
36
|
+
for (;;) {
|
|
37
|
+
const answer = (await prompt(`${question} ${fallback ? '(Y/n)' : '(y/N)'} `))
|
|
38
|
+
.trim()
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
if (answer === '')
|
|
41
|
+
return fallback;
|
|
42
|
+
if (answer === 'y' || answer === 'yes')
|
|
43
|
+
return true;
|
|
44
|
+
if (answer === 'n' || answer === 'no')
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Pick one of `options` by number; an empty answer takes the option at `fallback`. */
|
|
49
|
+
export async function choose(question, options, fallback = 0, prompt = ask) {
|
|
50
|
+
console.error(question);
|
|
51
|
+
options.forEach((option, index) => console.error(` ${index + 1}) ${option}`));
|
|
52
|
+
for (;;) {
|
|
53
|
+
const answer = (await prompt(`Choose 1-${options.length} (${fallback + 1}): `)).trim();
|
|
54
|
+
if (answer === '')
|
|
55
|
+
return fallback;
|
|
56
|
+
const index = Number(answer) - 1;
|
|
57
|
+
if (Number.isInteger(index) && index >= 0 && index < options.length)
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Prompter } from './cli-prompt.ts';
|
|
2
|
+
/** Variable names a Compose file requires, in order of appearance; ones with a default are optional. */
|
|
3
|
+
export declare function requiredVariables(source: string): string[];
|
|
4
|
+
/**
|
|
5
|
+
* Variables the Compose file references that nothing supplies: not a control secret, the environment
|
|
6
|
+
* or `.env`. Compose would render them empty, which for a password is never intended.
|
|
7
|
+
*/
|
|
8
|
+
export declare function findMissingSecrets(file: string, secretNames: readonly string[], env?: NodeJS.ProcessEnv): Promise<string[]>;
|
|
9
|
+
/** A URL-safe random secret with 192 bits of entropy. */
|
|
10
|
+
export declare function generateSecret(): string;
|
|
11
|
+
/** Resolve each missing secret interactively; returns the values to store. */
|
|
12
|
+
export declare function promptForSecrets(names: readonly string[], prompt?: Prompter): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ask } from "./cli-prompt.js";
|
|
5
|
+
/** `$$` is an escaped dollar; `${NAME}` is required only when it has no `:-`, `-`, `:+`, `+`, `:?` or `?` modifier. */
|
|
6
|
+
const REFERENCE = /\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
7
|
+
/** Variable names a Compose file requires, in order of appearance; ones with a default are optional. */
|
|
8
|
+
export function requiredVariables(source) {
|
|
9
|
+
const names = new Set();
|
|
10
|
+
for (const line of source.split('\n')) {
|
|
11
|
+
if (/^\s*#/.test(line))
|
|
12
|
+
continue;
|
|
13
|
+
for (const match of line.matchAll(REFERENCE)) {
|
|
14
|
+
const name = match[1] ?? match[2];
|
|
15
|
+
if (name)
|
|
16
|
+
names.add(name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...names];
|
|
20
|
+
}
|
|
21
|
+
/** Variables Compose reads from the `.env` file next to the Compose file. */
|
|
22
|
+
async function dotEnvNames(dir) {
|
|
23
|
+
try {
|
|
24
|
+
const text = await fs.readFile(path.join(dir, '.env'), 'utf8');
|
|
25
|
+
return new Set(text
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1])
|
|
28
|
+
.filter((name) => !!name));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Variables the Compose file references that nothing supplies: not a control secret, the environment
|
|
36
|
+
* or `.env`. Compose would render them empty, which for a password is never intended.
|
|
37
|
+
*/
|
|
38
|
+
export async function findMissingSecrets(file, secretNames, env = process.env) {
|
|
39
|
+
const known = new Set(secretNames);
|
|
40
|
+
const dotEnv = await dotEnvNames(path.dirname(file));
|
|
41
|
+
// An unreadable file is reported by `docker compose config` with its own message.
|
|
42
|
+
const source = await fs.readFile(file, 'utf8').catch(() => '');
|
|
43
|
+
return requiredVariables(source).filter((name) => !known.has(name) && env[name] === undefined && !dotEnv.has(name));
|
|
44
|
+
}
|
|
45
|
+
/** A URL-safe random secret with 192 bits of entropy. */
|
|
46
|
+
export function generateSecret() {
|
|
47
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
48
|
+
}
|
|
49
|
+
/** Resolve each missing secret interactively; returns the values to store. */
|
|
50
|
+
export async function promptForSecrets(names, prompt = ask) {
|
|
51
|
+
const values = {};
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
for (;;) {
|
|
54
|
+
const choice = (await prompt(`${name} is missing. [g]enerate a strong password or [e]nter a value? (g) `))
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
if (choice === '' || choice === 'g') {
|
|
58
|
+
values[name] = generateSecret();
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (choice === 'e') {
|
|
62
|
+
const value = await prompt(`${name}: `, true);
|
|
63
|
+
if (value) {
|
|
64
|
+
values[name] = value;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return values;
|
|
71
|
+
}
|
package/dist/cli-status.d.ts
CHANGED
|
@@ -27,3 +27,14 @@ export interface StatusResponse {
|
|
|
27
27
|
}
|
|
28
28
|
/** Render observations without treating a stale node's last report as current health. */
|
|
29
29
|
export declare function formatStatus(status: StatusResponse): string;
|
|
30
|
+
export interface NodeListing {
|
|
31
|
+
name: string;
|
|
32
|
+
status: string;
|
|
33
|
+
address: string | null;
|
|
34
|
+
ingress: number | boolean;
|
|
35
|
+
labels: string[];
|
|
36
|
+
last_seen_at: string | null;
|
|
37
|
+
disabled_at?: string | null;
|
|
38
|
+
}
|
|
39
|
+
/** A table of the registered nodes: state, inter-node address, ingress role, labels and last contact. */
|
|
40
|
+
export declare function formatNodes(nodes: NodeListing[]): string;
|
package/dist/cli-status.js
CHANGED
|
@@ -36,13 +36,7 @@ export function formatStatus(status) {
|
|
|
36
36
|
'serviceStates' in detail &&
|
|
37
37
|
detail.serviceStates &&
|
|
38
38
|
typeof detail.serviceStates === 'object') {
|
|
39
|
-
|
|
40
|
-
if (!state || typeof state !== 'object')
|
|
41
|
-
continue;
|
|
42
|
-
const item = state;
|
|
43
|
-
if (typeof item.rolloutState === 'string')
|
|
44
|
-
lines.push(` ${name}: ${item.rolloutState}${typeof item.rolloutError === 'string' && item.rolloutError ? ` — ${item.rolloutError}` : ''}`);
|
|
45
|
-
}
|
|
39
|
+
lines.push(...serviceStateLines(detail.serviceStates));
|
|
46
40
|
}
|
|
47
41
|
}
|
|
48
42
|
catch {
|
|
@@ -59,3 +53,39 @@ export function formatStatus(status) {
|
|
|
59
53
|
lines.push(` ${node.name}: ${node.status}; last seen ${node.last_seen_at || 'never'}`);
|
|
60
54
|
return lines.join('\n');
|
|
61
55
|
}
|
|
56
|
+
/** A table of the registered nodes: state, inter-node address, ingress role, labels and last contact. */
|
|
57
|
+
export function formatNodes(nodes) {
|
|
58
|
+
if (!nodes.length)
|
|
59
|
+
return 'No nodes';
|
|
60
|
+
const rows = [
|
|
61
|
+
['NODE', 'STATUS', 'ADDRESS', 'INGRESS', 'LABELS', 'LAST SEEN'],
|
|
62
|
+
...[...nodes]
|
|
63
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
64
|
+
.map((node) => [
|
|
65
|
+
node.name,
|
|
66
|
+
node.disabled_at ? 'disabled' : node.status,
|
|
67
|
+
node.address || '-',
|
|
68
|
+
node.ingress ? 'yes' : 'no',
|
|
69
|
+
node.labels.join(',') || '-',
|
|
70
|
+
node.last_seen_at || 'never',
|
|
71
|
+
]),
|
|
72
|
+
];
|
|
73
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
74
|
+
return rows
|
|
75
|
+
.map((row) => row
|
|
76
|
+
.map((cell, column) => cell.padEnd(widths[column]))
|
|
77
|
+
.join(' ')
|
|
78
|
+
.trimEnd())
|
|
79
|
+
.join('\n');
|
|
80
|
+
}
|
|
81
|
+
function serviceStateLines(serviceStates) {
|
|
82
|
+
return Object.entries(serviceStates)
|
|
83
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
84
|
+
.flatMap(([name, state]) => {
|
|
85
|
+
const item = (state && typeof state === 'object' ? state : {});
|
|
86
|
+
if (typeof item.rolloutState !== 'string')
|
|
87
|
+
return [];
|
|
88
|
+
const error = typeof item.rolloutError === 'string' && item.rolloutError ? ` — ${item.rolloutError}` : '';
|
|
89
|
+
return [` ${name}: ${item.rolloutState}${error}`];
|
|
90
|
+
});
|
|
91
|
+
}
|