@tonbo/cli 0.6.1 → 0.7.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 +30 -4
- package/dist/bin/tonbo.js +489 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,22 +50,29 @@ Every command other than `login` and `logout` calls one operation of the [Manage
|
|
|
50
50
|
| `tonbo database show <name>` | `getDatabase` | `GET /v1/databases/{name}` |
|
|
51
51
|
| `tonbo database update <name> (--public \| --private)` | `updateDatabase` | `PATCH /v1/databases/{name}` |
|
|
52
52
|
| `tonbo database delete <name> [--yes]` | `deleteDatabase` | `DELETE /v1/databases/{name}` |
|
|
53
|
-
| `tonbo database usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--limit <n>] [--cursor <c>]` | `listDatabaseUsage` | `GET /v1/usage/databases` |
|
|
53
|
+
| `tonbo database usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--names <a,b>] [--limit <n>] [--cursor <c>]` | `listDatabaseUsage` | `GET /v1/usage/databases` |
|
|
54
54
|
| `tonbo usage show [--period <YYYY-MM>]` | `getUsage` | `GET /v1/usage` |
|
|
55
|
+
| `tonbo machine list [--limit <n>] [--cursor <c>]` | `listMachines` | `GET /v1/machines` |
|
|
56
|
+
| `tonbo machine create <name> --image <reference> [--shape <shape>] [--region <region>] [--port <port[:access]>]… [--default-port <port>] [--env <KEY=VALUE>]… [--sleep-after <seconds>] [--idle-action <action>] [--stop-grace <seconds>] [--idempotency-key <key>] [-- <command>…]` | `createMachine` | `POST /v1/machines` |
|
|
57
|
+
| `tonbo machine show <name>` | `getMachine` | `GET /v1/machines/{name}` |
|
|
58
|
+
| `tonbo machine update <name> [--image <reference>] [--image-command] [--shape <shape>] [--port <port[:access]>]… [--clear-ports] [--default-port <port>] [--env <KEY=VALUE>]… [--unset-env <KEY>]… [--sleep-after <seconds>] [--idle-action <action>] [--stop-grace <seconds>] [--discard-snapshot] [--revision <n>] [-- <command>…]` | `updateMachine` | `PATCH /v1/machines/{name}` |
|
|
59
|
+
| `tonbo machine sleep <name>` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
|
|
60
|
+
| `tonbo machine delete <name> [--yes]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
|
|
61
|
+
| `tonbo machine shapes` | `listMachineShapes` | `GET /v1/machine-shapes` |
|
|
55
62
|
|
|
56
63
|
### Output and errors
|
|
57
64
|
|
|
58
|
-
Every command prints an aligned table or a list of fields for people. With `--json` it prints the answer's `data` exactly as the API returned it, one JSON value on stdout and nothing else there. A list prints one page; when there is another, the CLI writes `More results: run again with --cursor <cursor>` to stderr, so `--json` output stays the API's `data`. `database usage --json`
|
|
65
|
+
Every command prints an aligned table or a list of fields for people. With `--json` it prints the answer's `data` exactly as the API returned it, one JSON value on stdout and nothing else there. A list prints one page; when there is another, the CLI writes `More results: run again with --cursor <cursor>` to stderr, so `--json` output stays the API's `data`. `database usage --json` and `machine shapes --json` print the whole answer, since the usage's `other` row, checkpoint and prices, and the default shape, belong with the rows. `--limit` asks for up to that many items (the API's default is 50, its maximum 200).
|
|
59
66
|
|
|
60
67
|
A refusal prints the problem's detail, the fields a validation error names, and the request's `X-Request-Id` to stderr, and the command exits with status 1. An invalid or expired key adds `Run tonbo login`.
|
|
61
68
|
|
|
62
69
|
### Retried creations
|
|
63
70
|
|
|
64
|
-
`agent create`, `bucket create` and `
|
|
71
|
+
`agent create`, `bucket create`, `database create` and `machine create` send an `Idempotency-Key`, a new UUID unless `--idempotency-key` names one. When a creation is not confirmed (the network failed or the server answered 5xx), the CLI prints its key; retrying with that key and the same arguments returns what the first request created instead of creating a second one. `api-key create` has no idempotency key, because a key's secret is never stored and a replay could not return it; a live key's name is unique, so a blind retry is refused with 409 rather than creating a second key.
|
|
65
72
|
|
|
66
73
|
### Destructive commands
|
|
67
74
|
|
|
68
|
-
`api-key revoke`, `bucket delete` and `
|
|
75
|
+
`api-key revoke`, `bucket delete`, `database delete` and `machine delete` ask before they act when stdin and stderr are a terminal: a revocation is confirmed by typing `yes`, a deletion by typing the bucket's, database's or Machine's name, as in the console. Without a terminal they refuse unless `--yes` is given.
|
|
69
76
|
|
|
70
77
|
## Account
|
|
71
78
|
|
|
@@ -124,6 +131,25 @@ Next: npm i @tonbo/sqlite-ursula
|
|
|
124
131
|
|
|
125
132
|
`open("notes")` resolves the database through `GET /v1/databases/notes`. It takes its key from, in order, its `apiKey` option, then `TONBO_API_KEY`, then the `tonbo login` credentials file (`key_sources` in `contracts/cli/credentials-v1.json`). A key narrowed to databases reads the databases it reaches there, so an application can be given a narrowed key.
|
|
126
133
|
|
|
134
|
+
## Machines
|
|
135
|
+
|
|
136
|
+
```console
|
|
137
|
+
tonbo machine shapes
|
|
138
|
+
tonbo machine create web --image ghcr.io/acme/web:1.4 --port 8080:public --env MODE=prod
|
|
139
|
+
tonbo machine create worker --image python:3.13 -- python -m worker
|
|
140
|
+
tonbo machine update web --env MODE=staging --sleep-after 300
|
|
141
|
+
tonbo machine sleep web
|
|
142
|
+
tonbo machine delete web
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A Machine is a VM booted from a public OCI image with a linux/arm64 variant, in one region, with its own database (`contracts/machines/machine-v2.json`). Machines open by invitation: until a region is open for them, every `tonbo machine` command is refused with `region_unavailable`, and the CLI says that nothing was created or changed.
|
|
146
|
+
|
|
147
|
+
`machine create` resolves the image to its linux/arm64 digest and reserves the Machine's name and its hostnames: `https://<name>-<account>.<region>.tonbo.sh` for the default port, and `<name>.<account>@<region>.tonbo.sh` for SSH. Without `--shape` it takes the shape `machine shapes` marks as the default. `--port <port>` serves a private port (a member's token or SSH) and `--port <port>:public` an open one, which needs the `machines.public` capability; the first port is served at the hostname unless `--default-port` names another. A command after `--` replaces the image's CMD. The answer is the Machine while it is `creating`; `machine show` reports it `active` when it is ready.
|
|
148
|
+
|
|
149
|
+
`machine update` changes only what its options name. It reads the Machine and writes the change at the revision it read, or at `--revision`, so a change someone made in between is refused with `revision_mismatch` rather than overwritten. `--env` and `--unset-env` change single variables; `--port` replaces every port, naming a public port again keeps its limits, and the current default port stays the default while it is still listed unless `--default-port` names another. Ports and `--sleep-after` apply at once; image, command, shape and env at the Machine's next boot. `machine sleep` puts an awake Machine to sleep now; its next connection wakes it. `machine delete` deletes the Machine with its disk, database and logs, and its name is free for a new Machine at once.
|
|
150
|
+
|
|
151
|
+
A refusal of machine-v2's `errors` prints the server's detail and then what to do next, for example `machine shapes` for `shape_unavailable` or a shorter name for `hostname_too_long`.
|
|
152
|
+
|
|
127
153
|
## Usage
|
|
128
154
|
|
|
129
155
|
`usage show` prints the account's rated usage of one calendar month in UTC, the current one unless `--period YYYY-MM` names another: one line per product, region, dimension and model with its quantity and amount, and the total, in US dollars.
|
package/dist/bin/tonbo.js
CHANGED
|
@@ -544,11 +544,79 @@ var managementOperations = {
|
|
|
544
544
|
"period",
|
|
545
545
|
"sort",
|
|
546
546
|
"prefix",
|
|
547
|
+
"names",
|
|
547
548
|
"cursor",
|
|
548
549
|
"limit"
|
|
549
550
|
],
|
|
550
551
|
"idempotencyKey": false,
|
|
551
552
|
"cli": "tonbo database usage"
|
|
553
|
+
},
|
|
554
|
+
"listMachines": {
|
|
555
|
+
"method": "GET",
|
|
556
|
+
"path": "/machines",
|
|
557
|
+
"pathParams": [],
|
|
558
|
+
"query": [
|
|
559
|
+
"cursor",
|
|
560
|
+
"limit"
|
|
561
|
+
],
|
|
562
|
+
"idempotencyKey": false,
|
|
563
|
+
"cli": "tonbo machine list"
|
|
564
|
+
},
|
|
565
|
+
"createMachine": {
|
|
566
|
+
"method": "POST",
|
|
567
|
+
"path": "/machines",
|
|
568
|
+
"pathParams": [],
|
|
569
|
+
"query": [],
|
|
570
|
+
"idempotencyKey": true,
|
|
571
|
+
"cli": "tonbo machine create"
|
|
572
|
+
},
|
|
573
|
+
"getMachine": {
|
|
574
|
+
"method": "GET",
|
|
575
|
+
"path": "/machines/{name}",
|
|
576
|
+
"pathParams": [
|
|
577
|
+
"name"
|
|
578
|
+
],
|
|
579
|
+
"query": [],
|
|
580
|
+
"idempotencyKey": false,
|
|
581
|
+
"cli": "tonbo machine show"
|
|
582
|
+
},
|
|
583
|
+
"updateMachine": {
|
|
584
|
+
"method": "PATCH",
|
|
585
|
+
"path": "/machines/{name}",
|
|
586
|
+
"pathParams": [
|
|
587
|
+
"name"
|
|
588
|
+
],
|
|
589
|
+
"query": [],
|
|
590
|
+
"idempotencyKey": false,
|
|
591
|
+
"cli": "tonbo machine update"
|
|
592
|
+
},
|
|
593
|
+
"deleteMachine": {
|
|
594
|
+
"method": "DELETE",
|
|
595
|
+
"path": "/machines/{name}",
|
|
596
|
+
"pathParams": [
|
|
597
|
+
"name"
|
|
598
|
+
],
|
|
599
|
+
"query": [],
|
|
600
|
+
"idempotencyKey": false,
|
|
601
|
+
"cli": "tonbo machine delete"
|
|
602
|
+
},
|
|
603
|
+
"sleepMachine": {
|
|
604
|
+
"method": "POST",
|
|
605
|
+
"path": "/machines/{name}/sleep",
|
|
606
|
+
"pathParams": [
|
|
607
|
+
"name"
|
|
608
|
+
],
|
|
609
|
+
"query": [],
|
|
610
|
+
"idempotencyKey": false,
|
|
611
|
+
"cli": "tonbo machine sleep"
|
|
612
|
+
},
|
|
613
|
+
"listMachineShapes": {
|
|
614
|
+
"method": "GET",
|
|
615
|
+
"path": "/machine-shapes",
|
|
616
|
+
"pathParams": [],
|
|
617
|
+
"query": [],
|
|
618
|
+
"idempotencyKey": false,
|
|
619
|
+
"cli": "tonbo machine shapes"
|
|
552
620
|
}
|
|
553
621
|
};
|
|
554
622
|
var accountRoles = [
|
|
@@ -706,6 +774,14 @@ var credentialsContract = {
|
|
|
706
774
|
]
|
|
707
775
|
}
|
|
708
776
|
};
|
|
777
|
+
var machineIdleActions = [
|
|
778
|
+
"suspend",
|
|
779
|
+
"release"
|
|
780
|
+
];
|
|
781
|
+
var machinePortAccess = [
|
|
782
|
+
"private",
|
|
783
|
+
"public"
|
|
784
|
+
];
|
|
709
785
|
|
|
710
786
|
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
711
787
|
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
@@ -1021,7 +1097,7 @@ var ManagementApi = class {
|
|
|
1021
1097
|
page(operationId, key, { limit, cursor } = {}, query = {}) {
|
|
1022
1098
|
return this.request(operationId, key, { query: { ...query, limit, cursor } });
|
|
1023
1099
|
}
|
|
1024
|
-
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
|
|
1100
|
+
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }) {
|
|
1025
1101
|
const operation = managementOperations[operationId];
|
|
1026
1102
|
const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1027
1103
|
const value = params[name];
|
|
@@ -1044,6 +1120,8 @@ var ManagementApi = class {
|
|
|
1044
1120
|
headers["content-type"] = "application/json";
|
|
1045
1121
|
if (idempotencyKey)
|
|
1046
1122
|
headers["idempotency-key"] = idempotencyKey;
|
|
1123
|
+
if (ifMatch)
|
|
1124
|
+
headers["if-match"] = ifMatch;
|
|
1047
1125
|
const queryString = search.toString();
|
|
1048
1126
|
const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
|
|
1049
1127
|
try {
|
|
@@ -1141,6 +1219,44 @@ var ManagementApi = class {
|
|
|
1141
1219
|
getUsage(key, period) {
|
|
1142
1220
|
return this.call("getUsage", key, { query: { period } });
|
|
1143
1221
|
}
|
|
1222
|
+
listMachines(key, page) {
|
|
1223
|
+
return this.page("listMachines", key, page);
|
|
1224
|
+
}
|
|
1225
|
+
/** A replay with the same idempotency key returns the Machine the first
|
|
1226
|
+
* request created. */
|
|
1227
|
+
createMachine(key, request, idempotencyKey) {
|
|
1228
|
+
return this.call("createMachine", key, {
|
|
1229
|
+
body: withoutUndefined(request),
|
|
1230
|
+
idempotencyKey
|
|
1231
|
+
}).catch(unconfirmed("Machine creation", idempotencyKey, "the same arguments"));
|
|
1232
|
+
}
|
|
1233
|
+
getMachine(key, name) {
|
|
1234
|
+
return this.call("getMachine", key, { params: { name } });
|
|
1235
|
+
}
|
|
1236
|
+
/** Written only at `revision` (machine-v2 requires If-Match, the Machine's
|
|
1237
|
+
* ETag); a stale one is refused with `revision_mismatch`. */
|
|
1238
|
+
updateMachine(key, name, revision, change) {
|
|
1239
|
+
return this.call("updateMachine", key, {
|
|
1240
|
+
params: { name },
|
|
1241
|
+
body: withoutUndefined(change),
|
|
1242
|
+
ifMatch: `"${revision}"`
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
sleepMachine(key, name) {
|
|
1246
|
+
return this.call("sleepMachine", key, { params: { name } });
|
|
1247
|
+
}
|
|
1248
|
+
/** The Machine being deleted, or null when the API answered 204: the
|
|
1249
|
+
* name's create never reached its region, and only the name was released. */
|
|
1250
|
+
async deleteMachine(key, name) {
|
|
1251
|
+
const answer = await this.request("deleteMachine", key, {
|
|
1252
|
+
params: { name }
|
|
1253
|
+
});
|
|
1254
|
+
return answer?.data ?? null;
|
|
1255
|
+
}
|
|
1256
|
+
/** The whole answer: the shapes and the default one. */
|
|
1257
|
+
listMachineShapes(key) {
|
|
1258
|
+
return this.request("listMachineShapes", key, {});
|
|
1259
|
+
}
|
|
1144
1260
|
};
|
|
1145
1261
|
function withoutUndefined(value) {
|
|
1146
1262
|
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
@@ -1819,7 +1935,7 @@ async function databaseUsageCommand(deps, options = {}) {
|
|
|
1819
1935
|
const usage = await deps.api.listDatabaseUsage(
|
|
1820
1936
|
await deps.auth.apiKey(),
|
|
1821
1937
|
{ limit: options.limit, cursor: options.cursor },
|
|
1822
|
-
{ period: options.period, sort: options.sort, prefix: options.prefix }
|
|
1938
|
+
{ period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
|
|
1823
1939
|
);
|
|
1824
1940
|
if (options.json) deps.output(JSON.stringify(usage));
|
|
1825
1941
|
else
|
|
@@ -1877,6 +1993,283 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
1877
1993
|
);
|
|
1878
1994
|
}
|
|
1879
1995
|
|
|
1996
|
+
// src/machine.ts
|
|
1997
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1998
|
+
var MACHINE_ERROR_HINTS = {
|
|
1999
|
+
region_unavailable: "Machines are not open there yet, or that region cannot be reached from here; nothing was created or changed.",
|
|
2000
|
+
name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
|
|
2001
|
+
hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
|
|
2002
|
+
hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
|
|
2003
|
+
account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
|
|
2004
|
+
image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
|
|
2005
|
+
shape_unavailable: "`tonbo machine shapes` lists the shapes a Machine may name now.",
|
|
2006
|
+
port_reserved: "Serve the application on another port.",
|
|
2007
|
+
revision_mismatch: "The Machine changed since it was read; `tonbo machine show` it and run the update again.",
|
|
2008
|
+
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
2009
|
+
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
|
|
2010
|
+
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
2011
|
+
create_rate_limited: "Too many Machines were created in the last hour; try again later."
|
|
2012
|
+
};
|
|
2013
|
+
function explainMachineError(error) {
|
|
2014
|
+
if (!(error instanceof HttpError) || error.code === null) return error;
|
|
2015
|
+
const hint = MACHINE_ERROR_HINTS[error.code];
|
|
2016
|
+
if (!hint) return error;
|
|
2017
|
+
return new HttpError(`${error.message}
|
|
2018
|
+
${hint}`, error.status, error.body, error.requestId);
|
|
2019
|
+
}
|
|
2020
|
+
async function machineCommand(run) {
|
|
2021
|
+
try {
|
|
2022
|
+
return await run();
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
throw explainMachineError(error);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
function portText(port) {
|
|
2028
|
+
return `${port.port} ${port.access}${port.default ? " (default)" : ""}`;
|
|
2029
|
+
}
|
|
2030
|
+
function machinePortsText(machine) {
|
|
2031
|
+
return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
|
|
2032
|
+
}
|
|
2033
|
+
function idleActionText(machine) {
|
|
2034
|
+
const { action, reason } = machine.status.sleep;
|
|
2035
|
+
if (action === machine.idle_action) return action;
|
|
2036
|
+
return `${action} (${machine.idle_action} requested; ${reason.replaceAll("_", " ")})`;
|
|
2037
|
+
}
|
|
2038
|
+
function machineState(machine) {
|
|
2039
|
+
return machine.failure ? `${machine.state}: ${machine.failure.code}: ${machine.failure.message}` : machine.state;
|
|
2040
|
+
}
|
|
2041
|
+
function machineFields(machine) {
|
|
2042
|
+
return fields([
|
|
2043
|
+
["Name", machine.name],
|
|
2044
|
+
["ID", machine.id],
|
|
2045
|
+
["Region", machine.region],
|
|
2046
|
+
["State", machineState(machine)],
|
|
2047
|
+
["Power", machine.status.power],
|
|
2048
|
+
["Shape", machine.shape],
|
|
2049
|
+
["Image", `${machine.image.reference} (${machine.image.digest})`],
|
|
2050
|
+
["Command", machine.command ? machine.command.join(" ") : "the image's own"],
|
|
2051
|
+
["Ports", machinePortsText(machine)],
|
|
2052
|
+
["HTTPS", machine.hostnames.https],
|
|
2053
|
+
["SSH", machine.hostnames.ssh],
|
|
2054
|
+
["Env", Object.keys(machine.env).sort().join(", ") || "none"],
|
|
2055
|
+
["Sleep after", `${machine.sleep_after_seconds}s without a connection`],
|
|
2056
|
+
["Idle action", idleActionText(machine)],
|
|
2057
|
+
["Stop grace", `${machine.stop_grace_seconds}s`],
|
|
2058
|
+
["Database", machine.database.name],
|
|
2059
|
+
["Revision", String(machine.revision)],
|
|
2060
|
+
["Created", machine.created_at],
|
|
2061
|
+
["Updated", machine.updated_at]
|
|
2062
|
+
]);
|
|
2063
|
+
}
|
|
2064
|
+
function parsePorts(specs, current) {
|
|
2065
|
+
const accesses = machinePortAccess;
|
|
2066
|
+
return specs.map((spec) => {
|
|
2067
|
+
const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
|
|
2068
|
+
const access = match?.[2] ?? "private";
|
|
2069
|
+
if (!match || !accesses.includes(access))
|
|
2070
|
+
throw new Error(
|
|
2071
|
+
`--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
|
|
2072
|
+
);
|
|
2073
|
+
const port = Number(match[1]);
|
|
2074
|
+
const before = current.find((entry) => entry.port === port && entry.access === access);
|
|
2075
|
+
return {
|
|
2076
|
+
port,
|
|
2077
|
+
access,
|
|
2078
|
+
default: false,
|
|
2079
|
+
...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
|
|
2080
|
+
};
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
function withDefault(ports, defaultPort) {
|
|
2084
|
+
if (ports.length === 0) {
|
|
2085
|
+
if (defaultPort !== void 0) throw new Error("--default-port needs a port to choose.");
|
|
2086
|
+
return ports;
|
|
2087
|
+
}
|
|
2088
|
+
const chosen = defaultPort ?? ports[0].port;
|
|
2089
|
+
if (!ports.some((entry) => entry.port === chosen))
|
|
2090
|
+
throw new Error(`--default-port ${chosen} is not one of the Machine's ports.`);
|
|
2091
|
+
return ports.map((entry) => ({ ...entry, default: entry.port === chosen }));
|
|
2092
|
+
}
|
|
2093
|
+
function parseEnv(pairs) {
|
|
2094
|
+
const env = {};
|
|
2095
|
+
for (const pair of pairs) {
|
|
2096
|
+
const split = pair.indexOf("=");
|
|
2097
|
+
if (split < 1) throw new Error(`--env ${pair}: give KEY=VALUE.`);
|
|
2098
|
+
env[pair.slice(0, split)] = pair.slice(split + 1);
|
|
2099
|
+
}
|
|
2100
|
+
return env;
|
|
2101
|
+
}
|
|
2102
|
+
async function machineListCommand(deps, options = {}) {
|
|
2103
|
+
await machineCommand(async () => {
|
|
2104
|
+
const page = await deps.api.listMachines(await deps.auth.apiKey(), {
|
|
2105
|
+
limit: options.limit,
|
|
2106
|
+
cursor: options.cursor
|
|
2107
|
+
});
|
|
2108
|
+
printPage(
|
|
2109
|
+
deps,
|
|
2110
|
+
options.json,
|
|
2111
|
+
page,
|
|
2112
|
+
(machines) => machines.length ? table(
|
|
2113
|
+
["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
|
|
2114
|
+
machines.map((machine) => [
|
|
2115
|
+
machine.name,
|
|
2116
|
+
machine.region,
|
|
2117
|
+
machine.state,
|
|
2118
|
+
machine.status.power,
|
|
2119
|
+
machine.shape,
|
|
2120
|
+
machine.image.reference,
|
|
2121
|
+
machine.hostnames.https
|
|
2122
|
+
])
|
|
2123
|
+
) : "No Machines."
|
|
2124
|
+
);
|
|
2125
|
+
if (page.unavailable_regions.length)
|
|
2126
|
+
deps.notice(
|
|
2127
|
+
`Could not read the Machines in ${page.unavailable_regions.join(", ")}; they are missing from this list.`
|
|
2128
|
+
);
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
async function machineCreateCommand(deps, name, command, options = {}) {
|
|
2132
|
+
await machineCommand(async () => {
|
|
2133
|
+
if (!options.image) throw new Error("Pass --image <reference>.");
|
|
2134
|
+
const env = parseEnv(options.env ?? []);
|
|
2135
|
+
const ports = withDefault(parsePorts(options.port ?? [], []), options.defaultPort);
|
|
2136
|
+
const key = await deps.auth.apiKey();
|
|
2137
|
+
const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
|
|
2138
|
+
const machine = await deps.api.createMachine(
|
|
2139
|
+
key,
|
|
2140
|
+
{
|
|
2141
|
+
name,
|
|
2142
|
+
image: options.image,
|
|
2143
|
+
shape,
|
|
2144
|
+
region: options.region,
|
|
2145
|
+
command: command.length ? command : void 0,
|
|
2146
|
+
ports: ports.length ? ports : void 0,
|
|
2147
|
+
env: Object.keys(env).length ? env : void 0,
|
|
2148
|
+
sleep_after_seconds: options.sleepAfter,
|
|
2149
|
+
idle_action: options.idleAction,
|
|
2150
|
+
stop_grace_seconds: options.stopGrace
|
|
2151
|
+
},
|
|
2152
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2153
|
+
);
|
|
2154
|
+
print(
|
|
2155
|
+
deps,
|
|
2156
|
+
options.json,
|
|
2157
|
+
machine,
|
|
2158
|
+
[
|
|
2159
|
+
machine.state === "active" ? `Created Machine ${machine.name}.` : `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`,
|
|
2160
|
+
machineFields(machine)
|
|
2161
|
+
].join("\n")
|
|
2162
|
+
);
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
async function machineShowCommand(deps, name, options = {}) {
|
|
2166
|
+
await machineCommand(async () => {
|
|
2167
|
+
const machine = await deps.api.getMachine(await deps.auth.apiKey(), name);
|
|
2168
|
+
print(deps, options.json, machine, machineFields(machine));
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
2172
|
+
await machineCommand(async () => {
|
|
2173
|
+
if (command.length && options.imageCommand)
|
|
2174
|
+
throw new Error("Give a command or --image-command, not both.");
|
|
2175
|
+
if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
|
|
2176
|
+
throw new Error("Give --port or --clear-ports, not both.");
|
|
2177
|
+
const key = await deps.auth.apiKey();
|
|
2178
|
+
const current = await deps.api.getMachine(key, name);
|
|
2179
|
+
const change = {
|
|
2180
|
+
image: options.image,
|
|
2181
|
+
shape: options.shape,
|
|
2182
|
+
command: options.imageCommand ? null : command.length ? command : void 0,
|
|
2183
|
+
sleep_after_seconds: options.sleepAfter,
|
|
2184
|
+
idle_action: options.idleAction,
|
|
2185
|
+
stop_grace_seconds: options.stopGrace
|
|
2186
|
+
};
|
|
2187
|
+
if (options.clearPorts) change.ports = [];
|
|
2188
|
+
else if (options.port?.length) {
|
|
2189
|
+
const ports = parsePorts(options.port, current.ports);
|
|
2190
|
+
const kept = current.ports.find((entry) => entry.default)?.port;
|
|
2191
|
+
const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
|
|
2192
|
+
change.ports = withDefault(ports, defaultPort);
|
|
2193
|
+
} else if (options.defaultPort !== void 0)
|
|
2194
|
+
change.ports = withDefault(current.ports, options.defaultPort);
|
|
2195
|
+
if (options.env?.length || options.unsetEnv?.length) {
|
|
2196
|
+
const env = { ...current.env, ...parseEnv(options.env ?? []) };
|
|
2197
|
+
for (const variable of options.unsetEnv ?? []) delete env[variable];
|
|
2198
|
+
change.env = env;
|
|
2199
|
+
}
|
|
2200
|
+
if (options.discardSnapshot) change.discard_snapshot = true;
|
|
2201
|
+
if (Object.values(change).every((value) => value === void 0))
|
|
2202
|
+
throw new Error("Nothing to change: pass at least one option or a command.");
|
|
2203
|
+
const machine = await deps.api.updateMachine(
|
|
2204
|
+
key,
|
|
2205
|
+
name,
|
|
2206
|
+
options.revision ?? current.revision,
|
|
2207
|
+
change
|
|
2208
|
+
);
|
|
2209
|
+
print(
|
|
2210
|
+
deps,
|
|
2211
|
+
options.json,
|
|
2212
|
+
machine,
|
|
2213
|
+
[
|
|
2214
|
+
`Updated Machine ${machine.name} to revision ${machine.revision}. Ports and sleep-after apply now; image, command, shape and env at its next boot.`,
|
|
2215
|
+
machineFields(machine)
|
|
2216
|
+
].join("\n")
|
|
2217
|
+
);
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
async function machineSleepCommand(deps, name, options = {}) {
|
|
2221
|
+
await machineCommand(async () => {
|
|
2222
|
+
const machine = await deps.api.sleepMachine(await deps.auth.apiKey(), name);
|
|
2223
|
+
print(
|
|
2224
|
+
deps,
|
|
2225
|
+
options.json,
|
|
2226
|
+
machine,
|
|
2227
|
+
`Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); its next connection wakes it.`
|
|
2228
|
+
);
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
async function machineDeleteCommand(deps, name, options = {}) {
|
|
2232
|
+
await machineCommand(async () => {
|
|
2233
|
+
await confirm(
|
|
2234
|
+
deps,
|
|
2235
|
+
options.yes,
|
|
2236
|
+
`delete Machine ${name}`,
|
|
2237
|
+
() => Promise.resolve(
|
|
2238
|
+
`Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`
|
|
2239
|
+
),
|
|
2240
|
+
name
|
|
2241
|
+
);
|
|
2242
|
+
const machine = await deps.api.deleteMachine(await deps.auth.apiKey(), name);
|
|
2243
|
+
print(
|
|
2244
|
+
deps,
|
|
2245
|
+
options.json,
|
|
2246
|
+
machine,
|
|
2247
|
+
machine ? `Machine ${machine.name} is being deleted; its name is free for a new Machine.` : `Released the name ${name}; no Machine had been created under it.`
|
|
2248
|
+
);
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
async function machineShapesCommand(deps, options = {}) {
|
|
2252
|
+
await machineCommand(async () => {
|
|
2253
|
+
const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
|
|
2254
|
+
print(
|
|
2255
|
+
deps,
|
|
2256
|
+
options.json,
|
|
2257
|
+
shapes,
|
|
2258
|
+
table(
|
|
2259
|
+
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
|
|
2260
|
+
shapes.data.map((shape) => [
|
|
2261
|
+
shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
|
|
2262
|
+
String(shape.vcpus),
|
|
2263
|
+
`${shape.memory_gib} GiB`,
|
|
2264
|
+
`${shape.scratch_gib} GiB`,
|
|
2265
|
+
shape.offered ? "yes" : "no",
|
|
2266
|
+
shape.suspendable ? "yes" : "no"
|
|
2267
|
+
])
|
|
2268
|
+
)
|
|
2269
|
+
);
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1880
2273
|
// src/prompt.ts
|
|
1881
2274
|
import select from "@inquirer/select";
|
|
1882
2275
|
import { createInterface } from "node:readline/promises";
|
|
@@ -1925,6 +2318,10 @@ function parseLimit(value) {
|
|
|
1925
2318
|
if (!/^[1-9][0-9]*$/.test(value)) throw new InvalidArgumentError("Not a positive integer.");
|
|
1926
2319
|
return Number(value);
|
|
1927
2320
|
}
|
|
2321
|
+
function parseWhole(value) {
|
|
2322
|
+
if (!/^[0-9]+$/.test(value)) throw new InvalidArgumentError("Not a whole number.");
|
|
2323
|
+
return Number(value);
|
|
2324
|
+
}
|
|
1928
2325
|
function operationOptions(command, operationId) {
|
|
1929
2326
|
const query = managementOperations[operationId].query;
|
|
1930
2327
|
if (query.includes("limit"))
|
|
@@ -1935,6 +2332,34 @@ function operationOptions(command, operationId) {
|
|
|
1935
2332
|
}
|
|
1936
2333
|
var repeated = (value, previous = []) => [...previous, value];
|
|
1937
2334
|
var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
|
|
2335
|
+
function machineSpecOptions(command, update) {
|
|
2336
|
+
const later = update ? "; applies at the next boot" : "";
|
|
2337
|
+
return command.option("--shape <shape>", `the Machine's size (see tonbo machine shapes)${later}`).option(
|
|
2338
|
+
"--port <port[:access]>",
|
|
2339
|
+
`a port the Machine serves, private (the default) or public; repeat for several${update ? "; replaces every port" : ""}`,
|
|
2340
|
+
repeated
|
|
2341
|
+
).option(
|
|
2342
|
+
"--default-port <port>",
|
|
2343
|
+
`the port served at the Machine's hostname without a port prefix; the first --port by default${update ? ", or the current default while --port still lists it" : ""}`,
|
|
2344
|
+
parseWhole
|
|
2345
|
+
).option(
|
|
2346
|
+
"--env <KEY=VALUE>",
|
|
2347
|
+
`a non-secret environment variable; repeat for several${later}`,
|
|
2348
|
+
repeated
|
|
2349
|
+
).option(
|
|
2350
|
+
"--sleep-after <seconds>",
|
|
2351
|
+
"how long the Machine stays awake without a connection",
|
|
2352
|
+
parseWhole
|
|
2353
|
+
).addOption(
|
|
2354
|
+
new Option("--idle-action <action>", "what sleeping does to the Machine").choices(
|
|
2355
|
+
machineIdleActions
|
|
2356
|
+
)
|
|
2357
|
+
).option(
|
|
2358
|
+
"--stop-grace <seconds>",
|
|
2359
|
+
"the seconds between SIGTERM and SIGKILL on a planned stop",
|
|
2360
|
+
parseWhole
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
1938
2363
|
function createProgram(dependencies = createDependencies) {
|
|
1939
2364
|
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
|
|
1940
2365
|
program.command("login").description("sign in through the browser and save a personal key for this computer").option("--account <name>", "the account to log in to; defaults to your personal account").action(async (options) => loginCommand(dependencies(), options));
|
|
@@ -2098,11 +2523,72 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2098
2523
|
throw new InvalidArgumentError(error.message);
|
|
2099
2524
|
}
|
|
2100
2525
|
}
|
|
2101
|
-
).option("--prefix <prefix>", "only databases whose name starts with this"),
|
|
2526
|
+
).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
|
|
2102
2527
|
"listDatabaseUsage"
|
|
2103
2528
|
).action(
|
|
2104
2529
|
async (options) => databaseUsageCommand(dependencies(), options)
|
|
2105
2530
|
);
|
|
2531
|
+
const machine = program.command("machine").description("the account's Machines");
|
|
2532
|
+
operationOptions(
|
|
2533
|
+
machine.command("list").description("list the Machines in every region"),
|
|
2534
|
+
"listMachines"
|
|
2535
|
+
).action(
|
|
2536
|
+
async (options) => machineListCommand(dependencies(), options)
|
|
2537
|
+
);
|
|
2538
|
+
operationOptions(
|
|
2539
|
+
machineSpecOptions(
|
|
2540
|
+
machine.command("create <name> [command...]").description(
|
|
2541
|
+
"create a Machine from a public linux/arm64 image; [command...] after -- replaces the image's CMD"
|
|
2542
|
+
).requiredOption("--image <reference>", "the public OCI image the Machine boots").option(
|
|
2543
|
+
"--region <region>",
|
|
2544
|
+
"the Machine's region, fixed for its life; the account's default region by default"
|
|
2545
|
+
),
|
|
2546
|
+
false
|
|
2547
|
+
).option(
|
|
2548
|
+
"--idempotency-key <key>",
|
|
2549
|
+
"reuse the key of an unconfirmed creation to get the Machine it created"
|
|
2550
|
+
),
|
|
2551
|
+
"createMachine"
|
|
2552
|
+
).action(
|
|
2553
|
+
async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
|
|
2554
|
+
);
|
|
2555
|
+
operationOptions(
|
|
2556
|
+
machine.command("show <name>").description("show a Machine, its hostnames and its state"),
|
|
2557
|
+
"getMachine"
|
|
2558
|
+
).action(
|
|
2559
|
+
async (name, options) => machineShowCommand(dependencies(), name, options)
|
|
2560
|
+
);
|
|
2561
|
+
operationOptions(
|
|
2562
|
+
machineSpecOptions(
|
|
2563
|
+
machine.command("update <name> [command...]").description(
|
|
2564
|
+
"change a Machine; [command...] after -- replaces its command at the next boot"
|
|
2565
|
+
).option("--image <reference>", "a new image; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option(
|
|
2566
|
+
"--revision <revision>",
|
|
2567
|
+
"write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
|
|
2568
|
+
parseWhole
|
|
2569
|
+
).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
|
|
2570
|
+
true
|
|
2571
|
+
),
|
|
2572
|
+
"updateMachine"
|
|
2573
|
+
).action(
|
|
2574
|
+
async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
|
|
2575
|
+
);
|
|
2576
|
+
operationOptions(
|
|
2577
|
+
machine.command("sleep <name>").description("put a Machine to sleep now"),
|
|
2578
|
+
"sleepMachine"
|
|
2579
|
+
).action(
|
|
2580
|
+
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
2581
|
+
);
|
|
2582
|
+
operationOptions(
|
|
2583
|
+
machine.command("delete <name>").description("delete a Machine with its disk, database and logs").option("--yes", "delete without asking; required when stdin is not a terminal"),
|
|
2584
|
+
"deleteMachine"
|
|
2585
|
+
).action(
|
|
2586
|
+
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|
|
2587
|
+
);
|
|
2588
|
+
operationOptions(
|
|
2589
|
+
machine.command("shapes").description("list the shapes a Machine may have"),
|
|
2590
|
+
"listMachineShapes"
|
|
2591
|
+
).action(async (options) => machineShapesCommand(dependencies(), options));
|
|
2106
2592
|
operationOptions(
|
|
2107
2593
|
program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
|
|
2108
2594
|
"--period <month>",
|