@tonbo/cli 0.8.0 → 0.10.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 +19 -11
- package/dist/bin/tonbo.js +797 -118
- package/package.json +1 -1
package/dist/bin/tonbo.js
CHANGED
|
@@ -202,6 +202,7 @@ var agentNameContract = {
|
|
|
202
202
|
"artifacts",
|
|
203
203
|
"auth",
|
|
204
204
|
"inference",
|
|
205
|
+
"lb-check",
|
|
205
206
|
"network-health",
|
|
206
207
|
"sandbox-control",
|
|
207
208
|
"status",
|
|
@@ -221,6 +222,7 @@ var agentNameContract = {
|
|
|
221
222
|
"artifacts.tonbo.sh",
|
|
222
223
|
"auth.tonbo.sh",
|
|
223
224
|
"inference.tonbo.sh",
|
|
225
|
+
"lb-check.tonbo.sh",
|
|
224
226
|
"network-health.tonbo.sh",
|
|
225
227
|
"sandbox-control.tonbo.sh",
|
|
226
228
|
"status.tonbo.sh",
|
|
@@ -621,6 +623,19 @@ var managementOperations = {
|
|
|
621
623
|
"idempotencyKey": false,
|
|
622
624
|
"cli": "tonbo machine sleep"
|
|
623
625
|
},
|
|
626
|
+
"listMachineActivations": {
|
|
627
|
+
"method": "GET",
|
|
628
|
+
"path": "/machines/{name}/activations",
|
|
629
|
+
"pathParams": [
|
|
630
|
+
"name"
|
|
631
|
+
],
|
|
632
|
+
"query": [
|
|
633
|
+
"cursor",
|
|
634
|
+
"limit"
|
|
635
|
+
],
|
|
636
|
+
"idempotencyKey": false,
|
|
637
|
+
"cli": "tonbo machine activations"
|
|
638
|
+
},
|
|
624
639
|
"listMachineShapes": {
|
|
625
640
|
"method": "GET",
|
|
626
641
|
"path": "/machine-shapes",
|
|
@@ -709,7 +724,7 @@ var apiKeyExpirations = [
|
|
|
709
724
|
];
|
|
710
725
|
var credentialsContract = {
|
|
711
726
|
"version": 1,
|
|
712
|
-
"description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account. @tonbo/cli-core writes and reads it;
|
|
727
|
+
"description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account at the top level, the login made last, and, beside it, every account's login of this device in `accounts` with the device's `login_secret` (ADR-0016, \"Credentials\", PR 4b-ii). @tonbo/cli-core writes and reads it; the Tonbo SDK (@tonbo/sdk and tonbo-sdk, contracts/sdk/v1.json) reads it as its last key source, and every other reader follows `reader` and reads the top level alone.",
|
|
713
728
|
"path": {
|
|
714
729
|
"description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
|
|
715
730
|
"base_env": "XDG_CONFIG_HOME",
|
|
@@ -747,6 +762,13 @@ var credentialsContract = {
|
|
|
747
762
|
"expired": "A login whose `expires_at` is at or before now is expired: ask the person to run `tonbo login`. `expires_at: null` never expires.",
|
|
748
763
|
"login_hint": "tonbo login"
|
|
749
764
|
},
|
|
765
|
+
"accounts": {
|
|
766
|
+
"description": "One CLI login key per account (ADR-0016 R7 (a)): a daemon that publishes into an organization holds that organization's key beside the personal account's, since a key reaches only its own account. The fields are additive and optional, so a version 1 reader that knows only the top level keeps working, and the format stays version 1.",
|
|
767
|
+
"top_level": "The login made last, by `tonbo login` or `yield login`: the account `tonbo` acts in. A login made only to publish into an organization (`yield connect --account`) is added to `accounts` and leaves the top level alone.",
|
|
768
|
+
"entries": "`accounts` maps an account id to that account's login on this device, the top level's account included. `kind` and `name` are the redemption's answer; a file written before them, or by a server that does not answer them, has the top level alone, whose kind a client asks the API for.",
|
|
769
|
+
"personal": "The entry whose kind is `user` is the person's personal account's login: it alone captures raw sessions and registers the device (ADR-0016 W-10, W-14).",
|
|
770
|
+
"login_secret": "One random per-device secret, 32 bytes base64url, made at the first login and kept: it is sent only in the redeem POST, beside the PKCE verifier, never in the /cli/login URL, and User Center keeps only the suffix it derives from it, in an organization key's name `Yield CLI \xB7 <8 hex>`. `tonbo` and `yield` share it through this file, and so share one key per account. A lost file sends a new secret and gets a new suffix; the old key stays until it is revoked or idles out."
|
|
771
|
+
},
|
|
750
772
|
"current_version": 1,
|
|
751
773
|
"schema": {
|
|
752
774
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -787,6 +809,59 @@ var credentialsContract = {
|
|
|
787
809
|
],
|
|
788
810
|
"format": "date-time",
|
|
789
811
|
"description": "When the key expires, ISO 8601; null for a key that never expires."
|
|
812
|
+
},
|
|
813
|
+
"login_secret": {
|
|
814
|
+
"type": "string",
|
|
815
|
+
"pattern": "^[A-Za-z0-9_-]{43}$",
|
|
816
|
+
"description": "This device's login secret (`accounts.login_secret`)."
|
|
817
|
+
},
|
|
818
|
+
"accounts": {
|
|
819
|
+
"type": "object",
|
|
820
|
+
"description": "Every account's login of this device, keyed by the account id (`accounts.entries`).",
|
|
821
|
+
"additionalProperties": {
|
|
822
|
+
"type": "object",
|
|
823
|
+
"required": [
|
|
824
|
+
"key_id",
|
|
825
|
+
"key",
|
|
826
|
+
"expires_at"
|
|
827
|
+
],
|
|
828
|
+
"properties": {
|
|
829
|
+
"key_id": {
|
|
830
|
+
"type": "string",
|
|
831
|
+
"minLength": 1,
|
|
832
|
+
"description": "The key's id."
|
|
833
|
+
},
|
|
834
|
+
"key": {
|
|
835
|
+
"type": "string",
|
|
836
|
+
"pattern": "^tbo_",
|
|
837
|
+
"description": "The personal key's secret."
|
|
838
|
+
},
|
|
839
|
+
"expires_at": {
|
|
840
|
+
"type": [
|
|
841
|
+
"string",
|
|
842
|
+
"null"
|
|
843
|
+
],
|
|
844
|
+
"format": "date-time",
|
|
845
|
+
"description": "When the key expires; null for never."
|
|
846
|
+
},
|
|
847
|
+
"kind": {
|
|
848
|
+
"type": [
|
|
849
|
+
"string",
|
|
850
|
+
"null"
|
|
851
|
+
],
|
|
852
|
+
"pattern": "^(user|organization)$",
|
|
853
|
+
"description": "The account's kind, `user` for the personal account; null when unknown."
|
|
854
|
+
},
|
|
855
|
+
"name": {
|
|
856
|
+
"type": [
|
|
857
|
+
"string",
|
|
858
|
+
"null"
|
|
859
|
+
],
|
|
860
|
+
"minLength": 1,
|
|
861
|
+
"description": "The account's name, which `--account` takes; null when unknown."
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
790
865
|
}
|
|
791
866
|
}
|
|
792
867
|
},
|
|
@@ -805,6 +880,30 @@ var credentialsContract = {
|
|
|
805
880
|
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
806
881
|
"key": "tbo_secret",
|
|
807
882
|
"expires_at": null
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
"version": 1,
|
|
886
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
887
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
888
|
+
"key": "tbo_secret",
|
|
889
|
+
"expires_at": null,
|
|
890
|
+
"login_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
891
|
+
"accounts": {
|
|
892
|
+
"10000000-0000-4000-8000-000000000001": {
|
|
893
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
894
|
+
"key": "tbo_secret",
|
|
895
|
+
"expires_at": null,
|
|
896
|
+
"kind": "user",
|
|
897
|
+
"name": "sam"
|
|
898
|
+
},
|
|
899
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
900
|
+
"key_id": "20000000-0000-4000-8000-0000000000a2",
|
|
901
|
+
"key": "tbo_org",
|
|
902
|
+
"expires_at": "2027-04-24T00:00:00.000Z",
|
|
903
|
+
"kind": "organization",
|
|
904
|
+
"name": "acme"
|
|
905
|
+
}
|
|
906
|
+
}
|
|
808
907
|
}
|
|
809
908
|
],
|
|
810
909
|
"unusable": [
|
|
@@ -846,6 +945,51 @@ var credentialsContract = {
|
|
|
846
945
|
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
847
946
|
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
848
947
|
"key": "tbo_secret"
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
"version": 1,
|
|
951
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
952
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
953
|
+
"key": "tbo_secret",
|
|
954
|
+
"expires_at": null,
|
|
955
|
+
"accounts": {
|
|
956
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
957
|
+
"key_id": "k",
|
|
958
|
+
"key": "not-a-personal-key",
|
|
959
|
+
"expires_at": null
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
"version": 1,
|
|
965
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
966
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
967
|
+
"key": "tbo_secret",
|
|
968
|
+
"expires_at": null,
|
|
969
|
+
"accounts": {
|
|
970
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
971
|
+
"key_id": "k",
|
|
972
|
+
"key": "tbo_org",
|
|
973
|
+
"expires_at": null,
|
|
974
|
+
"kind": "team"
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
"version": 1,
|
|
980
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
981
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
982
|
+
"key": "tbo_secret",
|
|
983
|
+
"expires_at": null,
|
|
984
|
+
"accounts": []
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
"version": 1,
|
|
988
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
989
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
990
|
+
"key": "tbo_secret",
|
|
991
|
+
"expires_at": null,
|
|
992
|
+
"login_secret": "in-a-url"
|
|
849
993
|
}
|
|
850
994
|
]
|
|
851
995
|
}
|
|
@@ -858,6 +1002,12 @@ var machinePortAccess = [
|
|
|
858
1002
|
"private",
|
|
859
1003
|
"public"
|
|
860
1004
|
];
|
|
1005
|
+
var machineTimings = {
|
|
1006
|
+
"pollAfterSeconds": 2,
|
|
1007
|
+
"sleepPollAfterSeconds": 5,
|
|
1008
|
+
"wakeHoldSeconds": 25,
|
|
1009
|
+
"earlyReclaimSeconds": 120
|
|
1010
|
+
};
|
|
861
1011
|
|
|
862
1012
|
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
863
1013
|
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
@@ -924,13 +1074,37 @@ var FileCredentialStore = class {
|
|
|
924
1074
|
await rm(this.filename, { force: true });
|
|
925
1075
|
}
|
|
926
1076
|
};
|
|
927
|
-
var RULE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1077
|
+
var RULE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1078
|
+
"const",
|
|
1079
|
+
"type",
|
|
1080
|
+
"minLength",
|
|
1081
|
+
"pattern",
|
|
1082
|
+
"format",
|
|
1083
|
+
"description",
|
|
1084
|
+
"additionalProperties"
|
|
1085
|
+
]);
|
|
1086
|
+
function isPlainObject(value) {
|
|
1087
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1088
|
+
}
|
|
1089
|
+
function matchesObject(value, rule) {
|
|
1090
|
+
if (rule.type !== "object" || !isPlainObject(value))
|
|
1091
|
+
return false;
|
|
1092
|
+
if (!rule.required.every((name) => Object.hasOwn(value, name)))
|
|
1093
|
+
return false;
|
|
1094
|
+
return Object.entries(rule.properties).every(([name, inner]) => !Object.hasOwn(value, name) || matchesRule(value[name], inner));
|
|
1095
|
+
}
|
|
928
1096
|
function matchesRule(value, rule) {
|
|
929
1097
|
if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
|
|
930
1098
|
return false;
|
|
931
1099
|
if ("const" in rule)
|
|
932
1100
|
return value === rule.const;
|
|
933
1101
|
const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
|
|
1102
|
+
if (types.includes("object")) {
|
|
1103
|
+
if (!isPlainObject(value) || !rule.additionalProperties)
|
|
1104
|
+
return false;
|
|
1105
|
+
const entries = rule.additionalProperties;
|
|
1106
|
+
return Object.entries(value).every(([key, entry]) => key.length > 0 && matchesObject(entry, entries));
|
|
1107
|
+
}
|
|
934
1108
|
if (value === null)
|
|
935
1109
|
return types.includes("null");
|
|
936
1110
|
if (typeof value !== "string" || !types.includes("string"))
|
|
@@ -944,7 +1118,7 @@ function matchesRule(value, rule) {
|
|
|
944
1118
|
return true;
|
|
945
1119
|
}
|
|
946
1120
|
function isStoredLogin(value) {
|
|
947
|
-
if (!
|
|
1121
|
+
if (!isPlainObject(value))
|
|
948
1122
|
return false;
|
|
949
1123
|
const login = value;
|
|
950
1124
|
const schema = credentialsContract.schema;
|
|
@@ -955,6 +1129,44 @@ function isStoredLogin(value) {
|
|
|
955
1129
|
const properties = schema.properties;
|
|
956
1130
|
return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
|
|
957
1131
|
}
|
|
1132
|
+
function accountLogins(login) {
|
|
1133
|
+
const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
|
|
1134
|
+
...entry,
|
|
1135
|
+
account_id,
|
|
1136
|
+
kind: entry.kind ?? null,
|
|
1137
|
+
name: entry.name ?? null
|
|
1138
|
+
}));
|
|
1139
|
+
if (!entries.some((entry) => entry.account_id === login.account_id))
|
|
1140
|
+
entries.unshift({
|
|
1141
|
+
account_id: login.account_id,
|
|
1142
|
+
key_id: login.key_id,
|
|
1143
|
+
key: login.key,
|
|
1144
|
+
expires_at: login.expires_at,
|
|
1145
|
+
kind: null,
|
|
1146
|
+
name: null
|
|
1147
|
+
});
|
|
1148
|
+
return entries;
|
|
1149
|
+
}
|
|
1150
|
+
function withAccountLogin(file, entry, options) {
|
|
1151
|
+
const accounts = {};
|
|
1152
|
+
if (file)
|
|
1153
|
+
for (const known of accountLogins(file)) {
|
|
1154
|
+
const { account_id: account_id2, ...rest2 } = known;
|
|
1155
|
+
accounts[account_id2] = rest2;
|
|
1156
|
+
}
|
|
1157
|
+
const { account_id, ...rest } = entry;
|
|
1158
|
+
accounts[account_id] = rest;
|
|
1159
|
+
const top = options.current || !file ? entry : file;
|
|
1160
|
+
return {
|
|
1161
|
+
version: 1,
|
|
1162
|
+
account_id: top.account_id,
|
|
1163
|
+
key_id: top.key_id,
|
|
1164
|
+
key: top.key,
|
|
1165
|
+
expires_at: top.expires_at,
|
|
1166
|
+
login_secret: options.loginSecret,
|
|
1167
|
+
accounts
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
958
1170
|
async function preparePrivateDirectory(directory) {
|
|
959
1171
|
const stat = await lstat(directory);
|
|
960
1172
|
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
@@ -984,6 +1196,9 @@ async function assertPrivateRegularFile(filename) {
|
|
|
984
1196
|
var exec = promisify(execFile);
|
|
985
1197
|
var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
|
|
986
1198
|
var LOOPBACK_HOST = "127.0.0.1";
|
|
1199
|
+
function newLoginSecret() {
|
|
1200
|
+
return randomBytes(32).toString("base64url");
|
|
1201
|
+
}
|
|
987
1202
|
function loginHostname(hostname = os2.hostname()) {
|
|
988
1203
|
const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
989
1204
|
return cleaned || "computer";
|
|
@@ -1018,11 +1233,21 @@ var AuthClient = class {
|
|
|
1018
1233
|
}
|
|
1019
1234
|
/**
|
|
1020
1235
|
* The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
|
|
1021
|
-
* a free port, then the one-time code is redeemed
|
|
1022
|
-
*
|
|
1236
|
+
* a free port, then the one-time code is redeemed, with this device's login
|
|
1237
|
+
* secret, for the account's login key, which replaces this device's earlier
|
|
1238
|
+
* login key there ("CLI on <hostname>" in a personal account, `Yield CLI ·
|
|
1239
|
+
* <8 hex>` in any other). The key joins the file's `accounts`; it becomes
|
|
1240
|
+
* the top level too unless `current` is false (a login made only to publish
|
|
1241
|
+
* into an organization). Answers that account's login.
|
|
1023
1242
|
*/
|
|
1024
1243
|
async login(progress = () => {
|
|
1025
|
-
}, account) {
|
|
1244
|
+
}, account, { current = true } = {}) {
|
|
1245
|
+
const existing = await this.credentials.load().catch((error) => {
|
|
1246
|
+
if (error instanceof UnusableLoginError)
|
|
1247
|
+
return null;
|
|
1248
|
+
throw error;
|
|
1249
|
+
});
|
|
1250
|
+
const loginSecret = existing?.login_secret ?? newLoginSecret();
|
|
1026
1251
|
const verifier = randomBytes(48).toString("base64url");
|
|
1027
1252
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
1028
1253
|
const state = randomBytes(24).toString("base64url");
|
|
@@ -1042,18 +1267,24 @@ var AuthClient = class {
|
|
|
1042
1267
|
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
1043
1268
|
method: "POST",
|
|
1044
1269
|
headers: { "content-type": "application/json" },
|
|
1045
|
-
|
|
1270
|
+
// The secret goes only here, in the POST body, never in the URL.
|
|
1271
|
+
body: JSON.stringify({ code, code_verifier: verifier, login_secret: loginSecret })
|
|
1046
1272
|
});
|
|
1047
1273
|
progress({ status: "completed", step: "key-redemption" });
|
|
1048
1274
|
const login = {
|
|
1049
|
-
version: 1,
|
|
1050
1275
|
account_id: redeemed.account_id,
|
|
1051
1276
|
key_id: redeemed.api_key.id,
|
|
1052
1277
|
key: redeemed.key,
|
|
1053
|
-
expires_at: redeemed.api_key.expires_at
|
|
1278
|
+
expires_at: redeemed.api_key.expires_at,
|
|
1279
|
+
kind: redeemed.account_kind ?? null,
|
|
1280
|
+
name: redeemed.account_name ?? null
|
|
1054
1281
|
};
|
|
1055
1282
|
progress({ status: "started", step: "credential-store" });
|
|
1056
|
-
await this.credentials.
|
|
1283
|
+
const latest = await this.credentials.load().catch(() => existing);
|
|
1284
|
+
await this.credentials.save(withAccountLogin(latest, login, {
|
|
1285
|
+
current,
|
|
1286
|
+
loginSecret: latest?.login_secret ?? loginSecret
|
|
1287
|
+
}));
|
|
1057
1288
|
progress({ status: "completed", step: "credential-store" });
|
|
1058
1289
|
return login;
|
|
1059
1290
|
}
|
|
@@ -1185,10 +1416,12 @@ var ManagementApi = class {
|
|
|
1185
1416
|
fetcher;
|
|
1186
1417
|
apiOrigin;
|
|
1187
1418
|
loginHint;
|
|
1188
|
-
|
|
1419
|
+
pause;
|
|
1420
|
+
constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
|
|
1189
1421
|
this.fetcher = fetcher;
|
|
1190
1422
|
this.apiOrigin = apiOrigin2;
|
|
1191
1423
|
this.loginHint = loginHint;
|
|
1424
|
+
this.pause = pause;
|
|
1192
1425
|
}
|
|
1193
1426
|
/** The answer's `data`. A refusal throws an HttpError naming the request id. */
|
|
1194
1427
|
async call(operationId, key, request = {}) {
|
|
@@ -1356,47 +1589,140 @@ var ManagementApi = class {
|
|
|
1356
1589
|
listMachines(key, page) {
|
|
1357
1590
|
return this.page("listMachines", key, page);
|
|
1358
1591
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
*
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1592
|
+
/**
|
|
1593
|
+
* A replay with the same idempotency key returns the Machine the first
|
|
1594
|
+
* request created, so a transport failure or a 5xx answer is retried here
|
|
1595
|
+
* with the same key, at most `CREATE_ATTEMPTS` times, after the answer's
|
|
1596
|
+
* Retry-After (bounded) or a short backoff. A refusal is final; a create
|
|
1597
|
+
* still unconfirmed after the last attempt names the key to retry with.
|
|
1598
|
+
* The answer carries the Machine's `Location` and, while it is creating,
|
|
1599
|
+
* the `Retry-After` to read it again after (machine-v2.json
|
|
1600
|
+
* `creation.create_status`).
|
|
1601
|
+
*/
|
|
1602
|
+
async createMachine(key, request, idempotencyKey) {
|
|
1603
|
+
let last;
|
|
1604
|
+
for (let attempt = 1; attempt <= CREATE_ATTEMPTS; attempt++) {
|
|
1605
|
+
const headers = {
|
|
1606
|
+
location: null,
|
|
1607
|
+
retryAfter: null
|
|
1608
|
+
};
|
|
1609
|
+
try {
|
|
1610
|
+
const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
|
|
1611
|
+
headers.location = response.headers.get("location");
|
|
1612
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1613
|
+
});
|
|
1614
|
+
return {
|
|
1615
|
+
machine: answer.data,
|
|
1616
|
+
location: headers.location,
|
|
1617
|
+
retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
|
|
1618
|
+
};
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
if (error instanceof HttpError && error.status < 500)
|
|
1621
|
+
throw error;
|
|
1622
|
+
last = error;
|
|
1623
|
+
if (attempt === CREATE_ATTEMPTS)
|
|
1624
|
+
break;
|
|
1625
|
+
const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
|
|
1626
|
+
await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return unconfirmed("Machine creation", idempotencyKey, "the same arguments")(last);
|
|
1630
|
+
}
|
|
1631
|
+
/** The name of the Machine a create's `Location` names, when it is this
|
|
1632
|
+
* client's origin and `getMachine`'s path; null otherwise, so the key is
|
|
1633
|
+
* never sent to another origin and the caller reads the Machine by the
|
|
1634
|
+
* name it created. */
|
|
1635
|
+
machineNameAt(location) {
|
|
1636
|
+
if (!location)
|
|
1637
|
+
return null;
|
|
1638
|
+
let url;
|
|
1639
|
+
try {
|
|
1640
|
+
url = new URL(location, this.apiOrigin);
|
|
1641
|
+
} catch {
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
|
|
1645
|
+
return null;
|
|
1646
|
+
const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
|
|
1647
|
+
if (!url.pathname.startsWith(prefix))
|
|
1648
|
+
return null;
|
|
1649
|
+
const segment = url.pathname.slice(prefix.length);
|
|
1650
|
+
if (!segment || segment.includes("/"))
|
|
1651
|
+
return null;
|
|
1652
|
+
try {
|
|
1653
|
+
return decodeURIComponent(segment);
|
|
1654
|
+
} catch {
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1366
1657
|
}
|
|
1367
1658
|
getMachine(key, name) {
|
|
1368
1659
|
return this.call("getMachine", key, { params: { name } });
|
|
1369
1660
|
}
|
|
1370
|
-
/**
|
|
1371
|
-
*
|
|
1372
|
-
|
|
1661
|
+
/** The Machine with its ETag, which is opaque (machine-v2.json `etag`)
|
|
1662
|
+
* and is sent back as it was read. */
|
|
1663
|
+
async getMachineWithEtag(key, name) {
|
|
1664
|
+
const headers = { etag: null };
|
|
1665
|
+
const answer = await this.request("getMachine", key, { params: { name } }, (response) => {
|
|
1666
|
+
headers.etag = response.headers.get("etag");
|
|
1667
|
+
});
|
|
1668
|
+
return { machine: answer.data, etag: headers.etag };
|
|
1669
|
+
}
|
|
1670
|
+
/** Written only to the Machine and revision `etag` names, the ETag as read
|
|
1671
|
+
* (machine-v2 requires If-Match on update); a stale one is refused with
|
|
1672
|
+
* `precondition_failed`. */
|
|
1673
|
+
updateMachine(key, name, etag, change) {
|
|
1373
1674
|
return this.call("updateMachine", key, {
|
|
1374
1675
|
params: { name },
|
|
1375
1676
|
body: withoutUndefined(change),
|
|
1376
|
-
ifMatch:
|
|
1677
|
+
ifMatch: etag
|
|
1377
1678
|
});
|
|
1378
1679
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1680
|
+
/** With `etag`, only the Machine that ETag names, at that revision, is put
|
|
1681
|
+
* to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
|
|
1682
|
+
* carries the `Retry-After` to read the Machine again after
|
|
1683
|
+
* (machine-v2.json `sleep.poll_after_seconds`); a 200 carries none. */
|
|
1684
|
+
async sleepMachine(key, name, etag) {
|
|
1685
|
+
const headers = { retryAfter: null };
|
|
1686
|
+
const answer = await this.request("sleepMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1687
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1688
|
+
});
|
|
1689
|
+
return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
|
|
1381
1690
|
}
|
|
1382
1691
|
/** The Machine being deleted or the device forgotten, with its kind; the
|
|
1383
1692
|
* Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
|
|
1384
1693
|
* says whether a name was released (`managed`: its create never reached its
|
|
1385
|
-
* region) or a device was forgotten (`byoc`: its region held no row).
|
|
1386
|
-
|
|
1694
|
+
* region) or a device was forgotten (`byoc`: its region held no row). With
|
|
1695
|
+
* `etag`, only the Machine that ETag names is deleted
|
|
1696
|
+
* (machine-v2.json `etag.if_match.delete` is optional). */
|
|
1697
|
+
async deleteMachine(key, name, etag) {
|
|
1387
1698
|
const headers = { kind: null };
|
|
1388
|
-
const answer = await this.request("deleteMachine", key, { params: { name } }, (response) => {
|
|
1699
|
+
const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1389
1700
|
headers.kind = response.headers.get("x-tonbo-machine-kind");
|
|
1390
1701
|
});
|
|
1391
1702
|
if (answer?.data)
|
|
1392
1703
|
return { machine: answer.data, kind: answer.data.kind };
|
|
1393
1704
|
return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
|
|
1394
1705
|
}
|
|
1706
|
+
/** One page of a managed Machine's activations, newest first; the cursor
|
|
1707
|
+
* pages that Machine only. */
|
|
1708
|
+
listMachineActivations(key, name, { limit, cursor } = {}) {
|
|
1709
|
+
return this.request("listMachineActivations", key, {
|
|
1710
|
+
params: { name },
|
|
1711
|
+
query: { limit, cursor }
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1395
1714
|
/** The whole answer: the shapes and the default one. */
|
|
1396
1715
|
listMachineShapes(key) {
|
|
1397
1716
|
return this.request("listMachineShapes", key, {});
|
|
1398
1717
|
}
|
|
1399
1718
|
};
|
|
1719
|
+
var CREATE_ATTEMPTS = 3;
|
|
1720
|
+
var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
|
|
1721
|
+
function retryAfterSeconds(value) {
|
|
1722
|
+
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
1723
|
+
return null;
|
|
1724
|
+
return Number(value.trim());
|
|
1725
|
+
}
|
|
1400
1726
|
function withoutUndefined(value) {
|
|
1401
1727
|
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
1402
1728
|
}
|
|
@@ -1449,8 +1775,9 @@ function when(timestamp, absent = "never") {
|
|
|
1449
1775
|
// ../../packages/tonbo-cli-core/dist/src/login.js
|
|
1450
1776
|
async function loginCommand(deps, options = {}) {
|
|
1451
1777
|
try {
|
|
1452
|
-
const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account);
|
|
1453
|
-
deps.output(`Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
|
|
1778
|
+
const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current });
|
|
1779
|
+
deps.output(`Logged in to account ${login.name ?? login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
|
|
1780
|
+
return login;
|
|
1454
1781
|
} catch (error) {
|
|
1455
1782
|
deps.progress.fail();
|
|
1456
1783
|
throw error;
|
|
@@ -1514,17 +1841,23 @@ async function logoutCommand(deps) {
|
|
|
1514
1841
|
deps.output("Not logged in.");
|
|
1515
1842
|
return;
|
|
1516
1843
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1844
|
+
const failures = [];
|
|
1845
|
+
let first;
|
|
1846
|
+
for (const entry of accountLogins(login)) {
|
|
1847
|
+
try {
|
|
1848
|
+
await deps.api.revokeApiKey(entry.key, entry.key_id);
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
if (error instanceof HttpError && (error.status === 401 || error.status === 404))
|
|
1851
|
+
continue;
|
|
1852
|
+
const failure = error instanceof Error ? error : new Error("The revocation failed.");
|
|
1853
|
+
first ??= failure;
|
|
1854
|
+
failures.push(`${entry.key_id}: ${failure.message}`);
|
|
1855
|
+
}
|
|
1523
1856
|
}
|
|
1524
1857
|
await deps.credentials.remove();
|
|
1525
|
-
if (
|
|
1526
|
-
throw new Error(`Removed the saved login, but could not revoke its key ${
|
|
1527
|
-
deps.output("Logged out. The CLI key is revoked.");
|
|
1858
|
+
if (failures.length)
|
|
1859
|
+
throw new Error(`Removed the saved login, but could not revoke ${failures.length === 1 ? "its key" : "these keys"} ${failures.join("; ")} Revoke ${failures.length === 1 ? "it" : "them"} under API keys in the Tonbo console.`, { cause: first });
|
|
1860
|
+
deps.output(login.accounts && Object.keys(login.accounts).length > 1 ? "Logged out. Every account's CLI key on this computer is revoked." : "Logged out. The CLI key is revoked.");
|
|
1528
1861
|
}
|
|
1529
1862
|
|
|
1530
1863
|
// ../../packages/tonbo-cli-core/dist/src/origin.js
|
|
@@ -2330,16 +2663,19 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
2330
2663
|
// src/machine.ts
|
|
2331
2664
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2332
2665
|
var MACHINE_ERROR_HINTS = {
|
|
2333
|
-
|
|
2666
|
+
region_not_offered: "Machines are not offered in that region yet; nothing was created or changed. Name a region where they are offered.",
|
|
2667
|
+
region_unavailable: "That region cannot be reached right now; nothing was created or changed. Try again shortly.",
|
|
2334
2668
|
name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
|
|
2335
2669
|
hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
|
|
2336
2670
|
hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
|
|
2337
2671
|
account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
|
|
2338
2672
|
image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
|
|
2339
2673
|
image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
|
|
2340
|
-
|
|
2674
|
+
shape_not_offered: "`tonbo machine shapes` lists the shapes a Machine may name now.",
|
|
2341
2675
|
port_reserved: "Serve the application on another port.",
|
|
2342
|
-
|
|
2676
|
+
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.",
|
|
2677
|
+
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.",
|
|
2678
|
+
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.",
|
|
2343
2679
|
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
2344
2680
|
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
|
|
2345
2681
|
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
@@ -2354,6 +2690,28 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
|
2354
2690
|
image_too_large: "Make the image smaller (fewer or smaller layers)",
|
|
2355
2691
|
image_unsupported: "Rebuild the image without the entry the message names"
|
|
2356
2692
|
};
|
|
2693
|
+
var MACHINE_CREATION_FAILURE_HINTS = {
|
|
2694
|
+
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.",
|
|
2695
|
+
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.",
|
|
2696
|
+
payment_required: "The account's credit is exhausted: add credit to the account, then delete the Machine and create it again.",
|
|
2697
|
+
...Object.fromEntries(
|
|
2698
|
+
Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
|
|
2699
|
+
code,
|
|
2700
|
+
`${hint}, then delete the Machine and create it again naming the fixed image.`
|
|
2701
|
+
])
|
|
2702
|
+
)
|
|
2703
|
+
};
|
|
2704
|
+
function creationFailureText(machine) {
|
|
2705
|
+
const { failure } = machine;
|
|
2706
|
+
if (!failure) return { reason: "", hint: "" };
|
|
2707
|
+
const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code];
|
|
2708
|
+
return { reason: ` (${failure.code}: ${failure.message})`, hint: hint ? `
|
|
2709
|
+
${hint}` : "" };
|
|
2710
|
+
}
|
|
2711
|
+
function ifMatchHeader(value) {
|
|
2712
|
+
if (value === void 0) return void 0;
|
|
2713
|
+
return value.startsWith('"') || value.startsWith('W/"') ? value : `"${value}"`;
|
|
2714
|
+
}
|
|
2357
2715
|
function explainMachineError(error) {
|
|
2358
2716
|
if (!(error instanceof HttpError) || error.code === null) return error;
|
|
2359
2717
|
const hint = MACHINE_ERROR_HINTS[error.code];
|
|
@@ -2374,20 +2732,98 @@ function portText(port) {
|
|
|
2374
2732
|
function machinePortsText(machine) {
|
|
2375
2733
|
return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
|
|
2376
2734
|
}
|
|
2377
|
-
|
|
2735
|
+
var { earlyReclaimSeconds, pollAfterSeconds, sleepPollAfterSeconds, wakeHoldSeconds } = machineTimings;
|
|
2736
|
+
function machineState(machine) {
|
|
2737
|
+
if (machine.failure)
|
|
2738
|
+
return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
|
|
2739
|
+
if (machine.state === "creating")
|
|
2740
|
+
return `creating: not ready for requests yet; read it again in ${pollAfterSeconds} s, or create with --wait`;
|
|
2741
|
+
return machine.state;
|
|
2742
|
+
}
|
|
2743
|
+
function powerText(machine) {
|
|
2744
|
+
const { power } = machine.status;
|
|
2745
|
+
if (machine.state !== "active") return power;
|
|
2746
|
+
if (power === "asleep")
|
|
2747
|
+
return `asleep: the first request boots it, and the edge holds that request for up to ${wakeHoldSeconds} s`;
|
|
2748
|
+
if (power === "awake") return "awake: serving requests";
|
|
2749
|
+
return power;
|
|
2750
|
+
}
|
|
2751
|
+
function duration(seconds) {
|
|
2752
|
+
return seconds % 60 === 0 && seconds >= 120 ? `${seconds / 60} min` : `${seconds} s`;
|
|
2753
|
+
}
|
|
2754
|
+
function sleepText(machine) {
|
|
2378
2755
|
const { action, reason } = machine.status.sleep;
|
|
2379
|
-
|
|
2380
|
-
return `${
|
|
2756
|
+
const when2 = `after about ${duration(earlyReclaimSeconds)} idle (early) or at most ${machine.sleep_after_seconds} s without a connection`;
|
|
2757
|
+
if (action !== "release") return `${when2}; it then ${action}s`;
|
|
2758
|
+
return `${when2}; the disk resets at every sleep${reason === "suspend_unavailable" ? " until suspend ships" : ""}`;
|
|
2759
|
+
}
|
|
2760
|
+
function activationFields(machine) {
|
|
2761
|
+
const { activation } = machine;
|
|
2762
|
+
if (!activation) return [];
|
|
2763
|
+
const fields2 = [
|
|
2764
|
+
[
|
|
2765
|
+
"Activation",
|
|
2766
|
+
`${activation.state} since ${activation.started_at} (wake ${activation.wake_gen}, from ${activation.source})`
|
|
2767
|
+
]
|
|
2768
|
+
];
|
|
2769
|
+
if (typeof activation.revision === "number" && activation.revision !== machine.revision)
|
|
2770
|
+
fields2.push([
|
|
2771
|
+
"Pending",
|
|
2772
|
+
`Spec ${machine.revision}, admitted at ${activation.revision}: ports and sleep-after already apply; if the image, command, shape or env changed, run \`tonbo machine sleep ${machine.name}\` to apply it`
|
|
2773
|
+
]);
|
|
2774
|
+
return fields2;
|
|
2775
|
+
}
|
|
2776
|
+
var MACHINE_WAKE_BLOCKED_HINTS = {
|
|
2777
|
+
payment_required: "The account's credit does not cover a wake: add credit, and the next request wakes it.",
|
|
2778
|
+
standing: "The account's Streams standing is not solvent: settle the account, and the next request wakes it.",
|
|
2779
|
+
meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
|
|
2780
|
+
crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
|
|
2781
|
+
capacity: "No host had room for its shape; the next request tries again.",
|
|
2782
|
+
region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
|
|
2783
|
+
};
|
|
2784
|
+
function conditionText(name, condition) {
|
|
2785
|
+
switch (condition.type) {
|
|
2786
|
+
case "wake_blocked": {
|
|
2787
|
+
const retry = condition.retry_at ? `, retry after ${condition.retry_at}` : "";
|
|
2788
|
+
const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name);
|
|
2789
|
+
return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}${retry}.${hint ? ` ${hint}` : ""}`;
|
|
2790
|
+
}
|
|
2791
|
+
case "creation_delayed":
|
|
2792
|
+
return `still creating since ${condition.since}, longer than usual; Tonbo's operators are alerted, and creation goes on until it is active or failed.`;
|
|
2793
|
+
case "node_unreachable":
|
|
2794
|
+
return `its host is unreachable since ${condition.since}`;
|
|
2795
|
+
case "egress_blocked":
|
|
2796
|
+
return `outbound access blocked for the ${condition.scope} since ${condition.since}${condition.until ? ` until ${condition.until}` : ""}`;
|
|
2797
|
+
default:
|
|
2798
|
+
return JSON.stringify(condition);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
function conditionFields(machine) {
|
|
2802
|
+
return (machine.status.conditions ?? []).map((condition) => [
|
|
2803
|
+
"Condition",
|
|
2804
|
+
conditionText(machine.name, condition)
|
|
2805
|
+
]);
|
|
2381
2806
|
}
|
|
2382
|
-
function
|
|
2383
|
-
|
|
2807
|
+
function endText(end) {
|
|
2808
|
+
const status = end.exit_code !== null ? `, exit code ${end.exit_code}` : end.signal !== null ? `, signal ${end.signal}` : "";
|
|
2809
|
+
return `${end.state} (${end.reason.replaceAll("_", " ")}${status}) at ${end.at}`;
|
|
2810
|
+
}
|
|
2811
|
+
var DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees#sleep";
|
|
2812
|
+
var PRIVATE_PORTS_LINK = "https://tonbo.dev/docs/machines/requests#private-ports";
|
|
2813
|
+
function privatePortLines(machine) {
|
|
2814
|
+
const port = machine.ports.find((entry) => entry.default);
|
|
2815
|
+
if (!port || port.access !== "private") return [];
|
|
2816
|
+
return [
|
|
2817
|
+
`Port ${port.port} is private: call it with a personal key from \`tonbo api-key create\` (see ${PRIVATE_PORTS_LINK}):`,
|
|
2818
|
+
` curl -H "Authorization: Bearer $TONBO_KEY" ${machine.hostnames.https}/`,
|
|
2819
|
+
"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."
|
|
2820
|
+
];
|
|
2384
2821
|
}
|
|
2385
2822
|
function imageText(machine) {
|
|
2386
2823
|
const { reference, repository, digest, state } = machine.image;
|
|
2387
2824
|
if (digest === null) return `${reference} (platform image)`;
|
|
2388
2825
|
const pulled = repository === null ? digest : `${repository}@${digest}`;
|
|
2389
|
-
|
|
2390
|
-
return `${reference} (${pulled}, ${pulling ? "pulling" : state})`;
|
|
2826
|
+
return `${reference} (${pulled}, ${state})`;
|
|
2391
2827
|
}
|
|
2392
2828
|
function imageNextStep(machine, hint) {
|
|
2393
2829
|
if (machine.state === "deleting") return null;
|
|
@@ -2405,30 +2841,41 @@ function imageFailureFields(machine) {
|
|
|
2405
2841
|
...next ? [["Next step", next]] : []
|
|
2406
2842
|
];
|
|
2407
2843
|
}
|
|
2408
|
-
function machineFields(machine) {
|
|
2844
|
+
function machineFields(machine, etag = null, lastEnd = null) {
|
|
2409
2845
|
return fields([
|
|
2410
2846
|
["Name", machine.name],
|
|
2411
2847
|
["ID", machine.id],
|
|
2412
2848
|
["Region", machine.region],
|
|
2413
2849
|
["State", machineState(machine)],
|
|
2414
|
-
["Power", machine
|
|
2850
|
+
["Power", powerText(machine)],
|
|
2851
|
+
...activationFields(machine),
|
|
2852
|
+
...lastEnd ? [["Last end", endText(lastEnd)]] : [],
|
|
2853
|
+
...conditionFields(machine),
|
|
2854
|
+
["Sleeps", sleepText(machine)],
|
|
2415
2855
|
["Shape", machine.shape],
|
|
2416
2856
|
["Image", imageText(machine)],
|
|
2417
2857
|
...imageFailureFields(machine),
|
|
2418
2858
|
["Command", machine.command ? machine.command.join(" ") : "the image's own"],
|
|
2419
2859
|
["Ports", machinePortsText(machine)],
|
|
2420
2860
|
["HTTPS", machine.hostnames.https],
|
|
2421
|
-
|
|
2861
|
+
// The resource keeps the SSH destination as a receipt; SSH is not served
|
|
2862
|
+
// before tier P3.
|
|
2863
|
+
["SSH", `${machine.hostnames.ssh} (not served yet)`],
|
|
2422
2864
|
["Env", Object.keys(machine.env).sort().join(", ") || "none"],
|
|
2423
|
-
["
|
|
2424
|
-
["
|
|
2425
|
-
["
|
|
2426
|
-
["
|
|
2865
|
+
["Stop grace", `${machine.stop_grace_seconds} s`],
|
|
2866
|
+
["Database", `${machine.database.name} (${machine.database.url})`],
|
|
2867
|
+
["Logs", machine.logs.url],
|
|
2868
|
+
["Durability", DURABILITY_LINK],
|
|
2427
2869
|
["Revision", String(machine.revision)],
|
|
2870
|
+
...etag ? [["ETag", etag]] : [],
|
|
2871
|
+
...machine.created_by ? [["Created by", machine.created_by]] : [],
|
|
2428
2872
|
["Created", machine.created_at],
|
|
2429
2873
|
["Updated", machine.updated_at]
|
|
2430
2874
|
]);
|
|
2431
2875
|
}
|
|
2876
|
+
function machineText(machine, etag = null, lastEnd = null) {
|
|
2877
|
+
return [machineFields(machine, etag, lastEnd), ...privatePortLines(machine)].join("\n");
|
|
2878
|
+
}
|
|
2432
2879
|
function keyStateText(machine) {
|
|
2433
2880
|
const state = machine.byoc.key_state;
|
|
2434
2881
|
if (state === "active") return state;
|
|
@@ -2452,10 +2899,10 @@ function deviceFields(machine) {
|
|
|
2452
2899
|
["Updated", machine.updated_at]
|
|
2453
2900
|
]);
|
|
2454
2901
|
}
|
|
2455
|
-
function
|
|
2456
|
-
return machine.kind === "byoc" ? deviceFields(machine) :
|
|
2902
|
+
function anyMachineText(machine, etag = null, lastEnd = null) {
|
|
2903
|
+
return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag, lastEnd);
|
|
2457
2904
|
}
|
|
2458
|
-
function parsePorts(specs
|
|
2905
|
+
function parsePorts(specs) {
|
|
2459
2906
|
const accesses = machinePortAccess;
|
|
2460
2907
|
return specs.map((spec) => {
|
|
2461
2908
|
const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
|
|
@@ -2464,14 +2911,7 @@ function parsePorts(specs, current) {
|
|
|
2464
2911
|
throw new Error(
|
|
2465
2912
|
`--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
|
|
2466
2913
|
);
|
|
2467
|
-
|
|
2468
|
-
const before = current.find((entry) => entry.port === port && entry.access === access);
|
|
2469
|
-
return {
|
|
2470
|
-
port,
|
|
2471
|
-
access,
|
|
2472
|
-
default: false,
|
|
2473
|
-
...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
|
|
2474
|
-
};
|
|
2914
|
+
return { port: Number(match[1]), access, default: false };
|
|
2475
2915
|
});
|
|
2476
2916
|
}
|
|
2477
2917
|
function withDefault(ports, defaultPort) {
|
|
@@ -2493,6 +2933,42 @@ function parseEnv(pairs) {
|
|
|
2493
2933
|
}
|
|
2494
2934
|
return env;
|
|
2495
2935
|
}
|
|
2936
|
+
var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
|
|
2937
|
+
function specWarnings(deps, ports, envKeys) {
|
|
2938
|
+
if (ports.length === 0)
|
|
2939
|
+
deps.notice(
|
|
2940
|
+
"This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
|
|
2941
|
+
);
|
|
2942
|
+
const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
|
|
2943
|
+
if (secret.length)
|
|
2944
|
+
deps.notice(
|
|
2945
|
+
`${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; Machine secrets come later.`
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
function waitTimeout(options) {
|
|
2949
|
+
if (options.timeout !== void 0 && !options.wait) throw new Error("--timeout needs --wait.");
|
|
2950
|
+
return options.timeout ?? DEFAULT_WAIT_SECONDS;
|
|
2951
|
+
}
|
|
2952
|
+
var DEFAULT_WAIT_SECONDS = 300;
|
|
2953
|
+
async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
|
|
2954
|
+
const now = deps.now ?? Date.now;
|
|
2955
|
+
const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
2956
|
+
const start = now();
|
|
2957
|
+
let wait = firstWait;
|
|
2958
|
+
let last = null;
|
|
2959
|
+
for (; ; ) {
|
|
2960
|
+
const elapsed = (now() - start) / 1e3;
|
|
2961
|
+
if (elapsed + wait > timeout) return { machine: null, last, waited: Math.round(elapsed) };
|
|
2962
|
+
await pause(wait * 1e3);
|
|
2963
|
+
const machine = await deps.api.getMachine(key, name);
|
|
2964
|
+
if (machine.kind === "byoc")
|
|
2965
|
+
throw new Error(`${name} is one of your computers, not a Machine.`);
|
|
2966
|
+
if (settled(machine))
|
|
2967
|
+
return { machine, last: machine, waited: Math.round((now() - start) / 1e3) };
|
|
2968
|
+
last = machine;
|
|
2969
|
+
wait = every;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2496
2972
|
async function machineListCommand(deps, options = {}) {
|
|
2497
2973
|
await machineCommand(async () => {
|
|
2498
2974
|
const page = await deps.api.listMachines(await deps.auth.apiKey(), {
|
|
@@ -2506,7 +2982,7 @@ async function machineListCommand(deps, options = {}) {
|
|
|
2506
2982
|
if (managed.length)
|
|
2507
2983
|
tables.push(
|
|
2508
2984
|
table(
|
|
2509
|
-
["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
|
|
2985
|
+
["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS", "CONDITIONS"],
|
|
2510
2986
|
managed.map((machine) => [
|
|
2511
2987
|
machine.name,
|
|
2512
2988
|
machine.region,
|
|
@@ -2514,7 +2990,8 @@ async function machineListCommand(deps, options = {}) {
|
|
|
2514
2990
|
machine.status.power,
|
|
2515
2991
|
machine.shape,
|
|
2516
2992
|
machine.image.reference,
|
|
2517
|
-
machine.hostnames.https
|
|
2993
|
+
machine.hostnames.https,
|
|
2994
|
+
conditionsCell(machine)
|
|
2518
2995
|
])
|
|
2519
2996
|
)
|
|
2520
2997
|
);
|
|
@@ -2540,49 +3017,194 @@ async function machineListCommand(deps, options = {}) {
|
|
|
2540
3017
|
);
|
|
2541
3018
|
});
|
|
2542
3019
|
}
|
|
3020
|
+
function conditionsCell(machine) {
|
|
3021
|
+
const conditions = machine.status.conditions ?? [];
|
|
3022
|
+
if (!conditions.length) return "-";
|
|
3023
|
+
return conditions.map(
|
|
3024
|
+
(condition) => condition.type === "wake_blocked" ? `wake_blocked:${condition.reason}` : condition.type
|
|
3025
|
+
).join(",");
|
|
3026
|
+
}
|
|
2543
3027
|
function createdText(machine) {
|
|
2544
3028
|
if (machine.state === "creating")
|
|
2545
|
-
return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`;
|
|
3029
|
+
return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready, or create with --wait.`;
|
|
2546
3030
|
return `Created Machine ${machine.name}.`;
|
|
2547
3031
|
}
|
|
3032
|
+
function ago(deps, timestamp) {
|
|
3033
|
+
const seconds = Math.max(
|
|
3034
|
+
0,
|
|
3035
|
+
Math.round(((deps.now ?? Date.now)() - Date.parse(timestamp)) / 1e3)
|
|
3036
|
+
);
|
|
3037
|
+
if (seconds < 120) return `${seconds} s`;
|
|
3038
|
+
if (seconds < 7200) return `${Math.round(seconds / 60)} min`;
|
|
3039
|
+
if (seconds < 172800) return `${Math.round(seconds / 3600)} h`;
|
|
3040
|
+
return `${Math.round(seconds / 86400)} days`;
|
|
3041
|
+
}
|
|
3042
|
+
async function nameTaken(deps, key, name, error) {
|
|
3043
|
+
let holder;
|
|
3044
|
+
try {
|
|
3045
|
+
holder = await deps.api.getMachine(key, name);
|
|
3046
|
+
} catch {
|
|
3047
|
+
return error;
|
|
3048
|
+
}
|
|
3049
|
+
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}\`.`;
|
|
3050
|
+
return new HttpError(`${error.message}
|
|
3051
|
+
${what}`, error.status, error.body, error.requestId);
|
|
3052
|
+
}
|
|
3053
|
+
async function waitForCreate(deps, key, machine, name, firstWait, timeout, json) {
|
|
3054
|
+
const {
|
|
3055
|
+
machine: settled,
|
|
3056
|
+
last,
|
|
3057
|
+
waited
|
|
3058
|
+
} = await waitForMachine(
|
|
3059
|
+
deps,
|
|
3060
|
+
key,
|
|
3061
|
+
name,
|
|
3062
|
+
firstWait,
|
|
3063
|
+
pollAfterSeconds,
|
|
3064
|
+
timeout,
|
|
3065
|
+
(read) => read.state !== "creating"
|
|
3066
|
+
);
|
|
3067
|
+
if (!settled) {
|
|
3068
|
+
const known = last ?? machine;
|
|
3069
|
+
print(deps, json, known, machineFields(known));
|
|
3070
|
+
throw new Error(
|
|
3071
|
+
`Machine ${machine.name} is still creating after ${waited} s; nothing was undone. \`tonbo machine show ${machine.name}\` reports it active when it is ready.`
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
if (settled.state === "active") {
|
|
3075
|
+
const pulled = settled.image.digest === null ? "" : `, image ${settled.image.state}`;
|
|
3076
|
+
print(
|
|
3077
|
+
deps,
|
|
3078
|
+
json,
|
|
3079
|
+
settled,
|
|
3080
|
+
[
|
|
3081
|
+
`Created ${settled.name} in ${settled.region} (active after ${waited} s${pulled}).`,
|
|
3082
|
+
machineText(settled)
|
|
3083
|
+
].join("\n")
|
|
3084
|
+
);
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
print(deps, json, settled, machineFields(settled));
|
|
3088
|
+
if (settled.state === "failed") {
|
|
3089
|
+
const { reason, hint } = creationFailureText(settled);
|
|
3090
|
+
throw new Error(
|
|
3091
|
+
`Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
|
|
3092
|
+
);
|
|
3093
|
+
}
|
|
3094
|
+
throw new Error(`Machine ${settled.name} is ${settled.state}; it was not created.`);
|
|
3095
|
+
}
|
|
2548
3096
|
async function machineCreateCommand(deps, name, command, options = {}) {
|
|
2549
3097
|
await machineCommand(async () => {
|
|
2550
3098
|
if (!options.image) throw new Error("Pass --image <reference>.");
|
|
3099
|
+
const timeout = waitTimeout(options);
|
|
2551
3100
|
const env = parseEnv(options.env ?? []);
|
|
2552
|
-
const ports = withDefault(parsePorts(options.port ?? []
|
|
3101
|
+
const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
|
|
2553
3102
|
const key = await deps.auth.apiKey();
|
|
2554
3103
|
const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
3104
|
+
let created;
|
|
3105
|
+
try {
|
|
3106
|
+
created = await deps.api.createMachine(
|
|
3107
|
+
key,
|
|
3108
|
+
{
|
|
3109
|
+
name,
|
|
3110
|
+
image: options.image,
|
|
3111
|
+
shape,
|
|
3112
|
+
region: options.region,
|
|
3113
|
+
command: command.length ? command : void 0,
|
|
3114
|
+
ports: ports.length ? ports : void 0,
|
|
3115
|
+
env: Object.keys(env).length ? env : void 0,
|
|
3116
|
+
sleep_after_seconds: options.sleepAfter,
|
|
3117
|
+
idle_action: options.idleAction,
|
|
3118
|
+
stop_grace_seconds: options.stopGrace
|
|
3119
|
+
},
|
|
3120
|
+
options.idempotencyKey ?? randomUUID3()
|
|
3121
|
+
);
|
|
3122
|
+
} catch (error) {
|
|
3123
|
+
if (error instanceof HttpError && error.code === "name_taken")
|
|
3124
|
+
throw await nameTaken(deps, key, name, error);
|
|
3125
|
+
throw error;
|
|
3126
|
+
}
|
|
3127
|
+
const { machine } = created;
|
|
3128
|
+
specWarnings(deps, machine.ports, Object.keys(env));
|
|
2571
3129
|
if (machine.state === "failed") {
|
|
2572
3130
|
print(deps, options.json, machine, machineFields(machine));
|
|
3131
|
+
const { reason, hint } = creationFailureText(machine);
|
|
2573
3132
|
throw new Error(
|
|
2574
|
-
`Machine ${machine.name} failed to be created; delete it with \`tonbo machine delete ${machine.name}\` and create it again
|
|
3133
|
+
`Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
|
|
2575
3134
|
);
|
|
2576
3135
|
}
|
|
2577
|
-
|
|
3136
|
+
if (options.wait && machine.state === "creating")
|
|
3137
|
+
return waitForCreate(
|
|
3138
|
+
deps,
|
|
3139
|
+
key,
|
|
3140
|
+
machine,
|
|
3141
|
+
deps.api.machineNameAt(created.location) ?? machine.name,
|
|
3142
|
+
created.retryAfterSeconds ?? pollAfterSeconds,
|
|
3143
|
+
timeout,
|
|
3144
|
+
options.json
|
|
3145
|
+
);
|
|
3146
|
+
print(deps, options.json, machine, [createdText(machine), machineText(machine)].join("\n"));
|
|
2578
3147
|
});
|
|
2579
3148
|
}
|
|
2580
3149
|
async function machineShowCommand(deps, name, options = {}) {
|
|
2581
3150
|
await machineCommand(async () => {
|
|
2582
|
-
const
|
|
2583
|
-
|
|
3151
|
+
const key = await deps.auth.apiKey();
|
|
3152
|
+
const { machine, etag } = await deps.api.getMachineWithEtag(key, name);
|
|
3153
|
+
const lastEnd = options.json || machine.kind === "byoc" || machine.state !== "active" ? null : await lastActivationEnd(deps, key, machine.name);
|
|
3154
|
+
print(deps, options.json, machine, anyMachineText(machine, etag, lastEnd));
|
|
2584
3155
|
});
|
|
2585
3156
|
}
|
|
3157
|
+
async function lastActivationEnd(deps, key, name) {
|
|
3158
|
+
try {
|
|
3159
|
+
const page = await deps.api.listMachineActivations(key, name, { limit: 2 });
|
|
3160
|
+
return page.data.find((entry) => entry.end !== null)?.end ?? null;
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
if (!(error instanceof HttpError)) throw error;
|
|
3163
|
+
deps.notice(
|
|
3164
|
+
`Could not read how its last activation ended (${error.status}); \`tonbo machine activations ${name}\` lists them.`
|
|
3165
|
+
);
|
|
3166
|
+
return null;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
async function machineActivationsCommand(deps, name, options = {}) {
|
|
3170
|
+
await machineCommand(async () => {
|
|
3171
|
+
const page = await deps.api.listMachineActivations(await deps.auth.apiKey(), name, {
|
|
3172
|
+
limit: options.limit,
|
|
3173
|
+
cursor: options.cursor
|
|
3174
|
+
});
|
|
3175
|
+
printPage(
|
|
3176
|
+
deps,
|
|
3177
|
+
options.json,
|
|
3178
|
+
page,
|
|
3179
|
+
(activations) => activations.length ? table(
|
|
3180
|
+
["WAKE", "SOURCE", "REVISION", "SHAPE", "STARTED", "END"],
|
|
3181
|
+
activations.map((entry) => [
|
|
3182
|
+
String(entry.wake_gen),
|
|
3183
|
+
entry.source,
|
|
3184
|
+
String(entry.revision),
|
|
3185
|
+
entry.shape,
|
|
3186
|
+
entry.started_at,
|
|
3187
|
+
entry.end ? endText(entry.end) : "live"
|
|
3188
|
+
])
|
|
3189
|
+
) : `Machine ${name} has not been woken yet.`
|
|
3190
|
+
);
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
function appliesText(name, change) {
|
|
3194
|
+
const lines = [];
|
|
3195
|
+
if (change.ports !== void 0 || change.sleep_after_seconds !== void 0)
|
|
3196
|
+
lines.push("Ports and sleep-after apply now.");
|
|
3197
|
+
const later = [change.image, change.command, change.shape, change.env].some(
|
|
3198
|
+
(field) => field !== void 0
|
|
3199
|
+
);
|
|
3200
|
+
if (later) {
|
|
3201
|
+
lines.push(
|
|
3202
|
+
`The change applies at the next activation. To apply it now, run \`tonbo machine sleep ${name}\`; the next request boots the new spec.`
|
|
3203
|
+
);
|
|
3204
|
+
if (change.image !== void 0) lines.push("Naming the same tag again re-resolves its digest.");
|
|
3205
|
+
}
|
|
3206
|
+
return lines;
|
|
3207
|
+
}
|
|
2586
3208
|
async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
2587
3209
|
await machineCommand(async () => {
|
|
2588
3210
|
if (command.length && options.imageCommand)
|
|
@@ -2590,12 +3212,14 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
|
2590
3212
|
if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
|
|
2591
3213
|
throw new Error("Give --port or --clear-ports, not both.");
|
|
2592
3214
|
const key = await deps.auth.apiKey();
|
|
2593
|
-
const read = await deps.api.
|
|
3215
|
+
const { machine: read, etag: readTag } = await deps.api.getMachineWithEtag(key, name);
|
|
2594
3216
|
if (read.kind === "byoc")
|
|
2595
3217
|
throw new Error(
|
|
2596
3218
|
`${name} is one of your computers; only \`tonbo machine show\` and \`tonbo machine delete\` serve it.`
|
|
2597
3219
|
);
|
|
2598
3220
|
const current = read;
|
|
3221
|
+
const etag = ifMatchHeader(options.ifMatch) ?? readTag;
|
|
3222
|
+
if (etag === null) throw new Error(`The API answered ${name} without an ETag.`);
|
|
2599
3223
|
const change = {
|
|
2600
3224
|
image: options.image,
|
|
2601
3225
|
shape: options.shape,
|
|
@@ -2606,59 +3230,95 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
|
2606
3230
|
};
|
|
2607
3231
|
if (options.clearPorts) change.ports = [];
|
|
2608
3232
|
else if (options.port?.length) {
|
|
2609
|
-
const ports = parsePorts(options.port
|
|
3233
|
+
const ports = parsePorts(options.port);
|
|
2610
3234
|
const kept = current.ports.find((entry) => entry.default)?.port;
|
|
2611
3235
|
const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
|
|
2612
3236
|
change.ports = withDefault(ports, defaultPort);
|
|
2613
3237
|
} else if (options.defaultPort !== void 0)
|
|
2614
3238
|
change.ports = withDefault(current.ports, options.defaultPort);
|
|
3239
|
+
const setEnv = parseEnv(options.env ?? []);
|
|
2615
3240
|
if (options.env?.length || options.unsetEnv?.length) {
|
|
2616
|
-
const env = { ...current.env, ...
|
|
3241
|
+
const env = { ...current.env, ...setEnv };
|
|
2617
3242
|
for (const variable of options.unsetEnv ?? []) delete env[variable];
|
|
2618
3243
|
change.env = env;
|
|
2619
3244
|
}
|
|
2620
3245
|
if (options.discardSnapshot) change.discard_snapshot = true;
|
|
2621
3246
|
if (Object.values(change).every((value) => value === void 0))
|
|
2622
3247
|
throw new Error("Nothing to change: pass at least one option or a command.");
|
|
2623
|
-
const machine = await deps.api.updateMachine(
|
|
2624
|
-
|
|
2625
|
-
name,
|
|
2626
|
-
options.revision ?? current.revision,
|
|
2627
|
-
change
|
|
2628
|
-
);
|
|
3248
|
+
const machine = await deps.api.updateMachine(key, name, etag, change);
|
|
3249
|
+
specWarnings(deps, machine.ports, Object.keys(setEnv));
|
|
2629
3250
|
print(
|
|
2630
3251
|
deps,
|
|
2631
3252
|
options.json,
|
|
2632
3253
|
machine,
|
|
2633
3254
|
[
|
|
2634
|
-
`Updated Machine ${machine.name} to revision ${machine.revision}
|
|
2635
|
-
|
|
3255
|
+
`Updated Machine ${machine.name} to revision ${machine.revision}.`,
|
|
3256
|
+
...appliesText(machine.name, change),
|
|
3257
|
+
machineText(machine)
|
|
2636
3258
|
].join("\n")
|
|
2637
3259
|
);
|
|
2638
3260
|
});
|
|
2639
3261
|
}
|
|
3262
|
+
function sleptText(machine) {
|
|
3263
|
+
if (machine.state === "creating")
|
|
3264
|
+
return `Machine ${machine.name} is still creating; nothing to put to sleep.`;
|
|
3265
|
+
if (machine.status.power === "asleep")
|
|
3266
|
+
return `Machine ${machine.name} is already asleep; nothing to do. The next request boots it.`;
|
|
3267
|
+
return `Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); the next request boots it again, on its latest spec.`;
|
|
3268
|
+
}
|
|
2640
3269
|
async function machineSleepCommand(deps, name, options = {}) {
|
|
2641
3270
|
await machineCommand(async () => {
|
|
2642
|
-
const
|
|
3271
|
+
const timeout = waitTimeout(options);
|
|
3272
|
+
const key = await deps.auth.apiKey();
|
|
3273
|
+
const { machine, retryAfterSeconds: retryAfterSeconds2 } = await deps.api.sleepMachine(
|
|
3274
|
+
key,
|
|
3275
|
+
name,
|
|
3276
|
+
ifMatchHeader(options.ifMatch)
|
|
3277
|
+
);
|
|
3278
|
+
const going = options.wait && machine.state === "active" && machine.status.power !== "asleep";
|
|
3279
|
+
if (!going) {
|
|
3280
|
+
print(deps, options.json, machine, sleptText(machine));
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
const { machine: asleep, waited } = await waitForMachine(
|
|
3284
|
+
deps,
|
|
3285
|
+
key,
|
|
3286
|
+
name,
|
|
3287
|
+
retryAfterSeconds2 ?? sleepPollAfterSeconds,
|
|
3288
|
+
sleepPollAfterSeconds,
|
|
3289
|
+
timeout,
|
|
3290
|
+
(read) => read.status.power === "asleep" || read.state !== "active"
|
|
3291
|
+
);
|
|
3292
|
+
if (!asleep)
|
|
3293
|
+
throw new Error(
|
|
3294
|
+
`Machine ${name} is not asleep after ${waited} s; \`tonbo machine show ${name}\` says what keeps it awake.`
|
|
3295
|
+
);
|
|
3296
|
+
if (asleep.status.power !== "asleep")
|
|
3297
|
+
throw new Error(`Machine ${name} is ${asleep.state}; it did not go to sleep.`);
|
|
2643
3298
|
print(
|
|
2644
3299
|
deps,
|
|
2645
3300
|
options.json,
|
|
2646
|
-
|
|
2647
|
-
`Machine ${
|
|
3301
|
+
asleep,
|
|
3302
|
+
`Machine ${asleep.name} is asleep (after ${waited} s); the next request boots it, on its latest spec.`
|
|
2648
3303
|
);
|
|
2649
3304
|
});
|
|
2650
3305
|
}
|
|
2651
3306
|
async function machineDeleteCommand(deps, name, options = {}) {
|
|
2652
3307
|
await machineCommand(async () => {
|
|
2653
3308
|
const key = await deps.auth.apiKey();
|
|
3309
|
+
const read = { etag: null };
|
|
2654
3310
|
await confirm(
|
|
2655
3311
|
deps,
|
|
2656
3312
|
options.yes,
|
|
2657
3313
|
`delete Machine ${name}`,
|
|
2658
|
-
() => deleteQuestion(deps, key, name),
|
|
3314
|
+
() => deleteQuestion(deps, key, name, read),
|
|
2659
3315
|
name
|
|
2660
3316
|
);
|
|
2661
|
-
const { machine, kind } = await deps.api.deleteMachine(
|
|
3317
|
+
const { machine, kind } = await deps.api.deleteMachine(
|
|
3318
|
+
key,
|
|
3319
|
+
name,
|
|
3320
|
+
ifMatchHeader(options.ifMatch) ?? read.etag ?? void 0
|
|
3321
|
+
);
|
|
2662
3322
|
print(deps, options.json, machine, deletedText(name, machine, kind));
|
|
2663
3323
|
if (machine?.kind === "byoc" && machine.byoc.key_state === "active")
|
|
2664
3324
|
deps.notice(
|
|
@@ -2666,10 +3326,12 @@ async function machineDeleteCommand(deps, name, options = {}) {
|
|
|
2666
3326
|
);
|
|
2667
3327
|
});
|
|
2668
3328
|
}
|
|
2669
|
-
async function deleteQuestion(deps, key, name) {
|
|
3329
|
+
async function deleteQuestion(deps, key, name, read) {
|
|
2670
3330
|
let kind = null;
|
|
2671
3331
|
try {
|
|
2672
|
-
|
|
3332
|
+
const answer = await deps.api.getMachineWithEtag(key, name);
|
|
3333
|
+
kind = answer.machine.kind;
|
|
3334
|
+
read.etag = answer.etag;
|
|
2673
3335
|
} catch (error) {
|
|
2674
3336
|
if (!(error instanceof HttpError && error.status === 404)) throw error;
|
|
2675
3337
|
}
|
|
@@ -2686,6 +3348,9 @@ function deletedText(name, machine, kind) {
|
|
|
2686
3348
|
return `Machine ${machine.name} is being deleted; its name is free for a new Machine.`;
|
|
2687
3349
|
return `Released the name ${name}; no Machine had been created under it.`;
|
|
2688
3350
|
}
|
|
3351
|
+
function hourlyPrice(micros) {
|
|
3352
|
+
return `$${(micros / 1e6).toFixed(4)}/h`;
|
|
3353
|
+
}
|
|
2689
3354
|
async function machineShapesCommand(deps, options = {}) {
|
|
2690
3355
|
await machineCommand(async () => {
|
|
2691
3356
|
const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
|
|
@@ -2694,14 +3359,15 @@ async function machineShapesCommand(deps, options = {}) {
|
|
|
2694
3359
|
options.json,
|
|
2695
3360
|
shapes,
|
|
2696
3361
|
table(
|
|
2697
|
-
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
|
|
3362
|
+
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE", "LIST PRICE"],
|
|
2698
3363
|
shapes.data.map((shape) => [
|
|
2699
3364
|
shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
|
|
2700
3365
|
String(shape.vcpus),
|
|
2701
3366
|
`${shape.memory_gib} GiB`,
|
|
2702
3367
|
`${shape.scratch_gib} GiB`,
|
|
2703
3368
|
shape.offered ? "yes" : "no",
|
|
2704
|
-
shape.suspendable ? "yes" : "no"
|
|
3369
|
+
shape.suspendable ? "yes" : "no",
|
|
3370
|
+
hourlyPrice(shape.ceiling_usd_micros_per_hour)
|
|
2705
3371
|
])
|
|
2706
3372
|
)
|
|
2707
3373
|
);
|
|
@@ -2768,6 +3434,7 @@ function operationOptions(command, operationId) {
|
|
|
2768
3434
|
command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
|
|
2769
3435
|
return command.option("--json", "print the API data as JSON");
|
|
2770
3436
|
}
|
|
3437
|
+
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`;
|
|
2771
3438
|
var repeated = (value, previous = []) => [...previous, value];
|
|
2772
3439
|
var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
|
|
2773
3440
|
function machineSpecOptions(command, update) {
|
|
@@ -2814,7 +3481,9 @@ function parseUsageSort(value) {
|
|
|
2814
3481
|
}
|
|
2815
3482
|
function createProgram(dependencies = createDependencies) {
|
|
2816
3483
|
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
|
|
2817
|
-
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) =>
|
|
3484
|
+
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) => {
|
|
3485
|
+
await loginCommand(dependencies(), options);
|
|
3486
|
+
});
|
|
2818
3487
|
program.command("logout").description("revoke this computer's key and remove the saved login").action(async () => logoutCommand(dependencies()));
|
|
2819
3488
|
const account = program.command("account").description("the account you are logged in to");
|
|
2820
3489
|
operationOptions(
|
|
@@ -3075,6 +3744,10 @@ function createProgram(dependencies = createDependencies) {
|
|
|
3075
3744
|
).option(
|
|
3076
3745
|
"--idempotency-key <key>",
|
|
3077
3746
|
"reuse the key of an unconfirmed creation to get the Machine it created"
|
|
3747
|
+
).option("--wait", "wait until the Machine is active, or say why it failed").option(
|
|
3748
|
+
"--timeout <seconds>",
|
|
3749
|
+
`how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
|
|
3750
|
+
parseWhole
|
|
3078
3751
|
),
|
|
3079
3752
|
"createMachine"
|
|
3080
3753
|
).action(
|
|
@@ -3090,11 +3763,7 @@ function createProgram(dependencies = createDependencies) {
|
|
|
3090
3763
|
machineSpecOptions(
|
|
3091
3764
|
machine.command("update <name> [command...]").description(
|
|
3092
3765
|
"change a Machine; [command...] after -- replaces its command at the next boot"
|
|
3093
|
-
).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(
|
|
3094
|
-
"--revision <revision>",
|
|
3095
|
-
"write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
|
|
3096
|
-
parseWhole
|
|
3097
|
-
).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
|
|
3766
|
+
).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"),
|
|
3098
3767
|
true
|
|
3099
3768
|
),
|
|
3100
3769
|
"updateMachine"
|
|
@@ -3102,7 +3771,11 @@ function createProgram(dependencies = createDependencies) {
|
|
|
3102
3771
|
async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
|
|
3103
3772
|
);
|
|
3104
3773
|
operationOptions(
|
|
3105
|
-
machine.command("sleep <name>").description("put a Machine to sleep now"),
|
|
3774
|
+
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(
|
|
3775
|
+
"--timeout <seconds>",
|
|
3776
|
+
`how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
|
|
3777
|
+
parseWhole
|
|
3778
|
+
),
|
|
3106
3779
|
"sleepMachine"
|
|
3107
3780
|
).action(
|
|
3108
3781
|
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
@@ -3110,11 +3783,17 @@ function createProgram(dependencies = createDependencies) {
|
|
|
3110
3783
|
operationOptions(
|
|
3111
3784
|
machine.command("delete <name>").description(
|
|
3112
3785
|
"delete a Machine with its disk, database and logs, or forget one of your computers"
|
|
3113
|
-
).option("--yes", "delete without asking; required when stdin is not a terminal"),
|
|
3786
|
+
).option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
|
|
3114
3787
|
"deleteMachine"
|
|
3115
3788
|
).action(
|
|
3116
3789
|
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|
|
3117
3790
|
);
|
|
3791
|
+
operationOptions(
|
|
3792
|
+
machine.command("activations <name>").description("list a Machine's activations, newest first, and how each ended"),
|
|
3793
|
+
"listMachineActivations"
|
|
3794
|
+
).action(
|
|
3795
|
+
async (name, options) => machineActivationsCommand(dependencies(), name, options)
|
|
3796
|
+
);
|
|
3118
3797
|
operationOptions(
|
|
3119
3798
|
machine.command("shapes").description("list the shapes a Machine may have"),
|
|
3120
3799
|
"listMachineShapes"
|