@tonbo/cli 0.7.2 → 0.9.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 +24 -9
- package/dist/bin/tonbo.js +595 -122
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ The login and the credentials file are shared with [`yield`](../yield) through `
|
|
|
27
27
|
|
|
28
28
|
## Commands and the Management API
|
|
29
29
|
|
|
30
|
-
Every command other than `login` and `logout`
|
|
30
|
+
Every command other than `login` and `logout` is named by one operation of the [Management API](../../contracts/management/openapi-v1.yaml) through its `x-tonbo-cli` and calls that operation, plus reads of the same resource (a `--wait`, or the Machine a `name_taken` refusal names); `tonbo machine logs`, once it ships, is the named Streams exception, reading the stream at the Machine's `logs.url`. The command set and the operations tagged with a command are the same set, which `test/commands.test.ts` checks in both directions. The CLI resolves each request from the operation table of the internal package [`@tonbo/cli-core`](../../packages/tonbo-cli-core) (`src/generated/contracts.ts`, which `pnpm --filter @tonbo/cli-core generate:contracts` derives from the contract). `login` and `logout` are the named exception: they obtain and discard the key the other commands present as their Bearer credential. What a command may do is what your role in the logged-in account allows; the CLI never decides it.
|
|
31
31
|
|
|
32
32
|
| Command | Operation | Request |
|
|
33
33
|
| --- | --- | --- |
|
|
@@ -35,6 +35,7 @@ Every command other than `login` and `logout` calls one operation of the [Manage
|
|
|
35
35
|
| `tonbo region list` | `listRegions` | `GET /v1/regions` |
|
|
36
36
|
| `tonbo member list [--limit <n>] [--cursor <c>]` | `listMembers` | `GET /v1/members` |
|
|
37
37
|
| `tonbo member update <member> [--role <role>] [--name <name>]` | `updateMember` | `PATCH /v1/members/{subject}` |
|
|
38
|
+
| `tonbo workspace list [--limit <n>] [--cursor <c>]` | `listGitWorkspaces` | `GET /v1/git/workspaces` |
|
|
38
39
|
| `tonbo agent create <name> [--role <role>] [--region <region>] [--idempotency-key <key>]` | `createAgent` | `POST /v1/agents` |
|
|
39
40
|
| `tonbo api-key list [--limit <n>] [--cursor <c>]` | `listApiKeys` | `GET /v1/api-keys` |
|
|
40
41
|
| `tonbo api-key create <name> [--member <member>] [--read-only] [--bucket <name>]… [--database <name>]… [--database-prefix <prefix>]… [--stream <name>]… [--stream-prefix <prefix>]… [--expires-in <period>]` | `createApiKey` | `POST /v1/api-keys` |
|
|
@@ -59,11 +60,11 @@ Every command other than `login` and `logout` calls one operation of the [Manage
|
|
|
59
60
|
| `tonbo stream usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--names <a,b>] [--limit <n>] [--cursor <c>]` | `listStreamUsage` | `GET /v1/usage/streams` |
|
|
60
61
|
| `tonbo usage show [--period <YYYY-MM>]` | `getUsage` | `GET /v1/usage` |
|
|
61
62
|
| `tonbo machine list [--limit <n>] [--cursor <c>]` | `listMachines` | `GET /v1/machines` |
|
|
62
|
-
| `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` |
|
|
63
|
+
| `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>] [--wait [--timeout <seconds>]] [-- <command>…]` | `createMachine` | `POST /v1/machines` |
|
|
63
64
|
| `tonbo machine show <name>` | `getMachine` | `GET /v1/machines/{name}` |
|
|
64
|
-
| `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] [--
|
|
65
|
-
| `tonbo machine sleep <name
|
|
66
|
-
| `tonbo machine delete <name> [--yes]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
|
|
65
|
+
| `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] [--if-match <etag>] [-- <command>…]` | `updateMachine` | `PATCH /v1/machines/{name}` |
|
|
66
|
+
| `tonbo machine sleep <name> [--if-match <etag>] [--wait [--timeout <seconds>]]` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
|
|
67
|
+
| `tonbo machine delete <name> [--yes] [--if-match <etag>]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
|
|
67
68
|
| `tonbo machine shapes` | `listMachineShapes` | `GET /v1/machine-shapes` |
|
|
68
69
|
|
|
69
70
|
### Output and errors
|
|
@@ -101,6 +102,14 @@ tonbo agent create my-agent --region us-east-1
|
|
|
101
102
|
|
|
102
103
|
`agent create` creates an Agent identity with a role (`member` unless `--role` names another). Every Agent has a home region, chosen when it is created and never changed afterwards. Without `--region`, Tonbo uses the only region that accepts new Agents; when several do, an interactive `agent create` asks which one to use, and a non-interactive request without `--region` is refused.
|
|
103
104
|
|
|
105
|
+
## Git workspaces
|
|
106
|
+
|
|
107
|
+
```console
|
|
108
|
+
tonbo workspace list
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`workspace list` lists the account's git workspaces, the default first: each one's id, name, whether it is the default, its region and where gitd serves it (`<git origin>/<ws_hex>`, the prefix of every repository URL in it). Every account has a default workspace from its creation. Listing needs only a role on the account; it admits nothing, and every git token is still admitted when it is minted.
|
|
112
|
+
|
|
104
113
|
## API keys
|
|
105
114
|
|
|
106
115
|
```console
|
|
@@ -160,15 +169,21 @@ tonbo machine sleep web
|
|
|
160
169
|
tonbo machine delete web
|
|
161
170
|
```
|
|
162
171
|
|
|
163
|
-
A Machine is a VM booted from an image, in one region, with its own database (`contracts/machines/machine-v2.json`). Machines open by invitation: until a region is open for them,
|
|
172
|
+
A Machine is a VM booted from an image, in one region, with its own database (`contracts/machines/machine-v2.json`). Machines open by invitation: until a region is open for them, `machine create`, `update`, `sleep` and `delete` are refused with `region_not_offered`, and the CLI says that nothing was created or changed; `machine list`, `show` and `shapes` still answer. A region that cannot be reached is refused with `region_unavailable`, which a retry may clear.
|
|
164
173
|
|
|
165
174
|
The image is `tonbo.dev/platform`, the platform image each node release ships, or, once registry images are offered, a public OCI image with a linux/arm64 variant. Until then any other reference is refused with `image_not_offered`, before anything is created or changed; `machine show` prints the platform image as `tonbo.dev/platform (platform image)`.
|
|
166
175
|
|
|
167
|
-
`machine create` resolves a registry image to its linux/arm64 digest (the platform image resolves to none) 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
|
|
176
|
+
`machine create` resolves a registry image to its linux/arm64 digest (the platform image resolves to none) 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, which `machine show` marks as not served yet. Without `--shape` it takes the shape `machine shapes` marks as the default. `--port <port>` serves a private port (a member's token) and `--port <port>:public` an open one, which needs the `machines.public` capability and has no wake-rate, concurrency or runtime limit; 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`. With `--wait` the CLI reads the Machine at its `Location` after the answer's `Retry-After`, and again every `creation.poll_after_seconds`, until it is `active` or `failed` or `--timeout` (300 s by default) passes; a failure prints the Machine's failure with what to do and exits non-zero, and a timeout says it is still creating and undoes nothing. A create the network or a 5xx answer did not confirm is retried in the command with the same idempotency key, at most three times, honouring `Retry-After`; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply.
|
|
177
|
+
|
|
178
|
+
`machine create` and `machine update` warn on stderr when the Machine ends up with no port (nothing can wake it until exec and SSH arrive) and when an `--env` key looks secret (`*_KEY`, `*_TOKEN`, `*SECRET*`, `*PASSWORD*`): env values are readable by every member through the API, and Machine secrets come later. When the default port is private, `machine create` and `machine show` print the `curl` call that reaches it with a personal key, and say that on a private port Tonbo authenticates the caller, so the app sees neither `Authorization` nor who called; an app with its own users serves a public port and does its own auth.
|
|
179
|
+
|
|
180
|
+
`machine show` prints the state and what it means (an `active` Machine that is asleep boots on its first request, which the edge holds while it boots), the live activation and conditions when there are any, when the Machine sleeps and that its disk resets at every sleep until suspend ships, the database and logs URLs, a link to the durability text (the CLI prints the link only, never the text or a paraphrase of it), and the ETag. `machine show` prints image states as the API answers them: `pulling`, `ready`, `failed` or `not_pulled`.
|
|
181
|
+
|
|
182
|
+
`machine update` changes only what its options name. It reads the Machine and sends the ETag it read, or `--if-match`'s, so a change someone made in between, or another Machine that took the name, is refused with `precondition_failed` rather than overwritten; a Machine whose creation failed is refused with `machine_failed`. `--env` and `--unset-env` change single variables; `--port` replaces every port, 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 apply at the next activation, and the command says how to apply them now: `machine sleep`, after which the next request boots the new spec; naming the same tag again re-resolves its digest. `machine sleep` puts an awake Machine to sleep now, and says so when it is already asleep or still creating; with `--wait` it reads the Machine until it is asleep. `machine delete` sends the ETag it read when it asked for confirmation, and `machine sleep` and `machine delete` send `--if-match`'s when given. `--if-match` takes the ETag `machine show` prints, with or without its double quotes: a value with neither a leading `"` nor `W/"` is quoted again, since a shell drops the quotes of a pasted tag. `machine shapes` prints each shape's list price for an hour at full use. `machine delete` deletes the Machine with its disk, database and logs, and its name is free for a new Machine at once.
|
|
168
183
|
|
|
169
|
-
`machine
|
|
184
|
+
In a personal account, `machine list`, `machine show` and `machine delete` also cover your own computers, which `yield login` registers (Machines of kind `byoc`, which run nothing Tonbo operates). They are served even while Machines are not open in any region. `machine list` prints them in a second table with their system, agent harnesses, when they last reported and the state of their login key; `machine show` adds the hostname and device id, which only you see. `machine delete` forgets a computer: it disappears from the list at once and its name is free once the forget completes. Forgetting does not revoke the computer's key, so a computer whose key is still active registers again at its next start; the command says so and suggests `yield logout` on it or `tonbo api-key revoke` first. An organization lists managed Machines only.
|
|
170
185
|
|
|
171
|
-
A refusal of machine-v2's `errors` prints the server's detail and then what to do next, for example `machine shapes` for `
|
|
186
|
+
A refusal of machine-v2's `errors`, or of a shared code such as `region_not_offered`, `precondition_required` or `precondition_failed`, prints the server's detail and then what to do next, for example `machine shapes` for `shape_not_offered` or a shorter name for `hostname_too_long`.
|
|
172
187
|
|
|
173
188
|
## Usage
|
|
174
189
|
|
package/dist/bin/tonbo.js
CHANGED
|
@@ -486,6 +486,17 @@ var managementOperations = {
|
|
|
486
486
|
"idempotencyKey": false,
|
|
487
487
|
"cli": "tonbo usage show"
|
|
488
488
|
},
|
|
489
|
+
"listGitWorkspaces": {
|
|
490
|
+
"method": "GET",
|
|
491
|
+
"path": "/git/workspaces",
|
|
492
|
+
"pathParams": [],
|
|
493
|
+
"query": [
|
|
494
|
+
"cursor",
|
|
495
|
+
"limit"
|
|
496
|
+
],
|
|
497
|
+
"idempotencyKey": false,
|
|
498
|
+
"cli": "tonbo workspace list"
|
|
499
|
+
},
|
|
489
500
|
"listDatabases": {
|
|
490
501
|
"method": "GET",
|
|
491
502
|
"path": "/databases",
|
|
@@ -847,6 +858,11 @@ var machinePortAccess = [
|
|
|
847
858
|
"private",
|
|
848
859
|
"public"
|
|
849
860
|
];
|
|
861
|
+
var machineTimings = {
|
|
862
|
+
"pollAfterSeconds": 2,
|
|
863
|
+
"wakeHoldSeconds": 25,
|
|
864
|
+
"earlyReclaimSeconds": 120
|
|
865
|
+
};
|
|
850
866
|
|
|
851
867
|
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
852
868
|
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
@@ -1174,10 +1190,12 @@ var ManagementApi = class {
|
|
|
1174
1190
|
fetcher;
|
|
1175
1191
|
apiOrigin;
|
|
1176
1192
|
loginHint;
|
|
1177
|
-
|
|
1193
|
+
pause;
|
|
1194
|
+
constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
|
|
1178
1195
|
this.fetcher = fetcher;
|
|
1179
1196
|
this.apiOrigin = apiOrigin2;
|
|
1180
1197
|
this.loginHint = loginHint;
|
|
1198
|
+
this.pause = pause;
|
|
1181
1199
|
}
|
|
1182
1200
|
/** The answer's `data`. A refusal throws an HttpError naming the request id. */
|
|
1183
1201
|
async call(operationId, key, request = {}) {
|
|
@@ -1188,7 +1206,7 @@ var ManagementApi = class {
|
|
|
1188
1206
|
page(operationId, key, { limit, cursor } = {}, query = {}) {
|
|
1189
1207
|
return this.request(operationId, key, { query: { ...query, limit, cursor } });
|
|
1190
1208
|
}
|
|
1191
|
-
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }) {
|
|
1209
|
+
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }, seen) {
|
|
1192
1210
|
const operation = managementOperations[operationId];
|
|
1193
1211
|
const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1194
1212
|
const value = params[name];
|
|
@@ -1216,7 +1234,12 @@ var ManagementApi = class {
|
|
|
1216
1234
|
const queryString = search.toString();
|
|
1217
1235
|
const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
|
|
1218
1236
|
try {
|
|
1219
|
-
|
|
1237
|
+
const fetcher = seen ? async (input, init) => {
|
|
1238
|
+
const response = await this.fetcher(input, init);
|
|
1239
|
+
seen(response);
|
|
1240
|
+
return response;
|
|
1241
|
+
} : this.fetcher;
|
|
1242
|
+
return await requestJson(fetcher, url, {
|
|
1220
1243
|
method: operation.method,
|
|
1221
1244
|
headers,
|
|
1222
1245
|
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
@@ -1236,6 +1259,9 @@ var ManagementApi = class {
|
|
|
1236
1259
|
listMembers(key, page) {
|
|
1237
1260
|
return this.page("listMembers", key, page);
|
|
1238
1261
|
}
|
|
1262
|
+
listGitWorkspaces(key, page) {
|
|
1263
|
+
return this.page("listGitWorkspaces", key, page);
|
|
1264
|
+
}
|
|
1239
1265
|
updateMember(key, subject, change) {
|
|
1240
1266
|
return this.call("updateMember", key, { params: { subject }, body: change });
|
|
1241
1267
|
}
|
|
@@ -1337,42 +1363,129 @@ var ManagementApi = class {
|
|
|
1337
1363
|
listMachines(key, page) {
|
|
1338
1364
|
return this.page("listMachines", key, page);
|
|
1339
1365
|
}
|
|
1340
|
-
/**
|
|
1341
|
-
*
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1366
|
+
/**
|
|
1367
|
+
* A replay with the same idempotency key returns the Machine the first
|
|
1368
|
+
* request created, so a transport failure or a 5xx answer is retried here
|
|
1369
|
+
* with the same key, at most `CREATE_ATTEMPTS` times, after the answer's
|
|
1370
|
+
* Retry-After (bounded) or a short backoff. A refusal is final; a create
|
|
1371
|
+
* still unconfirmed after the last attempt names the key to retry with.
|
|
1372
|
+
* The answer carries the Machine's `Location` and, while it is creating,
|
|
1373
|
+
* the `Retry-After` to read it again after (machine-v2.json
|
|
1374
|
+
* `creation.create_status`).
|
|
1375
|
+
*/
|
|
1376
|
+
async createMachine(key, request, idempotencyKey) {
|
|
1377
|
+
let last;
|
|
1378
|
+
for (let attempt = 1; attempt <= CREATE_ATTEMPTS; attempt++) {
|
|
1379
|
+
const headers = {
|
|
1380
|
+
location: null,
|
|
1381
|
+
retryAfter: null
|
|
1382
|
+
};
|
|
1383
|
+
try {
|
|
1384
|
+
const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
|
|
1385
|
+
headers.location = response.headers.get("location");
|
|
1386
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1387
|
+
});
|
|
1388
|
+
return {
|
|
1389
|
+
machine: answer.data,
|
|
1390
|
+
location: headers.location,
|
|
1391
|
+
retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
|
|
1392
|
+
};
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
if (error instanceof HttpError && error.status < 500)
|
|
1395
|
+
throw error;
|
|
1396
|
+
last = error;
|
|
1397
|
+
if (attempt === CREATE_ATTEMPTS)
|
|
1398
|
+
break;
|
|
1399
|
+
const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
|
|
1400
|
+
await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return unconfirmed("Machine creation", idempotencyKey, "the same arguments")(last);
|
|
1404
|
+
}
|
|
1405
|
+
/** The name of the Machine a create's `Location` names, when it is this
|
|
1406
|
+
* client's origin and `getMachine`'s path; null otherwise, so the key is
|
|
1407
|
+
* never sent to another origin and the caller reads the Machine by the
|
|
1408
|
+
* name it created. */
|
|
1409
|
+
machineNameAt(location) {
|
|
1410
|
+
if (!location)
|
|
1411
|
+
return null;
|
|
1412
|
+
let url;
|
|
1413
|
+
try {
|
|
1414
|
+
url = new URL(location, this.apiOrigin);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
|
|
1419
|
+
return null;
|
|
1420
|
+
const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
|
|
1421
|
+
if (!url.pathname.startsWith(prefix))
|
|
1422
|
+
return null;
|
|
1423
|
+
const segment = url.pathname.slice(prefix.length);
|
|
1424
|
+
if (!segment || segment.includes("/"))
|
|
1425
|
+
return null;
|
|
1426
|
+
try {
|
|
1427
|
+
return decodeURIComponent(segment);
|
|
1428
|
+
} catch {
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1347
1431
|
}
|
|
1348
1432
|
getMachine(key, name) {
|
|
1349
1433
|
return this.call("getMachine", key, { params: { name } });
|
|
1350
1434
|
}
|
|
1351
|
-
/**
|
|
1352
|
-
*
|
|
1353
|
-
|
|
1435
|
+
/** The Machine with its ETag, which is opaque (machine-v2.json `etag`)
|
|
1436
|
+
* and is sent back as it was read. */
|
|
1437
|
+
async getMachineWithEtag(key, name) {
|
|
1438
|
+
const headers = { etag: null };
|
|
1439
|
+
const answer = await this.request("getMachine", key, { params: { name } }, (response) => {
|
|
1440
|
+
headers.etag = response.headers.get("etag");
|
|
1441
|
+
});
|
|
1442
|
+
return { machine: answer.data, etag: headers.etag };
|
|
1443
|
+
}
|
|
1444
|
+
/** Written only to the Machine and revision `etag` names, the ETag as read
|
|
1445
|
+
* (machine-v2 requires If-Match on update); a stale one is refused with
|
|
1446
|
+
* `precondition_failed`. */
|
|
1447
|
+
updateMachine(key, name, etag, change) {
|
|
1354
1448
|
return this.call("updateMachine", key, {
|
|
1355
1449
|
params: { name },
|
|
1356
1450
|
body: withoutUndefined(change),
|
|
1357
|
-
ifMatch:
|
|
1451
|
+
ifMatch: etag
|
|
1358
1452
|
});
|
|
1359
1453
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1454
|
+
/** With `etag`, only the Machine that ETag names, at that revision, is put
|
|
1455
|
+
* to sleep (machine-v2.json `etag.if_match.sleep` is optional). */
|
|
1456
|
+
sleepMachine(key, name, etag) {
|
|
1457
|
+
return this.call("sleepMachine", key, {
|
|
1458
|
+
params: { name },
|
|
1459
|
+
...etag ? { ifMatch: etag } : {}
|
|
1460
|
+
});
|
|
1362
1461
|
}
|
|
1363
|
-
/** The Machine being deleted
|
|
1364
|
-
*
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1462
|
+
/** The Machine being deleted or the device forgotten, with its kind; the
|
|
1463
|
+
* Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
|
|
1464
|
+
* says whether a name was released (`managed`: its create never reached its
|
|
1465
|
+
* region) or a device was forgotten (`byoc`: its region held no row). With
|
|
1466
|
+
* `etag`, only the Machine that ETag names is deleted
|
|
1467
|
+
* (machine-v2.json `etag.if_match.delete` is optional). */
|
|
1468
|
+
async deleteMachine(key, name, etag) {
|
|
1469
|
+
const headers = { kind: null };
|
|
1470
|
+
const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1471
|
+
headers.kind = response.headers.get("x-tonbo-machine-kind");
|
|
1368
1472
|
});
|
|
1369
|
-
|
|
1473
|
+
if (answer?.data)
|
|
1474
|
+
return { machine: answer.data, kind: answer.data.kind };
|
|
1475
|
+
return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
|
|
1370
1476
|
}
|
|
1371
1477
|
/** The whole answer: the shapes and the default one. */
|
|
1372
1478
|
listMachineShapes(key) {
|
|
1373
1479
|
return this.request("listMachineShapes", key, {});
|
|
1374
1480
|
}
|
|
1375
1481
|
};
|
|
1482
|
+
var CREATE_ATTEMPTS = 3;
|
|
1483
|
+
var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
|
|
1484
|
+
function retryAfterSeconds(value) {
|
|
1485
|
+
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
1486
|
+
return null;
|
|
1487
|
+
return Number(value.trim());
|
|
1488
|
+
}
|
|
1376
1489
|
function withoutUndefined(value) {
|
|
1377
1490
|
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
1378
1491
|
}
|
|
@@ -1700,6 +1813,29 @@ async function memberListCommand(deps, options = {}) {
|
|
|
1700
1813
|
) : "No members."
|
|
1701
1814
|
);
|
|
1702
1815
|
}
|
|
1816
|
+
async function workspaceListCommand(deps, options = {}) {
|
|
1817
|
+
const page = await deps.api.listGitWorkspaces(await deps.auth.apiKey(), {
|
|
1818
|
+
limit: options.limit,
|
|
1819
|
+
cursor: options.cursor
|
|
1820
|
+
});
|
|
1821
|
+
printPage(
|
|
1822
|
+
deps,
|
|
1823
|
+
options.json,
|
|
1824
|
+
page,
|
|
1825
|
+
(workspaces) => workspaces.length ? table(
|
|
1826
|
+
["ID", "NAME", "DEFAULT", "REGION", "GIT ORIGIN", "STATE", "CREATED"],
|
|
1827
|
+
workspaces.map((workspace) => [
|
|
1828
|
+
workspace.id,
|
|
1829
|
+
workspace.name ?? "",
|
|
1830
|
+
workspace.default ? "yes" : "",
|
|
1831
|
+
workspace.region,
|
|
1832
|
+
`${workspace.git_origin}/${workspace.ws_hex}`,
|
|
1833
|
+
workspace.state,
|
|
1834
|
+
workspace.created_at
|
|
1835
|
+
])
|
|
1836
|
+
) : "No git workspaces."
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1703
1839
|
async function resolveMemberSubject(deps, key, member) {
|
|
1704
1840
|
if (member.startsWith("agent.")) return member;
|
|
1705
1841
|
const subjects = /* @__PURE__ */ new Set();
|
|
@@ -2283,22 +2419,26 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
2283
2419
|
// src/machine.ts
|
|
2284
2420
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2285
2421
|
var MACHINE_ERROR_HINTS = {
|
|
2286
|
-
|
|
2422
|
+
region_not_offered: "Machines are not offered in that region yet; nothing was created or changed. Name a region where they are offered.",
|
|
2423
|
+
region_unavailable: "That region cannot be reached right now; nothing was created or changed. Try again shortly.",
|
|
2287
2424
|
name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
|
|
2288
2425
|
hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
|
|
2289
2426
|
hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
|
|
2290
2427
|
account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
|
|
2291
2428
|
image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
|
|
2292
2429
|
image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
|
|
2293
|
-
|
|
2430
|
+
shape_not_offered: "`tonbo machine shapes` lists the shapes a Machine may name now.",
|
|
2294
2431
|
port_reserved: "Serve the application on another port.",
|
|
2295
|
-
|
|
2432
|
+
precondition_required: "The API needs the Machine's ETag for this change; `tonbo machine show` prints it, and the command sends the ETag it reads unless --if-match names one.",
|
|
2433
|
+
precondition_failed: "The Machine changed since it was read, or another Machine now has its name; `tonbo machine show` it and run the command again.",
|
|
2434
|
+
machine_failed: "A Machine whose creation failed does not change: `tonbo machine show` says why; delete it with `tonbo machine delete` and create it again.",
|
|
2296
2435
|
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
2297
2436
|
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
|
|
2298
2437
|
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
2299
2438
|
create_rate_limited: "Too many Machines were created in the last hour; try again later.",
|
|
2300
2439
|
image_pulling: "The Machine's image is still being pulled; the request was not run. Try again shortly; `tonbo machine show` reports the image's state.",
|
|
2301
|
-
image_failed: "The Machine's image could not be pulled; `tonbo machine show` says why. Fix the image, then name it again with `tonbo machine update --image`."
|
|
2440
|
+
image_failed: "The Machine's image could not be pulled; `tonbo machine show` says why. Fix the image, then name it again with `tonbo machine update --image`.",
|
|
2441
|
+
machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does."
|
|
2302
2442
|
};
|
|
2303
2443
|
var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
2304
2444
|
image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
|
|
@@ -2306,6 +2446,28 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
|
2306
2446
|
image_too_large: "Make the image smaller (fewer or smaller layers)",
|
|
2307
2447
|
image_unsupported: "Rebuild the image without the entry the message names"
|
|
2308
2448
|
};
|
|
2449
|
+
var MACHINE_CREATION_FAILURE_HINTS = {
|
|
2450
|
+
database_creation_failed: "The Machine's database could not be created; the message names the stream and the code. If the code is payment_required, add credit to the account first; then delete the Machine and create it again.",
|
|
2451
|
+
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`), then delete this Machine and create it again.",
|
|
2452
|
+
payment_required: "The account's credit is exhausted: add credit to the account, then delete the Machine and create it again.",
|
|
2453
|
+
...Object.fromEntries(
|
|
2454
|
+
Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
|
|
2455
|
+
code,
|
|
2456
|
+
`${hint}, then delete the Machine and create it again naming the fixed image.`
|
|
2457
|
+
])
|
|
2458
|
+
)
|
|
2459
|
+
};
|
|
2460
|
+
function creationFailureText(machine) {
|
|
2461
|
+
const { failure } = machine;
|
|
2462
|
+
if (!failure) return { reason: "", hint: "" };
|
|
2463
|
+
const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code];
|
|
2464
|
+
return { reason: ` (${failure.code}: ${failure.message})`, hint: hint ? `
|
|
2465
|
+
${hint}` : "" };
|
|
2466
|
+
}
|
|
2467
|
+
function ifMatchHeader(value) {
|
|
2468
|
+
if (value === void 0) return void 0;
|
|
2469
|
+
return value.startsWith('"') || value.startsWith('W/"') ? value : `"${value}"`;
|
|
2470
|
+
}
|
|
2309
2471
|
function explainMachineError(error) {
|
|
2310
2472
|
if (!(error instanceof HttpError) || error.code === null) return error;
|
|
2311
2473
|
const hint = MACHINE_ERROR_HINTS[error.code];
|
|
@@ -2326,20 +2488,75 @@ function portText(port) {
|
|
|
2326
2488
|
function machinePortsText(machine) {
|
|
2327
2489
|
return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
|
|
2328
2490
|
}
|
|
2329
|
-
|
|
2491
|
+
var { earlyReclaimSeconds, pollAfterSeconds, wakeHoldSeconds } = machineTimings;
|
|
2492
|
+
function machineState(machine) {
|
|
2493
|
+
if (machine.failure)
|
|
2494
|
+
return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
|
|
2495
|
+
if (machine.state === "creating")
|
|
2496
|
+
return `creating: not ready for requests yet; read it again in ${pollAfterSeconds} s, or create with --wait`;
|
|
2497
|
+
return machine.state;
|
|
2498
|
+
}
|
|
2499
|
+
function powerText(machine) {
|
|
2500
|
+
const { power } = machine.status;
|
|
2501
|
+
if (machine.state !== "active") return power;
|
|
2502
|
+
if (power === "asleep")
|
|
2503
|
+
return `asleep: the first request boots it, and the edge holds that request for up to ${wakeHoldSeconds} s`;
|
|
2504
|
+
if (power === "awake") return "awake: serving requests";
|
|
2505
|
+
return power;
|
|
2506
|
+
}
|
|
2507
|
+
function duration(seconds) {
|
|
2508
|
+
return seconds % 60 === 0 && seconds >= 120 ? `${seconds / 60} min` : `${seconds} s`;
|
|
2509
|
+
}
|
|
2510
|
+
function sleepText(machine) {
|
|
2330
2511
|
const { action, reason } = machine.status.sleep;
|
|
2331
|
-
|
|
2332
|
-
return `${
|
|
2512
|
+
const when2 = `after about ${duration(earlyReclaimSeconds)} idle (early) or at most ${machine.sleep_after_seconds} s without a connection`;
|
|
2513
|
+
if (action !== "release") return `${when2}; it then ${action}s`;
|
|
2514
|
+
return `${when2}; the disk resets at every sleep${reason === "suspend_unavailable" ? " until suspend ships" : ""}`;
|
|
2333
2515
|
}
|
|
2334
|
-
function
|
|
2335
|
-
|
|
2516
|
+
function activationFields(machine) {
|
|
2517
|
+
const { activation } = machine;
|
|
2518
|
+
if (!activation) return [];
|
|
2519
|
+
return [
|
|
2520
|
+
[
|
|
2521
|
+
"Activation",
|
|
2522
|
+
`${activation.state} since ${activation.started_at} (wake ${activation.wake_gen}, from ${activation.source})`
|
|
2523
|
+
]
|
|
2524
|
+
];
|
|
2525
|
+
}
|
|
2526
|
+
function conditionText(condition) {
|
|
2527
|
+
switch (condition.type) {
|
|
2528
|
+
case "wake_blocked":
|
|
2529
|
+
return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}`;
|
|
2530
|
+
case "node_unreachable":
|
|
2531
|
+
return `its host is unreachable since ${condition.since}`;
|
|
2532
|
+
case "egress_blocked":
|
|
2533
|
+
return `outbound access blocked for the ${condition.scope} since ${condition.since}${condition.until ? ` until ${condition.until}` : ""}`;
|
|
2534
|
+
default:
|
|
2535
|
+
return JSON.stringify(condition);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
function conditionFields(machine) {
|
|
2539
|
+
return (machine.status.conditions ?? []).map((condition) => [
|
|
2540
|
+
"Condition",
|
|
2541
|
+
conditionText(condition)
|
|
2542
|
+
]);
|
|
2543
|
+
}
|
|
2544
|
+
var DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees#sleep";
|
|
2545
|
+
var PRIVATE_PORTS_LINK = "https://tonbo.dev/docs/machines/requests#private-ports";
|
|
2546
|
+
function privatePortLines(machine) {
|
|
2547
|
+
const port = machine.ports.find((entry) => entry.default);
|
|
2548
|
+
if (!port || port.access !== "private") return [];
|
|
2549
|
+
return [
|
|
2550
|
+
`Port ${port.port} is private: call it with a personal key from \`tonbo api-key create\` (see ${PRIVATE_PORTS_LINK}):`,
|
|
2551
|
+
` curl -H "Authorization: Bearer $TONBO_KEY" ${machine.hostnames.https}/`,
|
|
2552
|
+
"On a private port Tonbo authenticates the caller, and the app sees neither Authorization nor who called; an app with its own users serves a public port and does its own auth."
|
|
2553
|
+
];
|
|
2336
2554
|
}
|
|
2337
2555
|
function imageText(machine) {
|
|
2338
2556
|
const { reference, repository, digest, state } = machine.image;
|
|
2339
2557
|
if (digest === null) return `${reference} (platform image)`;
|
|
2340
2558
|
const pulled = repository === null ? digest : `${repository}@${digest}`;
|
|
2341
|
-
|
|
2342
|
-
return `${reference} (${pulled}, ${pulling ? "pulling" : state})`;
|
|
2559
|
+
return `${reference} (${pulled}, ${state})`;
|
|
2343
2560
|
}
|
|
2344
2561
|
function imageNextStep(machine, hint) {
|
|
2345
2562
|
if (machine.state === "deleting") return null;
|
|
@@ -2357,31 +2574,66 @@ function imageFailureFields(machine) {
|
|
|
2357
2574
|
...next ? [["Next step", next]] : []
|
|
2358
2575
|
];
|
|
2359
2576
|
}
|
|
2360
|
-
function machineFields(machine) {
|
|
2577
|
+
function machineFields(machine, etag = null) {
|
|
2361
2578
|
return fields([
|
|
2362
2579
|
["Name", machine.name],
|
|
2363
2580
|
["ID", machine.id],
|
|
2364
2581
|
["Region", machine.region],
|
|
2365
2582
|
["State", machineState(machine)],
|
|
2366
|
-
["Power", machine
|
|
2583
|
+
["Power", powerText(machine)],
|
|
2584
|
+
...activationFields(machine),
|
|
2585
|
+
...conditionFields(machine),
|
|
2586
|
+
["Sleeps", sleepText(machine)],
|
|
2367
2587
|
["Shape", machine.shape],
|
|
2368
2588
|
["Image", imageText(machine)],
|
|
2369
2589
|
...imageFailureFields(machine),
|
|
2370
2590
|
["Command", machine.command ? machine.command.join(" ") : "the image's own"],
|
|
2371
2591
|
["Ports", machinePortsText(machine)],
|
|
2372
2592
|
["HTTPS", machine.hostnames.https],
|
|
2373
|
-
|
|
2593
|
+
// The resource keeps the SSH destination as a receipt; SSH is not served
|
|
2594
|
+
// before tier P3.
|
|
2595
|
+
["SSH", `${machine.hostnames.ssh} (not served yet)`],
|
|
2374
2596
|
["Env", Object.keys(machine.env).sort().join(", ") || "none"],
|
|
2375
|
-
["
|
|
2376
|
-
["
|
|
2377
|
-
["
|
|
2378
|
-
["
|
|
2597
|
+
["Stop grace", `${machine.stop_grace_seconds} s`],
|
|
2598
|
+
["Database", `${machine.database.name} (${machine.database.url})`],
|
|
2599
|
+
["Logs", machine.logs.url],
|
|
2600
|
+
["Durability", DURABILITY_LINK],
|
|
2379
2601
|
["Revision", String(machine.revision)],
|
|
2602
|
+
...etag ? [["ETag", etag]] : [],
|
|
2380
2603
|
["Created", machine.created_at],
|
|
2381
2604
|
["Updated", machine.updated_at]
|
|
2382
2605
|
]);
|
|
2383
2606
|
}
|
|
2384
|
-
function
|
|
2607
|
+
function machineText(machine, etag = null) {
|
|
2608
|
+
return [machineFields(machine, etag), ...privatePortLines(machine)].join("\n");
|
|
2609
|
+
}
|
|
2610
|
+
function keyStateText(machine) {
|
|
2611
|
+
const state = machine.byoc.key_state;
|
|
2612
|
+
if (state === "active") return state;
|
|
2613
|
+
return `${state}: this computer is signed out; \`yield login\` on it signs it in again`;
|
|
2614
|
+
}
|
|
2615
|
+
function deviceFields(machine) {
|
|
2616
|
+
const { byoc } = machine;
|
|
2617
|
+
return fields([
|
|
2618
|
+
["Name", machine.name],
|
|
2619
|
+
["ID", machine.id],
|
|
2620
|
+
["Kind", "byoc (one of your computers)"],
|
|
2621
|
+
["Region", machine.region],
|
|
2622
|
+
...byoc.hostname === void 0 ? [] : [["Hostname", byoc.hostname]],
|
|
2623
|
+
...byoc.device_id === void 0 ? [] : [["Device ID", byoc.device_id]],
|
|
2624
|
+
["System", `${byoc.os}/${byoc.arch}`],
|
|
2625
|
+
["Client", `${byoc.client} ${byoc.client_version}`],
|
|
2626
|
+
["Harnesses", byoc.harnesses.join(", ") || "none"],
|
|
2627
|
+
["Last seen", byoc.last_seen],
|
|
2628
|
+
["Key", keyStateText(machine)],
|
|
2629
|
+
["Created", machine.created_at],
|
|
2630
|
+
["Updated", machine.updated_at]
|
|
2631
|
+
]);
|
|
2632
|
+
}
|
|
2633
|
+
function anyMachineText(machine, etag = null) {
|
|
2634
|
+
return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag);
|
|
2635
|
+
}
|
|
2636
|
+
function parsePorts(specs) {
|
|
2385
2637
|
const accesses = machinePortAccess;
|
|
2386
2638
|
return specs.map((spec) => {
|
|
2387
2639
|
const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
|
|
@@ -2390,14 +2642,7 @@ function parsePorts(specs, current) {
|
|
|
2390
2642
|
throw new Error(
|
|
2391
2643
|
`--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
|
|
2392
2644
|
);
|
|
2393
|
-
|
|
2394
|
-
const before = current.find((entry) => entry.port === port && entry.access === access);
|
|
2395
|
-
return {
|
|
2396
|
-
port,
|
|
2397
|
-
access,
|
|
2398
|
-
default: false,
|
|
2399
|
-
...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
|
|
2400
|
-
};
|
|
2645
|
+
return { port: Number(match[1]), access, default: false };
|
|
2401
2646
|
});
|
|
2402
2647
|
}
|
|
2403
2648
|
function withDefault(ports, defaultPort) {
|
|
@@ -2419,29 +2664,83 @@ function parseEnv(pairs) {
|
|
|
2419
2664
|
}
|
|
2420
2665
|
return env;
|
|
2421
2666
|
}
|
|
2667
|
+
var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
|
|
2668
|
+
function specWarnings(deps, ports, envKeys) {
|
|
2669
|
+
if (ports.length === 0)
|
|
2670
|
+
deps.notice(
|
|
2671
|
+
"This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
|
|
2672
|
+
);
|
|
2673
|
+
const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
|
|
2674
|
+
if (secret.length)
|
|
2675
|
+
deps.notice(
|
|
2676
|
+
`${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; Machine secrets come later.`
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
function waitTimeout(options) {
|
|
2680
|
+
if (options.timeout !== void 0 && !options.wait) throw new Error("--timeout needs --wait.");
|
|
2681
|
+
return options.timeout ?? DEFAULT_WAIT_SECONDS;
|
|
2682
|
+
}
|
|
2683
|
+
var DEFAULT_WAIT_SECONDS = 300;
|
|
2684
|
+
async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
|
|
2685
|
+
const now = deps.now ?? Date.now;
|
|
2686
|
+
const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
2687
|
+
const start = now();
|
|
2688
|
+
let wait = firstWait;
|
|
2689
|
+
let last = null;
|
|
2690
|
+
for (; ; ) {
|
|
2691
|
+
const elapsed = (now() - start) / 1e3;
|
|
2692
|
+
if (elapsed + wait > timeout) return { machine: null, last, waited: Math.round(elapsed) };
|
|
2693
|
+
await pause(wait * 1e3);
|
|
2694
|
+
const machine = await deps.api.getMachine(key, name);
|
|
2695
|
+
if (machine.kind === "byoc")
|
|
2696
|
+
throw new Error(`${name} is one of your computers, not a Machine.`);
|
|
2697
|
+
if (settled(machine))
|
|
2698
|
+
return { machine, last: machine, waited: Math.round((now() - start) / 1e3) };
|
|
2699
|
+
last = machine;
|
|
2700
|
+
wait = pollAfterSeconds;
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2422
2703
|
async function machineListCommand(deps, options = {}) {
|
|
2423
2704
|
await machineCommand(async () => {
|
|
2424
2705
|
const page = await deps.api.listMachines(await deps.auth.apiKey(), {
|
|
2425
2706
|
limit: options.limit,
|
|
2426
2707
|
cursor: options.cursor
|
|
2427
2708
|
});
|
|
2428
|
-
printPage(
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
(
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2709
|
+
printPage(deps, options.json, page, (machines) => {
|
|
2710
|
+
const managed = machines.filter((machine) => machine.kind !== "byoc");
|
|
2711
|
+
const devices = machines.filter((machine) => machine.kind === "byoc");
|
|
2712
|
+
const tables = [];
|
|
2713
|
+
if (managed.length)
|
|
2714
|
+
tables.push(
|
|
2715
|
+
table(
|
|
2716
|
+
["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
|
|
2717
|
+
managed.map((machine) => [
|
|
2718
|
+
machine.name,
|
|
2719
|
+
machine.region,
|
|
2720
|
+
machine.state,
|
|
2721
|
+
machine.status.power,
|
|
2722
|
+
machine.shape,
|
|
2723
|
+
machine.image.reference,
|
|
2724
|
+
machine.hostnames.https
|
|
2725
|
+
])
|
|
2726
|
+
)
|
|
2727
|
+
);
|
|
2728
|
+
if (devices.length)
|
|
2729
|
+
tables.push(
|
|
2730
|
+
table(
|
|
2731
|
+
["COMPUTER", "REGION", "SYSTEM", "HARNESSES", "LAST SEEN", "KEY"],
|
|
2732
|
+
devices.map((machine) => [
|
|
2733
|
+
machine.name,
|
|
2734
|
+
machine.region,
|
|
2735
|
+
`${machine.byoc.os}/${machine.byoc.arch}`,
|
|
2736
|
+
machine.byoc.harnesses.join(",") || "none",
|
|
2737
|
+
machine.byoc.last_seen,
|
|
2738
|
+
machine.byoc.key_state
|
|
2739
|
+
])
|
|
2740
|
+
)
|
|
2741
|
+
);
|
|
2742
|
+
return tables.length ? tables.join("\n\n") : "No Machines.";
|
|
2743
|
+
});
|
|
2445
2744
|
if (page.unavailable_regions.length)
|
|
2446
2745
|
deps.notice(
|
|
2447
2746
|
`Could not read the Machines in ${page.unavailable_regions.join(", ")}; they are missing from this list.`
|
|
@@ -2450,47 +2749,146 @@ async function machineListCommand(deps, options = {}) {
|
|
|
2450
2749
|
}
|
|
2451
2750
|
function createdText(machine) {
|
|
2452
2751
|
if (machine.state === "creating")
|
|
2453
|
-
return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`;
|
|
2752
|
+
return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready, or create with --wait.`;
|
|
2454
2753
|
return `Created Machine ${machine.name}.`;
|
|
2455
2754
|
}
|
|
2755
|
+
function ago(deps, timestamp) {
|
|
2756
|
+
const seconds = Math.max(
|
|
2757
|
+
0,
|
|
2758
|
+
Math.round(((deps.now ?? Date.now)() - Date.parse(timestamp)) / 1e3)
|
|
2759
|
+
);
|
|
2760
|
+
if (seconds < 120) return `${seconds} s`;
|
|
2761
|
+
if (seconds < 7200) return `${Math.round(seconds / 60)} min`;
|
|
2762
|
+
if (seconds < 172800) return `${Math.round(seconds / 3600)} h`;
|
|
2763
|
+
return `${Math.round(seconds / 86400)} days`;
|
|
2764
|
+
}
|
|
2765
|
+
async function nameTaken(deps, key, name, error) {
|
|
2766
|
+
let holder;
|
|
2767
|
+
try {
|
|
2768
|
+
holder = await deps.api.getMachine(key, name);
|
|
2769
|
+
} catch {
|
|
2770
|
+
return error;
|
|
2771
|
+
}
|
|
2772
|
+
const what = holder.kind === "byoc" ? `${name} is one of your computers; choose another name.` : `A Machine named ${name} already exists (created ${ago(deps, holder.created_at)} ago, ${holder.state}); if it is yours from a lost reply, \`tonbo machine show ${name}\`.`;
|
|
2773
|
+
return new HttpError(`${error.message}
|
|
2774
|
+
${what}`, error.status, error.body, error.requestId);
|
|
2775
|
+
}
|
|
2776
|
+
async function waitForCreate(deps, key, machine, name, firstWait, timeout, json) {
|
|
2777
|
+
const {
|
|
2778
|
+
machine: settled,
|
|
2779
|
+
last,
|
|
2780
|
+
waited
|
|
2781
|
+
} = await waitForMachine(
|
|
2782
|
+
deps,
|
|
2783
|
+
key,
|
|
2784
|
+
name,
|
|
2785
|
+
firstWait,
|
|
2786
|
+
timeout,
|
|
2787
|
+
(read) => read.state !== "creating"
|
|
2788
|
+
);
|
|
2789
|
+
if (!settled) {
|
|
2790
|
+
const known = last ?? machine;
|
|
2791
|
+
print(deps, json, known, machineFields(known));
|
|
2792
|
+
throw new Error(
|
|
2793
|
+
`Machine ${machine.name} is still creating after ${waited} s; nothing was undone. \`tonbo machine show ${machine.name}\` reports it active when it is ready.`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
if (settled.state === "active") {
|
|
2797
|
+
const pulled = settled.image.digest === null ? "" : `, image ${settled.image.state}`;
|
|
2798
|
+
print(
|
|
2799
|
+
deps,
|
|
2800
|
+
json,
|
|
2801
|
+
settled,
|
|
2802
|
+
[
|
|
2803
|
+
`Created ${settled.name} in ${settled.region} (active after ${waited} s${pulled}).`,
|
|
2804
|
+
machineText(settled)
|
|
2805
|
+
].join("\n")
|
|
2806
|
+
);
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
print(deps, json, settled, machineFields(settled));
|
|
2810
|
+
if (settled.state === "failed") {
|
|
2811
|
+
const { reason, hint } = creationFailureText(settled);
|
|
2812
|
+
throw new Error(
|
|
2813
|
+
`Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
throw new Error(`Machine ${settled.name} is ${settled.state}; it was not created.`);
|
|
2817
|
+
}
|
|
2456
2818
|
async function machineCreateCommand(deps, name, command, options = {}) {
|
|
2457
2819
|
await machineCommand(async () => {
|
|
2458
2820
|
if (!options.image) throw new Error("Pass --image <reference>.");
|
|
2821
|
+
const timeout = waitTimeout(options);
|
|
2459
2822
|
const env = parseEnv(options.env ?? []);
|
|
2460
|
-
const ports = withDefault(parsePorts(options.port ?? []
|
|
2823
|
+
const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
|
|
2461
2824
|
const key = await deps.auth.apiKey();
|
|
2462
2825
|
const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2826
|
+
let created;
|
|
2827
|
+
try {
|
|
2828
|
+
created = await deps.api.createMachine(
|
|
2829
|
+
key,
|
|
2830
|
+
{
|
|
2831
|
+
name,
|
|
2832
|
+
image: options.image,
|
|
2833
|
+
shape,
|
|
2834
|
+
region: options.region,
|
|
2835
|
+
command: command.length ? command : void 0,
|
|
2836
|
+
ports: ports.length ? ports : void 0,
|
|
2837
|
+
env: Object.keys(env).length ? env : void 0,
|
|
2838
|
+
sleep_after_seconds: options.sleepAfter,
|
|
2839
|
+
idle_action: options.idleAction,
|
|
2840
|
+
stop_grace_seconds: options.stopGrace
|
|
2841
|
+
},
|
|
2842
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2843
|
+
);
|
|
2844
|
+
} catch (error) {
|
|
2845
|
+
if (error instanceof HttpError && error.code === "name_taken")
|
|
2846
|
+
throw await nameTaken(deps, key, name, error);
|
|
2847
|
+
throw error;
|
|
2848
|
+
}
|
|
2849
|
+
const { machine } = created;
|
|
2850
|
+
specWarnings(deps, machine.ports, Object.keys(env));
|
|
2479
2851
|
if (machine.state === "failed") {
|
|
2480
2852
|
print(deps, options.json, machine, machineFields(machine));
|
|
2853
|
+
const { reason, hint } = creationFailureText(machine);
|
|
2481
2854
|
throw new Error(
|
|
2482
|
-
`Machine ${machine.name} failed to be created; delete it with \`tonbo machine delete ${machine.name}\` and create it again
|
|
2855
|
+
`Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
|
|
2483
2856
|
);
|
|
2484
2857
|
}
|
|
2485
|
-
|
|
2858
|
+
if (options.wait && machine.state === "creating")
|
|
2859
|
+
return waitForCreate(
|
|
2860
|
+
deps,
|
|
2861
|
+
key,
|
|
2862
|
+
machine,
|
|
2863
|
+
deps.api.machineNameAt(created.location) ?? machine.name,
|
|
2864
|
+
created.retryAfterSeconds ?? pollAfterSeconds,
|
|
2865
|
+
timeout,
|
|
2866
|
+
options.json
|
|
2867
|
+
);
|
|
2868
|
+
print(deps, options.json, machine, [createdText(machine), machineText(machine)].join("\n"));
|
|
2486
2869
|
});
|
|
2487
2870
|
}
|
|
2488
2871
|
async function machineShowCommand(deps, name, options = {}) {
|
|
2489
2872
|
await machineCommand(async () => {
|
|
2490
|
-
const machine = await deps.api.
|
|
2491
|
-
print(deps, options.json, machine,
|
|
2873
|
+
const { machine, etag } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
|
|
2874
|
+
print(deps, options.json, machine, anyMachineText(machine, etag));
|
|
2492
2875
|
});
|
|
2493
2876
|
}
|
|
2877
|
+
function appliesText(name, change) {
|
|
2878
|
+
const lines = [];
|
|
2879
|
+
if (change.ports !== void 0 || change.sleep_after_seconds !== void 0)
|
|
2880
|
+
lines.push("Ports and sleep-after apply now.");
|
|
2881
|
+
const later = [change.image, change.command, change.shape, change.env].some(
|
|
2882
|
+
(field) => field !== void 0
|
|
2883
|
+
);
|
|
2884
|
+
if (later) {
|
|
2885
|
+
lines.push(
|
|
2886
|
+
`The change applies at the next activation. To apply it now, run \`tonbo machine sleep ${name}\`; the next request boots the new spec.`
|
|
2887
|
+
);
|
|
2888
|
+
if (change.image !== void 0) lines.push("Naming the same tag again re-resolves its digest.");
|
|
2889
|
+
}
|
|
2890
|
+
return lines;
|
|
2891
|
+
}
|
|
2494
2892
|
async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
2495
2893
|
await machineCommand(async () => {
|
|
2496
2894
|
if (command.length && options.imageCommand)
|
|
@@ -2498,7 +2896,14 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
|
2498
2896
|
if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
|
|
2499
2897
|
throw new Error("Give --port or --clear-ports, not both.");
|
|
2500
2898
|
const key = await deps.auth.apiKey();
|
|
2501
|
-
const
|
|
2899
|
+
const { machine: read, etag: readTag } = await deps.api.getMachineWithEtag(key, name);
|
|
2900
|
+
if (read.kind === "byoc")
|
|
2901
|
+
throw new Error(
|
|
2902
|
+
`${name} is one of your computers; only \`tonbo machine show\` and \`tonbo machine delete\` serve it.`
|
|
2903
|
+
);
|
|
2904
|
+
const current = read;
|
|
2905
|
+
const etag = ifMatchHeader(options.ifMatch) ?? readTag;
|
|
2906
|
+
if (etag === null) throw new Error(`The API answered ${name} without an ETag.`);
|
|
2502
2907
|
const change = {
|
|
2503
2908
|
image: options.image,
|
|
2504
2909
|
shape: options.shape,
|
|
@@ -2509,68 +2914,122 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
|
2509
2914
|
};
|
|
2510
2915
|
if (options.clearPorts) change.ports = [];
|
|
2511
2916
|
else if (options.port?.length) {
|
|
2512
|
-
const ports = parsePorts(options.port
|
|
2917
|
+
const ports = parsePorts(options.port);
|
|
2513
2918
|
const kept = current.ports.find((entry) => entry.default)?.port;
|
|
2514
2919
|
const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
|
|
2515
2920
|
change.ports = withDefault(ports, defaultPort);
|
|
2516
2921
|
} else if (options.defaultPort !== void 0)
|
|
2517
2922
|
change.ports = withDefault(current.ports, options.defaultPort);
|
|
2923
|
+
const setEnv = parseEnv(options.env ?? []);
|
|
2518
2924
|
if (options.env?.length || options.unsetEnv?.length) {
|
|
2519
|
-
const env = { ...current.env, ...
|
|
2925
|
+
const env = { ...current.env, ...setEnv };
|
|
2520
2926
|
for (const variable of options.unsetEnv ?? []) delete env[variable];
|
|
2521
2927
|
change.env = env;
|
|
2522
2928
|
}
|
|
2523
2929
|
if (options.discardSnapshot) change.discard_snapshot = true;
|
|
2524
2930
|
if (Object.values(change).every((value) => value === void 0))
|
|
2525
2931
|
throw new Error("Nothing to change: pass at least one option or a command.");
|
|
2526
|
-
const machine = await deps.api.updateMachine(
|
|
2527
|
-
|
|
2528
|
-
name,
|
|
2529
|
-
options.revision ?? current.revision,
|
|
2530
|
-
change
|
|
2531
|
-
);
|
|
2932
|
+
const machine = await deps.api.updateMachine(key, name, etag, change);
|
|
2933
|
+
specWarnings(deps, machine.ports, Object.keys(setEnv));
|
|
2532
2934
|
print(
|
|
2533
2935
|
deps,
|
|
2534
2936
|
options.json,
|
|
2535
2937
|
machine,
|
|
2536
2938
|
[
|
|
2537
|
-
`Updated Machine ${machine.name} to revision ${machine.revision}
|
|
2538
|
-
|
|
2939
|
+
`Updated Machine ${machine.name} to revision ${machine.revision}.`,
|
|
2940
|
+
...appliesText(machine.name, change),
|
|
2941
|
+
machineText(machine)
|
|
2539
2942
|
].join("\n")
|
|
2540
2943
|
);
|
|
2541
2944
|
});
|
|
2542
2945
|
}
|
|
2946
|
+
function sleptText(machine) {
|
|
2947
|
+
if (machine.state === "creating")
|
|
2948
|
+
return `Machine ${machine.name} is still creating; nothing to put to sleep.`;
|
|
2949
|
+
if (machine.status.power === "asleep")
|
|
2950
|
+
return `Machine ${machine.name} is already asleep; nothing to do. The next request boots it.`;
|
|
2951
|
+
return `Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); the next request boots it again, on its latest spec.`;
|
|
2952
|
+
}
|
|
2543
2953
|
async function machineSleepCommand(deps, name, options = {}) {
|
|
2544
2954
|
await machineCommand(async () => {
|
|
2545
|
-
const
|
|
2955
|
+
const timeout = waitTimeout(options);
|
|
2956
|
+
const key = await deps.auth.apiKey();
|
|
2957
|
+
const machine = await deps.api.sleepMachine(key, name, ifMatchHeader(options.ifMatch));
|
|
2958
|
+
const going = options.wait && machine.state === "active" && machine.status.power !== "asleep";
|
|
2959
|
+
if (!going) {
|
|
2960
|
+
print(deps, options.json, machine, sleptText(machine));
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
const { machine: asleep, waited } = await waitForMachine(
|
|
2964
|
+
deps,
|
|
2965
|
+
key,
|
|
2966
|
+
name,
|
|
2967
|
+
pollAfterSeconds,
|
|
2968
|
+
timeout,
|
|
2969
|
+
(read) => read.status.power === "asleep" || read.state !== "active"
|
|
2970
|
+
);
|
|
2971
|
+
if (!asleep)
|
|
2972
|
+
throw new Error(
|
|
2973
|
+
`Machine ${name} is not asleep after ${waited} s; \`tonbo machine show ${name}\` says what keeps it awake.`
|
|
2974
|
+
);
|
|
2975
|
+
if (asleep.status.power !== "asleep")
|
|
2976
|
+
throw new Error(`Machine ${name} is ${asleep.state}; it did not go to sleep.`);
|
|
2546
2977
|
print(
|
|
2547
2978
|
deps,
|
|
2548
2979
|
options.json,
|
|
2549
|
-
|
|
2550
|
-
`Machine ${
|
|
2980
|
+
asleep,
|
|
2981
|
+
`Machine ${asleep.name} is asleep (after ${waited} s); the next request boots it, on its latest spec.`
|
|
2551
2982
|
);
|
|
2552
2983
|
});
|
|
2553
2984
|
}
|
|
2554
2985
|
async function machineDeleteCommand(deps, name, options = {}) {
|
|
2555
2986
|
await machineCommand(async () => {
|
|
2987
|
+
const key = await deps.auth.apiKey();
|
|
2988
|
+
const read = { etag: null };
|
|
2556
2989
|
await confirm(
|
|
2557
2990
|
deps,
|
|
2558
2991
|
options.yes,
|
|
2559
2992
|
`delete Machine ${name}`,
|
|
2560
|
-
() =>
|
|
2561
|
-
`Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`
|
|
2562
|
-
),
|
|
2993
|
+
() => deleteQuestion(deps, key, name, read),
|
|
2563
2994
|
name
|
|
2564
2995
|
);
|
|
2565
|
-
const machine = await deps.api.deleteMachine(
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
options.
|
|
2569
|
-
machine,
|
|
2570
|
-
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.`
|
|
2996
|
+
const { machine, kind } = await deps.api.deleteMachine(
|
|
2997
|
+
key,
|
|
2998
|
+
name,
|
|
2999
|
+
ifMatchHeader(options.ifMatch) ?? read.etag ?? void 0
|
|
2571
3000
|
);
|
|
3001
|
+
print(deps, options.json, machine, deletedText(name, machine, kind));
|
|
3002
|
+
if (machine?.kind === "byoc" && machine.byoc.key_state === "active")
|
|
3003
|
+
deps.notice(
|
|
3004
|
+
`Its key is still active, so ${name} registers again at its next start. Run \`yield logout\` on it, or revoke its key with \`tonbo api-key revoke\`, then forget it again.`
|
|
3005
|
+
);
|
|
2572
3006
|
});
|
|
2573
3007
|
}
|
|
3008
|
+
async function deleteQuestion(deps, key, name, read) {
|
|
3009
|
+
let kind = null;
|
|
3010
|
+
try {
|
|
3011
|
+
const answer = await deps.api.getMachineWithEtag(key, name);
|
|
3012
|
+
kind = answer.machine.kind;
|
|
3013
|
+
read.etag = answer.etag;
|
|
3014
|
+
} catch (error) {
|
|
3015
|
+
if (!(error instanceof HttpError && error.status === 404)) throw error;
|
|
3016
|
+
}
|
|
3017
|
+
if (kind === "byoc")
|
|
3018
|
+
return `Forget your computer ${name}? It disappears from your Machines; its key is not revoked. Type its name to confirm:`;
|
|
3019
|
+
if (kind === "managed")
|
|
3020
|
+
return `Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`;
|
|
3021
|
+
return `Delete Machine ${name}? Type its name to confirm:`;
|
|
3022
|
+
}
|
|
3023
|
+
function deletedText(name, machine, kind) {
|
|
3024
|
+
if (kind === "byoc")
|
|
3025
|
+
return `Forgot your computer ${machine?.name ?? name}; its name is free once the forget completes.`;
|
|
3026
|
+
if (machine)
|
|
3027
|
+
return `Machine ${machine.name} is being deleted; its name is free for a new Machine.`;
|
|
3028
|
+
return `Released the name ${name}; no Machine had been created under it.`;
|
|
3029
|
+
}
|
|
3030
|
+
function hourlyPrice(micros) {
|
|
3031
|
+
return `$${(micros / 1e6).toFixed(4)}/h`;
|
|
3032
|
+
}
|
|
2574
3033
|
async function machineShapesCommand(deps, options = {}) {
|
|
2575
3034
|
await machineCommand(async () => {
|
|
2576
3035
|
const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
|
|
@@ -2579,14 +3038,15 @@ async function machineShapesCommand(deps, options = {}) {
|
|
|
2579
3038
|
options.json,
|
|
2580
3039
|
shapes,
|
|
2581
3040
|
table(
|
|
2582
|
-
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
|
|
3041
|
+
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE", "LIST PRICE"],
|
|
2583
3042
|
shapes.data.map((shape) => [
|
|
2584
3043
|
shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
|
|
2585
3044
|
String(shape.vcpus),
|
|
2586
3045
|
`${shape.memory_gib} GiB`,
|
|
2587
3046
|
`${shape.scratch_gib} GiB`,
|
|
2588
3047
|
shape.offered ? "yes" : "no",
|
|
2589
|
-
shape.suspendable ? "yes" : "no"
|
|
3048
|
+
shape.suspendable ? "yes" : "no",
|
|
3049
|
+
hourlyPrice(shape.ceiling_usd_micros_per_hour)
|
|
2590
3050
|
])
|
|
2591
3051
|
)
|
|
2592
3052
|
);
|
|
@@ -2653,6 +3113,7 @@ function operationOptions(command, operationId) {
|
|
|
2653
3113
|
command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
|
|
2654
3114
|
return command.option("--json", "print the API data as JSON");
|
|
2655
3115
|
}
|
|
3116
|
+
var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes); the ETag the command reads, if it reads one, by default`;
|
|
2656
3117
|
var repeated = (value, previous = []) => [...previous, value];
|
|
2657
3118
|
var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
|
|
2658
3119
|
function machineSpecOptions(command, update) {
|
|
@@ -2710,6 +3171,12 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2710
3171
|
program.command("region").description("regions new resources can be created in").command("list").description("list the regions"),
|
|
2711
3172
|
"listRegions"
|
|
2712
3173
|
).action(async (options) => regionListCommand(dependencies(), options));
|
|
3174
|
+
operationOptions(
|
|
3175
|
+
program.command("workspace").description("the account's git workspaces").command("list").description("list the git workspaces, the default first"),
|
|
3176
|
+
"listGitWorkspaces"
|
|
3177
|
+
).action(
|
|
3178
|
+
async (options) => workspaceListCommand(dependencies(), options)
|
|
3179
|
+
);
|
|
2713
3180
|
const member = program.command("member").description("the people and Agents of the account");
|
|
2714
3181
|
operationOptions(
|
|
2715
3182
|
member.command("list").description("list the members and their roles"),
|
|
@@ -2934,7 +3401,7 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2934
3401
|
);
|
|
2935
3402
|
const machine = program.command("machine").description("the account's Machines");
|
|
2936
3403
|
operationOptions(
|
|
2937
|
-
machine.command("list").description("list the Machines in every region"),
|
|
3404
|
+
machine.command("list").description("list the Machines in every region, and your computers in a personal account"),
|
|
2938
3405
|
"listMachines"
|
|
2939
3406
|
).action(
|
|
2940
3407
|
async (options) => machineListCommand(dependencies(), options)
|
|
@@ -2954,13 +3421,17 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2954
3421
|
).option(
|
|
2955
3422
|
"--idempotency-key <key>",
|
|
2956
3423
|
"reuse the key of an unconfirmed creation to get the Machine it created"
|
|
3424
|
+
).option("--wait", "wait until the Machine is active, or say why it failed").option(
|
|
3425
|
+
"--timeout <seconds>",
|
|
3426
|
+
`how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
|
|
3427
|
+
parseWhole
|
|
2957
3428
|
),
|
|
2958
3429
|
"createMachine"
|
|
2959
3430
|
).action(
|
|
2960
3431
|
async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
|
|
2961
3432
|
);
|
|
2962
3433
|
operationOptions(
|
|
2963
|
-
machine.command("show <name>").description("show a Machine, its hostnames and its state"),
|
|
3434
|
+
machine.command("show <name>").description("show a Machine, its hostnames and its state, or one of your computers"),
|
|
2964
3435
|
"getMachine"
|
|
2965
3436
|
).action(
|
|
2966
3437
|
async (name, options) => machineShowCommand(dependencies(), name, options)
|
|
@@ -2969,11 +3440,7 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2969
3440
|
machineSpecOptions(
|
|
2970
3441
|
machine.command("update <name> [command...]").description(
|
|
2971
3442
|
"change a Machine; [command...] after -- replaces its command at the next boot"
|
|
2972
|
-
).option("--image <reference>", "a new image, named as for create; 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(
|
|
2973
|
-
"--revision <revision>",
|
|
2974
|
-
"write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
|
|
2975
|
-
parseWhole
|
|
2976
|
-
).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
|
|
3443
|
+
).option("--image <reference>", "a new image, named as for create; 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("--if-match <etag>", ifMatchHelp("change")).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
|
|
2977
3444
|
true
|
|
2978
3445
|
),
|
|
2979
3446
|
"updateMachine"
|
|
@@ -2981,13 +3448,19 @@ function createProgram(dependencies = createDependencies) {
|
|
|
2981
3448
|
async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
|
|
2982
3449
|
);
|
|
2983
3450
|
operationOptions(
|
|
2984
|
-
machine.command("sleep <name>").description("put a Machine to sleep now"),
|
|
3451
|
+
machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option(
|
|
3452
|
+
"--timeout <seconds>",
|
|
3453
|
+
`how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
|
|
3454
|
+
parseWhole
|
|
3455
|
+
),
|
|
2985
3456
|
"sleepMachine"
|
|
2986
3457
|
).action(
|
|
2987
3458
|
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
2988
3459
|
);
|
|
2989
3460
|
operationOptions(
|
|
2990
|
-
machine.command("delete <name>").description(
|
|
3461
|
+
machine.command("delete <name>").description(
|
|
3462
|
+
"delete a Machine with its disk, database and logs, or forget one of your computers"
|
|
3463
|
+
).option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
|
|
2991
3464
|
"deleteMachine"
|
|
2992
3465
|
).action(
|
|
2993
3466
|
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|