@tonbo/cli 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/tonbo.js +1060 -15
- package/package.json +1 -1
package/dist/bin/tonbo.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/app.ts
|
|
4
4
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
5
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
6
6
|
|
|
7
7
|
// ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
|
|
8
8
|
var agentNameContract = {
|
|
@@ -359,6 +359,17 @@ var managementOperations = {
|
|
|
359
359
|
"idempotencyKey": false,
|
|
360
360
|
"cli": "tonbo workspace list"
|
|
361
361
|
},
|
|
362
|
+
"listProjects": {
|
|
363
|
+
"method": "GET",
|
|
364
|
+
"path": "/projects",
|
|
365
|
+
"pathParams": [],
|
|
366
|
+
"query": [
|
|
367
|
+
"origin",
|
|
368
|
+
"cursor",
|
|
369
|
+
"limit"
|
|
370
|
+
],
|
|
371
|
+
"idempotencyKey": false
|
|
372
|
+
},
|
|
362
373
|
"listProjectBindings": {
|
|
363
374
|
"method": "GET",
|
|
364
375
|
"path": "/projects/bindings",
|
|
@@ -804,6 +815,133 @@ var managementOperations = {
|
|
|
804
815
|
"idempotencyKey": false,
|
|
805
816
|
"cli": "tonbo machine shapes"
|
|
806
817
|
},
|
|
818
|
+
"listMachineSecrets": {
|
|
819
|
+
"method": "GET",
|
|
820
|
+
"path": "/machines/{name}/secrets",
|
|
821
|
+
"pathParams": [
|
|
822
|
+
"name"
|
|
823
|
+
],
|
|
824
|
+
"query": [],
|
|
825
|
+
"idempotencyKey": false,
|
|
826
|
+
"cli": "tonbo machine secret list"
|
|
827
|
+
},
|
|
828
|
+
"setMachineSecret": {
|
|
829
|
+
"method": "PUT",
|
|
830
|
+
"path": "/machines/{name}/secrets/{key}",
|
|
831
|
+
"pathParams": [
|
|
832
|
+
"name",
|
|
833
|
+
"key"
|
|
834
|
+
],
|
|
835
|
+
"query": [],
|
|
836
|
+
"idempotencyKey": false,
|
|
837
|
+
"cli": "tonbo machine secret set"
|
|
838
|
+
},
|
|
839
|
+
"unsetMachineSecret": {
|
|
840
|
+
"method": "DELETE",
|
|
841
|
+
"path": "/machines/{name}/secrets/{key}",
|
|
842
|
+
"pathParams": [
|
|
843
|
+
"name",
|
|
844
|
+
"key"
|
|
845
|
+
],
|
|
846
|
+
"query": [],
|
|
847
|
+
"idempotencyKey": false,
|
|
848
|
+
"cli": "tonbo machine secret unset"
|
|
849
|
+
},
|
|
850
|
+
"execMachineCommand": {
|
|
851
|
+
"method": "POST",
|
|
852
|
+
"path": "/machines/{name}/exec",
|
|
853
|
+
"pathParams": [
|
|
854
|
+
"name"
|
|
855
|
+
],
|
|
856
|
+
"query": [],
|
|
857
|
+
"idempotencyKey": true,
|
|
858
|
+
"cli": "tonbo machine exec",
|
|
859
|
+
"cell": true
|
|
860
|
+
},
|
|
861
|
+
"readMachineFile": {
|
|
862
|
+
"method": "GET",
|
|
863
|
+
"path": "/machines/{name}/files/content",
|
|
864
|
+
"pathParams": [
|
|
865
|
+
"name"
|
|
866
|
+
],
|
|
867
|
+
"query": [
|
|
868
|
+
"path"
|
|
869
|
+
],
|
|
870
|
+
"idempotencyKey": false,
|
|
871
|
+
"cli": "tonbo machine file read",
|
|
872
|
+
"cell": true
|
|
873
|
+
},
|
|
874
|
+
"writeMachineFile": {
|
|
875
|
+
"method": "PUT",
|
|
876
|
+
"path": "/machines/{name}/files/content",
|
|
877
|
+
"pathParams": [
|
|
878
|
+
"name"
|
|
879
|
+
],
|
|
880
|
+
"query": [
|
|
881
|
+
"path"
|
|
882
|
+
],
|
|
883
|
+
"idempotencyKey": true,
|
|
884
|
+
"cli": "tonbo machine file write",
|
|
885
|
+
"cell": true
|
|
886
|
+
},
|
|
887
|
+
"deleteMachineFile": {
|
|
888
|
+
"method": "DELETE",
|
|
889
|
+
"path": "/machines/{name}/files/content",
|
|
890
|
+
"pathParams": [
|
|
891
|
+
"name"
|
|
892
|
+
],
|
|
893
|
+
"query": [
|
|
894
|
+
"path",
|
|
895
|
+
"recursive"
|
|
896
|
+
],
|
|
897
|
+
"idempotencyKey": true,
|
|
898
|
+
"cli": "tonbo machine file delete",
|
|
899
|
+
"cell": true
|
|
900
|
+
},
|
|
901
|
+
"statMachineFile": {
|
|
902
|
+
"method": "GET",
|
|
903
|
+
"path": "/machines/{name}/files/stat",
|
|
904
|
+
"pathParams": [
|
|
905
|
+
"name"
|
|
906
|
+
],
|
|
907
|
+
"query": [
|
|
908
|
+
"path"
|
|
909
|
+
],
|
|
910
|
+
"idempotencyKey": false,
|
|
911
|
+
"cli": "tonbo machine file stat",
|
|
912
|
+
"cell": true
|
|
913
|
+
},
|
|
914
|
+
"listMachineFiles": {
|
|
915
|
+
"method": "GET",
|
|
916
|
+
"path": "/machines/{name}/files/list",
|
|
917
|
+
"pathParams": [
|
|
918
|
+
"name"
|
|
919
|
+
],
|
|
920
|
+
"query": [
|
|
921
|
+
"path"
|
|
922
|
+
],
|
|
923
|
+
"idempotencyKey": false,
|
|
924
|
+
"cli": "tonbo machine file list",
|
|
925
|
+
"cell": true
|
|
926
|
+
},
|
|
927
|
+
"issueSshCertificate": {
|
|
928
|
+
"method": "POST",
|
|
929
|
+
"path": "/ssh/certificates",
|
|
930
|
+
"pathParams": [],
|
|
931
|
+
"query": [],
|
|
932
|
+
"idempotencyKey": false,
|
|
933
|
+
"cli": "tonbo ssh certificate issue",
|
|
934
|
+
"cell": true
|
|
935
|
+
},
|
|
936
|
+
"getSshHostCa": {
|
|
937
|
+
"method": "GET",
|
|
938
|
+
"path": "/ssh/host-ca",
|
|
939
|
+
"pathParams": [],
|
|
940
|
+
"query": [],
|
|
941
|
+
"idempotencyKey": false,
|
|
942
|
+
"cli": "tonbo ssh host-ca",
|
|
943
|
+
"cell": true
|
|
944
|
+
},
|
|
807
945
|
"listVolumes": {
|
|
808
946
|
"method": "GET",
|
|
809
947
|
"path": "/volumes",
|
|
@@ -1538,6 +1676,11 @@ var machineTimings = {
|
|
|
1538
1676
|
"wakeHoldSeconds": 25,
|
|
1539
1677
|
"earlyReclaimSeconds": 120
|
|
1540
1678
|
};
|
|
1679
|
+
var machineSecretLimits = {
|
|
1680
|
+
"perMachine": 64,
|
|
1681
|
+
"valueMaxBytes": 65536,
|
|
1682
|
+
"totalMaxBytes": 262144
|
|
1683
|
+
};
|
|
1541
1684
|
|
|
1542
1685
|
// ../../packages/tonbo-cli-core/dist/src/bearer.js
|
|
1543
1686
|
var MANAGEMENT_AUDIENCE = cliSessionContract.audiences.management;
|
|
@@ -1817,6 +1960,21 @@ var ManagementApi = class {
|
|
|
1817
1960
|
listMachines(key, page) {
|
|
1818
1961
|
return this.page("listMachines", key, page);
|
|
1819
1962
|
}
|
|
1963
|
+
async listMachineSecrets(key, name) {
|
|
1964
|
+
const answer = await this.request("listMachineSecrets", key, {
|
|
1965
|
+
params: { name }
|
|
1966
|
+
});
|
|
1967
|
+
return answer.data;
|
|
1968
|
+
}
|
|
1969
|
+
setMachineSecret(key, name, secretKey, value) {
|
|
1970
|
+
return this.call("setMachineSecret", key, {
|
|
1971
|
+
params: { name, key: secretKey },
|
|
1972
|
+
body: value
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
unsetMachineSecret(key, name, secretKey) {
|
|
1976
|
+
return this.request("unsetMachineSecret", key, { params: { name, key: secretKey } });
|
|
1977
|
+
}
|
|
1820
1978
|
/**
|
|
1821
1979
|
* A replay with the same idempotency key returns the Machine the first
|
|
1822
1980
|
* request created, so a transport failure or a 5xx answer is retried here
|
|
@@ -3141,6 +3299,73 @@ async function openBrowser(url) {
|
|
|
3141
3299
|
}
|
|
3142
3300
|
}
|
|
3143
3301
|
|
|
3302
|
+
// ../../packages/tonbo-cli-core/dist/src/cell.js
|
|
3303
|
+
function cellOrigin(region, configured = process.env.TONBO_CELL_API_ORIGIN) {
|
|
3304
|
+
if (!/^[a-z0-9-]+$/.test(region))
|
|
3305
|
+
throw new Error(`${region} is not a region.`);
|
|
3306
|
+
const url = new URL((configured?.trim() || "https://api.{region}.tonbo.sh").replace("{region}", region));
|
|
3307
|
+
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
3308
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
|
|
3309
|
+
throw new Error("TONBO_CELL_API_ORIGIN must be an https origin.");
|
|
3310
|
+
return url.origin;
|
|
3311
|
+
}
|
|
3312
|
+
var CellApi = class {
|
|
3313
|
+
fetcher;
|
|
3314
|
+
constructor(fetcher) {
|
|
3315
|
+
this.fetcher = fetcher;
|
|
3316
|
+
}
|
|
3317
|
+
url(operationId, region, request) {
|
|
3318
|
+
const operation = managementOperations[operationId];
|
|
3319
|
+
const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
3320
|
+
const value = request.params[name];
|
|
3321
|
+
if (value === void 0)
|
|
3322
|
+
throw new Error(`${operationId} needs the path parameter ${name}.`);
|
|
3323
|
+
return encodeURIComponent(value);
|
|
3324
|
+
});
|
|
3325
|
+
const search = new URLSearchParams();
|
|
3326
|
+
for (const [name, value] of Object.entries(request.query ?? {}))
|
|
3327
|
+
if (value !== void 0)
|
|
3328
|
+
search.set(name, value);
|
|
3329
|
+
const query = search.toString();
|
|
3330
|
+
return `${cellOrigin(region)}${managementApiBasePath}${path4}${query ? `?${query}` : ""}`;
|
|
3331
|
+
}
|
|
3332
|
+
async init(operationId, key, request) {
|
|
3333
|
+
const headers = {
|
|
3334
|
+
authorization: `Bearer ${await managementBearer(key)}`
|
|
3335
|
+
};
|
|
3336
|
+
for (const [name, value] of Object.entries(request.headers ?? {}))
|
|
3337
|
+
if (value !== void 0)
|
|
3338
|
+
headers[name.toLowerCase()] = value;
|
|
3339
|
+
return { method: managementOperations[operationId].method, headers, body: request.body };
|
|
3340
|
+
}
|
|
3341
|
+
/** The raw answer; a refusal throws an HttpError naming the request id. */
|
|
3342
|
+
async fetch(operationId, key, region, request) {
|
|
3343
|
+
const url = this.url(operationId, region, request);
|
|
3344
|
+
const response = await this.fetcher(url, await this.init(operationId, key, request));
|
|
3345
|
+
if (response.ok)
|
|
3346
|
+
return response;
|
|
3347
|
+
const answered = () => Promise.resolve(response);
|
|
3348
|
+
await requestJson(answered, url);
|
|
3349
|
+
throw new HttpError(`Request failed with HTTP ${response.status}.`, response.status, null, null);
|
|
3350
|
+
}
|
|
3351
|
+
/** The answer's `data`. */
|
|
3352
|
+
async json(operationId, key, region, request) {
|
|
3353
|
+
const url = this.url(operationId, region, request);
|
|
3354
|
+
const answer = await requestJson(this.fetcher, url, await this.init(operationId, key, request));
|
|
3355
|
+
return answer.data;
|
|
3356
|
+
}
|
|
3357
|
+
/** machine-v2.json `op_kill` (`DELETE /v1/machines/{name}/ops/{op}`), which the Management
|
|
3358
|
+
* API document leaves out as superseded by the planned process kill. Answers the op's record. */
|
|
3359
|
+
async killOp(key, region, name, op) {
|
|
3360
|
+
const url = `${cellOrigin(region)}${managementApiBasePath}/machines/${encodeURIComponent(name)}/ops/${encodeURIComponent(op)}`;
|
|
3361
|
+
const answer = await requestJson(this.fetcher, url, {
|
|
3362
|
+
method: "DELETE",
|
|
3363
|
+
headers: { authorization: `Bearer ${await managementBearer(key)}` }
|
|
3364
|
+
});
|
|
3365
|
+
return answer.data;
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
|
|
3144
3369
|
// ../../packages/tonbo-cli-core/dist/src/format.js
|
|
3145
3370
|
function table(headings, rows) {
|
|
3146
3371
|
const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
@@ -4269,6 +4494,7 @@ import { Help } from "commander";
|
|
|
4269
4494
|
|
|
4270
4495
|
// src/machine.ts
|
|
4271
4496
|
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
4497
|
+
import { readFileSync } from "node:fs";
|
|
4272
4498
|
|
|
4273
4499
|
// src/volume.ts
|
|
4274
4500
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
@@ -4306,7 +4532,19 @@ function volumeFields(volume) {
|
|
|
4306
4532
|
["Machine", boundText(volume)],
|
|
4307
4533
|
["Size", `${volume.size_gib} GiB`],
|
|
4308
4534
|
["Live bytes", `${bytes(volume.live_bytes)} (billed)`],
|
|
4309
|
-
["Last
|
|
4535
|
+
["Last meter report", volume.last_commit_at ?? "none yet"],
|
|
4536
|
+
[
|
|
4537
|
+
"Restored to",
|
|
4538
|
+
volume.restored_to ? `${volume.restored_to.committed_at} (generation ${volume.restored_to.data_generation})` : "none"
|
|
4539
|
+
],
|
|
4540
|
+
[
|
|
4541
|
+
"Backed up to",
|
|
4542
|
+
volume.backed_up_to ? volume.backed_up_to.committed_at ? `${volume.backed_up_to.committed_at} (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : `empty snapshot (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : "unknown"
|
|
4543
|
+
],
|
|
4544
|
+
[
|
|
4545
|
+
"Cold GETs",
|
|
4546
|
+
volume.cold ? `${volume.cold.demand_gets} demand, ${volume.cold.hydrate_gets} hydration` : "none"
|
|
4547
|
+
],
|
|
4310
4548
|
["Durability", VOLUME_DURABILITY_LINK],
|
|
4311
4549
|
["Created", volume.created_at]
|
|
4312
4550
|
]);
|
|
@@ -4415,14 +4653,16 @@ var MACHINE_ERROR_HINTS = {
|
|
|
4415
4653
|
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.",
|
|
4416
4654
|
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.",
|
|
4417
4655
|
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
4418
|
-
machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`)
|
|
4656
|
+
machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`).",
|
|
4419
4657
|
database_bound: "Another Machine binds that database, and a database binds one Machine: name another, or delete the Machine that binds it first.",
|
|
4420
4658
|
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
4421
4659
|
create_rate_limited: "Too many Machines were created in the last hour; try again later.",
|
|
4422
4660
|
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.",
|
|
4423
4661
|
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`.",
|
|
4424
4662
|
machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
|
|
4425
|
-
volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed."
|
|
4663
|
+
volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed.",
|
|
4664
|
+
secret_env_conflict: "Use another secret key or remove the non-secret env variable with that name.",
|
|
4665
|
+
secret_limit: "Reduce the number or byte size of this Machine's secrets."
|
|
4426
4666
|
};
|
|
4427
4667
|
var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
4428
4668
|
image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
|
|
@@ -4531,7 +4771,9 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
|
|
|
4531
4771
|
meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
|
|
4532
4772
|
crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
|
|
4533
4773
|
capacity: "No host had room for its shape; the next request tries again.",
|
|
4534
|
-
region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
|
|
4774
|
+
region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it.",
|
|
4775
|
+
budget_slowdown: "Its budget is close to running out, and this wake would pass the budget's slow-down rate: sleep another Machine of the budget, or lower this one's shape, then wake it again. Machines already awake keep running.",
|
|
4776
|
+
budget_exhausted: "Its budget is used up for the month: it wakes after the budget resets, or once a person raises it in the console."
|
|
4535
4777
|
};
|
|
4536
4778
|
var MACHINE_ACCOUNT_EGRESS_BLOCKED_HINT = "If this is the free egress cap: 10 GiB a month without purchased credit; buy credit at <billing> to lift it.";
|
|
4537
4779
|
function conditionText(name, condition, billing) {
|
|
@@ -4617,7 +4859,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
|
|
|
4617
4859
|
["HTTPS", machine.hostnames.https],
|
|
4618
4860
|
// The resource keeps the SSH destination as a receipt; SSH is not served
|
|
4619
4861
|
// before tier P3.
|
|
4620
|
-
["SSH", `${machine.hostnames.ssh} (
|
|
4862
|
+
["SSH", `${machine.hostnames.ssh} (tonbo ssh ${machine.name})`],
|
|
4621
4863
|
["Env", Object.keys(machine.env).sort().join(", ") || "none"],
|
|
4622
4864
|
["Stop grace", `${machine.stop_grace_seconds} s`],
|
|
4623
4865
|
["Volume", volumeText(machine)],
|
|
@@ -4708,15 +4950,11 @@ function parseEnv(pairs) {
|
|
|
4708
4950
|
return env;
|
|
4709
4951
|
}
|
|
4710
4952
|
var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
|
|
4711
|
-
function specWarnings(deps,
|
|
4712
|
-
if (ports.length === 0)
|
|
4713
|
-
deps.notice(
|
|
4714
|
-
"This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
|
|
4715
|
-
);
|
|
4953
|
+
function specWarnings(deps, _ports, envKeys) {
|
|
4716
4954
|
const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
|
|
4717
4955
|
if (secret.length)
|
|
4718
4956
|
deps.notice(
|
|
4719
|
-
`${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; Machine secrets
|
|
4957
|
+
`${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; use Machine secrets for private values.`
|
|
4720
4958
|
);
|
|
4721
4959
|
}
|
|
4722
4960
|
function waitTimeout(options) {
|
|
@@ -4743,6 +4981,41 @@ async function waitForMachine(deps, key, name, firstWait, every, timeout, settle
|
|
|
4743
4981
|
wait = every;
|
|
4744
4982
|
}
|
|
4745
4983
|
}
|
|
4984
|
+
function secretFromFile(key, file, delivery) {
|
|
4985
|
+
if (!key || !file) throw new Error("Pass a secret as KEY=FILE.");
|
|
4986
|
+
const bytes2 = readFileSync(file === "-" ? 0 : file);
|
|
4987
|
+
if (bytes2.byteLength > machineSecretLimits.valueMaxBytes)
|
|
4988
|
+
throw new Error(`Secret ${key} exceeds the ${machineSecretLimits.valueMaxBytes}-byte limit.`);
|
|
4989
|
+
if (delivery === "file") return { delivery, value_base64: bytes2.toString("base64") };
|
|
4990
|
+
let value;
|
|
4991
|
+
try {
|
|
4992
|
+
value = new TextDecoder("utf-8", { fatal: true }).decode(bytes2);
|
|
4993
|
+
} catch {
|
|
4994
|
+
throw new Error(`Secret ${key} is not UTF-8; use --secret-file for binary bytes.`);
|
|
4995
|
+
}
|
|
4996
|
+
return { delivery, value };
|
|
4997
|
+
}
|
|
4998
|
+
function createSecrets(options) {
|
|
4999
|
+
const values = {};
|
|
5000
|
+
let total = 0;
|
|
5001
|
+
for (const [delivery, entries] of [
|
|
5002
|
+
["env", options.secretEnv],
|
|
5003
|
+
["file", options.secretFile]
|
|
5004
|
+
]) {
|
|
5005
|
+
for (const entry of entries ?? []) {
|
|
5006
|
+
const separator = entry.indexOf("=");
|
|
5007
|
+
if (separator < 1) throw new Error("Pass a secret as KEY=FILE.");
|
|
5008
|
+
const key = entry.slice(0, separator);
|
|
5009
|
+
if (Object.hasOwn(values, key)) throw new Error(`Secret ${key} was named twice.`);
|
|
5010
|
+
const secret = secretFromFile(key, entry.slice(separator + 1), delivery);
|
|
5011
|
+
total += "value" in secret ? Buffer.byteLength(secret.value, "utf8") : Buffer.from(secret.value_base64, "base64").byteLength;
|
|
5012
|
+
if (Object.keys(values).length >= machineSecretLimits.perMachine || total > machineSecretLimits.totalMaxBytes)
|
|
5013
|
+
throw new Error("Machine secrets exceed their count or total byte limit.");
|
|
5014
|
+
values[key] = secret;
|
|
5015
|
+
}
|
|
5016
|
+
}
|
|
5017
|
+
return values;
|
|
5018
|
+
}
|
|
4746
5019
|
function deleteAfterSeconds(option) {
|
|
4747
5020
|
if (typeof option === "number") return option;
|
|
4748
5021
|
return option === false ? null : void 0;
|
|
@@ -4795,6 +5068,50 @@ async function machineListCommand(deps, options = {}) {
|
|
|
4795
5068
|
);
|
|
4796
5069
|
});
|
|
4797
5070
|
}
|
|
5071
|
+
async function machineSecretListCommand(deps, name, options = {}) {
|
|
5072
|
+
await machineCommand(async () => {
|
|
5073
|
+
const secrets = await deps.api.listMachineSecrets(await deps.auth.apiKey(), name);
|
|
5074
|
+
print(
|
|
5075
|
+
deps,
|
|
5076
|
+
options.json,
|
|
5077
|
+
secrets,
|
|
5078
|
+
secrets.length ? table(
|
|
5079
|
+
["KEY", "DELIVERY", "VERSION", "UPDATED", "BY"],
|
|
5080
|
+
secrets.map((secret) => [
|
|
5081
|
+
secret.key,
|
|
5082
|
+
secret.delivery,
|
|
5083
|
+
String(secret.version),
|
|
5084
|
+
secret.updated_at,
|
|
5085
|
+
secret.updated_by.sub
|
|
5086
|
+
])
|
|
5087
|
+
) : `Machine ${name} has no secrets.`
|
|
5088
|
+
);
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
async function machineSecretSetCommand(deps, name, secretKey, options) {
|
|
5092
|
+
await machineCommand(async () => {
|
|
5093
|
+
const entry = await deps.api.setMachineSecret(
|
|
5094
|
+
await deps.auth.apiKey(),
|
|
5095
|
+
name,
|
|
5096
|
+
secretKey,
|
|
5097
|
+
secretFromFile(secretKey, options.from, options.delivery ?? "env")
|
|
5098
|
+
);
|
|
5099
|
+
print(
|
|
5100
|
+
deps,
|
|
5101
|
+
options.json,
|
|
5102
|
+
entry,
|
|
5103
|
+
`Set ${entry.key} on Machine ${name} (version ${entry.version}; applies at the next activation).`
|
|
5104
|
+
);
|
|
5105
|
+
});
|
|
5106
|
+
}
|
|
5107
|
+
async function machineSecretUnsetCommand(deps, name, secretKey) {
|
|
5108
|
+
await machineCommand(async () => {
|
|
5109
|
+
await deps.api.unsetMachineSecret(await deps.auth.apiKey(), name, secretKey);
|
|
5110
|
+
deps.output(
|
|
5111
|
+
`Removed ${secretKey} from Machine ${name}; the next activation will not receive it.`
|
|
5112
|
+
);
|
|
5113
|
+
});
|
|
5114
|
+
}
|
|
4798
5115
|
function conditionsCell(machine) {
|
|
4799
5116
|
const conditions = machine.status.conditions ?? [];
|
|
4800
5117
|
if (!conditions.length) return "-";
|
|
@@ -4877,6 +5194,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
|
|
|
4877
5194
|
if (!options.image) throw new Error("Pass --image <reference>.");
|
|
4878
5195
|
const timeout = waitTimeout(options);
|
|
4879
5196
|
const env = parseEnv(options.env ?? []);
|
|
5197
|
+
const secrets = createSecrets(options);
|
|
4880
5198
|
const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
|
|
4881
5199
|
const key = await deps.auth.apiKey();
|
|
4882
5200
|
const bound = typeof options.volume === "string" ? await resolveVolumeBinding(deps, key, options.volume) : null;
|
|
@@ -4899,6 +5217,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
|
|
|
4899
5217
|
command: command.length ? command : void 0,
|
|
4900
5218
|
ports: ports.length ? ports : void 0,
|
|
4901
5219
|
env: Object.keys(env).length ? env : void 0,
|
|
5220
|
+
...Object.keys(secrets).length ? { secrets } : {},
|
|
4902
5221
|
sleep_after_seconds: options.sleepAfter,
|
|
4903
5222
|
idle_action: options.idleAction,
|
|
4904
5223
|
stop_grace_seconds: options.stopGrace,
|
|
@@ -5199,6 +5518,10 @@ var ENVIRONMENT = [
|
|
|
5199
5518
|
names: ["TONBO_API_ORIGIN"],
|
|
5200
5519
|
description: "The Management API origin, `https://api.tonbo.dev` when unset. It must be https, or http on a loopback address. `tonbo login` opens the login page on the same origin without its `api.` label."
|
|
5201
5520
|
},
|
|
5521
|
+
{
|
|
5522
|
+
names: ["TONBO_CELL_API_ORIGIN"],
|
|
5523
|
+
description: "The cell origin `tonbo machine exec` and the file commands call, with `{region}` for the Machine's region; `https://api.{region}.tonbo.sh` when unset. It must be https, or http on a loopback address."
|
|
5524
|
+
},
|
|
5202
5525
|
{
|
|
5203
5526
|
names: ["XDG_CONFIG_HOME"],
|
|
5204
5527
|
description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/login.json` when it is set and not empty, else `$HOME/.config/tonbo/login.json`. A login an earlier release saved, `credentials.json` beside it, is read only when there is no `login.json`."
|
|
@@ -5540,7 +5863,7 @@ var COMMAND_HELP = {
|
|
|
5540
5863
|
"tonbo machine create": {
|
|
5541
5864
|
about: [
|
|
5542
5865
|
"Creates a Machine, a VM booted from an image in one region. It has no database of its own: `--database <name>` binds one of the account's databases of its region, which it then reaches at `TONBO_DATABASE_URL`, and a database binds one Machine at a time; its files can last on a Volume (`--volume`), and its program can open any other database by URL with a key. The image is a public OCI image with a linux/arm64 variant, from Docker Hub or another public registry (`nginx:1.27`, `ghcr.io/org/app:v1`, or one pinned by digest), or `tonbo.dev/platform`, the platform image. A registry reachable only over IPv6, one named by an IP address, and one that redirects its manifests are refused. The CLI resolves a registry image to its linux/arm64 digest, and the image is pulled before the Machine leaves `creating`.",
|
|
5543
|
-
"The Machine is served at `https://<name>-<account>.tonbo.sh` on its default port. `--port <port>` serves a private port, which takes a member's token, and `--port <port>:public` an open one, which needs the `machines.public` capability. A command after `--` replaces the image's CMD. The CLI warns on stderr when the Machine has no port, since nothing can wake it then, and when an `--env` key looks secret: env values are readable by every member through the API.",
|
|
5866
|
+
"The Machine is served at `https://<name>-<account>.tonbo.sh` on its default port. `--port <port>` serves a private port, which takes a member's token, and `--port <port>:public` an open one, which needs the `machines.public` capability. A command after `--` replaces the image's CMD. `--secret-env KEY=FILE` and `--secret-file KEY=FILE` read initial secrets from local files and store them with the Machine before its first boot; their values are never returned. The CLI warns on stderr when the Machine has no port, since nothing can wake it then, and when an `--env` key looks secret: env values are readable by every member through the API.",
|
|
5544
5867
|
"The answer is the Machine while it is `creating`. With `--wait` the CLI reads it until it is `active` or `failed`, or `--timeout` passes: a failure prints what to do and exits 1, and a timeout says it is still creating and undoes nothing. A creation the network or a 5xx answer did not confirm is sent at most three times with the same idempotency key, each retry after its `Retry-After` (at most 30 s) or a short backoff; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply. In a region not open for Machines the creation is refused with `region_not_offered`, and nothing is created."
|
|
5545
5868
|
],
|
|
5546
5869
|
examples: ["tonbo machine create web --image nginx:1.27 --port 80:public --wait"],
|
|
@@ -5565,6 +5888,28 @@ var COMMAND_HELP = {
|
|
|
5565
5888
|
examples: ["tonbo machine show web"],
|
|
5566
5889
|
arguments: NAME("Machine")
|
|
5567
5890
|
},
|
|
5891
|
+
"tonbo machine secret list": {
|
|
5892
|
+
about: [
|
|
5893
|
+
"Lists a Machine's secret keys, delivery, version and last writer. The values are never returned."
|
|
5894
|
+
],
|
|
5895
|
+
examples: ["tonbo machine secret list web"],
|
|
5896
|
+
arguments: { name: "the Machine whose secrets to list" }
|
|
5897
|
+
},
|
|
5898
|
+
"tonbo machine secret set": {
|
|
5899
|
+
about: [
|
|
5900
|
+
"Reads the secret from a local file, or standard input with `--from -`, without putting its value in shell history. `env` delivers UTF-8 text as an environment variable; `file` delivers arbitrary bytes under `$TONBO_SECRETS_DIR`. The new value applies at the next activation."
|
|
5901
|
+
],
|
|
5902
|
+
examples: ["tonbo machine secret set web API_KEY --from ./api-key.txt"],
|
|
5903
|
+
arguments: { name: "the Machine to change", key: "the secret's environment or file name" },
|
|
5904
|
+
defaults: { "--delivery": "env" }
|
|
5905
|
+
},
|
|
5906
|
+
"tonbo machine secret unset": {
|
|
5907
|
+
about: [
|
|
5908
|
+
"Removes a Machine secret whether or not it exists. A running activation keeps its existing value until the next activation."
|
|
5909
|
+
],
|
|
5910
|
+
examples: ["tonbo machine secret unset web API_KEY"],
|
|
5911
|
+
arguments: { name: "the Machine to change", key: "the secret to remove" }
|
|
5912
|
+
},
|
|
5568
5913
|
"tonbo machine update": {
|
|
5569
5914
|
about: [
|
|
5570
5915
|
"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 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.",
|
|
@@ -5581,6 +5926,59 @@ var COMMAND_HELP = {
|
|
|
5581
5926
|
"--if-match": "the ETag the command reads"
|
|
5582
5927
|
}
|
|
5583
5928
|
},
|
|
5929
|
+
"tonbo machine exec": {
|
|
5930
|
+
about: [
|
|
5931
|
+
"Runs one command in the Machine's running activation through its region's cell origin, waking the Machine first when it is asleep, and streams its stdout and stderr here as they come; a call that woke the Machine prints its hourly ceiling on stderr. The command runs without a shell, as the image's User, and lives exactly as long as the call: there is no terminal and no streamed stdin, and `--stdin` sends at most 1 MiB read before the call.",
|
|
5932
|
+
"The CLI exits with the command's status: 128 plus the signal's number when a signal ended it, 124 at `--timeout`, 125 when it was refused, killed or passed 64 MiB of output, 126 or 127 when it could not run, and 255 when the connection ended first and the outcome is unknown. `--json` prints the answer's document instead, with the first and last 512 KiB of each stream."
|
|
5933
|
+
],
|
|
5934
|
+
examples: [
|
|
5935
|
+
"tonbo machine exec web -- ls -la /app",
|
|
5936
|
+
"tonbo machine exec web --timeout 60 -- npm test"
|
|
5937
|
+
],
|
|
5938
|
+
arguments: {
|
|
5939
|
+
name: "the Machine's name",
|
|
5940
|
+
command: "the command and its arguments, after `--`"
|
|
5941
|
+
},
|
|
5942
|
+
defaults: { "--timeout": "600", "--cwd": "the image's WorkingDir" }
|
|
5943
|
+
},
|
|
5944
|
+
"tonbo machine file read": {
|
|
5945
|
+
about: [
|
|
5946
|
+
"Prints one regular file of the Machine, waking it when it is asleep. A file is gone when the Machine sleeps, so a read that woke it sees only its image."
|
|
5947
|
+
],
|
|
5948
|
+
examples: [
|
|
5949
|
+
"tonbo machine file read web /etc/hostname",
|
|
5950
|
+
"tonbo machine file read web /app/out.tar --output out.tar"
|
|
5951
|
+
],
|
|
5952
|
+
arguments: { name: "the Machine's name", path: "the file's absolute path in the Machine" }
|
|
5953
|
+
},
|
|
5954
|
+
"tonbo machine file write": {
|
|
5955
|
+
about: [
|
|
5956
|
+
"Replaces one file of the Machine atomically with stdin, or with `--from`, at most 64 MiB, creating missing directories; a reader sees the old file or the new one. `--if-match` replaces only the version `tonbo machine file stat` printed, and `--create` only creates. The file lasts until the Machine sleeps."
|
|
5957
|
+
],
|
|
5958
|
+
examples: ["tonbo machine file write web /app/config.json --from config.json"],
|
|
5959
|
+
arguments: { name: "the Machine's name", path: "the file's absolute path in the Machine" }
|
|
5960
|
+
},
|
|
5961
|
+
"tonbo machine file stat": {
|
|
5962
|
+
about: [
|
|
5963
|
+
"Prints one path's type, size, mode, owner, modification time and ETag, without following a symbolic link."
|
|
5964
|
+
],
|
|
5965
|
+
examples: ["tonbo machine file stat web /app/config.json"],
|
|
5966
|
+
arguments: { name: "the Machine's name", path: "the absolute path in the Machine" }
|
|
5967
|
+
},
|
|
5968
|
+
"tonbo machine file list": {
|
|
5969
|
+
about: [
|
|
5970
|
+
"Lists one directory of the Machine, not recursively, sorted by name; at most 10,000 entries."
|
|
5971
|
+
],
|
|
5972
|
+
examples: ["tonbo machine file list web /app"],
|
|
5973
|
+
arguments: { name: "the Machine's name", path: "the directory's absolute path in the Machine" }
|
|
5974
|
+
},
|
|
5975
|
+
"tonbo machine file delete": {
|
|
5976
|
+
about: [
|
|
5977
|
+
"Deletes one file, symbolic link (never its target) or empty directory of the Machine, and a directory with everything in it with `--recursive`. It never deletes `/`, a mount point or Tonbo's own paths."
|
|
5978
|
+
],
|
|
5979
|
+
examples: ["tonbo machine file delete web /app/tmp --recursive"],
|
|
5980
|
+
arguments: { name: "the Machine's name", path: "the absolute path in the Machine" }
|
|
5981
|
+
},
|
|
5584
5982
|
"tonbo machine sleep": {
|
|
5585
5983
|
about: [
|
|
5586
5984
|
"Puts an awake Machine to sleep now, and says so when it is already asleep or still creating; its next request boots it on its latest spec. With `--wait` the CLI reads the Machine until it is asleep or `--timeout` passes."
|
|
@@ -5613,6 +6011,40 @@ var COMMAND_HELP = {
|
|
|
5613
6011
|
examples: ["tonbo machine shapes"],
|
|
5614
6012
|
json: "answer"
|
|
5615
6013
|
},
|
|
6014
|
+
"tonbo ssh": {
|
|
6015
|
+
about: [
|
|
6016
|
+
"Opens a terminal on the Machine, or runs the command after `--` there, with your system's `ssh`. The CLI keeps one key for this computer in `~/.config/tonbo/ssh`, gets a short-lived certificate for you from the Machine's region whenever the one it holds is about to expire, and trusts Tonbo's host CA, so nothing is installed in the Machine's image. A Machine that is asleep wakes for the login.",
|
|
6017
|
+
"A certificate lasts at most 10 minutes and only has to be valid when you log in: an open session stays open. A session counts as a connection, so the Machine stays awake while you use it, and the connection closes after 60 minutes with nothing typed."
|
|
6018
|
+
],
|
|
6019
|
+
examples: ["tonbo ssh web", "tonbo ssh web -- uname -a"]
|
|
6020
|
+
},
|
|
6021
|
+
"tonbo ssh config": {
|
|
6022
|
+
about: [
|
|
6023
|
+
"Prints an ssh_config block for every Machine. Append it to `~/.ssh/config` and plain `ssh <name>.<account>@<region>.tonbo.sh`, `scp`, `sftp`, `rsync -e ssh` and VS Code or Cursor Remote-SSH work, with `tonbo ssh refresh` renewing the certificate before each connection that needs it. Port forwarding with `-L` reaches the Machine's own loopback."
|
|
6024
|
+
],
|
|
6025
|
+
examples: ["tonbo ssh config >> ~/.ssh/config"]
|
|
6026
|
+
},
|
|
6027
|
+
"tonbo ssh host-ca": {
|
|
6028
|
+
about: [
|
|
6029
|
+
"Prints the `@cert-authority` lines that let OpenSSH verify Tonbo's SSH hosts, one per host CA key every region trusts, for a known_hosts file of your own. `tonbo ssh` and `tonbo ssh config` keep their own copy; this is for setups without the CLI."
|
|
6030
|
+
],
|
|
6031
|
+
examples: ["tonbo ssh host-ca --region eu-test-1 >> ~/.ssh/known_hosts"]
|
|
6032
|
+
},
|
|
6033
|
+
"tonbo ssh certificate issue": {
|
|
6034
|
+
about: [
|
|
6035
|
+
"Signs your own public key into a certificate of the account for SSH to its Machines, for a key the CLI does not keep, such as one in CI. The certificate is valid for at most 10 minutes, and never past the login's token, so ask again before each login; an open session outlives it. Use it with `ssh -i <key> -o CertificateFile=<certificate> <name>.<account>@<region>.tonbo.sh`."
|
|
6036
|
+
],
|
|
6037
|
+
examples: [
|
|
6038
|
+
"tonbo ssh certificate issue --region eu-test-1 --public-key ~/.ssh/ci.pub > ~/.ssh/ci-cert.pub"
|
|
6039
|
+
],
|
|
6040
|
+
defaults: { "--ttl": "the API's default; the certificate lasts at most 10 minutes" }
|
|
6041
|
+
},
|
|
6042
|
+
"tonbo ssh refresh": {
|
|
6043
|
+
about: [
|
|
6044
|
+
"Gets a new certificate for the destination's account when the one this computer holds expires within two minutes, and exits 0 once a valid one is held. `ssh` runs it through the `Match exec` line of `tonbo ssh config`; you rarely run it yourself."
|
|
6045
|
+
],
|
|
6046
|
+
examples: ["tonbo ssh refresh web.acme@eu-test-1.tonbo.sh"]
|
|
6047
|
+
},
|
|
5616
6048
|
"tonbo volume list": {
|
|
5617
6049
|
about: [
|
|
5618
6050
|
"Lists the account's Volumes in every region with each one's state, the Machine bound to it, its size and its live bytes."
|
|
@@ -5731,6 +6163,283 @@ function applyHelp(program) {
|
|
|
5731
6163
|
return program;
|
|
5732
6164
|
}
|
|
5733
6165
|
|
|
6166
|
+
// src/machine-cell.ts
|
|
6167
|
+
import { readFile as readFile3, writeFile } from "node:fs/promises";
|
|
6168
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
6169
|
+
var EXEC_EXIT_CODES = {
|
|
6170
|
+
timeout: 124,
|
|
6171
|
+
output_limit: 125,
|
|
6172
|
+
killed: 125,
|
|
6173
|
+
lost: 255,
|
|
6174
|
+
refused: 125,
|
|
6175
|
+
command_not_executable: 126,
|
|
6176
|
+
command_not_found: 127
|
|
6177
|
+
};
|
|
6178
|
+
var SIGNALS = {
|
|
6179
|
+
SIGHUP: 1,
|
|
6180
|
+
SIGINT: 2,
|
|
6181
|
+
SIGQUIT: 3,
|
|
6182
|
+
SIGILL: 4,
|
|
6183
|
+
SIGTRAP: 5,
|
|
6184
|
+
SIGABRT: 6,
|
|
6185
|
+
SIGBUS: 7,
|
|
6186
|
+
SIGFPE: 8,
|
|
6187
|
+
SIGKILL: 9,
|
|
6188
|
+
SIGUSR1: 10,
|
|
6189
|
+
SIGSEGV: 11,
|
|
6190
|
+
SIGUSR2: 12,
|
|
6191
|
+
SIGPIPE: 13,
|
|
6192
|
+
SIGALRM: 14,
|
|
6193
|
+
SIGTERM: 15
|
|
6194
|
+
};
|
|
6195
|
+
function execExitCode(end) {
|
|
6196
|
+
switch (end.reason) {
|
|
6197
|
+
case "exited":
|
|
6198
|
+
return end.exit_code ?? 0;
|
|
6199
|
+
case "signaled":
|
|
6200
|
+
return 128 + (SIGNALS[end.signal ?? ""] ?? 0);
|
|
6201
|
+
default:
|
|
6202
|
+
return EXEC_EXIT_CODES[end.reason];
|
|
6203
|
+
}
|
|
6204
|
+
}
|
|
6205
|
+
function execRefusalExitCode(error) {
|
|
6206
|
+
if (error instanceof HttpError) {
|
|
6207
|
+
if (error.code === "command_not_found") return EXEC_EXIT_CODES.command_not_found;
|
|
6208
|
+
if (error.code === "command_not_executable") return EXEC_EXIT_CODES.command_not_executable;
|
|
6209
|
+
if (error.code === "lost") return EXEC_EXIT_CODES.lost;
|
|
6210
|
+
}
|
|
6211
|
+
return EXEC_EXIT_CODES.refused;
|
|
6212
|
+
}
|
|
6213
|
+
function cellText(value) {
|
|
6214
|
+
if (value === null || value === void 0) return "-";
|
|
6215
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value);
|
|
6216
|
+
}
|
|
6217
|
+
function cellApi(deps) {
|
|
6218
|
+
return deps.cell ?? new CellApi(fetch);
|
|
6219
|
+
}
|
|
6220
|
+
function writeBytes(deps, stream, bytes2) {
|
|
6221
|
+
if (deps.write) deps.write(stream, bytes2);
|
|
6222
|
+
else (stream === "stdout" ? process.stdout : process.stderr).write(bytes2);
|
|
6223
|
+
}
|
|
6224
|
+
function exitWith(deps, code) {
|
|
6225
|
+
if (deps.exit) deps.exit(code);
|
|
6226
|
+
else process.exitCode = code;
|
|
6227
|
+
}
|
|
6228
|
+
async function readAllStdin(deps) {
|
|
6229
|
+
if (deps.readStdin) return deps.readStdin();
|
|
6230
|
+
const chunks = [];
|
|
6231
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6232
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
6233
|
+
}
|
|
6234
|
+
async function regionOf(deps, key, name) {
|
|
6235
|
+
const machine = await deps.api.getMachine(key, name);
|
|
6236
|
+
if (machine.kind !== "managed")
|
|
6237
|
+
throw new Error(
|
|
6238
|
+
`${name} is one of your computers; exec and files reach only managed Machines.`
|
|
6239
|
+
);
|
|
6240
|
+
return machine.region;
|
|
6241
|
+
}
|
|
6242
|
+
function noticeWake(deps, ref, name) {
|
|
6243
|
+
if (!ref?.woke) return;
|
|
6244
|
+
const price = ref.ceiling_usd_micros_per_hour === null ? "an unknown price" : `at most ${usd(ref.ceiling_usd_micros_per_hour)} an hour`;
|
|
6245
|
+
deps.notice(`Woke ${name} (activation ${ref.activation}), ${price} while it stays awake.`);
|
|
6246
|
+
}
|
|
6247
|
+
function parseEnv2(pairs) {
|
|
6248
|
+
const env = {};
|
|
6249
|
+
for (const pair of pairs) {
|
|
6250
|
+
const at = pair.indexOf("=");
|
|
6251
|
+
if (at <= 0) throw new Error(`--env ${pair} is not KEY=VALUE.`);
|
|
6252
|
+
env[pair.slice(0, at)] = pair.slice(at + 1);
|
|
6253
|
+
}
|
|
6254
|
+
return env;
|
|
6255
|
+
}
|
|
6256
|
+
async function machineExecCommand(deps, name, command, options = {}) {
|
|
6257
|
+
if (command.length === 0)
|
|
6258
|
+
throw new Error("Name the command after --, such as `tonbo machine exec web -- ls /`.");
|
|
6259
|
+
const key = await deps.auth.apiKey();
|
|
6260
|
+
const region = await regionOf(deps, key, name);
|
|
6261
|
+
const body = { command };
|
|
6262
|
+
if (options.cwd) body.cwd = options.cwd;
|
|
6263
|
+
if (options.env?.length) body.env = parseEnv2(options.env);
|
|
6264
|
+
if (options.timeout !== void 0) body.timeout_seconds = options.timeout;
|
|
6265
|
+
if (options.expectActivation) body.expect_activation = options.expectActivation;
|
|
6266
|
+
if (options.stdin) body.stdin = Buffer.from(await readAllStdin(deps)).toString("base64");
|
|
6267
|
+
let response;
|
|
6268
|
+
try {
|
|
6269
|
+
response = await cellApi(deps).fetch("execMachineCommand", key, region, {
|
|
6270
|
+
params: { name },
|
|
6271
|
+
headers: {
|
|
6272
|
+
"content-type": "application/json",
|
|
6273
|
+
accept: options.json ? "application/json" : "application/x-ndjson",
|
|
6274
|
+
"idempotency-key": options.idempotencyKey
|
|
6275
|
+
},
|
|
6276
|
+
body: JSON.stringify(body)
|
|
6277
|
+
});
|
|
6278
|
+
} catch (error) {
|
|
6279
|
+
exitWith(deps, execRefusalExitCode(error));
|
|
6280
|
+
throw explainMachineError(error);
|
|
6281
|
+
}
|
|
6282
|
+
if (options.json) {
|
|
6283
|
+
const answer = await response.json();
|
|
6284
|
+
print(deps, true, answer.data, "");
|
|
6285
|
+
exitWith(deps, answer.data.reason ? execExitCode(answer.data) : 0);
|
|
6286
|
+
return;
|
|
6287
|
+
}
|
|
6288
|
+
const decoder = new TextDecoder();
|
|
6289
|
+
let buffered = "";
|
|
6290
|
+
let end = null;
|
|
6291
|
+
const line = (text) => {
|
|
6292
|
+
if (!text.trim()) return;
|
|
6293
|
+
const frame = JSON.parse(text);
|
|
6294
|
+
if (frame.type === "start") noticeWake(deps, frame.machine, name);
|
|
6295
|
+
else if (frame.type === "stdout" || frame.type === "stderr")
|
|
6296
|
+
writeBytes(deps, frame.type, new Uint8Array(Buffer.from(frame.data ?? "", "base64")));
|
|
6297
|
+
else if (frame.type === "end") end = frame;
|
|
6298
|
+
};
|
|
6299
|
+
if (response.headers.get("content-type")?.startsWith("application/json")) {
|
|
6300
|
+
const answer = await response.json();
|
|
6301
|
+
print(deps, true, answer.data, "");
|
|
6302
|
+
return;
|
|
6303
|
+
}
|
|
6304
|
+
const reader = response.body?.getReader();
|
|
6305
|
+
while (reader) {
|
|
6306
|
+
const { done, value } = await reader.read();
|
|
6307
|
+
if (done) break;
|
|
6308
|
+
buffered += decoder.decode(value, { stream: true });
|
|
6309
|
+
let at;
|
|
6310
|
+
while ((at = buffered.indexOf("\n")) >= 0) {
|
|
6311
|
+
line(buffered.slice(0, at));
|
|
6312
|
+
buffered = buffered.slice(at + 1);
|
|
6313
|
+
}
|
|
6314
|
+
}
|
|
6315
|
+
line(buffered);
|
|
6316
|
+
const ended = end ?? { reason: "lost", exit_code: null, signal: null };
|
|
6317
|
+
if (ended.reason !== "exited" && ended.reason !== "signaled")
|
|
6318
|
+
deps.notice(`The command ended: ${ended.reason}.`);
|
|
6319
|
+
exitWith(deps, execExitCode(ended));
|
|
6320
|
+
}
|
|
6321
|
+
async function machineFileReadCommand(deps, name, path4, options = {}) {
|
|
6322
|
+
try {
|
|
6323
|
+
const key = await deps.auth.apiKey();
|
|
6324
|
+
const region = await regionOf(deps, key, name);
|
|
6325
|
+
const response = await cellApi(deps).fetch("readMachineFile", key, region, {
|
|
6326
|
+
params: { name },
|
|
6327
|
+
query: { path: path4 },
|
|
6328
|
+
headers: { range: options.range, "tonbo-expect-activation": options.expectActivation }
|
|
6329
|
+
});
|
|
6330
|
+
const bytes2 = new Uint8Array(await response.arrayBuffer());
|
|
6331
|
+
if (options.output) await writeFile(options.output, bytes2);
|
|
6332
|
+
else writeBytes(deps, "stdout", bytes2);
|
|
6333
|
+
} catch (error) {
|
|
6334
|
+
throw explainMachineError(error);
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
async function machineFileWriteCommand(deps, name, path4, options = {}) {
|
|
6338
|
+
try {
|
|
6339
|
+
const bytes2 = options.from ? new Uint8Array(await readFile3(options.from)) : await readAllStdin(deps);
|
|
6340
|
+
const key = await deps.auth.apiKey();
|
|
6341
|
+
const region = await regionOf(deps, key, name);
|
|
6342
|
+
const written = await cellApi(deps).json("writeMachineFile", key, region, {
|
|
6343
|
+
params: { name },
|
|
6344
|
+
query: { path: path4 },
|
|
6345
|
+
headers: {
|
|
6346
|
+
"content-type": "application/octet-stream",
|
|
6347
|
+
"if-match": options.ifMatch,
|
|
6348
|
+
"if-none-match": options.create ? "*" : void 0,
|
|
6349
|
+
"tonbo-file-mode": options.mode,
|
|
6350
|
+
"tonbo-expect-activation": options.expectActivation,
|
|
6351
|
+
"idempotency-key": randomUUID6()
|
|
6352
|
+
},
|
|
6353
|
+
body: new Uint8Array(bytes2)
|
|
6354
|
+
});
|
|
6355
|
+
noticeWake(deps, written.machine, name);
|
|
6356
|
+
print(
|
|
6357
|
+
deps,
|
|
6358
|
+
options.json,
|
|
6359
|
+
written,
|
|
6360
|
+
`${written.created ? "Created" : "Replaced"} ${written.path} (${written.size} bytes, ${written.etag}); it lasts until the Machine sleeps.`
|
|
6361
|
+
);
|
|
6362
|
+
} catch (error) {
|
|
6363
|
+
throw explainMachineError(error);
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
async function machineFileStatCommand(deps, name, path4, options = {}) {
|
|
6367
|
+
try {
|
|
6368
|
+
const key = await deps.auth.apiKey();
|
|
6369
|
+
const region = await regionOf(deps, key, name);
|
|
6370
|
+
const stat = await cellApi(deps).json(
|
|
6371
|
+
"statMachineFile",
|
|
6372
|
+
key,
|
|
6373
|
+
region,
|
|
6374
|
+
{
|
|
6375
|
+
params: { name },
|
|
6376
|
+
query: { path: path4 },
|
|
6377
|
+
headers: { "tonbo-expect-activation": options.expectActivation }
|
|
6378
|
+
}
|
|
6379
|
+
);
|
|
6380
|
+
noticeWake(deps, stat.machine, name);
|
|
6381
|
+
print(
|
|
6382
|
+
deps,
|
|
6383
|
+
options.json,
|
|
6384
|
+
stat,
|
|
6385
|
+
fields(
|
|
6386
|
+
["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
|
|
6387
|
+
(field) => [field, cellText(stat[field])]
|
|
6388
|
+
)
|
|
6389
|
+
)
|
|
6390
|
+
);
|
|
6391
|
+
} catch (error) {
|
|
6392
|
+
throw explainMachineError(error);
|
|
6393
|
+
}
|
|
6394
|
+
}
|
|
6395
|
+
async function machineFileListCommand(deps, name, path4, options = {}) {
|
|
6396
|
+
try {
|
|
6397
|
+
const key = await deps.auth.apiKey();
|
|
6398
|
+
const region = await regionOf(deps, key, name);
|
|
6399
|
+
const listing = await cellApi(deps).json("listMachineFiles", key, region, {
|
|
6400
|
+
params: { name },
|
|
6401
|
+
query: { path: path4 },
|
|
6402
|
+
headers: { "tonbo-expect-activation": options.expectActivation }
|
|
6403
|
+
});
|
|
6404
|
+
noticeWake(deps, listing.machine, name);
|
|
6405
|
+
print(
|
|
6406
|
+
deps,
|
|
6407
|
+
options.json,
|
|
6408
|
+
listing,
|
|
6409
|
+
table(
|
|
6410
|
+
["NAME", "TYPE", "SIZE", "MODE", "MODIFIED"],
|
|
6411
|
+
listing.entries.map((entry) => [
|
|
6412
|
+
entry.name ?? `(base64) ${entry.name_b64}`,
|
|
6413
|
+
entry.type,
|
|
6414
|
+
String(entry.size),
|
|
6415
|
+
entry.mode,
|
|
6416
|
+
entry.mtime
|
|
6417
|
+
])
|
|
6418
|
+
)
|
|
6419
|
+
);
|
|
6420
|
+
if (listing.truncated) deps.notice("The directory has more entries than one listing holds.");
|
|
6421
|
+
} catch (error) {
|
|
6422
|
+
throw explainMachineError(error);
|
|
6423
|
+
}
|
|
6424
|
+
}
|
|
6425
|
+
async function machineFileDeleteCommand(deps, name, path4, options = {}) {
|
|
6426
|
+
try {
|
|
6427
|
+
const key = await deps.auth.apiKey();
|
|
6428
|
+
const region = await regionOf(deps, key, name);
|
|
6429
|
+
await cellApi(deps).fetch("deleteMachineFile", key, region, {
|
|
6430
|
+
params: { name },
|
|
6431
|
+
query: { path: path4, recursive: options.recursive ? "true" : void 0 },
|
|
6432
|
+
headers: {
|
|
6433
|
+
"tonbo-expect-activation": options.expectActivation,
|
|
6434
|
+
"idempotency-key": randomUUID6()
|
|
6435
|
+
}
|
|
6436
|
+
});
|
|
6437
|
+
print(deps, options.json, { path: path4 }, `Deleted ${path4}.`);
|
|
6438
|
+
} catch (error) {
|
|
6439
|
+
throw explainMachineError(error);
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
|
|
5734
6443
|
// src/prompt.ts
|
|
5735
6444
|
import select from "@inquirer/select";
|
|
5736
6445
|
import { createInterface } from "node:readline/promises";
|
|
@@ -5756,9 +6465,240 @@ async function terminalAsk(question) {
|
|
|
5756
6465
|
}
|
|
5757
6466
|
}
|
|
5758
6467
|
|
|
6468
|
+
// src/ssh.ts
|
|
6469
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6470
|
+
import { existsSync } from "node:fs";
|
|
6471
|
+
import { chmod as chmod2, mkdir as mkdir3, readFile as readFile4, readdir, rename as rename3, writeFile as writeFile2 } from "node:fs/promises";
|
|
6472
|
+
import { homedir } from "node:os";
|
|
6473
|
+
import { join } from "node:path";
|
|
6474
|
+
function sshDirectory(env = process.env) {
|
|
6475
|
+
const config = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
6476
|
+
return join(config, "tonbo", "ssh");
|
|
6477
|
+
}
|
|
6478
|
+
var RENEW_BEFORE_MS = 12e4;
|
|
6479
|
+
var REQUESTED_TTL_SECONDS = 3600;
|
|
6480
|
+
function parseDestination(destination) {
|
|
6481
|
+
const at = destination.lastIndexOf("@");
|
|
6482
|
+
const user = destination.slice(0, at);
|
|
6483
|
+
const host = destination.slice(at + 1).toLowerCase();
|
|
6484
|
+
const dot = user.lastIndexOf(".");
|
|
6485
|
+
if (at <= 0 || dot <= 0 || dot === user.length - 1 || !host.includes(".")) {
|
|
6486
|
+
throw new Error(`${destination} is not a Machine's SSH destination (<name>.<account>@<host>)`);
|
|
6487
|
+
}
|
|
6488
|
+
return { user, host, account: user.slice(dot + 1) };
|
|
6489
|
+
}
|
|
6490
|
+
function cellOrigin2(host) {
|
|
6491
|
+
return `https://api.${host}`;
|
|
6492
|
+
}
|
|
6493
|
+
async function ensureKey(directory) {
|
|
6494
|
+
const key = join(directory, "id_ed25519");
|
|
6495
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
6496
|
+
await chmod2(directory, 448);
|
|
6497
|
+
if (!existsSync(key)) {
|
|
6498
|
+
const made = spawnSync(
|
|
6499
|
+
"ssh-keygen",
|
|
6500
|
+
["-q", "-t", "ed25519", "-N", "", "-C", "tonbo", "-f", key],
|
|
6501
|
+
{
|
|
6502
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
6503
|
+
}
|
|
6504
|
+
);
|
|
6505
|
+
if (made.error || made.status !== 0) {
|
|
6506
|
+
throw new Error(
|
|
6507
|
+
"ssh-keygen could not make the key ~/.config/tonbo/ssh/id_ed25519; install OpenSSH's client tools and run the command again"
|
|
6508
|
+
);
|
|
6509
|
+
}
|
|
6510
|
+
}
|
|
6511
|
+
return key;
|
|
6512
|
+
}
|
|
6513
|
+
async function writeAtomic(path4, data) {
|
|
6514
|
+
const temporary = `${path4}.${process.pid}.tmp`;
|
|
6515
|
+
await writeFile2(temporary, data, { mode: 384 });
|
|
6516
|
+
await rename3(temporary, path4);
|
|
6517
|
+
}
|
|
6518
|
+
function certificatePath(directory, user) {
|
|
6519
|
+
return join(directory, `${user}-cert.pub`);
|
|
6520
|
+
}
|
|
6521
|
+
function hostCAPath(directory, host) {
|
|
6522
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
|
6523
|
+
throw new Error(`${host} is not a Machine's SSH host`);
|
|
6524
|
+
}
|
|
6525
|
+
return join(directory, "host-ca", host);
|
|
6526
|
+
}
|
|
6527
|
+
async function trustHostCA(directory, host, lines) {
|
|
6528
|
+
const cells = join(directory, "host-ca");
|
|
6529
|
+
await mkdir3(cells, { recursive: true, mode: 448 });
|
|
6530
|
+
await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
|
|
6531
|
+
`);
|
|
6532
|
+
const trusted = /* @__PURE__ */ new Set();
|
|
6533
|
+
for (const name of (await readdir(cells)).sort()) {
|
|
6534
|
+
if (name.endsWith(".tmp")) {
|
|
6535
|
+
continue;
|
|
6536
|
+
}
|
|
6537
|
+
for (const line of (await readFile4(join(cells, name), "utf8")).split("\n")) {
|
|
6538
|
+
if (line.trim() !== "") {
|
|
6539
|
+
trusted.add(line.trim());
|
|
6540
|
+
}
|
|
6541
|
+
}
|
|
6542
|
+
}
|
|
6543
|
+
await writeAtomic(join(directory, "known_hosts"), `${[...trusted].join("\n")}
|
|
6544
|
+
`);
|
|
6545
|
+
}
|
|
6546
|
+
async function heldUntil(directory, user) {
|
|
6547
|
+
try {
|
|
6548
|
+
const sidecar = JSON.parse(
|
|
6549
|
+
await readFile4(`${certificatePath(directory, user)}.json`, "utf8")
|
|
6550
|
+
);
|
|
6551
|
+
return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
|
|
6552
|
+
} catch {
|
|
6553
|
+
return 0;
|
|
6554
|
+
}
|
|
6555
|
+
}
|
|
6556
|
+
async function cellRequest(bearer, method, url, body) {
|
|
6557
|
+
for (let attempt = 0; ; attempt++) {
|
|
6558
|
+
const token = await managementBearer(bearer);
|
|
6559
|
+
const response = await fetch(url, {
|
|
6560
|
+
method,
|
|
6561
|
+
headers: {
|
|
6562
|
+
Authorization: `Bearer ${token}`,
|
|
6563
|
+
Accept: "application/json",
|
|
6564
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
6565
|
+
},
|
|
6566
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
6567
|
+
});
|
|
6568
|
+
if (response.status === 401 && attempt === 0 && isAccountBearer(bearer)) {
|
|
6569
|
+
bearer.invalidate(MANAGEMENT_AUDIENCE);
|
|
6570
|
+
continue;
|
|
6571
|
+
}
|
|
6572
|
+
const answer = await response.json().catch(() => null);
|
|
6573
|
+
if (!response.ok) {
|
|
6574
|
+
throw new Error(
|
|
6575
|
+
`${url} answered ${response.status}${answer?.code ? ` ${answer.code}` : ""}${answer?.detail ? `: ${answer.detail}` : ""}`
|
|
6576
|
+
);
|
|
6577
|
+
}
|
|
6578
|
+
return answer?.data;
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6581
|
+
async function refreshCertificate(bearer, destination, now = Date.now, directory = sshDirectory()) {
|
|
6582
|
+
const key = await ensureKey(directory);
|
|
6583
|
+
const certificate = certificatePath(directory, destination.user);
|
|
6584
|
+
const knownHosts = join(directory, "known_hosts");
|
|
6585
|
+
if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync(hostCAPath(directory, destination.host)) && existsSync(knownHosts)) {
|
|
6586
|
+
return { key, certificate, knownHosts };
|
|
6587
|
+
}
|
|
6588
|
+
const origin = cellOrigin2(destination.host);
|
|
6589
|
+
const publicKey = (await readFile4(`${key}.pub`, "utf8")).trim();
|
|
6590
|
+
const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
|
|
6591
|
+
public_key: publicKey,
|
|
6592
|
+
ttl_seconds: REQUESTED_TTL_SECONDS
|
|
6593
|
+
});
|
|
6594
|
+
if (typeof issued?.certificate !== "string" || typeof issued.valid_before !== "string") {
|
|
6595
|
+
throw new Error(`${origin} answered no certificate`);
|
|
6596
|
+
}
|
|
6597
|
+
await writeAtomic(certificate, `${issued.certificate}
|
|
6598
|
+
`);
|
|
6599
|
+
await writeAtomic(
|
|
6600
|
+
`${certificate}.json`,
|
|
6601
|
+
`${JSON.stringify({ valid_before: issued.valid_before })}
|
|
6602
|
+
`
|
|
6603
|
+
);
|
|
6604
|
+
const hostCA = await cellRequest(bearer, "GET", `${origin}/v1/ssh/host-ca`);
|
|
6605
|
+
if (!Array.isArray(hostCA?.known_hosts) || hostCA.known_hosts.length === 0) {
|
|
6606
|
+
throw new Error(`${origin} answered no host CA`);
|
|
6607
|
+
}
|
|
6608
|
+
await trustHostCA(directory, destination.host, hostCA.known_hosts);
|
|
6609
|
+
return { key, certificate, knownHosts };
|
|
6610
|
+
}
|
|
6611
|
+
function sshOptions(paths) {
|
|
6612
|
+
return [
|
|
6613
|
+
"-i",
|
|
6614
|
+
paths.key,
|
|
6615
|
+
"-o",
|
|
6616
|
+
`CertificateFile=${paths.certificate}`,
|
|
6617
|
+
"-o",
|
|
6618
|
+
"IdentitiesOnly=yes",
|
|
6619
|
+
"-o",
|
|
6620
|
+
`UserKnownHostsFile=${paths.knownHosts}`,
|
|
6621
|
+
"-o",
|
|
6622
|
+
"StrictHostKeyChecking=yes"
|
|
6623
|
+
];
|
|
6624
|
+
}
|
|
6625
|
+
async function sshCommand(deps, name, command) {
|
|
6626
|
+
let destination;
|
|
6627
|
+
let paths;
|
|
6628
|
+
try {
|
|
6629
|
+
const bearer = await deps.auth.apiKey();
|
|
6630
|
+
const { machine } = await deps.api.getMachineWithEtag(bearer, name);
|
|
6631
|
+
if (machine.kind !== "managed") {
|
|
6632
|
+
throw new Error(`${name} is one of your computers; SSH reaches only Tonbo-run Machines`);
|
|
6633
|
+
}
|
|
6634
|
+
destination = parseDestination(machine.hostnames.ssh);
|
|
6635
|
+
paths = await refreshCertificate(bearer, destination, deps.now);
|
|
6636
|
+
} catch (error) {
|
|
6637
|
+
throw explainMachineError(error);
|
|
6638
|
+
}
|
|
6639
|
+
const target = `${destination.user}@${destination.host}`;
|
|
6640
|
+
const status = await new Promise((resolve, reject) => {
|
|
6641
|
+
const child = spawn("ssh", [...sshOptions(paths), target, ...command], { stdio: "inherit" });
|
|
6642
|
+
child.on(
|
|
6643
|
+
"error",
|
|
6644
|
+
(error) => reject(new Error(`could not run ssh (${error.message}); install an OpenSSH client`))
|
|
6645
|
+
);
|
|
6646
|
+
child.on("exit", (code, signal) => resolve(code ?? (signal ? 255 : 0)));
|
|
6647
|
+
});
|
|
6648
|
+
process.exitCode = status;
|
|
6649
|
+
}
|
|
6650
|
+
async function sshRefreshCommand(makeDeps, target) {
|
|
6651
|
+
const destination = parseDestination(target);
|
|
6652
|
+
const deps = makeDeps(destination.account);
|
|
6653
|
+
await refreshCertificate(await deps.auth.apiKey(), destination, deps.now);
|
|
6654
|
+
}
|
|
6655
|
+
function sshConfigText(directory = sshDirectory()) {
|
|
6656
|
+
const home = homedir();
|
|
6657
|
+
const shown = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
|
|
6658
|
+
return [
|
|
6659
|
+
"# Tonbo Machines: append to ~/.ssh/config (tonbo ssh config >> ~/.ssh/config).",
|
|
6660
|
+
"# Then: ssh <name>.<account>@<region>.tonbo.sh, scp, sftp and Remote-SSH work as usual.",
|
|
6661
|
+
`Match host "*.tonbo.sh,tonbo.sh" exec "tonbo ssh refresh %r@%h"`,
|
|
6662
|
+
` IdentityFile ${shown}/id_ed25519`,
|
|
6663
|
+
` CertificateFile ${shown}/%r-cert.pub`,
|
|
6664
|
+
" IdentitiesOnly yes",
|
|
6665
|
+
` UserKnownHostsFile ${shown}/known_hosts`,
|
|
6666
|
+
" StrictHostKeyChecking yes",
|
|
6667
|
+
" ServerAliveInterval 30"
|
|
6668
|
+
].join("\n");
|
|
6669
|
+
}
|
|
6670
|
+
async function sshHostCaCommand(deps, options) {
|
|
6671
|
+
const key = await deps.auth.apiKey();
|
|
6672
|
+
const data = await (deps.cell ?? new CellApi(fetch)).json(
|
|
6673
|
+
"getSshHostCa",
|
|
6674
|
+
key,
|
|
6675
|
+
options.region,
|
|
6676
|
+
{ params: {} }
|
|
6677
|
+
);
|
|
6678
|
+
print(deps, options.json, data, data.known_hosts.join("\n"));
|
|
6679
|
+
}
|
|
6680
|
+
async function sshCertificateIssueCommand(deps, options) {
|
|
6681
|
+
const publicKey = (await readFile4(options.publicKey, "utf8")).trim();
|
|
6682
|
+
const key = await deps.auth.apiKey();
|
|
6683
|
+
const data = await (deps.cell ?? new CellApi(fetch)).json(
|
|
6684
|
+
"issueSshCertificate",
|
|
6685
|
+
key,
|
|
6686
|
+
options.region,
|
|
6687
|
+
{
|
|
6688
|
+
params: {},
|
|
6689
|
+
headers: { "content-type": "application/json" },
|
|
6690
|
+
body: JSON.stringify({
|
|
6691
|
+
public_key: publicKey,
|
|
6692
|
+
...options.ttl === void 0 ? {} : { ttl_seconds: options.ttl }
|
|
6693
|
+
})
|
|
6694
|
+
}
|
|
6695
|
+
);
|
|
6696
|
+
print(deps, options.json, data, data.certificate);
|
|
6697
|
+
}
|
|
6698
|
+
|
|
5759
6699
|
// src/app.ts
|
|
5760
6700
|
var packageVersion = JSON.parse(
|
|
5761
|
-
|
|
6701
|
+
readFileSync2(new URL("../../package.json", import.meta.url), "utf8")
|
|
5762
6702
|
);
|
|
5763
6703
|
function createDependencies(selection = {}) {
|
|
5764
6704
|
const api = apiOrigin();
|
|
@@ -6132,6 +7072,10 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
6132
7072
|
).option("--no-database", "bind no database (the default)"),
|
|
6133
7073
|
false
|
|
6134
7074
|
).option(
|
|
7075
|
+
"--secret-env <KEY=FILE>",
|
|
7076
|
+
"read a UTF-8 environment secret from FILE; repeat",
|
|
7077
|
+
repeated
|
|
7078
|
+
).option("--secret-file <KEY=FILE>", "read binary file secret from FILE; repeat", repeated).option(
|
|
6135
7079
|
"--idempotency-key <key>",
|
|
6136
7080
|
"reuse the key of an unconfirmed creation to get the Machine it created"
|
|
6137
7081
|
).option("--wait", "wait until the Machine is active, or say why it failed").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
|
|
@@ -6145,6 +7089,29 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
6145
7089
|
).action(
|
|
6146
7090
|
async (name, options) => machineShowCommand(dependencies(), name, options)
|
|
6147
7091
|
);
|
|
7092
|
+
const machineSecret = machine.command("secret").description("manage a Machine's write-only secrets");
|
|
7093
|
+
operationOptions(
|
|
7094
|
+
machineSecret.command("list <name>").description("list secret keys and delivery, never values"),
|
|
7095
|
+
"listMachineSecrets"
|
|
7096
|
+
).action(
|
|
7097
|
+
async (name, options) => machineSecretListCommand(dependencies(), name, options)
|
|
7098
|
+
);
|
|
7099
|
+
operationOptions(
|
|
7100
|
+
machineSecret.command("set <name> <key>").description(
|
|
7101
|
+
"set a secret from a local file or standard input; applies at the next activation"
|
|
7102
|
+
).requiredOption("--from <file>", "read the value from FILE, or - for standard input").addOption(
|
|
7103
|
+
new Option("--delivery <mode>", "env or file (binary)").choices(["env", "file"]).default("env")
|
|
7104
|
+
),
|
|
7105
|
+
"setMachineSecret"
|
|
7106
|
+
).action(
|
|
7107
|
+
async (name, key, options) => machineSecretSetCommand(dependencies(), name, key, options)
|
|
7108
|
+
);
|
|
7109
|
+
operationOptions(
|
|
7110
|
+
machineSecret.command("unset <name> <key>").description("remove a secret at the next activation"),
|
|
7111
|
+
"unsetMachineSecret"
|
|
7112
|
+
).action(
|
|
7113
|
+
async (name, key) => machineSecretUnsetCommand(dependencies(), name, key)
|
|
7114
|
+
);
|
|
6148
7115
|
operationOptions(
|
|
6149
7116
|
machineSpecOptions(
|
|
6150
7117
|
machine.command("update <name> [command...]").description(
|
|
@@ -6156,6 +7123,59 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
6156
7123
|
).action(
|
|
6157
7124
|
async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
|
|
6158
7125
|
);
|
|
7126
|
+
operationOptions(
|
|
7127
|
+
machine.command("exec <name> [command...]").description(
|
|
7128
|
+
"run a command in a Machine, waking it first, and print its output; exits with its status"
|
|
7129
|
+
).option("--cwd <path>", "the working directory, an absolute path").option(
|
|
7130
|
+
"--env <KEY=VALUE>",
|
|
7131
|
+
"set an environment variable for the command; repeat for several",
|
|
7132
|
+
repeated
|
|
7133
|
+
).option(
|
|
7134
|
+
"--timeout <seconds>",
|
|
7135
|
+
"kill the command after this many seconds (default 600, at most 3600)",
|
|
7136
|
+
parseWhole
|
|
7137
|
+
).option("--stdin", "send this process's stdin to the command (at most 1 MiB)").option(
|
|
7138
|
+
"--expect-activation <activation>",
|
|
7139
|
+
"run only in this activation; refuse and wake nothing otherwise"
|
|
7140
|
+
).option(
|
|
7141
|
+
"--idempotency-key <key>",
|
|
7142
|
+
"a key that makes a repeat answer the first run instead of running again"
|
|
7143
|
+
),
|
|
7144
|
+
"execMachineCommand"
|
|
7145
|
+
).action(
|
|
7146
|
+
async (name, command, options) => machineExecCommand(dependencies(), name, command, options)
|
|
7147
|
+
);
|
|
7148
|
+
const file = machine.command("file").description("read, write, stat, list and delete a Machine's files");
|
|
7149
|
+
operationOptions(
|
|
7150
|
+
file.command("read <name> <path>").description("print a file of a Machine, waking it first").option("--output <file>", "write the bytes to this local file instead of stdout").option("--range <range>", "one byte range, such as bytes=0-1023").option("--expect-activation <activation>", "read only in this activation"),
|
|
7151
|
+
"readMachineFile"
|
|
7152
|
+
).action(
|
|
7153
|
+
async (name, path4, options) => machineFileReadCommand(dependencies(), name, path4, options)
|
|
7154
|
+
);
|
|
7155
|
+
operationOptions(
|
|
7156
|
+
file.command("write <name> <path>").description("replace a file of a Machine with stdin or --from, atomically").option("--from <file>", "the local file to send instead of stdin").option("--mode <mode>", "the file's mode, four octal digits such as 0755").option("--if-match <etag>", "replace only the version with this ETag").option("--create", "only create the file; refuse when it exists").option("--expect-activation <activation>", "write only in this activation"),
|
|
7157
|
+
"writeMachineFile"
|
|
7158
|
+
).action(
|
|
7159
|
+
async (name, path4, options) => machineFileWriteCommand(dependencies(), name, path4, options)
|
|
7160
|
+
);
|
|
7161
|
+
operationOptions(
|
|
7162
|
+
file.command("stat <name> <path>").description("show a path's type, size, mode, owner and ETag").option("--expect-activation <activation>", "stat only in this activation"),
|
|
7163
|
+
"statMachineFile"
|
|
7164
|
+
).action(
|
|
7165
|
+
async (name, path4, options) => machineFileStatCommand(dependencies(), name, path4, options)
|
|
7166
|
+
);
|
|
7167
|
+
operationOptions(
|
|
7168
|
+
file.command("list <name> <path>").description("list a directory of a Machine").option("--expect-activation <activation>", "list only in this activation"),
|
|
7169
|
+
"listMachineFiles"
|
|
7170
|
+
).action(
|
|
7171
|
+
async (name, path4, options) => machineFileListCommand(dependencies(), name, path4, options)
|
|
7172
|
+
);
|
|
7173
|
+
operationOptions(
|
|
7174
|
+
file.command("delete <name> <path>").description("delete a file, a symbolic link or an empty directory of a Machine").option("--recursive", "delete a directory and everything in it").option("--expect-activation <activation>", "delete only in this activation"),
|
|
7175
|
+
"deleteMachineFile"
|
|
7176
|
+
).action(
|
|
7177
|
+
async (name, path4, options) => machineFileDeleteCommand(dependencies(), name, path4, options)
|
|
7178
|
+
);
|
|
6159
7179
|
operationOptions(
|
|
6160
7180
|
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("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
|
|
6161
7181
|
"sleepMachine"
|
|
@@ -6223,6 +7243,31 @@ function createProgram(makeDependencies = createDependencies) {
|
|
|
6223
7243
|
),
|
|
6224
7244
|
"getUsageToday"
|
|
6225
7245
|
).action(async (options) => usageTodayCommand(dependencies(), options));
|
|
7246
|
+
const ssh = program.command("ssh").description("open a terminal on a Machine, or run a command there, over SSH").argument("<machine>", "the Machine's name").argument("[command...]", "a command to run instead of a shell, after --").action(async (name, command) => sshCommand(dependencies(), name, command));
|
|
7247
|
+
ssh.command("config").description(
|
|
7248
|
+
"print the ssh_config block that lets ssh, scp, sftp and Remote-SSH reach every Machine"
|
|
7249
|
+
).action(() => dependencies().output(sshConfigText()));
|
|
7250
|
+
ssh.command("refresh").argument("<destination>", "a Machine's SSH destination, <name>.<account>@<host>").description(
|
|
7251
|
+
"renew the SSH certificate of <name>.<account>@<host> when it is about to expire (ssh_config's Match exec runs it)"
|
|
7252
|
+
).action(
|
|
7253
|
+
async (destination) => sshRefreshCommand((account2) => makeDependencies({ account: account2 }), destination)
|
|
7254
|
+
);
|
|
7255
|
+
operationOptions(
|
|
7256
|
+
ssh.command("host-ca").description("print the known_hosts lines that trust Tonbo's SSH hosts").requiredOption("--region <region>", "the region whose cell origin to ask"),
|
|
7257
|
+
"getSshHostCa"
|
|
7258
|
+
).action(
|
|
7259
|
+
async (options) => sshHostCaCommand(dependencies(), options)
|
|
7260
|
+
);
|
|
7261
|
+
const certificate = ssh.command("certificate").description("SSH certificates of the account for a key of your own");
|
|
7262
|
+
operationOptions(
|
|
7263
|
+
certificate.command("issue").description("sign an SSH public key into a certificate of the account").requiredOption("--region <region>", "the region whose cell origin signs it").requiredOption(
|
|
7264
|
+
"--public-key <file>",
|
|
7265
|
+
"an OpenSSH ssh-ed25519 or ecdsa-sha2-nistp256 public key file"
|
|
7266
|
+
).option("--ttl <seconds>", "how long to ask for, 300 to 86400", parseWhole),
|
|
7267
|
+
"issueSshCertificate"
|
|
7268
|
+
).action(
|
|
7269
|
+
async (options) => sshCertificateIssueCommand(dependencies(), options)
|
|
7270
|
+
);
|
|
6226
7271
|
return applyHelp(program);
|
|
6227
7272
|
}
|
|
6228
7273
|
|
|
@@ -6231,5 +7276,5 @@ try {
|
|
|
6231
7276
|
await createProgram().parseAsync(process.argv);
|
|
6232
7277
|
} catch (error) {
|
|
6233
7278
|
console.error(error instanceof Error ? error.message : error);
|
|
6234
|
-
process.exitCode = 1;
|
|
7279
|
+
if (!process.exitCode) process.exitCode = 1;
|
|
6235
7280
|
}
|