@serve.zone/cli 33.0.0 → 33.2.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/dist_ts_cliclient/classes.cliclient.d.ts +7 -0
- package/dist_ts_cliclient/classes.cliclient.js +16 -1
- package/dist_ts_cliclient/commands.d.ts +22 -0
- package/dist_ts_cliclient/commands.js +52 -0
- package/dist_ts_cliclient/index.d.ts +2 -0
- package/dist_ts_cliclient/index.js +65 -11
- package/dist_ts_cliclient/tokeninput.d.ts +25 -0
- package/dist_ts_cliclient/tokeninput.js +51 -0
- package/package.json +3 -3
- package/readme.md +23 -2
- package/ts_cliclient/classes.cliclient.ts +21 -1
- package/ts_cliclient/commands.ts +65 -0
- package/ts_cliclient/index.ts +68 -10
- package/ts_cliclient/readme.md +23 -2
- package/ts_cliclient/tokeninput.ts +60 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
1
2
|
import { CloudlyApiClient } from '@serve.zone/api';
|
|
2
3
|
export declare class CliClient {
|
|
3
4
|
cloudlyApiClient: CloudlyApiClient;
|
|
4
5
|
constructor(cloudlyApiClientArg: CloudlyApiClient);
|
|
5
6
|
getClusters(): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Hands Cloudly the dcrouter admin token and prints the enrollment that results. The token is
|
|
9
|
+
* the caller's to wipe; the API client seals its own copy and never logs or keeps it.
|
|
10
|
+
*/
|
|
11
|
+
bootstrapGateway(targetArg: plugins.servezoneApi.IExternalGatewayBootstrapTarget, bootstrapTokenArg: Uint8Array): Promise<void>;
|
|
12
|
+
getGatewayStatus(): Promise<void>;
|
|
6
13
|
}
|
|
@@ -8,5 +8,20 @@ export class CliClient {
|
|
|
8
8
|
const clusters = await this.cloudlyApiClient.cluster.getClusters();
|
|
9
9
|
console.log(clusters);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Hands Cloudly the dcrouter admin token and prints the enrollment that results. The token is
|
|
13
|
+
* the caller's to wipe; the API client seals its own copy and never logs or keeps it.
|
|
14
|
+
*/
|
|
15
|
+
async bootstrapGateway(targetArg, bootstrapTokenArg) {
|
|
16
|
+
const { state } = await this.cloudlyApiClient.settings.bootstrapExternalGatewayCredential({
|
|
17
|
+
target: targetArg,
|
|
18
|
+
bootstrapToken: bootstrapTokenArg,
|
|
19
|
+
});
|
|
20
|
+
console.log(JSON.stringify(state, null, 2));
|
|
21
|
+
}
|
|
22
|
+
async getGatewayStatus() {
|
|
23
|
+
const { state } = await this.cloudlyApiClient.settings.getExternalGatewayEnrollmentState();
|
|
24
|
+
console.log(JSON.stringify(state, null, 2));
|
|
25
|
+
}
|
|
11
26
|
}
|
|
12
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
27
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5jbGljbGllbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvY2xhc3Nlcy5jbGljbGllbnQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFDeEMsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFFbkQsTUFBTSxPQUFPLFNBQVM7SUFHcEIsWUFBWSxtQkFBcUM7UUFDL0MsSUFBSSxDQUFDLGdCQUFnQixHQUFHLG1CQUFtQixDQUFDO0lBQzlDLENBQUM7SUFFTSxLQUFLLENBQUMsV0FBVztRQUN0QixNQUFNLFFBQVEsR0FBRyxNQUFNLElBQUksQ0FBQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsV0FBVyxFQUFFLENBQUM7UUFDbkUsT0FBTyxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN4QixDQUFDO0lBRUQ7OztPQUdHO0lBQ0ksS0FBSyxDQUFDLGdCQUFnQixDQUMzQixTQUErRCxFQUMvRCxpQkFBNkI7UUFFN0IsTUFBTSxFQUFFLEtBQUssRUFBRSxHQUFHLE1BQU0sSUFBSSxDQUFDLGdCQUFnQixDQUFDLFFBQVEsQ0FBQyxrQ0FBa0MsQ0FBQztZQUN4RixNQUFNLEVBQUUsU0FBUztZQUNqQixjQUFjLEVBQUUsaUJBQWlCO1NBQ2xDLENBQUMsQ0FBQztRQUNILE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDOUMsQ0FBQztJQUVNLEtBQUssQ0FBQyxnQkFBZ0I7UUFDM0IsTUFBTSxFQUFFLEtBQUssRUFBRSxHQUFHLE1BQU0sSUFBSSxDQUFDLGdCQUFnQixDQUFDLFFBQVEsQ0FBQyxpQ0FBaUMsRUFBRSxDQUFDO1FBQzNGLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxLQUFLLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDOUMsQ0FBQztDQUNGIn0=
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
/** What one invocation of the CLI asks for, parsed before anything is read or connected. */
|
|
3
|
+
export type TCliCommand =
|
|
4
|
+
/** No arguments: the default action, listing the clusters. */
|
|
5
|
+
{
|
|
6
|
+
kind: 'clusters';
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'gateway-status';
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'gateway-bootstrap';
|
|
11
|
+
target: plugins.servezoneApi.IExternalGatewayBootstrapTarget;
|
|
12
|
+
};
|
|
13
|
+
export declare const cliUsage: string;
|
|
14
|
+
/**
|
|
15
|
+
* An invocation the CLI does not run. It never quotes what it was given, because an argument may
|
|
16
|
+
* be a token pasted where it does not belong.
|
|
17
|
+
*/
|
|
18
|
+
export declare class CliUsageError extends Error {
|
|
19
|
+
constructor();
|
|
20
|
+
}
|
|
21
|
+
/** The command an argument vector names; any other vector is refused as usage. */
|
|
22
|
+
export declare const parseCliCommand: (argsArg: readonly string[]) => TCliCommand;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const cliUsage = [
|
|
2
|
+
'Usage:',
|
|
3
|
+
' servezone',
|
|
4
|
+
' servezone gateway status',
|
|
5
|
+
' servezone gateway bootstrap --gateway-url <https url> --client-id <id>',
|
|
6
|
+
' The dcrouter admin token is read from stdin, never from arguments or the environment.',
|
|
7
|
+
].join('\n');
|
|
8
|
+
/**
|
|
9
|
+
* An invocation the CLI does not run. It never quotes what it was given, because an argument may
|
|
10
|
+
* be a token pasted where it does not belong.
|
|
11
|
+
*/
|
|
12
|
+
export class CliUsageError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(cliUsage);
|
|
15
|
+
this.name = 'CliUsageError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function requireUsage(conditionArg) {
|
|
19
|
+
if (!conditionArg)
|
|
20
|
+
throw new CliUsageError();
|
|
21
|
+
}
|
|
22
|
+
/** Reads `--name value` pairs, each named option exactly once and nothing else. */
|
|
23
|
+
const parseOptions = (argsArg, namesArg) => {
|
|
24
|
+
const options = new Map();
|
|
25
|
+
requireUsage(argsArg.length === namesArg.length * 2);
|
|
26
|
+
for (let index = 0; index < argsArg.length; index += 2) {
|
|
27
|
+
const name = argsArg[index];
|
|
28
|
+
const value = argsArg[index + 1];
|
|
29
|
+
requireUsage(namesArg.some((nameArg) => nameArg === name) && !options.has(name));
|
|
30
|
+
requireUsage(value.length > 0 && !value.startsWith('--'));
|
|
31
|
+
options.set(name, value);
|
|
32
|
+
}
|
|
33
|
+
return Object.fromEntries(options);
|
|
34
|
+
};
|
|
35
|
+
/** The command an argument vector names; any other vector is refused as usage. */
|
|
36
|
+
export const parseCliCommand = (argsArg) => {
|
|
37
|
+
if (argsArg.length === 0)
|
|
38
|
+
return { kind: 'clusters' };
|
|
39
|
+
const [group, action, ...rest] = argsArg;
|
|
40
|
+
requireUsage(group === 'gateway');
|
|
41
|
+
if (action === 'status') {
|
|
42
|
+
requireUsage(rest.length === 0);
|
|
43
|
+
return { kind: 'gateway-status' };
|
|
44
|
+
}
|
|
45
|
+
requireUsage(action === 'bootstrap');
|
|
46
|
+
const options = parseOptions(rest, ['--gateway-url', '--client-id']);
|
|
47
|
+
return {
|
|
48
|
+
kind: 'gateway-bootstrap',
|
|
49
|
+
target: { gatewayUrl: options['--gateway-url'], gatewayClientId: options['--client-id'] },
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29tbWFuZHMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvY29tbWFuZHMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBU0EsTUFBTSxDQUFDLE1BQU0sUUFBUSxHQUFHO0lBQ3RCLFFBQVE7SUFDUixhQUFhO0lBQ2IsNEJBQTRCO0lBQzVCLDBFQUEwRTtJQUMxRSwyRkFBMkY7Q0FDNUYsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUM7QUFFYjs7O0dBR0c7QUFDSCxNQUFNLE9BQU8sYUFBYyxTQUFRLEtBQUs7SUFDdEM7UUFDRSxLQUFLLENBQUMsUUFBUSxDQUFDLENBQUM7UUFDaEIsSUFBSSxDQUFDLElBQUksR0FBRyxlQUFlLENBQUM7SUFDOUIsQ0FBQztDQUNGO0FBRUQsU0FBUyxZQUFZLENBQUMsWUFBcUI7SUFDekMsSUFBSSxDQUFDLFlBQVk7UUFBRSxNQUFNLElBQUksYUFBYSxFQUFFLENBQUM7QUFDL0MsQ0FBQztBQUVELG1GQUFtRjtBQUNuRixNQUFNLFlBQVksR0FBRyxDQUNuQixPQUEwQixFQUMxQixRQUEwQixFQUNILEVBQUU7SUFDekIsTUFBTSxPQUFPLEdBQUcsSUFBSSxHQUFHLEVBQWtCLENBQUM7SUFDMUMsWUFBWSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEtBQUssUUFBUSxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUMsQ0FBQztJQUNyRCxLQUFLLElBQUksS0FBSyxHQUFHLENBQUMsRUFBRSxLQUFLLEdBQUcsT0FBTyxDQUFDLE1BQU0sRUFBRSxLQUFLLElBQUksQ0FBQyxFQUFFLENBQUM7UUFDdkQsTUFBTSxJQUFJLEdBQUcsT0FBTyxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQzVCLE1BQU0sS0FBSyxHQUFHLE9BQU8sQ0FBQyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUM7UUFDakMsWUFBWSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsQ0FBQyxPQUFPLEVBQUUsRUFBRSxDQUFDLE9BQU8sS0FBSyxJQUFJLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQztRQUNqRixZQUFZLENBQUMsS0FBSyxDQUFDLE1BQU0sR0FBRyxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUM7UUFDMUQsT0FBTyxDQUFDLEdBQUcsQ0FBQyxJQUFJLEVBQUUsS0FBSyxDQUFDLENBQUM7SUFDM0IsQ0FBQztJQUNELE9BQU8sTUFBTSxDQUFDLFdBQVcsQ0FBQyxPQUFPLENBQTBCLENBQUM7QUFDOUQsQ0FBQyxDQUFDO0FBRUYsa0ZBQWtGO0FBQ2xGLE1BQU0sQ0FBQyxNQUFNLGVBQWUsR0FBRyxDQUFDLE9BQTBCLEVBQWUsRUFBRTtJQUN6RSxJQUFJLE9BQU8sQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsVUFBVSxFQUFFLENBQUM7SUFDdEQsTUFBTSxDQUFDLEtBQUssRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJLENBQUMsR0FBRyxPQUFPLENBQUM7SUFDekMsWUFBWSxDQUFDLEtBQUssS0FBSyxTQUFTLENBQUMsQ0FBQztJQUNsQyxJQUFJLE1BQU0sS0FBSyxRQUFRLEVBQUUsQ0FBQztRQUN4QixZQUFZLENBQUMsSUFBSSxDQUFDLE1BQU0sS0FBSyxDQUFDLENBQUMsQ0FBQztRQUNoQyxPQUFPLEVBQUUsSUFBSSxFQUFFLGdCQUFnQixFQUFFLENBQUM7SUFDcEMsQ0FBQztJQUNELFlBQVksQ0FBQyxNQUFNLEtBQUssV0FBVyxDQUFDLENBQUM7SUFDckMsTUFBTSxPQUFPLEdBQUcsWUFBWSxDQUFDLElBQUksRUFBRSxDQUFDLGVBQWUsRUFBRSxhQUFhLENBQUMsQ0FBQyxDQUFDO0lBQ3JFLE9BQU87UUFDTCxJQUFJLEVBQUUsbUJBQW1CO1FBQ3pCLE1BQU0sRUFBRSxFQUFFLFVBQVUsRUFBRSxPQUFPLENBQUMsZUFBZSxDQUFDLEVBQUUsZUFBZSxFQUFFLE9BQU8sQ0FBQyxhQUFhLENBQUMsRUFBRTtLQUMxRixDQUFDO0FBQ0osQ0FBQyxDQUFDIn0=
|
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { CliClient } from './classes.cliclient.js';
|
|
3
|
+
import { parseCliCommand } from './commands.js';
|
|
4
|
+
import { readBootstrapTokenInput } from './tokeninput.js';
|
|
5
|
+
export { CliUsageError, parseCliCommand } from './commands.js';
|
|
6
|
+
export { BootstrapTokenInputError, readBootstrapTokenInput, } from './tokeninput.js';
|
|
3
7
|
export const runCli = async () => {
|
|
8
|
+
// The command and the token are settled before anything connects, so a wrong invocation or a
|
|
9
|
+
// token that could never be one fails without reaching Cloudly.
|
|
10
|
+
let command;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = parseCliCommand(process.argv.slice(2));
|
|
13
|
+
command = parsed.kind === 'gateway-bootstrap'
|
|
14
|
+
? { ...parsed, bootstrapToken: await readBootstrapTokenInput(process.stdin) }
|
|
15
|
+
: parsed;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
console.error(error.message);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const apiClient = await connect();
|
|
24
|
+
const cliClient = new CliClient(apiClient);
|
|
25
|
+
if (command.kind === 'clusters') {
|
|
26
|
+
// Default action example: list clusters when invoked without subcommands
|
|
27
|
+
await cliClient.getClusters();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
if (command.kind === 'gateway-bootstrap') {
|
|
32
|
+
await cliClient.bootstrapGateway(command.target, command.bootstrapToken);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await cliClient.getGatewayStatus();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// Cloudly answers a refusal by name; it never carries the token.
|
|
40
|
+
console.error(error.message);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await apiClient.stop();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (command.kind === 'gateway-bootstrap')
|
|
49
|
+
command.bootstrapToken.fill(0);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** The API client, started and signed in the way every command of this CLI signs in. */
|
|
53
|
+
const connect = async () => {
|
|
4
54
|
// The directory the operator runs the CLI in is read for sources, exactly as before, but the
|
|
5
55
|
// `required:` list it may find there is never enforced: that list is the Cloudly server's own
|
|
6
56
|
// start-time contract, and none of a remote server's database, storage or keyring facts is this
|
|
@@ -15,17 +65,21 @@ export const runCli = async () => {
|
|
|
15
65
|
cloudlyUrl,
|
|
16
66
|
});
|
|
17
67
|
await apiClient.start();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
68
|
+
try {
|
|
69
|
+
if (token) {
|
|
70
|
+
await apiClient.getIdentityByToken(token, { tagConnection: true, statefullIdentity: true });
|
|
71
|
+
}
|
|
72
|
+
else if (username && password) {
|
|
73
|
+
await apiClient.loginWithUsernameAndPassword(username, password);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log('No credentials provided. Set CLOUDLY_TOKEN or CLOUDLY_USERNAME/CLOUDLY_PASSWORD.');
|
|
77
|
+
}
|
|
23
78
|
}
|
|
24
|
-
|
|
25
|
-
|
|
79
|
+
catch (error) {
|
|
80
|
+
await apiClient.stop();
|
|
81
|
+
throw error;
|
|
26
82
|
}
|
|
27
|
-
|
|
28
|
-
// Default action example: list clusters when invoked without subcommands
|
|
29
|
-
await cliClient.getClusters();
|
|
83
|
+
return apiClient;
|
|
30
84
|
};
|
|
31
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
85
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90c19jbGljbGllbnQvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxjQUFjLENBQUM7QUFDeEMsT0FBTyxFQUFFLFNBQVMsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBQ25ELE9BQU8sRUFBRSxlQUFlLEVBQW9CLE1BQU0sZUFBZSxDQUFDO0FBQ2xFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBRTFELE9BQU8sRUFBRSxhQUFhLEVBQUUsZUFBZSxFQUFvQixNQUFNLGVBQWUsQ0FBQztBQUNqRixPQUFPLEVBQ0wsd0JBQXdCLEVBQ3hCLHVCQUF1QixHQUV4QixNQUFNLGlCQUFpQixDQUFDO0FBT3pCLE1BQU0sQ0FBQyxNQUFNLE1BQU0sR0FBRyxLQUFLLElBQUksRUFBRTtJQUMvQiw2RkFBNkY7SUFDN0YsZ0VBQWdFO0lBQ2hFLElBQUksT0FBdUIsQ0FBQztJQUM1QixJQUFJLENBQUM7UUFDSCxNQUFNLE1BQU0sR0FBRyxlQUFlLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUN0RCxPQUFPLEdBQUcsTUFBTSxDQUFDLElBQUksS0FBSyxtQkFBbUI7WUFDM0MsQ0FBQyxDQUFDLEVBQUUsR0FBRyxNQUFNLEVBQUUsY0FBYyxFQUFFLE1BQU0sdUJBQXVCLENBQUMsT0FBTyxDQUFDLEtBQUssQ0FBQyxFQUFFO1lBQzdFLENBQUMsQ0FBQyxNQUFNLENBQUM7SUFDYixDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLE9BQU8sQ0FBQyxLQUFLLENBQUUsS0FBZSxDQUFDLE9BQU8sQ0FBQyxDQUFDO1FBQ3hDLE9BQU8sQ0FBQyxRQUFRLEdBQUcsQ0FBQyxDQUFDO1FBQ3JCLE9BQU87SUFDVCxDQUFDO0lBRUQsSUFBSSxDQUFDO1FBQ0gsTUFBTSxT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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Why the admin token piped in was not taken, named without quoting any of it. */
|
|
2
|
+
export type TBootstrapTokenInputRefusal =
|
|
3
|
+
/** stdin is a terminal, which would echo a token typed into it; pipe the token in instead. */
|
|
4
|
+
'token-input-terminal'
|
|
5
|
+
/** stdin carried more bytes than a token and one line break. */
|
|
6
|
+
| 'token-input-oversized'
|
|
7
|
+
/** What stdin carried is not a token: empty, or a byte outside visible ASCII, a space included. */
|
|
8
|
+
| 'token-input-invalid';
|
|
9
|
+
export declare class BootstrapTokenInputError extends Error {
|
|
10
|
+
readonly refusal: TBootstrapTokenInputRefusal;
|
|
11
|
+
constructor(refusal: TBootstrapTokenInputRefusal);
|
|
12
|
+
}
|
|
13
|
+
/** What the token is read from: the command's stdin, or a stream a test hands in. */
|
|
14
|
+
export type TBootstrapTokenInput = AsyncIterable<Uint8Array> & {
|
|
15
|
+
isTTY?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The dcrouter admin token piped into `gateway bootstrap`, as bytes the caller wipes.
|
|
19
|
+
*
|
|
20
|
+
* stdin is the only source: an argument would sit in the process list and the shell history, and
|
|
21
|
+
* the environment leaks into child processes. One trailing line break, `\n` or `\r\n`, is what
|
|
22
|
+
* `echo` or a file adds and is not part of the token; everything else must pass the contract's
|
|
23
|
+
* token rule. Every byte read is wiped before this answers, whatever it answers.
|
|
24
|
+
*/
|
|
25
|
+
export declare const readBootstrapTokenInput: (inputArg: TBootstrapTokenInput) => Promise<Uint8Array>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
export class BootstrapTokenInputError extends Error {
|
|
3
|
+
constructor(refusal) {
|
|
4
|
+
super(`The dcrouter admin token on stdin was refused: ${refusal}.`);
|
|
5
|
+
this.refusal = refusal;
|
|
6
|
+
this.name = 'BootstrapTokenInputError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function refuseUnless(conditionArg, refusalArg) {
|
|
10
|
+
if (!conditionArg)
|
|
11
|
+
throw new BootstrapTokenInputError(refusalArg);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The dcrouter admin token piped into `gateway bootstrap`, as bytes the caller wipes.
|
|
15
|
+
*
|
|
16
|
+
* stdin is the only source: an argument would sit in the process list and the shell history, and
|
|
17
|
+
* the environment leaks into child processes. One trailing line break, `\n` or `\r\n`, is what
|
|
18
|
+
* `echo` or a file adds and is not part of the token; everything else must pass the contract's
|
|
19
|
+
* token rule. Every byte read is wiped before this answers, whatever it answers.
|
|
20
|
+
*/
|
|
21
|
+
export const readBootstrapTokenInput = async (inputArg) => {
|
|
22
|
+
refuseUnless(inputArg.isTTY !== true, 'token-input-terminal');
|
|
23
|
+
const maximumBytes = plugins.servezoneInterfaces.data.externalGatewayBootstrapContract.maximumTokenBytes + 2;
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let input;
|
|
26
|
+
try {
|
|
27
|
+
let byteLength = 0;
|
|
28
|
+
for await (const chunk of inputArg) {
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
byteLength += chunk.byteLength;
|
|
31
|
+
refuseUnless(byteLength <= maximumBytes, 'token-input-oversized');
|
|
32
|
+
}
|
|
33
|
+
input = Buffer.concat(chunks, byteLength);
|
|
34
|
+
let end = input.byteLength;
|
|
35
|
+
if (end > 0 && input[end - 1] === 0x0a) {
|
|
36
|
+
end -= 1;
|
|
37
|
+
if (end > 0 && input[end - 1] === 0x0d)
|
|
38
|
+
end -= 1;
|
|
39
|
+
}
|
|
40
|
+
const token = input.subarray(0, end);
|
|
41
|
+
refuseUnless(plugins.servezoneInterfaces.data.isExternalGatewayBootstrapToken(token), 'token-input-invalid');
|
|
42
|
+
// A copy, because the input and every view of it are wiped below.
|
|
43
|
+
return new Uint8Array(token);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
input?.fill(0);
|
|
47
|
+
for (const chunk of chunks)
|
|
48
|
+
chunk.fill(0);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidG9rZW5pbnB1dC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzX2NsaWNsaWVudC90b2tlbmlucHV0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBV3hDLE1BQU0sT0FBTyx3QkFBeUIsU0FBUSxLQUFLO0lBQ2pELFlBQTRCLE9BQW9DO1FBQzlELEtBQUssQ0FBQyxrREFBa0QsT0FBTyxHQUFHLENBQUMsQ0FBQztRQUQxQyxZQUFPLEdBQVAsT0FBTyxDQUE2QjtRQUU5RCxJQUFJLENBQUMsSUFBSSxHQUFHLDBCQUEwQixDQUFDO0lBQ3pDLENBQUM7Q0FDRjtBQUVELFNBQVMsWUFBWSxDQUFDLFlBQXFCLEVBQUUsVUFBdUM7SUFDbEYsSUFBSSxDQUFDLFlBQVk7UUFBRSxNQUFNLElBQUksd0JBQXdCLENBQUMsVUFBVSxDQUFDLENBQUM7QUFDcEUsQ0FBQztBQUtEOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLENBQUMsTUFBTSx1QkFBdUIsR0FBRyxLQUFLLEVBQUUsUUFBOEIsRUFBdUIsRUFBRTtJQUNuRyxZQUFZLENBQUMsUUFBUSxDQUFDLEtBQUssS0FBSyxJQUFJLEVBQUUsc0JBQXNCLENBQUMsQ0FBQztJQUM5RCxNQUFNLFlBQVksR0FBRyxPQUFPLENBQUMsbUJBQW1CLENBQUMsSUFBSSxDQUFDLGdDQUFnQyxDQUFDLGlCQUFpQixHQUFHLENBQUMsQ0FBQztJQUM3RyxNQUFNLE1BQU0sR0FBaUIsRUFBRSxDQUFDO0lBQ2hDLElBQUksS0FBNkIsQ0FBQztJQUNsQyxJQUFJLENBQUM7UUFDSCxJQUFJLFVBQVUsR0FBRyxDQUFDLENBQUM7UUFDbkIsSUFBSSxLQUFLLEVBQUUsTUFBTSxLQUFLLElBQUksUUFBUSxFQUFFLENBQUM7WUFDbkMsTUFBTSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNuQixVQUFVLElBQUksS0FBSyxDQUFDLFVBQVUsQ0FBQztZQUMvQixZQUFZLENBQUMsVUFBVSxJQUFJLFlBQVksRUFBRSx1QkFBdUIsQ0FBQyxDQUFDO1FBQ3BFLENBQUM7UUFDRCxLQUFLLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQyxNQUFNLEVBQUUsVUFBVSxDQUFDLENBQUM7UUFDMUMsSUFBSSxHQUFHLEdBQUcsS0FBSyxDQUFDLFVBQVUsQ0FBQztRQUMzQixJQUFJLEdBQUcsR0FBRyxDQUFDLElBQUksS0FBSyxDQUFDLEdBQUcsR0FBRyxDQUFDLENBQUMsS0FBSyxJQUFJLEVBQUUsQ0FBQztZQUN2QyxHQUFHLElBQUksQ0FBQyxDQUFDO1lBQ1QsSUFBSSxHQUFHLEdBQUcsQ0FBQyxJQUFJLEtBQUssQ0FBQyxHQUFHLEdBQUcsQ0FBQyxDQUFDLEtBQUssSUFBSTtnQkFBRSxHQUFHLElBQUksQ0FBQyxDQUFDO1FBQ25ELENBQUM7UUFDRCxNQUFNLEtBQUssR0FBRyxLQUFLLENBQUMsUUFBUSxDQUFDLENBQUMsRUFBRSxHQUFHLENBQUMsQ0FBQztRQUNyQyxZQUFZLENBQUMsT0FBTyxDQUFDLG1CQUFtQixDQUFDLElBQUksQ0FBQywrQkFBK0IsQ0FBQyxLQUFLLENBQUMsRUFBRSxxQkFBcUIsQ0FBQyxDQUFDO1FBQzdHLGtFQUFrRTtRQUNsRSxPQUFPLElBQUksVUFBVSxDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQy9CLENBQUM7WUFBUyxDQUFDO1FBQ1QsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQztRQUNmLEtBQUssTUFBTSxLQUFLLElBQUksTUFBTTtZQUFFLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDNUMsQ0FBQztBQUNILENBQUMsQ0FBQyJ9
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"security"
|
|
39
39
|
],
|
|
40
40
|
"name": "@serve.zone/cli",
|
|
41
|
-
"version": "33.
|
|
41
|
+
"version": "33.2.0",
|
|
42
42
|
"type": "module",
|
|
43
43
|
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
|
|
44
44
|
"main": "./dist_ts_cliclient/index.js",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@serve.zone/api": "32.
|
|
54
|
-
"@serve.zone/interfaces": "32.
|
|
53
|
+
"@serve.zone/api": "32.1.0",
|
|
54
|
+
"@serve.zone/interfaces": "32.29.0",
|
|
55
55
|
"@push.rocks/projectinfo": "^5.1.0",
|
|
56
56
|
"@push.rocks/qenv": "^8.1.0",
|
|
57
57
|
"@push.rocks/smartcli": "^4.3.0"
|
package/readme.md
CHANGED
|
@@ -14,7 +14,8 @@ This submodule is intentionally small in the current codebase:
|
|
|
14
14
|
- Authenticates with either `CLOUDLY_TOKEN` or `CLOUDLY_USERNAME` plus `CLOUDLY_PASSWORD`.
|
|
15
15
|
- Starts a `CloudlyApiClient` registered as `cli`.
|
|
16
16
|
- Creates a `CliClient` wrapper around the API client.
|
|
17
|
-
-
|
|
17
|
+
- Without arguments, calls `CliClient.getClusters()` and prints the result.
|
|
18
|
+
- Runs `servezone gateway bootstrap` and `servezone gateway status` for Cloudly's dcrouter gateway enrollment.
|
|
18
19
|
|
|
19
20
|
It is not currently a full command tree for services, secrets, deployments, logs, profiles, or shell completion. Those flows should be implemented against `@serve.zone/api` before documenting them as CLI commands.
|
|
20
21
|
|
|
@@ -54,6 +55,24 @@ servezone
|
|
|
54
55
|
|
|
55
56
|
When `CLOUDLY_TOKEN` is present, the CLI exchanges it for an identity and tags the WebSocket with a JWT-only credential. Cloudly verifies the JWT and reconstructs authoritative identity fields from persisted user data. Machine JWTs never outlive their source tokens, and cluster machine users must belong to exactly one persisted cluster. When username/password are present instead, the CLI uses Cloudly's admin login flow. If no credentials are provided, it prints a warning before attempting the default cluster-list operation.
|
|
56
57
|
|
|
58
|
+
## Gateway Enrollment
|
|
59
|
+
|
|
60
|
+
Cloudly enrolls itself at dcrouter as one gateway client, with a dcrouter admin token an administrator hands in once. Both commands sign in exactly as the default action does and need an administrator identity.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# Mint a short-lived admin token at dcrouter first, then pipe it in:
|
|
64
|
+
CLOUDLY_URL=https://cloudly.example.com \
|
|
65
|
+
CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
|
|
66
|
+
servezone gateway bootstrap --gateway-url https://gateway.example.com --client-id cloudly.example.com < admin-token.txt
|
|
67
|
+
|
|
68
|
+
servezone gateway status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `gateway bootstrap --gateway-url <https url> --client-id <id>` names Cloudly's `dcrouterGatewayUrl` and `dcrouterGatewayClientId` as its settings state them; Cloudly refuses any other target. The admin token is read from stdin and from nowhere else: an argument, `--token`, or any other extra argument is refused as usage without echoing it, and the environment is never read for it. stdin must not be a terminal. One trailing line break (`\n` or `\r\n`) is stripped; what remains must be 1 to 1024 bytes of visible ASCII. The token is checked before anything connects, sealed by `@serve.zone/api` for Cloudly, and wiped afterwards. The command prints the resulting enrollment state as JSON and exits 0; a refusal (`External gateway bootstrap refused: <refusal>.`) or a refused stdin is printed to stderr with exit code 1.
|
|
72
|
+
- `gateway status` prints the current enrollment state as JSON.
|
|
73
|
+
|
|
74
|
+
Revoke the admin token at dcrouter once `gateway status` reads `ready`.
|
|
75
|
+
|
|
57
76
|
## Programmatic Use
|
|
58
77
|
|
|
59
78
|
The published submodule exports the `runCli()` entry point. For automation, most callers should use `@serve.zone/api` directly; the internal `CliClient` currently wraps `CloudlyApiClient` only to run the default cluster-list action:
|
|
@@ -79,7 +98,9 @@ await cli.getClusters();
|
|
|
79
98
|
| Path | Purpose |
|
|
80
99
|
| --- | --- |
|
|
81
100
|
| `index.ts` | Runtime entry point for the published CLI. |
|
|
82
|
-
| `classes.cliclient.ts` | Minimal client wrapper
|
|
101
|
+
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `getGatewayStatus()`. |
|
|
102
|
+
| `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
|
|
103
|
+
| `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
|
|
83
104
|
| `plugins.ts` | Centralized imports for the submodule. |
|
|
84
105
|
| `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
|
|
85
106
|
|
|
@@ -12,4 +12,24 @@ export class CliClient {
|
|
|
12
12
|
const clusters = await this.cloudlyApiClient.cluster.getClusters();
|
|
13
13
|
console.log(clusters);
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hands Cloudly the dcrouter admin token and prints the enrollment that results. The token is
|
|
18
|
+
* the caller's to wipe; the API client seals its own copy and never logs or keeps it.
|
|
19
|
+
*/
|
|
20
|
+
public async bootstrapGateway(
|
|
21
|
+
targetArg: plugins.servezoneApi.IExternalGatewayBootstrapTarget,
|
|
22
|
+
bootstrapTokenArg: Uint8Array,
|
|
23
|
+
) {
|
|
24
|
+
const { state } = await this.cloudlyApiClient.settings.bootstrapExternalGatewayCredential({
|
|
25
|
+
target: targetArg,
|
|
26
|
+
bootstrapToken: bootstrapTokenArg,
|
|
27
|
+
});
|
|
28
|
+
console.log(JSON.stringify(state, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async getGatewayStatus() {
|
|
32
|
+
const { state } = await this.cloudlyApiClient.settings.getExternalGatewayEnrollmentState();
|
|
33
|
+
console.log(JSON.stringify(state, null, 2));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
/** What one invocation of the CLI asks for, parsed before anything is read or connected. */
|
|
4
|
+
export type TCliCommand =
|
|
5
|
+
/** No arguments: the default action, listing the clusters. */
|
|
6
|
+
| { kind: 'clusters' }
|
|
7
|
+
| { kind: 'gateway-status' }
|
|
8
|
+
| { kind: 'gateway-bootstrap'; target: plugins.servezoneApi.IExternalGatewayBootstrapTarget };
|
|
9
|
+
|
|
10
|
+
export const cliUsage = [
|
|
11
|
+
'Usage:',
|
|
12
|
+
' servezone',
|
|
13
|
+
' servezone gateway status',
|
|
14
|
+
' servezone gateway bootstrap --gateway-url <https url> --client-id <id>',
|
|
15
|
+
' The dcrouter admin token is read from stdin, never from arguments or the environment.',
|
|
16
|
+
].join('\n');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An invocation the CLI does not run. It never quotes what it was given, because an argument may
|
|
20
|
+
* be a token pasted where it does not belong.
|
|
21
|
+
*/
|
|
22
|
+
export class CliUsageError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super(cliUsage);
|
|
25
|
+
this.name = 'CliUsageError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireUsage(conditionArg: unknown): asserts conditionArg {
|
|
30
|
+
if (!conditionArg) throw new CliUsageError();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Reads `--name value` pairs, each named option exactly once and nothing else. */
|
|
34
|
+
const parseOptions = <TName extends string>(
|
|
35
|
+
argsArg: readonly string[],
|
|
36
|
+
namesArg: readonly TName[],
|
|
37
|
+
): Record<TName, string> => {
|
|
38
|
+
const options = new Map<string, string>();
|
|
39
|
+
requireUsage(argsArg.length === namesArg.length * 2);
|
|
40
|
+
for (let index = 0; index < argsArg.length; index += 2) {
|
|
41
|
+
const name = argsArg[index];
|
|
42
|
+
const value = argsArg[index + 1];
|
|
43
|
+
requireUsage(namesArg.some((nameArg) => nameArg === name) && !options.has(name));
|
|
44
|
+
requireUsage(value.length > 0 && !value.startsWith('--'));
|
|
45
|
+
options.set(name, value);
|
|
46
|
+
}
|
|
47
|
+
return Object.fromEntries(options) as Record<TName, string>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The command an argument vector names; any other vector is refused as usage. */
|
|
51
|
+
export const parseCliCommand = (argsArg: readonly string[]): TCliCommand => {
|
|
52
|
+
if (argsArg.length === 0) return { kind: 'clusters' };
|
|
53
|
+
const [group, action, ...rest] = argsArg;
|
|
54
|
+
requireUsage(group === 'gateway');
|
|
55
|
+
if (action === 'status') {
|
|
56
|
+
requireUsage(rest.length === 0);
|
|
57
|
+
return { kind: 'gateway-status' };
|
|
58
|
+
}
|
|
59
|
+
requireUsage(action === 'bootstrap');
|
|
60
|
+
const options = parseOptions(rest, ['--gateway-url', '--client-id']);
|
|
61
|
+
return {
|
|
62
|
+
kind: 'gateway-bootstrap',
|
|
63
|
+
target: { gatewayUrl: options['--gateway-url'], gatewayClientId: options['--client-id'] },
|
|
64
|
+
};
|
|
65
|
+
};
|
package/ts_cliclient/index.ts
CHANGED
|
@@ -1,7 +1,63 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { CliClient } from './classes.cliclient.js';
|
|
3
|
+
import { parseCliCommand, type TCliCommand } from './commands.js';
|
|
4
|
+
import { readBootstrapTokenInput } from './tokeninput.js';
|
|
5
|
+
|
|
6
|
+
export { CliUsageError, parseCliCommand, type TCliCommand } from './commands.js';
|
|
7
|
+
export {
|
|
8
|
+
BootstrapTokenInputError,
|
|
9
|
+
readBootstrapTokenInput,
|
|
10
|
+
type TBootstrapTokenInputRefusal,
|
|
11
|
+
} from './tokeninput.js';
|
|
12
|
+
|
|
13
|
+
/** A parsed command with everything it reads locally; a bootstrap carries the token from stdin. */
|
|
14
|
+
type TCliInvocation =
|
|
15
|
+
| Exclude<TCliCommand, { kind: 'gateway-bootstrap' }>
|
|
16
|
+
| (Extract<TCliCommand, { kind: 'gateway-bootstrap' }> & { bootstrapToken: Uint8Array });
|
|
3
17
|
|
|
4
18
|
export const runCli = async () => {
|
|
19
|
+
// The command and the token are settled before anything connects, so a wrong invocation or a
|
|
20
|
+
// token that could never be one fails without reaching Cloudly.
|
|
21
|
+
let command: TCliInvocation;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = parseCliCommand(process.argv.slice(2));
|
|
24
|
+
command = parsed.kind === 'gateway-bootstrap'
|
|
25
|
+
? { ...parsed, bootstrapToken: await readBootstrapTokenInput(process.stdin) }
|
|
26
|
+
: parsed;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error((error as Error).message);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const apiClient = await connect();
|
|
35
|
+
const cliClient = new CliClient(apiClient);
|
|
36
|
+
if (command.kind === 'clusters') {
|
|
37
|
+
// Default action example: list clusters when invoked without subcommands
|
|
38
|
+
await cliClient.getClusters();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (command.kind === 'gateway-bootstrap') {
|
|
43
|
+
await cliClient.bootstrapGateway(command.target, command.bootstrapToken);
|
|
44
|
+
} else {
|
|
45
|
+
await cliClient.getGatewayStatus();
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Cloudly answers a refusal by name; it never carries the token.
|
|
49
|
+
console.error((error as Error).message);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
} finally {
|
|
52
|
+
await apiClient.stop();
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
if (command.kind === 'gateway-bootstrap') command.bootstrapToken.fill(0);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The API client, started and signed in the way every command of this CLI signs in. */
|
|
60
|
+
const connect = async (): Promise<plugins.servezoneApi.CloudlyApiClient> => {
|
|
5
61
|
// The directory the operator runs the CLI in is read for sources, exactly as before, but the
|
|
6
62
|
// `required:` list it may find there is never enforced: that list is the Cloudly server's own
|
|
7
63
|
// start-time contract, and none of a remote server's database, storage or keyring facts is this
|
|
@@ -18,15 +74,17 @@ export const runCli = async () => {
|
|
|
18
74
|
});
|
|
19
75
|
await apiClient.start();
|
|
20
76
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
77
|
+
try {
|
|
78
|
+
if (token) {
|
|
79
|
+
await apiClient.getIdentityByToken(token, { tagConnection: true, statefullIdentity: true });
|
|
80
|
+
} else if (username && password) {
|
|
81
|
+
await apiClient.loginWithUsernameAndPassword(username, password);
|
|
82
|
+
} else {
|
|
83
|
+
console.log('No credentials provided. Set CLOUDLY_TOKEN or CLOUDLY_USERNAME/CLOUDLY_PASSWORD.');
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await apiClient.stop();
|
|
87
|
+
throw error;
|
|
27
88
|
}
|
|
28
|
-
|
|
29
|
-
const cliClient = new CliClient(apiClient);
|
|
30
|
-
// Default action example: list clusters when invoked without subcommands
|
|
31
|
-
await cliClient.getClusters();
|
|
89
|
+
return apiClient;
|
|
32
90
|
};
|
package/ts_cliclient/readme.md
CHANGED
|
@@ -14,7 +14,8 @@ This submodule is intentionally small in the current codebase:
|
|
|
14
14
|
- Authenticates with either `CLOUDLY_TOKEN` or `CLOUDLY_USERNAME` plus `CLOUDLY_PASSWORD`.
|
|
15
15
|
- Starts a `CloudlyApiClient` registered as `cli`.
|
|
16
16
|
- Creates a `CliClient` wrapper around the API client.
|
|
17
|
-
-
|
|
17
|
+
- Without arguments, calls `CliClient.getClusters()` and prints the result.
|
|
18
|
+
- Runs `servezone gateway bootstrap` and `servezone gateway status` for Cloudly's dcrouter gateway enrollment.
|
|
18
19
|
|
|
19
20
|
It is not currently a full command tree for services, secrets, deployments, logs, profiles, or shell completion. Those flows should be implemented against `@serve.zone/api` before documenting them as CLI commands.
|
|
20
21
|
|
|
@@ -54,6 +55,24 @@ servezone
|
|
|
54
55
|
|
|
55
56
|
When `CLOUDLY_TOKEN` is present, the CLI exchanges it for an identity and tags the WebSocket with a JWT-only credential. Cloudly verifies the JWT and reconstructs authoritative identity fields from persisted user data. Machine JWTs never outlive their source tokens, and cluster machine users must belong to exactly one persisted cluster. When username/password are present instead, the CLI uses Cloudly's admin login flow. If no credentials are provided, it prints a warning before attempting the default cluster-list operation.
|
|
56
57
|
|
|
58
|
+
## Gateway Enrollment
|
|
59
|
+
|
|
60
|
+
Cloudly enrolls itself at dcrouter as one gateway client, with a dcrouter admin token an administrator hands in once. Both commands sign in exactly as the default action does and need an administrator identity.
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# Mint a short-lived admin token at dcrouter first, then pipe it in:
|
|
64
|
+
CLOUDLY_URL=https://cloudly.example.com \
|
|
65
|
+
CLOUDLY_USERNAME=admin CLOUDLY_PASSWORD=change-me \
|
|
66
|
+
servezone gateway bootstrap --gateway-url https://gateway.example.com --client-id cloudly.example.com < admin-token.txt
|
|
67
|
+
|
|
68
|
+
servezone gateway status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `gateway bootstrap --gateway-url <https url> --client-id <id>` names Cloudly's `dcrouterGatewayUrl` and `dcrouterGatewayClientId` as its settings state them; Cloudly refuses any other target. The admin token is read from stdin and from nowhere else: an argument, `--token`, or any other extra argument is refused as usage without echoing it, and the environment is never read for it. stdin must not be a terminal. One trailing line break (`\n` or `\r\n`) is stripped; what remains must be 1 to 1024 bytes of visible ASCII. The token is checked before anything connects, sealed by `@serve.zone/api` for Cloudly, and wiped afterwards. The command prints the resulting enrollment state as JSON and exits 0; a refusal (`External gateway bootstrap refused: <refusal>.`) or a refused stdin is printed to stderr with exit code 1.
|
|
72
|
+
- `gateway status` prints the current enrollment state as JSON.
|
|
73
|
+
|
|
74
|
+
Revoke the admin token at dcrouter once `gateway status` reads `ready`.
|
|
75
|
+
|
|
57
76
|
## Programmatic Use
|
|
58
77
|
|
|
59
78
|
The published submodule exports the `runCli()` entry point. For automation, most callers should use `@serve.zone/api` directly; the internal `CliClient` currently wraps `CloudlyApiClient` only to run the default cluster-list action:
|
|
@@ -79,7 +98,9 @@ await cli.getClusters();
|
|
|
79
98
|
| Path | Purpose |
|
|
80
99
|
| --- | --- |
|
|
81
100
|
| `index.ts` | Runtime entry point for the published CLI. |
|
|
82
|
-
| `classes.cliclient.ts` | Minimal client wrapper
|
|
101
|
+
| `classes.cliclient.ts` | Minimal client wrapper: `getClusters()`, `bootstrapGateway()` and `getGatewayStatus()`. |
|
|
102
|
+
| `commands.ts` | Parses the argument vector into one command, refusing anything else as usage. |
|
|
103
|
+
| `tokeninput.ts` | Reads the dcrouter admin token from stdin. |
|
|
83
104
|
| `plugins.ts` | Centralized imports for the submodule. |
|
|
84
105
|
| `tspublish.json` | Published package name, dependencies, `servezone` bin metadata, and `useBase`, which takes the registries from the release's npm target. |
|
|
85
106
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
/** Why the admin token piped in was not taken, named without quoting any of it. */
|
|
4
|
+
export type TBootstrapTokenInputRefusal =
|
|
5
|
+
/** stdin is a terminal, which would echo a token typed into it; pipe the token in instead. */
|
|
6
|
+
| 'token-input-terminal'
|
|
7
|
+
/** stdin carried more bytes than a token and one line break. */
|
|
8
|
+
| 'token-input-oversized'
|
|
9
|
+
/** What stdin carried is not a token: empty, or a byte outside visible ASCII, a space included. */
|
|
10
|
+
| 'token-input-invalid';
|
|
11
|
+
|
|
12
|
+
export class BootstrapTokenInputError extends Error {
|
|
13
|
+
constructor(public readonly refusal: TBootstrapTokenInputRefusal) {
|
|
14
|
+
super(`The dcrouter admin token on stdin was refused: ${refusal}.`);
|
|
15
|
+
this.name = 'BootstrapTokenInputError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function refuseUnless(conditionArg: unknown, refusalArg: TBootstrapTokenInputRefusal): asserts conditionArg {
|
|
20
|
+
if (!conditionArg) throw new BootstrapTokenInputError(refusalArg);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the token is read from: the command's stdin, or a stream a test hands in. */
|
|
24
|
+
export type TBootstrapTokenInput = AsyncIterable<Uint8Array> & { isTTY?: boolean };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The dcrouter admin token piped into `gateway bootstrap`, as bytes the caller wipes.
|
|
28
|
+
*
|
|
29
|
+
* stdin is the only source: an argument would sit in the process list and the shell history, and
|
|
30
|
+
* the environment leaks into child processes. One trailing line break, `\n` or `\r\n`, is what
|
|
31
|
+
* `echo` or a file adds and is not part of the token; everything else must pass the contract's
|
|
32
|
+
* token rule. Every byte read is wiped before this answers, whatever it answers.
|
|
33
|
+
*/
|
|
34
|
+
export const readBootstrapTokenInput = async (inputArg: TBootstrapTokenInput): Promise<Uint8Array> => {
|
|
35
|
+
refuseUnless(inputArg.isTTY !== true, 'token-input-terminal');
|
|
36
|
+
const maximumBytes = plugins.servezoneInterfaces.data.externalGatewayBootstrapContract.maximumTokenBytes + 2;
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
let input: Uint8Array | undefined;
|
|
39
|
+
try {
|
|
40
|
+
let byteLength = 0;
|
|
41
|
+
for await (const chunk of inputArg) {
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
byteLength += chunk.byteLength;
|
|
44
|
+
refuseUnless(byteLength <= maximumBytes, 'token-input-oversized');
|
|
45
|
+
}
|
|
46
|
+
input = Buffer.concat(chunks, byteLength);
|
|
47
|
+
let end = input.byteLength;
|
|
48
|
+
if (end > 0 && input[end - 1] === 0x0a) {
|
|
49
|
+
end -= 1;
|
|
50
|
+
if (end > 0 && input[end - 1] === 0x0d) end -= 1;
|
|
51
|
+
}
|
|
52
|
+
const token = input.subarray(0, end);
|
|
53
|
+
refuseUnless(plugins.servezoneInterfaces.data.isExternalGatewayBootstrapToken(token), 'token-input-invalid');
|
|
54
|
+
// A copy, because the input and every view of it are wiped below.
|
|
55
|
+
return new Uint8Array(token);
|
|
56
|
+
} finally {
|
|
57
|
+
input?.fill(0);
|
|
58
|
+
for (const chunk of chunks) chunk.fill(0);
|
|
59
|
+
}
|
|
60
|
+
};
|