withruntime 0.21.0 → 0.33.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/COMPATIBILITY.md +64 -3
- package/E2B.md +1 -1
- package/README.md +36 -2
- package/README.src.md +35 -1
- package/compatibility-lock.json +3 -3
- package/dist/agents/browser.d.ts +12 -0
- package/dist/agents/browser.js +12 -0
- package/dist/agents/index.d.ts +7 -7
- package/dist/agents/index.js +7 -8
- package/dist/agents-cli.d.ts +1 -1
- package/dist/agents-cli.js +250 -3
- package/dist/agents-deploy.d.ts +186 -0
- package/dist/agents-deploy.js +752 -0
- package/dist/agents-file.d.ts +19 -0
- package/dist/agents-file.js +135 -0
- package/dist/apps/index.d.ts +10 -0
- package/dist/apps/index.js +11 -0
- package/dist/apps-cli.d.ts +3 -1
- package/dist/apps-cli.js +285 -38
- package/dist/blaxel/errors.js +2 -1
- package/dist/ci/index.d.ts +10 -0
- package/dist/ci/index.js +11 -0
- package/dist/ci-cli.d.ts +10 -0
- package/dist/ci-cli.js +343 -0
- package/dist/cli.d.ts +18 -7
- package/dist/cli.js +862 -99
- package/dist/daytona/errors.js +2 -1
- package/dist/e2b/errors.js +2 -1
- package/dist/e2b/filesystem.d.ts +1 -0
- package/dist/e2b/filesystem.js +1 -1
- package/dist/e2b/sandbox.d.ts +11 -2
- package/dist/e2b/sandbox.js +22 -6
- package/dist/errors.d.ts +11 -4
- package/dist/errors.js +27 -4
- package/dist/fly/index.d.ts +102 -0
- package/dist/fly/index.js +930 -0
- package/dist/index.d.ts +7 -3
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +10 -1
- package/dist/machine.d.ts +10 -6
- package/dist/machine.js +14 -6
- package/dist/modal/index.d.ts +34 -0
- package/dist/modal/index.js +128 -22
- package/dist/product-names.js +7 -0
- package/dist/products/agents.d.ts +101 -3
- package/dist/products/agents.js +49 -0
- package/dist/products/apps.d.ts +206 -10
- package/dist/products/apps.js +172 -1
- package/dist/products/ci.d.ts +332 -0
- package/dist/products/ci.js +101 -0
- package/dist/products/index.d.ts +8 -0
- package/dist/products/index.js +15 -0
- package/dist/products/jobs.d.ts +105 -5
- package/dist/products/jobs.js +47 -3
- package/dist/products/network.d.ts +13 -0
- package/dist/products/observability.d.ts +1 -1
- package/dist/products/plans.d.ts +131 -0
- package/dist/products/plans.js +24 -0
- package/dist/products/reservations.d.ts +108 -0
- package/dist/products/reservations.js +41 -0
- package/dist/products/resources.d.ts +2 -1
- package/dist/products/secrets.d.ts +109 -6
- package/dist/products/secrets.js +38 -2
- package/dist/proxy.d.ts +3 -3
- package/dist/proxy.js +20 -0
- package/dist/sandbox.d.ts +29 -1
- package/dist/sandbox.js +56 -3
- package/dist/snapshots.d.ts +1 -1
- package/dist/switched-off.d.ts +5 -0
- package/dist/switched-off.js +8 -0
- package/dist/tar.d.ts +1 -1
- package/dist/tar.js +19 -1
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +48 -9
- package/dist/types.d.ts +35 -4
- package/dist/vercel/errors.d.ts +1 -1
- package/dist/vercel/errors.js +2 -1
- package/dist/vercel/snapshot.js +4 -2
- package/package.json +23 -6
package/dist/cli.js
CHANGED
|
@@ -47,27 +47,47 @@ Account
|
|
|
47
47
|
usage export --since <UTC time> --until <UTC time>
|
|
48
48
|
Every settlement in the range as CSV; --json, each page
|
|
49
49
|
limits Whether this key is read-only, and its daily spending limit
|
|
50
|
+
reservations book --sandboxes <n> --from <time> --to <time> [--vcpu <n>] [--memory <MiB>]
|
|
51
|
+
[--disk <MiB>] [--region <r>] Hold room for a burst: confirmed or declined at once
|
|
52
|
+
reservations ls | get <id> | cancel <id> Your bookings; cancel gives the room back
|
|
50
53
|
secrets set <NAME> --host <host>... [--header <name> [--format '<text {value}>']]
|
|
51
54
|
[--allow '[GET,HEAD] /path/*']... Store a secret sandboxes use without seeing it; the
|
|
52
55
|
[--only-label key=value[,k=v]]... value is read from standard input, never an argument.
|
|
53
56
|
[--only-sandbox <id>]... --allow (paid): only these methods and paths get it.
|
|
54
|
-
[--
|
|
55
|
-
|
|
56
|
-
only that sandbox
|
|
57
|
-
|
|
57
|
+
[--only-app <app id>]... --only-label: only sandboxes with those labels (each
|
|
58
|
+
[--all-sandboxes] [--env <VAR>] flag one selector, any may match); --only-sandbox:
|
|
59
|
+
only that sandbox; --only-app: every copy of that App,
|
|
60
|
+
any version. A new value keeps the scope;
|
|
61
|
+
--all-sandboxes gives it to every sandbox again.
|
|
62
|
+
--env: the variable sandboxes see it in, if not NAME;
|
|
63
|
+
secrets scoped to different sandboxes may share one
|
|
64
|
+
secrets set <NAME> --keep-value [--host <host>]... [--allow '[GET] /path']...
|
|
65
|
+
Narrow where it goes without its value: a subset of
|
|
66
|
+
its hosts, only requests its rules allow now.
|
|
67
|
+
Anything wider needs the value
|
|
58
68
|
secrets set <NAME> --jobs [--host ...] A copy scheduled jobs put in their environment
|
|
59
69
|
(job create --secret NAME); with --host, both
|
|
60
|
-
secrets scope <NAME> --only-label k=v... | --only-sandbox <id>... | --all-sandboxes
|
|
70
|
+
secrets scope <NAME> --only-label k=v... | --only-sandbox <id>... | --only-app <id>... | --all-sandboxes
|
|
61
71
|
Change which sandboxes get it; no value needed
|
|
62
72
|
secrets ls | get <NAME> Names, hosts, placeholders and jobs versions, never values
|
|
63
73
|
secrets rotate <NAME> A new version of the jobs copy, from standard input
|
|
64
74
|
secrets reveal <NAME> [--version N] Print the jobs copy's value. The sandboxes' copy is
|
|
65
75
|
never readable, by anyone
|
|
76
|
+
secrets share <NAME> --agent <id>... | --all | --none
|
|
77
|
+
Which of your keys may use the jobs copy besides the one
|
|
78
|
+
that stored it (ids: from 'me'); --all: every key
|
|
66
79
|
secrets rm <NAME> Delete a secret, every copy
|
|
67
80
|
keys create [--name <name>] [--read-only] [--daily-limit <usd>] [--account-wide]
|
|
68
81
|
A new API key for CI, approved by an owner in the browser
|
|
69
82
|
referrals Your referral link: you both get up to $1,000, then
|
|
70
83
|
you earn 10% of what they spend for 12 months
|
|
84
|
+
plans The plans you can choose and yours: a monthly fee
|
|
85
|
+
that comes back as usage, spent first (beta)
|
|
86
|
+
plans choose <plan> [--yearly] | cancel | resume
|
|
87
|
+
Start or change a plan (a first one opens a payment
|
|
88
|
+
page), end it at the period's end, or keep it (beta)
|
|
89
|
+
plans add-on <id> | remove-add-on <id> Add an add-on to the plan, or stop one (beta)
|
|
90
|
+
plans auto-upgrade on|off Move up a plan by itself when a limit refuses (beta)
|
|
71
91
|
compare --from <provider> [--days 30] What your usage would cost at e2b, daytona, vercel,
|
|
72
92
|
modal, cloudflare, fly, blaxel, lambda-microvms,
|
|
73
93
|
freestyle, prime and others, and what you save
|
|
@@ -85,6 +105,7 @@ Account
|
|
|
85
105
|
support deny <actionId> --conversation <id>
|
|
86
106
|
docs [topic] Print a docs page (start, cli, api, python...)
|
|
87
107
|
mcp Serve MCP on stdio for agents that need it
|
|
108
|
+
fly-api [--port 4280] Answer Fly Machines API calls here, with Runtime sandboxes
|
|
88
109
|
|
|
89
110
|
Connect
|
|
90
111
|
sandboxes ssh <id|name> [-- <command...>] Log in over SSH; \`runtime sandboxes ssh help\` for editors
|
|
@@ -111,7 +132,9 @@ Products
|
|
|
111
132
|
volumes Persistent disks to attach to sandboxes
|
|
112
133
|
jobs A command in a fresh sandbox, once or on a cron schedule, with its logs
|
|
113
134
|
apps Your web server from an image or a build, copies started by requests (beta)
|
|
135
|
+
functions Your HTTP handler from an image or a build, asleep until calls come (beta)
|
|
114
136
|
builds A connected repository's commits built into what an App deploys (beta)
|
|
137
|
+
ci CI/CD: each push and pull request of a repository checked in sandboxes (beta)
|
|
115
138
|
containers One OCI image's process on its own microVM, at the sandbox rates (beta)
|
|
116
139
|
agents Claude Code, Codex, Gemini CLI or your own agent in a sandbox per session (beta)
|
|
117
140
|
domains Your own hostname for a sandbox's port, with HTTPS
|
|
@@ -144,7 +167,8 @@ const SANDBOX_HELP = `runtime sandboxes <command>
|
|
|
144
167
|
Create one; prints its id
|
|
145
168
|
ls [--all] [--label k=v]... [--state running] List (live ones unless --all)
|
|
146
169
|
get <id> One sandbox, in full
|
|
147
|
-
exec <id> [--cwd /workspace] [--env K[=V]]... [--timeout <s>] [--root]
|
|
170
|
+
exec <id> [--cwd /workspace] [--env K[=V]]... [--timeout <s>] [--root]
|
|
171
|
+
[--trace-files <file.json>] -- <command...>
|
|
148
172
|
Run and stream output; exits with its code.
|
|
149
173
|
--root runs it as root: no sudo needed
|
|
150
174
|
spawn <id> [--pty] [--root] -- <command...> Start in the background; prints a process id
|
|
@@ -171,6 +195,7 @@ const SANDBOX_HELP = `runtime sandboxes <command>
|
|
|
171
195
|
Change its size by a restart: the whole disk stays and
|
|
172
196
|
--disk only grows it; programs stop (snapshot it first
|
|
173
197
|
to keep memory)
|
|
198
|
+
resize <id> --disk 20480 Grow a running sandbox's disk live, programs running
|
|
174
199
|
extend <id> <seconds> Move a time limit on (one with none needs nothing)
|
|
175
200
|
update <id> [--name n] [--label k=v]... [--env K[=V]]... [--unset-env K]... [--idle-pause <seconds>] [--auto-wake on|off]
|
|
176
201
|
[--persistent on|off] [--max-total-cost <usd>|none]
|
|
@@ -202,6 +227,11 @@ const SANDBOX_HELP = `runtime sandboxes <command>
|
|
|
202
227
|
preview rotate <id> <port> Refuse every token given out for a private port so
|
|
203
228
|
far; prints the new token
|
|
204
229
|
previews <id> Every shared port
|
|
230
|
+
link <id> <path> [--put] [--expires 1h] [--filename n] [--max-bytes n] [--archive] [--no-wake]
|
|
231
|
+
A link anyone can open with no key: GET downloads the
|
|
232
|
+
file (--archive: a folder as .tar.gz), --put uploads to it;
|
|
233
|
+
--expires 60s to 7d, 1h by default
|
|
234
|
+
link revoke <id> End every link made for the sandbox so far
|
|
205
235
|
unshare <id> <port> Stop sharing a port
|
|
206
236
|
session create <id> [--origin <url>]... [--ttl <s>] [--name <n>]
|
|
207
237
|
A token a web page uses for this sandbox's commands,
|
|
@@ -296,7 +326,7 @@ commandAfter) {
|
|
|
296
326
|
return { positional, flags, rest };
|
|
297
327
|
}
|
|
298
328
|
/** Options given once per item, which a plural takes as a comma list. */
|
|
299
|
-
const LISTS = new Set(["host", "allow", "deny", "connect"]);
|
|
329
|
+
const LISTS = new Set(["host", "allow", "deny", "connect", "snapshot"]);
|
|
300
330
|
/** The declared option a mistyped one most likely meant. --memory-mib for
|
|
301
331
|
* --memory, --timeout-seconds for --timeout: the SDK's spelling of an option is
|
|
302
332
|
* the CLI's with its unit added. */
|
|
@@ -367,9 +397,19 @@ const kit = {
|
|
|
367
397
|
table: (rows) => table(rows),
|
|
368
398
|
usage: (message) => usage(message),
|
|
369
399
|
unknown: (what, word, choices) => unknown(what, word, choices),
|
|
400
|
+
/** `sandboxes exec`'s run and output, for any machine that execs. */
|
|
401
|
+
execute: (machine, command, args, out, env) => execute(machine, command, args, out, integer(args, "timeout"), env),
|
|
402
|
+
copyFile: (from, to, use, side) => copyFile(from, to, use, side),
|
|
370
403
|
};
|
|
371
404
|
/** The product being run, so a mistake points at its own help. */
|
|
372
405
|
let product = "";
|
|
406
|
+
/** `90`, `90s`, `30m`, `1h` or `7d`, in seconds. */
|
|
407
|
+
function durationSeconds(text) {
|
|
408
|
+
const found = /^(\d{1,7})([smhd]?)$/.exec(text.trim());
|
|
409
|
+
if (!found)
|
|
410
|
+
throw usage(`--expires takes a number of seconds or 30m, 1h, 7d; not ${text}.`);
|
|
411
|
+
return Number(found[1]) * { "": 1, s: 1, m: 60, h: 3600, d: 86_400 }[found[2]];
|
|
412
|
+
}
|
|
373
413
|
function usage(message) {
|
|
374
414
|
return new RuntimeError({
|
|
375
415
|
message,
|
|
@@ -415,7 +455,9 @@ const COMMANDS = [
|
|
|
415
455
|
"sso",
|
|
416
456
|
"secrets",
|
|
417
457
|
"keys",
|
|
458
|
+
"reservations",
|
|
418
459
|
"referrals",
|
|
460
|
+
"plans",
|
|
419
461
|
"compare",
|
|
420
462
|
"switch",
|
|
421
463
|
"ls",
|
|
@@ -423,13 +465,16 @@ const COMMANDS = [
|
|
|
423
465
|
"support",
|
|
424
466
|
"docs",
|
|
425
467
|
"mcp",
|
|
468
|
+
"fly-api",
|
|
426
469
|
"sandboxes",
|
|
427
470
|
"snapshots",
|
|
428
471
|
"images",
|
|
429
472
|
"volumes",
|
|
430
473
|
"jobs",
|
|
431
474
|
"apps",
|
|
475
|
+
"functions",
|
|
432
476
|
"builds",
|
|
477
|
+
"ci",
|
|
433
478
|
"containers",
|
|
434
479
|
"agents",
|
|
435
480
|
"domains",
|
|
@@ -451,10 +496,13 @@ const OWN_HELP = [
|
|
|
451
496
|
"volumes",
|
|
452
497
|
"jobs",
|
|
453
498
|
"apps",
|
|
499
|
+
"functions",
|
|
454
500
|
"builds",
|
|
501
|
+
"ci",
|
|
455
502
|
"containers",
|
|
456
503
|
"agents",
|
|
457
504
|
"keys",
|
|
505
|
+
"reservations",
|
|
458
506
|
"webhooks",
|
|
459
507
|
"webhook",
|
|
460
508
|
"billing",
|
|
@@ -501,6 +549,7 @@ const SANDBOX_VERBS = [
|
|
|
501
549
|
"run-code",
|
|
502
550
|
"preview",
|
|
503
551
|
"previews",
|
|
552
|
+
"link",
|
|
504
553
|
"unshare",
|
|
505
554
|
"session",
|
|
506
555
|
"network",
|
|
@@ -624,6 +673,22 @@ function describe(info) {
|
|
|
624
673
|
["charged", dollars(BigInt(Math.round(Number(info.chargedMicros) || 0)))],
|
|
625
674
|
]);
|
|
626
675
|
}
|
|
676
|
+
/** `cp` between this machine and a sandbox or an App's copy: exactly one
|
|
677
|
+
* side is `<id>:/path`. A destination ending in / (or a local directory)
|
|
678
|
+
* takes the source's own name inside it, as cp does. */
|
|
679
|
+
async function copyFile(from, to, use, side) {
|
|
680
|
+
const source = remote(from);
|
|
681
|
+
const target = remote(to);
|
|
682
|
+
const inside = (dir, path) => `${dir.replace(/\/+$/, "")}/${path.replace(/\/+$/, "").split("/").pop()}`;
|
|
683
|
+
if (source && !target) {
|
|
684
|
+
const local = await import("node:fs/promises").then((fs) => fs.stat(to).catch(() => null));
|
|
685
|
+
await (await use(source.id)).files.download(source.path, to.endsWith("/") || local?.isDirectory() ? inside(to, source.path) : to);
|
|
686
|
+
}
|
|
687
|
+
else if (!source && target)
|
|
688
|
+
await (await use(target.id)).files.upload(from, target.path.endsWith("/") ? inside(target.path, from) : target.path);
|
|
689
|
+
else
|
|
690
|
+
throw usage(`Name exactly one side as ${side}:/path.`);
|
|
691
|
+
}
|
|
627
692
|
/** Resolves `<id>:/path` into its parts, or undefined for a local path. */
|
|
628
693
|
function remote(spec) {
|
|
629
694
|
const match = /^([A-Za-z0-9][A-Za-z0-9_.-]{0,127}):(\/.*)$/.exec(spec);
|
|
@@ -694,7 +759,9 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
694
759
|
return 0;
|
|
695
760
|
}
|
|
696
761
|
if (command === "run") {
|
|
697
|
-
const args = parse(rest, [...CREATE_FLAGS, "keep", "root"],
|
|
762
|
+
const args = parse(rest, [...CREATE_FLAGS, "keep", "root"],
|
|
763
|
+
// execute() reads --trace-files for run as for exec.
|
|
764
|
+
[...CREATE_VALUES, "cwd", "env", "trace-files"], `${me} sandboxes run -- python3 < invoice.py`);
|
|
698
765
|
const commandLine = args.rest ?? args.positional;
|
|
699
766
|
if (!commandLine.length)
|
|
700
767
|
throw usage(`Give the command after --: ${me} sandboxes run -- python3 < invoice.py`);
|
|
@@ -707,8 +774,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
707
774
|
// SIGINT and normal completion share one delete, with an absolute
|
|
708
775
|
// deadline across its retries, so cleanup cannot hang. A delete, not a
|
|
709
776
|
// stop: a stopped sandbox keeps its disk, billed, and an account without
|
|
710
|
-
// credit keeps at most
|
|
711
|
-
//
|
|
777
|
+
// credit keeps at most 5, so five runs that only stopped refused the
|
|
778
|
+
// sixth (no_credit_kept_limit). --keep is how to keep one.
|
|
712
779
|
const stop = () => (cleanup ??= (async () => {
|
|
713
780
|
const signal = AbortSignal.timeout(30_000);
|
|
714
781
|
try {
|
|
@@ -904,6 +971,8 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
904
971
|
}
|
|
905
972
|
if (command === "keys")
|
|
906
973
|
return keys(rest, env, out);
|
|
974
|
+
if (command === "reservations")
|
|
975
|
+
return reservationsCommand(rest, env, out);
|
|
907
976
|
if (command === "sso") {
|
|
908
977
|
const status = await (await client(env)).sso.get();
|
|
909
978
|
print([
|
|
@@ -1013,6 +1082,128 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
1013
1082
|
].join("\n"), r);
|
|
1014
1083
|
return 0;
|
|
1015
1084
|
}
|
|
1085
|
+
if (command === "plans") {
|
|
1086
|
+
product = "plans";
|
|
1087
|
+
const args = parse(rest, ["yearly"], []);
|
|
1088
|
+
const [verb, id, extra] = args.positional;
|
|
1089
|
+
const runtime = await client(env);
|
|
1090
|
+
const usd = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
1091
|
+
const day = (iso) => (iso ? iso.slice(0, 10) : "-");
|
|
1092
|
+
const yearly = args.flags.get("yearly")?.at(-1) === "true";
|
|
1093
|
+
if (yearly && verb !== "choose")
|
|
1094
|
+
throw usage("--yearly goes with plans choose <plan>.");
|
|
1095
|
+
const now = (current) => !current
|
|
1096
|
+
? "No plan: you pay for what runs from your credit."
|
|
1097
|
+
: [
|
|
1098
|
+
`Your plan: ${current.name} (${current.planId}), ${current.status.replace("_", " ")}, ${current.interval === "year" ? "yearly" : "monthly"}.`,
|
|
1099
|
+
current.status === "trialing" && current.trialEndsAt
|
|
1100
|
+
? `The trial ends on ${day(current.trialEndsAt)}.`
|
|
1101
|
+
: undefined,
|
|
1102
|
+
current.cancelAtPeriodEnd
|
|
1103
|
+
? `It ends on ${day(current.periodEnd)}; \`${me} plans resume\` keeps it.`
|
|
1104
|
+
: current.nextPlanId
|
|
1105
|
+
? `It moves to ${current.nextPlanId} on ${day(current.periodEnd)}.`
|
|
1106
|
+
: `Next fee on ${day(current.periodEnd)}.`,
|
|
1107
|
+
current.planUsage
|
|
1108
|
+
? `Plan usage left: ${usd(current.planUsage.leftMicros)} of ${usd(current.planUsage.grantedMicros)}, until ${day(current.planUsage.expiresAt)}.`
|
|
1109
|
+
: undefined,
|
|
1110
|
+
current.addOns.length
|
|
1111
|
+
? `Add-ons: ${current.addOns
|
|
1112
|
+
.map((a) => `${a.id}${a.quantity > 1 ? ` x${a.quantity}` : ""}${a.endsAt ? ` (ends ${day(a.endsAt)})` : ""}`)
|
|
1113
|
+
.join(", ")}.`
|
|
1114
|
+
: undefined,
|
|
1115
|
+
current.autoUpgrade
|
|
1116
|
+
? `Auto-upgrade: on; a create a limit refuses moves you up a plan. \`${me} plans auto-upgrade off\` stops it.`
|
|
1117
|
+
: "Auto-upgrade: off.",
|
|
1118
|
+
]
|
|
1119
|
+
.filter((line) => line !== undefined)
|
|
1120
|
+
.join("\n");
|
|
1121
|
+
if (verb === undefined || verb === "ls") {
|
|
1122
|
+
if (id !== undefined)
|
|
1123
|
+
throw usage(`Unexpected ${id}.`);
|
|
1124
|
+
const r = await runtime.plans.list();
|
|
1125
|
+
const amount = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
|
|
1126
|
+
print([
|
|
1127
|
+
now(r.current),
|
|
1128
|
+
"",
|
|
1129
|
+
r.plans.length
|
|
1130
|
+
? table([
|
|
1131
|
+
["plan", "name", "fee a month", "a year", "bonus", "trial", "support"],
|
|
1132
|
+
...r.plans.map((p) => [
|
|
1133
|
+
p.id,
|
|
1134
|
+
p.name,
|
|
1135
|
+
p.collection === "invoice" ? "invoiced" : usd(p.feeMicros),
|
|
1136
|
+
p.yearlyFeeMicros === null ? "-" : usd(p.yearlyFeeMicros),
|
|
1137
|
+
BigInt(p.bonusMicros) > 0n ? usd(p.bonusMicros) : "-",
|
|
1138
|
+
p.trialDays > 0 ? `${p.trialDays} days` : "-",
|
|
1139
|
+
p.support,
|
|
1140
|
+
]),
|
|
1141
|
+
])
|
|
1142
|
+
: "No plans are offered yet.",
|
|
1143
|
+
...r.plans
|
|
1144
|
+
.filter((p) => p.included.length || p.limits.length || p.addOns.length)
|
|
1145
|
+
.flatMap((p) => [
|
|
1146
|
+
"",
|
|
1147
|
+
`${p.id}:`,
|
|
1148
|
+
...(p.included.length
|
|
1149
|
+
? [
|
|
1150
|
+
` includes ${p.included.map((i) => `${amount(i.quantity)} ${i.unit} of ${i.product} ${i.meter}`).join(", ")} a month`,
|
|
1151
|
+
]
|
|
1152
|
+
: []),
|
|
1153
|
+
...(p.limits.length
|
|
1154
|
+
? [
|
|
1155
|
+
` limits ${p.limits.map((l) => `${l.limit} ${l.max === null ? "unlimited" : l.max}`).join(", ")}`,
|
|
1156
|
+
]
|
|
1157
|
+
: []),
|
|
1158
|
+
...(p.addOns.length
|
|
1159
|
+
? [
|
|
1160
|
+
` add-ons ${p.addOns.map((a) => `${a.id} (${usd(a.feeMicros)} a month)`).join(", ")}`,
|
|
1161
|
+
]
|
|
1162
|
+
: []),
|
|
1163
|
+
]),
|
|
1164
|
+
"",
|
|
1165
|
+
"A plan's fee comes back as plan usage, spent first each month at the same measured prices;",
|
|
1166
|
+
`usage left at the period's end ends with it. Choose one: ${me} plans choose <plan> [--yearly].`,
|
|
1167
|
+
].join("\n"), r);
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
if (verb === "choose" || verb === "cancel" || verb === "resume") {
|
|
1171
|
+
if (verb === "choose" && !id)
|
|
1172
|
+
throw usage(`Say which plan: ${me} plans choose <plan>.`);
|
|
1173
|
+
if ((verb === "choose" ? extra : id) !== undefined)
|
|
1174
|
+
throw usage(`Unexpected ${verb === "choose" ? extra : id}.`);
|
|
1175
|
+
const r = verb === "choose"
|
|
1176
|
+
? await runtime.plans.choose(id, yearly ? { interval: "year" } : {})
|
|
1177
|
+
: verb === "cancel"
|
|
1178
|
+
? await runtime.plans.cancel()
|
|
1179
|
+
: await runtime.plans.resume();
|
|
1180
|
+
print(r.checkoutUrl
|
|
1181
|
+
? `A person chooses it and pays the first month here, then the plan starts:\n${r.checkoutUrl}`
|
|
1182
|
+
: now(r.current), r);
|
|
1183
|
+
return 0;
|
|
1184
|
+
}
|
|
1185
|
+
if (verb === "add-on" || verb === "remove-add-on") {
|
|
1186
|
+
if (!id)
|
|
1187
|
+
throw usage(`Say which add-on: ${me} plans ${verb} <id>.`);
|
|
1188
|
+
if (extra !== undefined)
|
|
1189
|
+
throw usage(`Unexpected ${extra}.`);
|
|
1190
|
+
const r = verb === "add-on" ? await runtime.plans.addOn(id) : await runtime.plans.removeAddOn(id);
|
|
1191
|
+
print(r.checkoutUrl
|
|
1192
|
+
? `A person confirms it here, then the add-on starts:\n${r.checkoutUrl}`
|
|
1193
|
+
: now(r.current), r);
|
|
1194
|
+
return 0;
|
|
1195
|
+
}
|
|
1196
|
+
if (verb === "auto-upgrade") {
|
|
1197
|
+
if (id !== "on" && id !== "off")
|
|
1198
|
+
throw usage(`Say on or off: ${me} plans auto-upgrade on|off.`);
|
|
1199
|
+
if (extra !== undefined)
|
|
1200
|
+
throw usage(`Unexpected ${extra}.`);
|
|
1201
|
+
const r = await runtime.plans.autoUpgrade(id === "on");
|
|
1202
|
+
print(now(r.current), r);
|
|
1203
|
+
return 0;
|
|
1204
|
+
}
|
|
1205
|
+
throw usage(`Unknown plans command ${verb}: ls, choose <plan> [--yearly], cancel, resume, add-on <id>, remove-add-on <id> or auto-upgrade on|off.`);
|
|
1206
|
+
}
|
|
1016
1207
|
if (command === "compare" || command === "switch")
|
|
1017
1208
|
return switching(command, rest, env, out);
|
|
1018
1209
|
if (command === "mcp") {
|
|
@@ -1024,6 +1215,22 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
1024
1215
|
await new Promise((resolve) => process.stdout.write("", () => resolve()));
|
|
1025
1216
|
process.exit(0);
|
|
1026
1217
|
}
|
|
1218
|
+
if (command === "fly-api") {
|
|
1219
|
+
const args = parse(rest, [], ["port", "host"]);
|
|
1220
|
+
const { serveMachinesApi } = await import("./fly/index.js");
|
|
1221
|
+
const served = await serveMachinesApi({
|
|
1222
|
+
client: await client(env),
|
|
1223
|
+
port: args.flags.has("port") ? portOf(flag(args, "port"), "4280") : 4280,
|
|
1224
|
+
hostname: flag(args, "host") ?? "127.0.0.1",
|
|
1225
|
+
});
|
|
1226
|
+
out.write(`Fly Machines API on ${served.url}: point FLY_API_HOSTNAME (fly-admin's apiUrl, fly-go's FLY_FLAPS_BASE_URL) here.\nEach request runs as this login, or as the Runtime key (rtcloud_...) in its bearer token. Ctrl-C stops it.\n`);
|
|
1227
|
+
await new Promise((resolve) => {
|
|
1228
|
+
process.once("SIGINT", resolve);
|
|
1229
|
+
process.once("SIGTERM", resolve);
|
|
1230
|
+
});
|
|
1231
|
+
await served.close();
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
1027
1234
|
if (command === "docs") {
|
|
1028
1235
|
const topic = rest[0] ?? "index";
|
|
1029
1236
|
const response = await envFetch(`https://withruntime.com/docs/${topic === "index" ? "start" : encodeURIComponent(topic)}.md`, { redirect: "error" });
|
|
@@ -1128,10 +1335,18 @@ export async function run(argv, env = process.env, out = defaultOut(argv)) {
|
|
|
1128
1335
|
product = "apps";
|
|
1129
1336
|
return (await import("./apps-cli.js")).appsCommand(rest, env, out, () => client(env), kit);
|
|
1130
1337
|
}
|
|
1338
|
+
if (command === "function" || command === "functions") {
|
|
1339
|
+
product = "functions";
|
|
1340
|
+
return (await import("./apps-cli.js")).functionsCommand(rest, env, out, () => client(env), kit);
|
|
1341
|
+
}
|
|
1131
1342
|
if (command === "build" || command === "builds") {
|
|
1132
1343
|
product = "builds";
|
|
1133
1344
|
return (await import("./apps-cli.js")).buildsCommand(rest, out, () => client(env), kit);
|
|
1134
1345
|
}
|
|
1346
|
+
if (command === "ci") {
|
|
1347
|
+
product = "ci";
|
|
1348
|
+
return (await import("./ci-cli.js")).ciCommand(rest, out, () => client(env), kit);
|
|
1349
|
+
}
|
|
1135
1350
|
if (command === "container" || command === "containers") {
|
|
1136
1351
|
product = "containers";
|
|
1137
1352
|
return (await import("./apps-cli.js")).containersCommand(rest, env, out, () => client(env), kit);
|
|
@@ -1466,14 +1681,29 @@ const DROP_IN = {
|
|
|
1466
1681
|
js: 'import { SandboxInstance } from "withruntime/blaxel"; // was: from "@blaxel/core"',
|
|
1467
1682
|
py: "from withruntime.blaxel import SandboxInstance # was: from blaxel.core import SandboxInstance",
|
|
1468
1683
|
},
|
|
1684
|
+
modal: {
|
|
1685
|
+
js: 'import { ModalClient } from "withruntime/modal"; // was: from "modal"',
|
|
1686
|
+
py: "from withruntime import modal # was: import modal",
|
|
1687
|
+
},
|
|
1469
1688
|
// A Worker-only SDK: no Python. More than the import changes, so say what.
|
|
1470
1689
|
cloudflare: {
|
|
1471
1690
|
js: 'import { getSandbox } from "withruntime/cloudflare"; // was: from "@cloudflare/sandbox"',
|
|
1472
1691
|
also: "Change the `export { Sandbox }` line the same way, delete the `containers` block from the Wrangler config and run `npx wrangler secret put RUNTIME_API_KEY`.",
|
|
1473
1692
|
},
|
|
1474
1693
|
};
|
|
1475
|
-
/**
|
|
1476
|
-
|
|
1694
|
+
/** Fly's Machines API moves by its address, not an import: the lines of the
|
|
1695
|
+
* Fly guide's "From Machines". */
|
|
1696
|
+
const FLY_MACHINES = [
|
|
1697
|
+
"Your Fly Machines code runs on Runtime with one address changed:",
|
|
1698
|
+
" npx withruntime fly-api # answers on http://127.0.0.1:4280 as your Runtime login",
|
|
1699
|
+
" export FLY_API_HOSTNAME=http://127.0.0.1:4280 # Fly's curl examples",
|
|
1700
|
+
" export FLY_FLAPS_BASE_URL=http://127.0.0.1:4280 # fly-go, Fly's Go client",
|
|
1701
|
+
];
|
|
1702
|
+
/** How to move a rival's code across, when one import (or, for Fly's
|
|
1703
|
+
* Machines, one address) does it. `from` is the name the caller gave. */
|
|
1704
|
+
export function dropInLines(provider, from) {
|
|
1705
|
+
if (from === "fly-machines")
|
|
1706
|
+
return FLY_MACHINES;
|
|
1477
1707
|
const drop = provider && DROP_IN[provider];
|
|
1478
1708
|
if (!drop)
|
|
1479
1709
|
return [];
|
|
@@ -1593,13 +1823,13 @@ async function switching(command, argv, env, out) {
|
|
|
1593
1823
|
"A switch is recorded before the first top-up, and this organization has made one already.", summary);
|
|
1594
1824
|
return 0;
|
|
1595
1825
|
}
|
|
1596
|
-
const provider = SWITCH_FROM[from];
|
|
1597
|
-
if (!provider
|
|
1598
|
-
throw unknown("provider", from, Object.keys(
|
|
1826
|
+
const provider = Object.hasOwn(SWITCH_FROM, from) ? SWITCH_FROM[from] : undefined;
|
|
1827
|
+
if (!provider)
|
|
1828
|
+
throw unknown("provider", from, Object.keys(SWITCH_FROM));
|
|
1599
1829
|
const summary = await api.record({ provider });
|
|
1600
1830
|
print([
|
|
1601
1831
|
`Recorded: switching from ${RIVAL_NAMES[provider]}. ${welcomeLine(summary) ?? ""}`.trim(),
|
|
1602
|
-
...dropInLines(provider),
|
|
1832
|
+
...dropInLines(provider, from),
|
|
1603
1833
|
].join("\n"), summary);
|
|
1604
1834
|
return 0;
|
|
1605
1835
|
}
|
|
@@ -1646,7 +1876,7 @@ async function switching(command, argv, env, out) {
|
|
|
1646
1876
|
const line = switchingLine(c.switching, rival);
|
|
1647
1877
|
if (line)
|
|
1648
1878
|
lines.push("", line);
|
|
1649
|
-
const drop = dropInLines(rival);
|
|
1879
|
+
const drop = dropInLines(rival, from);
|
|
1650
1880
|
if (drop.length)
|
|
1651
1881
|
lines.push("", ...drop);
|
|
1652
1882
|
print(lines.join("\n"), c);
|
|
@@ -1811,9 +2041,11 @@ async function sandbox(argv, env, out) {
|
|
|
1811
2041
|
};
|
|
1812
2042
|
if (size.vcpu === undefined && size.memoryMiB === undefined && size.diskMiB === undefined)
|
|
1813
2043
|
throw usage(`Name a new size, e.g. ${me} sandboxes resize ${sbx.id} --memory 4096 --restart`);
|
|
1814
|
-
|
|
2044
|
+
// A disk alone grows live, or is refused saying it needs --restart.
|
|
2045
|
+
const live = size.vcpu === undefined && size.memoryMiB === undefined;
|
|
2046
|
+
if (!live && !args.flags.has("restart"))
|
|
1815
2047
|
throw usage(`A resize restarts it: its whole disk stays and its programs stop. Add --restart to say so, or snapshot it first (${me} sandboxes snapshot ${sbx.id}) to keep its memory too.`);
|
|
1816
|
-
await sbx.resize(size, { restart:
|
|
2048
|
+
await sbx.resize(size, { restart: args.flags.has("restart") });
|
|
1817
2049
|
print(describe(sbx.info), sbx.info);
|
|
1818
2050
|
return 0;
|
|
1819
2051
|
}
|
|
@@ -2007,7 +2239,7 @@ async function sandbox(argv, env, out) {
|
|
|
2007
2239
|
return 0;
|
|
2008
2240
|
}
|
|
2009
2241
|
case "exec": {
|
|
2010
|
-
const args = parse(rest, ["root"], ["cwd", "env", "timeout"], `${me} sandboxes exec <id> -- ls -la`);
|
|
2242
|
+
const args = parse(rest, ["root"], ["cwd", "env", "timeout", "trace-files"], `${me} sandboxes exec <id> -- ls -la`);
|
|
2011
2243
|
const command = args.rest ?? args.positional.slice(1);
|
|
2012
2244
|
if (!command.length)
|
|
2013
2245
|
throw usage("Give the command after --: runtime sandboxes exec <id> -- ls -la");
|
|
@@ -2133,19 +2365,7 @@ async function sandbox(argv, env, out) {
|
|
|
2133
2365
|
}
|
|
2134
2366
|
case "cp": {
|
|
2135
2367
|
const [from, to] = parse(rest, ["r", "recursive"], []).positional;
|
|
2136
|
-
|
|
2137
|
-
const target = remote(need(to, "a destination"));
|
|
2138
|
-
// A destination ending in / (or a local directory) takes the source's
|
|
2139
|
-
// own name inside it, as cp does.
|
|
2140
|
-
const inside = (dir, path) => `${dir.replace(/\/+$/, "")}/${path.replace(/\/+$/, "").split("/").pop()}`;
|
|
2141
|
-
if (source && !target) {
|
|
2142
|
-
const local = await import("node:fs/promises").then((fs) => fs.stat(to).catch(() => null));
|
|
2143
|
-
await (await use(source.id)).files.download(source.path, to.endsWith("/") || local?.isDirectory() ? inside(to, source.path) : to);
|
|
2144
|
-
}
|
|
2145
|
-
else if (!source && target)
|
|
2146
|
-
await (await use(target.id)).files.upload(from, target.path.endsWith("/") ? inside(target.path, from) : target.path);
|
|
2147
|
-
else
|
|
2148
|
-
throw usage("Name exactly one side as <sandbox-id>:/path.");
|
|
2368
|
+
await copyFile(need(from, "a source"), need(to, "a destination"), use, "<sandbox-id>");
|
|
2149
2369
|
print(`Copied ${from} to ${to}.`, { from, to });
|
|
2150
2370
|
return 0;
|
|
2151
2371
|
}
|
|
@@ -2175,6 +2395,35 @@ async function sandbox(argv, env, out) {
|
|
|
2175
2395
|
: `${preview.urlWithToken ?? preview.url}\nPrivate: send the header x-runtime-preview-token: ${preview.token} (expires ${preview.tokenExpiresAt}).`, preview);
|
|
2176
2396
|
return 0;
|
|
2177
2397
|
}
|
|
2398
|
+
case "link": {
|
|
2399
|
+
if (rest[0] === "revoke") {
|
|
2400
|
+
const sbx = await use(need(rest[1], "runtime sandboxes link revoke <id>"));
|
|
2401
|
+
const revoked = await sbx.files.revokeLinks();
|
|
2402
|
+
print(`Every link made for ${sbx.id} until now is refused.`, revoked);
|
|
2403
|
+
return 0;
|
|
2404
|
+
}
|
|
2405
|
+
const args = parse(rest, ["put", "archive", "no-wake"], ["expires", "filename", "max-bytes"]);
|
|
2406
|
+
const example = "runtime sandboxes link <id> /workspace/report.pdf";
|
|
2407
|
+
const path = need(args.positional[1], example);
|
|
2408
|
+
const sbx = await use(need(args.positional[0], example));
|
|
2409
|
+
const expires = flag(args, "expires");
|
|
2410
|
+
const expiresInSeconds = expires === undefined ? undefined : durationSeconds(expires);
|
|
2411
|
+
const wake = has(args, "no-wake") ? false : undefined;
|
|
2412
|
+
const link = has(args, "put")
|
|
2413
|
+
? await sbx.files.uploadUrl(path, {
|
|
2414
|
+
expiresInSeconds,
|
|
2415
|
+
maxBytes: integer(args, "max-bytes"),
|
|
2416
|
+
wake,
|
|
2417
|
+
})
|
|
2418
|
+
: await sbx.files.downloadUrl(path, {
|
|
2419
|
+
expiresInSeconds,
|
|
2420
|
+
filename: flag(args, "filename"),
|
|
2421
|
+
archive: has(args, "archive") || undefined,
|
|
2422
|
+
wake,
|
|
2423
|
+
});
|
|
2424
|
+
print(`${link.url}\n${link.method === "PUT" ? `Upload: curl -T <file> '${link.url}'` : `Download: curl -fLo ${JSON.stringify(link.filename ?? link.path.split("/").pop() ?? "file")}${link.archive ? ".tar.gz" : ""} '${link.url}'`} (works until ${link.expiresAt}).`, link);
|
|
2425
|
+
return 0;
|
|
2426
|
+
}
|
|
2178
2427
|
case "previews": {
|
|
2179
2428
|
const list = await (await use(rest[0])).previews.list();
|
|
2180
2429
|
print(list.length
|
|
@@ -2371,16 +2620,17 @@ export function parseSecretRules(values) {
|
|
|
2371
2620
|
}
|
|
2372
2621
|
return [...byMethods.values()];
|
|
2373
2622
|
}
|
|
2374
|
-
/** `--only-label role=hub,tier=x --only-sandbox <id>`: a
|
|
2375
|
-
* hold every pair of any one --only-label gets the
|
|
2376
|
-
* --only-sandbox
|
|
2377
|
-
*
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2623
|
+
/** `--only-label role=hub,tier=x --only-sandbox <id> --only-app <id>`: a
|
|
2624
|
+
* sandbox whose labels hold every pair of any one --only-label gets the
|
|
2625
|
+
* secret, and so does each --only-sandbox and every copy of each --only-app.
|
|
2626
|
+
* `--all-sandboxes`: every sandbox again. Nothing: undefined, so a
|
|
2627
|
+
* replacement keeps the scope it has. */
|
|
2628
|
+
export function parseSecretScope(labels, sandboxes, all, apps) {
|
|
2629
|
+
if (all && (labels?.length || sandboxes?.length || apps?.length))
|
|
2630
|
+
throw usage("--all-sandboxes gives the secret to every sandbox; leave out --only-label, --only-sandbox and --only-app.");
|
|
2381
2631
|
if (all)
|
|
2382
2632
|
return {};
|
|
2383
|
-
if (!labels?.length && !sandboxes?.length)
|
|
2633
|
+
if (!labels?.length && !sandboxes?.length && !apps?.length)
|
|
2384
2634
|
return undefined;
|
|
2385
2635
|
const selectors = (labels ?? []).map((text) => {
|
|
2386
2636
|
const selector = {};
|
|
@@ -2394,23 +2644,28 @@ export function parseSecretScope(labels, sandboxes, all) {
|
|
|
2394
2644
|
throw usage(`--only-label takes key=value pairs, such as role=hub; got "${text}".`);
|
|
2395
2645
|
return selector;
|
|
2396
2646
|
});
|
|
2647
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2397
2648
|
for (const id of sandboxes ?? [])
|
|
2398
|
-
if (
|
|
2649
|
+
if (!uuid.test(id))
|
|
2399
2650
|
throw usage(`--only-sandbox takes a sandbox id; "${id}" is not one.`);
|
|
2651
|
+
for (const id of apps ?? [])
|
|
2652
|
+
if (!uuid.test(id))
|
|
2653
|
+
throw usage(`--only-app takes an App's id (GET /v1/apps lists them); "${id}" is not one.`);
|
|
2654
|
+
const ids = (list) => [...new Set(list.map((id) => id.toLowerCase()))];
|
|
2400
2655
|
return {
|
|
2401
2656
|
...(selectors.length ? { labels: selectors } : {}),
|
|
2402
|
-
...(sandboxes?.length
|
|
2403
|
-
|
|
2404
|
-
: {}),
|
|
2657
|
+
...(sandboxes?.length ? { sandboxes: ids(sandboxes) } : {}),
|
|
2658
|
+
...(apps?.length ? { apps: ids(apps) } : {}),
|
|
2405
2659
|
};
|
|
2406
2660
|
}
|
|
2407
2661
|
/** Who gets a secret, in a few words. */
|
|
2408
|
-
export const scopeText = (only) => only && (only.labels?.length || only.sandboxes?.length)
|
|
2662
|
+
export const scopeText = (only) => only && (only.labels?.length || only.sandboxes?.length || only.apps?.length)
|
|
2409
2663
|
? [
|
|
2410
2664
|
...(only.labels ?? []).map((selector) => Object.entries(selector)
|
|
2411
2665
|
.map(([key, value]) => `${key}=${value}`)
|
|
2412
2666
|
.join(",")),
|
|
2413
2667
|
...(only.sandboxes ?? []),
|
|
2668
|
+
...(only.apps ?? []).map((id) => `app ${id}`),
|
|
2414
2669
|
].join(" | ")
|
|
2415
2670
|
: "all";
|
|
2416
2671
|
const ruleText = (rules) => rules?.length
|
|
@@ -2422,13 +2677,25 @@ export async function secretsCommand(argv, env, out,
|
|
|
2422
2677
|
/** Tests give the value here; the CLI reads standard input. */
|
|
2423
2678
|
read = readSecret) {
|
|
2424
2679
|
const [verb, ...rest] = argv;
|
|
2425
|
-
const args = parse(rest, ["jobs", "all-sandboxes"
|
|
2680
|
+
const args = parse(rest, ["jobs", "all-sandboxes", "keep-value", "all", "none"], [
|
|
2681
|
+
"host",
|
|
2682
|
+
"header",
|
|
2683
|
+
"format",
|
|
2684
|
+
"allow",
|
|
2685
|
+
"value",
|
|
2686
|
+
"version",
|
|
2687
|
+
"only-label",
|
|
2688
|
+
"only-sandbox",
|
|
2689
|
+
"only-app",
|
|
2690
|
+
"agent",
|
|
2691
|
+
"env",
|
|
2692
|
+
]);
|
|
2426
2693
|
const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
|
|
2427
2694
|
const rows = (list) => list.length
|
|
2428
2695
|
? table([
|
|
2429
2696
|
["name", "hosts", "header", "placeholder", "allow", "sandboxes", "jobs"],
|
|
2430
2697
|
...list.map(({ name, sandboxes: secret, jobs }) => [
|
|
2431
|
-
name,
|
|
2698
|
+
secret?.env ? `${name} (as ${secret.env})` : name,
|
|
2432
2699
|
secret ? secret.hosts.join(",") : "-",
|
|
2433
2700
|
secret?.header ? `${secret.header}: ${secret.format ?? "{value}"}` : "-",
|
|
2434
2701
|
secret ? secret.placeholder : "-",
|
|
@@ -2462,15 +2729,44 @@ read = readSecret) {
|
|
|
2462
2729
|
args.flags.has("allow") ||
|
|
2463
2730
|
args.flags.has("only-label") ||
|
|
2464
2731
|
args.flags.has("only-sandbox") ||
|
|
2465
|
-
args.flags.has("
|
|
2732
|
+
args.flags.has("only-app") ||
|
|
2733
|
+
args.flags.has("all-sandboxes") ||
|
|
2734
|
+
args.flags.has("env")))
|
|
2466
2735
|
throw usage(`rotate replaces the copy for jobs. ${jobsOnly}; to change the sandboxes' copy, set it again with --host.`);
|
|
2736
|
+
if (verb === "set" && has(args, "keep-value")) {
|
|
2737
|
+
// Narrowing needs no value: it only takes places away (secrets.narrow).
|
|
2738
|
+
if (jobs ||
|
|
2739
|
+
args.flags.has("value") ||
|
|
2740
|
+
args.flags.has("header") ||
|
|
2741
|
+
args.flags.has("format") ||
|
|
2742
|
+
args.flags.has("only-label") ||
|
|
2743
|
+
args.flags.has("only-sandbox") ||
|
|
2744
|
+
args.flags.has("only-app") ||
|
|
2745
|
+
args.flags.has("all-sandboxes") ||
|
|
2746
|
+
args.flags.has("env"))
|
|
2747
|
+
throw usage(`--keep-value changes only --host and --allow, and only to narrower ones. Change who gets it with \`${me} secrets scope\`; anything else needs the value.`);
|
|
2748
|
+
const allow = args.flags.get("allow");
|
|
2749
|
+
if (!hosts.length && !allow)
|
|
2750
|
+
throw usage("With --keep-value, give the narrower --host and/or --allow it keeps.");
|
|
2751
|
+
const saved = await (await client(env)).secrets.narrow(name, {
|
|
2752
|
+
...(hosts.length ? { hosts } : {}),
|
|
2753
|
+
...(allow ? { rules: parseSecretRules(allow) } : {}),
|
|
2754
|
+
});
|
|
2755
|
+
print([
|
|
2756
|
+
`Narrowed ${saved.name}; its value is unchanged. It goes only to ${saved.hosts.join(", ")}${saved.rules?.length ? `, only in these requests: ${ruleText(saved.rules)}` : ""}.`,
|
|
2757
|
+
...(saved.enforced
|
|
2758
|
+
? []
|
|
2759
|
+
: ["A host is catching up; running sandboxes have it within a minute."]),
|
|
2760
|
+
].join("\n"), saved);
|
|
2761
|
+
return 0;
|
|
2762
|
+
}
|
|
2467
2763
|
if (!hosts.length && !jobs)
|
|
2468
2764
|
throw usage("Give --host once for each host the value may go to, such as --host api.openai.com, and/or --jobs to let scheduled jobs use it.");
|
|
2469
2765
|
if (args.flags.has("value"))
|
|
2470
2766
|
throw usage('The value is read from standard input, never an argument: printf %s "$KEY" | runtime secrets set NAME --host ...');
|
|
2471
2767
|
const value = await read();
|
|
2472
2768
|
if (!value)
|
|
2473
|
-
throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host
|
|
2769
|
+
throw usage('The value is empty. Pipe it in: printf %s "$KEY" | runtime secrets set NAME --host ...; or, to narrow its hosts and rules without it, add --keep-value.');
|
|
2474
2770
|
const runtime = await client(env);
|
|
2475
2771
|
if (verb === "rotate") {
|
|
2476
2772
|
const rotated = await runtime.secrets.rotate(name, value);
|
|
@@ -2478,15 +2774,18 @@ read = readSecret) {
|
|
|
2478
2774
|
return 0;
|
|
2479
2775
|
}
|
|
2480
2776
|
if (!hosts.length) {
|
|
2777
|
+
if (args.flags.has("env"))
|
|
2778
|
+
throw usage("--env names the variable sandboxes see the secret in, so it needs --host; a job names its variable on jobs create (--secret NAME=VAR).");
|
|
2481
2779
|
const saved = await runtime.secrets.set(name, { value, jobs: true });
|
|
2482
2780
|
print(`Stored ${name} for jobs, version ${saved.jobs.version}. Bind it: ${me} jobs create ... --secret ${name}`, saved);
|
|
2483
2781
|
return 0;
|
|
2484
2782
|
}
|
|
2485
2783
|
const header = flag(args, "header");
|
|
2486
2784
|
const format = flag(args, "format");
|
|
2785
|
+
const variable = flag(args, "env");
|
|
2487
2786
|
const allow = args.flags.get("allow");
|
|
2488
2787
|
const rules = allow ? parseSecretRules(allow) : undefined;
|
|
2489
|
-
const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
|
|
2788
|
+
const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"), args.flags.get("only-app"));
|
|
2490
2789
|
const saved = await runtime.secrets.set(name, {
|
|
2491
2790
|
value,
|
|
2492
2791
|
hosts,
|
|
@@ -2494,12 +2793,14 @@ read = readSecret) {
|
|
|
2494
2793
|
...(format ? { format } : {}),
|
|
2495
2794
|
...(rules ? { rules } : {}),
|
|
2496
2795
|
...(only ? { only } : {}),
|
|
2796
|
+
...(variable ? { env: variable } : {}),
|
|
2497
2797
|
...(jobs ? { jobs: true } : {}),
|
|
2498
2798
|
});
|
|
2799
|
+
const sees = `${saved.env ?? saved.name}=${saved.placeholder}`;
|
|
2499
2800
|
print([
|
|
2500
2801
|
saved.only
|
|
2501
|
-
? `Stored ${saved.name}. Only these sandboxes get it and see ${
|
|
2502
|
-
: `Stored ${saved.name}. Every sandbox sees ${
|
|
2802
|
+
? `Stored ${saved.name}. Only these sandboxes get it and see ${sees}: ${scopeText(saved.only)}.`
|
|
2803
|
+
: `Stored ${saved.name}. Every sandbox sees ${sees}.`,
|
|
2503
2804
|
saved.header
|
|
2504
2805
|
? `Requests to ${saved.hosts.join(", ")} get ${saved.header}: ${saved.format ?? "{value}"} over HTTPS.`
|
|
2505
2806
|
: `Over HTTPS to ${saved.hosts.join(", ")}, the placeholder becomes the value in the Authorization header or another credential header (x-api-key, x-goog-api-key); in the URL it stays a placeholder.`,
|
|
@@ -2517,13 +2818,24 @@ read = readSecret) {
|
|
|
2517
2818
|
}
|
|
2518
2819
|
if (verb === "scope") {
|
|
2519
2820
|
const name = need(args.positional[0], "the secret's name");
|
|
2520
|
-
const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"));
|
|
2821
|
+
const only = parseSecretScope(args.flags.get("only-label"), args.flags.get("only-sandbox"), has(args, "all-sandboxes"), args.flags.get("only-app"));
|
|
2521
2822
|
if (!only)
|
|
2522
|
-
throw usage("Say who gets it: --only-label key=value (once per selector), --only-sandbox <id>, or --all-sandboxes.");
|
|
2823
|
+
throw usage("Say who gets it: --only-label key=value (once per selector), --only-sandbox <id>, --only-app <app id>, or --all-sandboxes.");
|
|
2523
2824
|
const saved = await (await client(env)).secrets.scope(name, only);
|
|
2524
2825
|
print(`${saved.name}: ${saved.only ? `only ${scopeText(saved.only)}` : "every sandbox"}.${saved.enforced ? "" : " A host is catching up; running sandboxes have it within a minute."}`, saved);
|
|
2525
2826
|
return 0;
|
|
2526
2827
|
}
|
|
2828
|
+
if (verb === "share") {
|
|
2829
|
+
const name = need(args.positional[0], "the secret's name");
|
|
2830
|
+
const named = args.flags.get("agent") ?? [];
|
|
2831
|
+
if (Number(named.length > 0) + Number(has(args, "all")) + Number(has(args, "none")) !== 1)
|
|
2832
|
+
throw usage("Say who may use it: --agent <id> (once per agent), --all for every key of yours, or --none for only the key that stored it.");
|
|
2833
|
+
const saved = await (await client(env)).secrets.share(name, has(args, "all") ? "all" : named);
|
|
2834
|
+
print(`${saved.name}: ${saved.usableBy === "all"
|
|
2835
|
+
? "every key of yours"
|
|
2836
|
+
: `only the key that stored it${saved.usableBy.length ? `, and ${saved.usableBy.length} named agent${saved.usableBy.length === 1 ? "" : "s"}` : ""}`}.`, saved);
|
|
2837
|
+
return 0;
|
|
2838
|
+
}
|
|
2527
2839
|
if (verb === "reveal") {
|
|
2528
2840
|
const name = need(args.positional[0], "the secret's name");
|
|
2529
2841
|
const version = flag(args, "version");
|
|
@@ -2539,7 +2851,7 @@ read = readSecret) {
|
|
|
2539
2851
|
print(`Deleted ${name}${removed.from.length > 1 ? " for sandboxes and jobs" : removed.from[0] === "jobs" ? " for jobs" : ""}.`, removed);
|
|
2540
2852
|
return 0;
|
|
2541
2853
|
}
|
|
2542
|
-
throw usage("secrets takes set, scope, ls, get, rotate, reveal or rm. Run `runtime help`.");
|
|
2854
|
+
throw usage("secrets takes set, scope, ls, get, rotate, share, reveal or rm. Run `runtime help`.");
|
|
2543
2855
|
}
|
|
2544
2856
|
async function readInput(source) {
|
|
2545
2857
|
if (source !== "-")
|
|
@@ -2701,9 +3013,11 @@ const KEYS_HELP = `runtime keys <command>
|
|
|
2701
3013
|
stdout, and lasts until revoked. --json prints it as JSON, still alone on
|
|
2702
3014
|
stdout. --account-wide makes a key that sees and uses every sandbox and
|
|
2703
3015
|
other resource in the account; without it, a key sees only what it makes.
|
|
2704
|
-
create --sandbox <id,...> --actions <action,...> [--name <name>] [--daily-limit <usd>]
|
|
2705
|
-
A key limited to those sandboxes (up to 20, by id
|
|
2706
|
-
them, made by the saved key
|
|
3016
|
+
create --sandbox <id,...> --actions <action,...> [--name <name>] [--daily-limit <usd>] [--browser]
|
|
3017
|
+
A key limited to those sandboxes (up to 20, by id, whichever key made
|
|
3018
|
+
them) and those actions on them, made by the saved key, or with no saved
|
|
3019
|
+
key (or --browser) approved by an owner or admin in the browser, as
|
|
3020
|
+
above: get reads them; create starts, wakes and
|
|
2707
3021
|
restarts them, and needs --daily-limit when the making key has one; stop
|
|
2708
3022
|
stops and pauses them; exec runs commands; read_file and write_file reach
|
|
2709
3023
|
their files. It is served nothing else: no list, no other sandbox, no
|
|
@@ -2715,6 +3029,7 @@ const KEYS_HELP = `runtime keys <command>
|
|
|
2715
3029
|
runtime keys create --name worker-1 --daily-limit 5
|
|
2716
3030
|
runtime keys create --name ci --daily-limit 25 --browser | gh secret set RUNTIME_API_KEY
|
|
2717
3031
|
runtime keys create --name watcher --sandbox sbx_id --actions get,create --daily-limit 2
|
|
3032
|
+
runtime keys create --name helper --sandbox sbx_a,sbx_b --actions get,exec,read_file,write_file --browser
|
|
2718
3033
|
|
|
2719
3034
|
Making, listing and revoking keys with a key needs one an owner or admin made.
|
|
2720
3035
|
Every key is also at https://withruntime.com/account/keys.
|
|
@@ -2848,6 +3163,209 @@ async function nameSaved(env) {
|
|
|
2848
3163
|
await store.save({ ...again, orgName: who.orgName });
|
|
2849
3164
|
}
|
|
2850
3165
|
}
|
|
3166
|
+
const RESERVATIONS_HELP = `runtime reservations <command>
|
|
3167
|
+
|
|
3168
|
+
book --sandboxes <n> --from <time> --to <time> [--vcpu <n>] [--memory <MiB>]
|
|
3169
|
+
[--disk <MiB>] [--region <region>]
|
|
3170
|
+
Hold room for n sandboxes of one size for a window of up to 24 hours that
|
|
3171
|
+
starts within 14 days. The answer is at once: booked, or declined with
|
|
3172
|
+
the most that fits and the nearest window that fits all of them (exit
|
|
3173
|
+
code 1). In the window, creates are never refused for room or limits up
|
|
3174
|
+
to what you booked. Sandboxes you run bill as usual; booked room none
|
|
3175
|
+
of them uses costs a quarter of the memory rate, and the answer says the
|
|
3176
|
+
most that comes to if none run. A time is an ISO time, now, or HH:MM on this machine's clock: today, or tomorrow when it
|
|
3177
|
+
has passed. --to before --from means the next day.
|
|
3178
|
+
keep --sandboxes <n> [--snapshot <id>]... [--for-key] [--vcpu <n>]
|
|
3179
|
+
[--memory <MiB>] [--disk <MiB>] [--region <region>]
|
|
3180
|
+
Standing room: hold room for n sandboxes from now until you cancel it,
|
|
3181
|
+
for a pool that deletes a sandbox after each use and makes another.
|
|
3182
|
+
With --snapshot, on the servers holding those snapshots, at their size.
|
|
3183
|
+
--for-key keeps it for this key's sandboxes alone. Room none of them
|
|
3184
|
+
uses costs a quarter of the memory rate every hour it is held.
|
|
3185
|
+
change <id> [--sandboxes <n>] [--snapshot <id>]...
|
|
3186
|
+
Change standing room in place; it moves or grows only when all of it fits.
|
|
3187
|
+
ls [--limit <n>] [--before <next>]
|
|
3188
|
+
Your bookings, newest first
|
|
3189
|
+
get <id> One booking, with how much of it is in use
|
|
3190
|
+
cancel <id> Give the room back
|
|
3191
|
+
|
|
3192
|
+
runtime reservations book --sandboxes 300 --from 21:00 --to 23:00
|
|
3193
|
+
runtime reservations book --sandboxes 50 --vcpu 4 --memory 8192 --from now --to 02:00
|
|
3194
|
+
runtime reservations keep --sandboxes 40 --snapshot <id> --for-key
|
|
3195
|
+
|
|
3196
|
+
A booking is the whole account's: a compartment's key cannot book or read one.
|
|
3197
|
+
`;
|
|
3198
|
+
/** A booking's time on the command line: an ISO time, `now`, or HH:MM on this
|
|
3199
|
+
* machine's clock. HH:MM is the next such time at or after `after` (the
|
|
3200
|
+
* window's start for `--to`, so an end before the start is the next day, and
|
|
3201
|
+
* `now` for `--from`: today, or tomorrow once it has passed). */
|
|
3202
|
+
export function bookingTime(name, value, now, after = now) {
|
|
3203
|
+
if (value === "now")
|
|
3204
|
+
return new Date(now);
|
|
3205
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(value);
|
|
3206
|
+
if (clock) {
|
|
3207
|
+
const [hours, minutes] = [Number(clock[1]), Number(clock[2])];
|
|
3208
|
+
if (hours > 23 || minutes > 59)
|
|
3209
|
+
throw usage(`--${name} ${value} is not a time of day.`);
|
|
3210
|
+
const at = new Date(after);
|
|
3211
|
+
at.setHours(hours, minutes, 0, 0);
|
|
3212
|
+
// A start a minute or less ago still reads as now; the server treats it so.
|
|
3213
|
+
if (at.getTime() < after - (after === now ? 60_000 : 0))
|
|
3214
|
+
at.setDate(at.getDate() + 1);
|
|
3215
|
+
return at;
|
|
3216
|
+
}
|
|
3217
|
+
const at = Date.parse(value);
|
|
3218
|
+
if (Number.isNaN(at))
|
|
3219
|
+
throw usage(`--${name} takes an ISO time such as 2026-10-17T21:00:00Z, HH:MM or now; got ${value}.`);
|
|
3220
|
+
return new Date(at);
|
|
3221
|
+
}
|
|
3222
|
+
/** The words for a booking: the line for a confirmed one, the server's message
|
|
3223
|
+
* and what would fit for a declined one. */
|
|
3224
|
+
function bookingText(b) {
|
|
3225
|
+
if (b.standing) {
|
|
3226
|
+
const where = b.snapshots.length ? " on the servers holding its snapshots" : "";
|
|
3227
|
+
const whose = b.forKey ? " for this key" : "";
|
|
3228
|
+
if (b.status === "confirmed") {
|
|
3229
|
+
const unused = b.unusedRateMicros !== "0"
|
|
3230
|
+
? ` Room none of your sandboxes uses costs $${Number(b.unusedRateMicros) / 1e6} per GiB-hour: at most $${(Math.ceil(Number(b.unusedPerHourMicros) / 10_000) / 100).toFixed(2)} an hour if none run.`
|
|
3231
|
+
: "";
|
|
3232
|
+
return `Holding ${b.sandboxes} sandboxes (${b.vcpu} vCPU, ${b.memoryMiB} MiB) in ${b.region}${where}${whose} until you cancel; ${b.inUse} in use. Creates up to that are never refused for room or limits.${unused} id ${b.id}`;
|
|
3233
|
+
}
|
|
3234
|
+
if (b.status === "cancelled")
|
|
3235
|
+
return `Cancelled the standing room for ${b.sandboxes} sandboxes in ${b.region}. id ${b.id}`;
|
|
3236
|
+
return [b.message, `Largest that fits: ${b.largestSandboxes} sandboxes.`].join("\n");
|
|
3237
|
+
}
|
|
3238
|
+
const window = `from ${new Date(b.startsAt).toLocaleString()} to ${new Date(b.endsAt).toLocaleString()}`;
|
|
3239
|
+
if (b.status === "confirmed") {
|
|
3240
|
+
const booked = `Booked ${b.sandboxes} sandboxes (${b.vcpu} vCPU, ${b.memoryMiB} MiB) in ${b.region} ${window}. Creates in the window are never refused for room or limits up to that.`;
|
|
3241
|
+
// Bookings made before unused room had a price are never charged for it.
|
|
3242
|
+
const unused = b.unusedRateMicros && b.unusedRateMicros !== "0"
|
|
3243
|
+
? ` Room none of your sandboxes uses costs $${Number(b.unusedRateMicros) / 1e6} per GiB-hour: at most $${(Math.ceil(Number(b.unusedMaxMicros) / 10_000) / 100).toFixed(2)} if none run, nothing if cancelled at least an hour before the start.`
|
|
3244
|
+
: "";
|
|
3245
|
+
return `${booked}${unused} id ${b.id}`;
|
|
3246
|
+
}
|
|
3247
|
+
if (b.status === "cancelled")
|
|
3248
|
+
return `Cancelled the booking of ${b.sandboxes} sandboxes in ${b.region} ${window}. id ${b.id}`;
|
|
3249
|
+
return [
|
|
3250
|
+
b.message,
|
|
3251
|
+
`Largest that fits: ${b.largestSandboxes} sandboxes.`,
|
|
3252
|
+
b.nearestWindow
|
|
3253
|
+
? `Nearest window for all ${b.sandboxes}: from ${new Date(b.nearestWindow.startsAt).toLocaleString()} to ${new Date(b.nearestWindow.endsAt).toLocaleString()}.`
|
|
3254
|
+
: `No window in the next 14 days holds all ${b.sandboxes}.`,
|
|
3255
|
+
].join("\n");
|
|
3256
|
+
}
|
|
3257
|
+
async function reservationsCommand(argv, env, out) {
|
|
3258
|
+
product = "reservations";
|
|
3259
|
+
const [verb, ...rest] = argv;
|
|
3260
|
+
const print = (human, value) => out.write(out.json ? JSON.stringify(value) : human);
|
|
3261
|
+
if (!verb || verb === "help" || verb === "--help" || verb === "-h") {
|
|
3262
|
+
print(named(RESERVATIONS_HELP), { usage: named(RESERVATIONS_HELP) });
|
|
3263
|
+
return 0;
|
|
3264
|
+
}
|
|
3265
|
+
if (verb === "book") {
|
|
3266
|
+
const args = parse(rest, [], ["sandboxes", "from", "to", "vcpu", "memory", "disk", "region"]);
|
|
3267
|
+
const count = integer(args, "sandboxes");
|
|
3268
|
+
const from = flag(args, "from");
|
|
3269
|
+
const to = flag(args, "to");
|
|
3270
|
+
if (count === undefined || !from || !to || args.positional.length)
|
|
3271
|
+
throw usage(`Usage: ${named("runtime reservations book --sandboxes <n> --from <time> --to <time>")}`);
|
|
3272
|
+
const now = Date.now();
|
|
3273
|
+
const startsAt = bookingTime("from", from, now);
|
|
3274
|
+
const endsAt = bookingTime("to", to, now, startsAt.getTime());
|
|
3275
|
+
const vcpu = integer(args, "vcpu");
|
|
3276
|
+
const memoryMiB = integer(args, "memory");
|
|
3277
|
+
const diskMiB = integer(args, "disk");
|
|
3278
|
+
const region = flag(args, "region");
|
|
3279
|
+
const booking = await (await client(env)).reservations.create({
|
|
3280
|
+
sandboxes: count,
|
|
3281
|
+
startsAt,
|
|
3282
|
+
endsAt,
|
|
3283
|
+
...(vcpu !== undefined ? { vcpu } : {}),
|
|
3284
|
+
...(memoryMiB !== undefined ? { memoryMiB } : {}),
|
|
3285
|
+
...(diskMiB !== undefined ? { diskMiB } : {}),
|
|
3286
|
+
...(region ? { region } : {}),
|
|
3287
|
+
});
|
|
3288
|
+
print(bookingText(booking), booking);
|
|
3289
|
+
return booking.status === "declined" ? 1 : 0;
|
|
3290
|
+
}
|
|
3291
|
+
if (verb === "keep") {
|
|
3292
|
+
const args = parse(rest, ["for-key"], ["sandboxes", "snapshot", "vcpu", "memory", "disk", "region"]);
|
|
3293
|
+
const count = integer(args, "sandboxes");
|
|
3294
|
+
if (count === undefined || args.positional.length)
|
|
3295
|
+
throw usage(`Usage: ${named("runtime reservations keep --sandboxes <n> [--snapshot <id>]...")}`);
|
|
3296
|
+
const vcpu = integer(args, "vcpu");
|
|
3297
|
+
const memoryMiB = integer(args, "memory");
|
|
3298
|
+
const diskMiB = integer(args, "disk");
|
|
3299
|
+
const region = flag(args, "region");
|
|
3300
|
+
const snapshots = args.flags.get("snapshot") ?? [];
|
|
3301
|
+
const booking = await (await client(env)).reservations.keep({
|
|
3302
|
+
sandboxes: count,
|
|
3303
|
+
...(snapshots.length ? { snapshots } : {}),
|
|
3304
|
+
...(has(args, "for-key") ? { forKey: true } : {}),
|
|
3305
|
+
...(vcpu !== undefined ? { vcpu } : {}),
|
|
3306
|
+
...(memoryMiB !== undefined ? { memoryMiB } : {}),
|
|
3307
|
+
...(diskMiB !== undefined ? { diskMiB } : {}),
|
|
3308
|
+
...(region ? { region } : {}),
|
|
3309
|
+
});
|
|
3310
|
+
print(bookingText(booking), booking);
|
|
3311
|
+
return booking.status === "declined" ? 1 : 0;
|
|
3312
|
+
}
|
|
3313
|
+
if (verb === "change") {
|
|
3314
|
+
const args = parse(rest, [], ["sandboxes", "snapshot"]);
|
|
3315
|
+
const id = args.positional[0];
|
|
3316
|
+
const count = integer(args, "sandboxes");
|
|
3317
|
+
const snapshots = args.flags.get("snapshot");
|
|
3318
|
+
if (!id || args.positional.length > 1 || (count === undefined && !snapshots?.length))
|
|
3319
|
+
throw usage(`Usage: ${named("runtime reservations change <id> [--sandboxes <n>] [--snapshot <id>]...")}`);
|
|
3320
|
+
const booking = await (await client(env)).reservations.update(id, {
|
|
3321
|
+
...(count !== undefined ? { sandboxes: count } : {}),
|
|
3322
|
+
...(snapshots?.length ? { snapshots } : {}),
|
|
3323
|
+
});
|
|
3324
|
+
print(bookingText(booking), booking);
|
|
3325
|
+
return 0;
|
|
3326
|
+
}
|
|
3327
|
+
if (verb === "ls" || verb === "list") {
|
|
3328
|
+
const args = parse(rest, [], ["limit", "before"]);
|
|
3329
|
+
if (args.positional.length)
|
|
3330
|
+
throw usage(`Usage: ${named("runtime reservations ls")}`);
|
|
3331
|
+
const limit = integer(args, "limit");
|
|
3332
|
+
const cursor = flag(args, "before");
|
|
3333
|
+
const page = await (await client(env)).reservations.list({
|
|
3334
|
+
...(limit !== undefined ? { limit } : {}),
|
|
3335
|
+
...(cursor ? { cursor } : {}),
|
|
3336
|
+
});
|
|
3337
|
+
print(page.items.length
|
|
3338
|
+
? table([
|
|
3339
|
+
["ID", "STATE", "SANDBOXES", "SIZE", "REGION", "FROM", "TO", "IN USE"],
|
|
3340
|
+
...page.items.map((b) => [
|
|
3341
|
+
b.id,
|
|
3342
|
+
b.status === "confirmed" ? (b.phase ?? "confirmed") : b.status,
|
|
3343
|
+
String(b.sandboxes),
|
|
3344
|
+
`${b.vcpu} vCPU, ${b.memoryMiB} MiB`,
|
|
3345
|
+
b.region,
|
|
3346
|
+
new Date(b.startsAt).toLocaleString(),
|
|
3347
|
+
b.endsAt ? new Date(b.endsAt).toLocaleString() : "until cancelled",
|
|
3348
|
+
b.status === "confirmed" ? `${b.inUse} (peak ${b.peakInUse})` : "-",
|
|
3349
|
+
]),
|
|
3350
|
+
]) + (page.nextCursor ? `\nMore: ${me} reservations ls --before ${page.nextCursor}` : "")
|
|
3351
|
+
: "No bookings.", page);
|
|
3352
|
+
return 0;
|
|
3353
|
+
}
|
|
3354
|
+
if (verb === "get" || verb === "cancel") {
|
|
3355
|
+
const args = parse(rest, [], []);
|
|
3356
|
+
const id = args.positional[0];
|
|
3357
|
+
if (!id || args.positional.length > 1)
|
|
3358
|
+
throw usage(`Usage: ${named(`runtime reservations ${verb} <id>`)}`);
|
|
3359
|
+
const api = (await client(env)).reservations;
|
|
3360
|
+
const booking = await (verb === "get" ? api.get(id) : api.cancel(id));
|
|
3361
|
+
print(bookingText(booking) +
|
|
3362
|
+
(verb === "get" && booking.status === "confirmed"
|
|
3363
|
+
? `\nIn use now: ${booking.inUse} (peak ${booking.peakInUse}). Estimate at most ${usdOf(booking.estimateMicros)}.`
|
|
3364
|
+
: ""), booking);
|
|
3365
|
+
return 0;
|
|
3366
|
+
}
|
|
3367
|
+
throw unknown("reservations command", verb, ["book", "keep", "change", "ls", "get", "cancel"]);
|
|
3368
|
+
}
|
|
2851
3369
|
/** Dollars from integer microdollars, for a person to read: "$5.00". */
|
|
2852
3370
|
const usdOf = (micros) => `$${(Number(BigInt(micros) / 10000n) / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
2853
3371
|
async function keys(argv, env, out) {
|
|
@@ -2972,12 +3490,14 @@ async function keys(argv, env, out) {
|
|
|
2972
3490
|
return 0;
|
|
2973
3491
|
}
|
|
2974
3492
|
/** `runtime keys create --sandbox ... --actions ...`: a key limited to
|
|
2975
|
-
* sandboxes, made by the saved key
|
|
2976
|
-
*
|
|
3493
|
+
* sandboxes, made by the saved key, or, with no key on this machine or with
|
|
3494
|
+
* --browser, asked for and approved by an owner or admin in the browser
|
|
3495
|
+
* (0921): an agent whose person reaches Runtime only through a connector
|
|
3496
|
+
* holds no key to make one with. */
|
|
2977
3497
|
async function limitedKey(args, env, out, limit) {
|
|
2978
|
-
for (const refused of ["read-only", "account-wide"
|
|
3498
|
+
for (const refused of ["read-only", "account-wide"])
|
|
2979
3499
|
if (has(args, refused))
|
|
2980
|
-
throw usage(`A key limited to sandboxes reaches only them
|
|
3500
|
+
throw usage(`A key limited to sandboxes reaches only them, so it takes no --${refused}.`);
|
|
2981
3501
|
const list = (name) => (flag(args, name) ?? "")
|
|
2982
3502
|
.split(",")
|
|
2983
3503
|
.map((each) => each.trim())
|
|
@@ -2986,15 +3506,30 @@ async function limitedKey(args, env, out, limit) {
|
|
|
2986
3506
|
const actions = list("actions");
|
|
2987
3507
|
if (!sandboxes.length || !actions.length)
|
|
2988
3508
|
throw usage(`Usage: ${named("runtime keys create --sandbox <id,...> --actions <get,create,stop,exec,read_file,write_file> [--daily-limit <usd>]")}`);
|
|
2989
|
-
const { dailyLimitMicros } = await import("./keys.js");
|
|
2990
|
-
const
|
|
3509
|
+
const { createKey, dailyLimitMicros } = await import("./keys.js");
|
|
3510
|
+
const request = {
|
|
2991
3511
|
name: flag(args, "name") ?? "Sandbox key",
|
|
2992
3512
|
sandboxes,
|
|
2993
3513
|
actions: actions,
|
|
2994
3514
|
dailyLimitMicros: limit === null ? null : dailyLimitMicros(limit),
|
|
2995
|
-
}
|
|
2996
|
-
|
|
2997
|
-
|
|
3515
|
+
};
|
|
3516
|
+
const saved = has(args, "browser")
|
|
3517
|
+
? null
|
|
3518
|
+
: env.RUNTIME_API_KEY || (await connectionStore(env).read())?.key || null;
|
|
3519
|
+
const asked = saved
|
|
3520
|
+
? null
|
|
3521
|
+
: await createKey({ ...request, access: "full" }, env, { notify: out.error });
|
|
3522
|
+
const made = asked
|
|
3523
|
+
? { ...asked, sandboxes, actions }
|
|
3524
|
+
: await new Runtime({
|
|
3525
|
+
apiKey: saved,
|
|
3526
|
+
baseUrl: env.RUNTIME_API_URL,
|
|
3527
|
+
maxRetries: 4,
|
|
3528
|
+
}).keys.create(request);
|
|
3529
|
+
const { confirm, ...shown } = made;
|
|
3530
|
+
out.write(out.json ? JSON.stringify(shown) : shown.key);
|
|
3531
|
+
out.error(`Created key "${shown.name}", limited to ${shown.sandboxes?.length === 1 ? "sandbox" : "sandboxes"} ${(shown.sandboxes ?? []).join(", ")} for ${(shown.actions ?? []).join(", ")}${shown.dailyLimitMicros === null ? "" : `, daily limit ${usdOf(shown.dailyLimitMicros)}`}, ${saved ? "made by this machine's key" : "approved in the browser"}. Store it now: it is not shown again.\nIt calls those sandboxes by id and nothing else; revoke it at https://withruntime.com/account/keys${saved ? ` or with ${named("runtime keys revoke")} ${shown.keyId}` : ""}.`);
|
|
3532
|
+
await confirm?.();
|
|
2998
3533
|
return 0;
|
|
2999
3534
|
}
|
|
3000
3535
|
const SNAPSHOT_HELP = `runtime snapshots <command>
|
|
@@ -3066,13 +3601,17 @@ const IMAGE_HELP = `runtime images <command>
|
|
|
3066
3601
|
build [<folder>] [--dockerfile|-f <Dockerfile>] | --from <image> | --pip <pkg>... --apt <pkg>... --npm <pkg>...
|
|
3067
3602
|
[--name <name>] [--tag|-t name[:tag]]... [--target <stage>] [--build-arg K=V]... [--no-cache]
|
|
3068
3603
|
[--start <command>] [--ready-port <port> | --ready-command <command>]
|
|
3069
|
-
[--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>]
|
|
3604
|
+
[--max-image-mib 8192] [--disk-mib <1024-32768>] [--timeout <seconds>] [--keep <n>]
|
|
3070
3605
|
Build and stream the log; prints the image id when ready.
|
|
3071
3606
|
--disk-mib sizes the build's scratch disk, where earlier
|
|
3072
|
-
stages and the context live.
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3607
|
+
stages and the context live. --keep, once it is ready,
|
|
3608
|
+
deletes the name's older versions but the newest n
|
|
3609
|
+
(this one among them) and any an App still needs.
|
|
3610
|
+
The folder is the context (without one, the
|
|
3611
|
+
Dockerfile's own folder): everything its .dockerignore
|
|
3612
|
+
leaves in (100 MiB compressed at most) is sent, and a
|
|
3613
|
+
rebuild sends only what changed. -f is read from where
|
|
3614
|
+
you run the command, as docker build reads it.
|
|
3076
3615
|
ls Your images
|
|
3077
3616
|
versions <name> Every version of a name, newest first
|
|
3078
3617
|
get <image> One image: an id, name, name:tag or name@version
|
|
@@ -3112,7 +3651,42 @@ const IMAGE_BUILD_VALUES = [
|
|
|
3112
3651
|
"max-image-mib",
|
|
3113
3652
|
"disk-mib",
|
|
3114
3653
|
"timeout",
|
|
3654
|
+
"keep",
|
|
3115
3655
|
];
|
|
3656
|
+
/** `images build --keep n`: the name's versions older than its newest n,
|
|
3657
|
+
* deleted once `built` is ready, so a name rebuilt on each merge does not keep
|
|
3658
|
+
* every version (21 October 2026: our website's App, rebuilt from main on
|
|
3659
|
+
* each merge, held 221 images on one server, 24 GiB more a day). A
|
|
3660
|
+
* version an App still needs is refused by the API (image_in_use) and kept; a
|
|
3661
|
+
* build still running is never touched; any other failure is said and kept,
|
|
3662
|
+
* since the build itself is done. */
|
|
3663
|
+
async function keepNewest(api, built, keep, out) {
|
|
3664
|
+
// Newest first; the build just made is never among those deleted, even when
|
|
3665
|
+
// a later build of the name finished first.
|
|
3666
|
+
const older = (await (await api.list({ name: built.name, limit: 100 })).toArray(Infinity))
|
|
3667
|
+
.sort((a, b) => (b.version ?? 0) - (a.version ?? 0))
|
|
3668
|
+
.slice(keep)
|
|
3669
|
+
.filter((image) => image.id !== built.id && (image.state === "ready" || image.state === "failed"));
|
|
3670
|
+
const deleted = [];
|
|
3671
|
+
const kept = [];
|
|
3672
|
+
for (const image of older) {
|
|
3673
|
+
const named = `${image.name}@${image.version}`;
|
|
3674
|
+
try {
|
|
3675
|
+
await api.delete(image.id);
|
|
3676
|
+
deleted.push(image.id);
|
|
3677
|
+
if (!out.json)
|
|
3678
|
+
out.error(`Deleted ${named}`);
|
|
3679
|
+
}
|
|
3680
|
+
catch (error) {
|
|
3681
|
+
if (!(error instanceof RuntimeError))
|
|
3682
|
+
throw error;
|
|
3683
|
+
kept.push({ id: image.id, code: error.code });
|
|
3684
|
+
if (!out.json)
|
|
3685
|
+
out.error(`Kept ${named}: ${error.message}`);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return { deleted, kept };
|
|
3689
|
+
}
|
|
3116
3690
|
export async function imageCommand(argv, env, out,
|
|
3117
3691
|
/** Tests give the registry secret here; the CLI reads standard input. */
|
|
3118
3692
|
read = readSecret) {
|
|
@@ -3177,10 +3751,26 @@ read = readSecret) {
|
|
|
3177
3751
|
let input;
|
|
3178
3752
|
if (fromDockerfile) {
|
|
3179
3753
|
const context = resolve(folder ?? dirname(dockerfile));
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3754
|
+
// -f is read from where the command runs, as `docker build` reads it;
|
|
3755
|
+
// a path inside the folder still works, as it always has. Before, a
|
|
3756
|
+
// relative -f was read inside the folder only, and without a folder
|
|
3757
|
+
// twice over: apps/web/apps/web/Dockerfile (18 October 2026).
|
|
3758
|
+
const files = dockerfile
|
|
3759
|
+
? [...new Set([resolve(dockerfile), resolve(context, dockerfile)])]
|
|
3760
|
+
: [join(context, "Dockerfile")];
|
|
3761
|
+
let file = files[0];
|
|
3762
|
+
let text;
|
|
3763
|
+
for (const candidate of files) {
|
|
3764
|
+
text = await readFile(candidate, "utf8").catch(() => undefined);
|
|
3765
|
+
if (text !== undefined) {
|
|
3766
|
+
file = candidate;
|
|
3767
|
+
break;
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
if (text === undefined)
|
|
3771
|
+
throw usage(`No Dockerfile at ${files.join(" or ")}.`);
|
|
3772
|
+
if (!out.json && file !== join(context, "Dockerfile"))
|
|
3773
|
+
out.error(`Using the Dockerfile ${file}`);
|
|
3184
3774
|
input = {
|
|
3185
3775
|
...common,
|
|
3186
3776
|
dockerfile: text,
|
|
@@ -3198,10 +3788,18 @@ read = readSecret) {
|
|
|
3198
3788
|
...common,
|
|
3199
3789
|
recipe: Object.fromEntries(Object.entries(recipe).filter(([, v]) => v.length)),
|
|
3200
3790
|
};
|
|
3791
|
+
const keep = integer(args, "keep");
|
|
3792
|
+
if (keep !== undefined && (keep < 1 || !name))
|
|
3793
|
+
throw usage("--keep takes 1 or more, and a --name or -t for the versions it keeps.");
|
|
3201
3794
|
const built = await api.build(input, {
|
|
3202
3795
|
...(out.json ? {} : { onLog: (line) => out.error(line.text) }),
|
|
3203
3796
|
});
|
|
3204
|
-
|
|
3797
|
+
if (keep === undefined) {
|
|
3798
|
+
print(built.id, built);
|
|
3799
|
+
return 0;
|
|
3800
|
+
}
|
|
3801
|
+
const kept = await keepNewest(api, built, keep, out);
|
|
3802
|
+
print(built.id, { ...built, ...kept });
|
|
3205
3803
|
return 0;
|
|
3206
3804
|
}
|
|
3207
3805
|
case "ls":
|
|
@@ -3327,16 +3925,33 @@ const JOB_HELP = `runtime jobs <command>
|
|
|
3327
3925
|
create <name> (--cron "<min hour day month weekday>" [--timezone Europe/Berlin] | --at <time|now>)
|
|
3328
3926
|
[--vcpu 2] [--memory 4096] [--disk 4096] [--cpu shared|reserved] [--cpu-floor <thousandths>]
|
|
3329
3927
|
[--timeout 1800] [--attempts 1] [--backoff 30] [--secret NAME[=ENV_NAME]]...
|
|
3330
|
-
[--cwd /workspace/dir] [--max-cost <usd>] [--max-total-cost <usd>] [--region r]
|
|
3928
|
+
[--cwd /workspace/dir] [--max-cost <usd>] [--max-total-cost <usd>] [--region r]
|
|
3929
|
+
[--image <name[:tag]|name@version|id>] [--alert-webhook <https url> [--alert-secret NAME]]
|
|
3930
|
+
[--alert-email] [--alert-after <runs>] [--volume <volumeId>:/data[:snapshot]]...
|
|
3931
|
+
-- <command...>
|
|
3331
3932
|
Schedule it; prints its id. Nothing runs until its time.
|
|
3332
3933
|
--at takes an ISO time (2026-10-01T03:00:00Z) or now.
|
|
3333
3934
|
The command runs as given, no shell: -- bash -lc '...'
|
|
3334
3935
|
--attempts: tries per occurrence when it exits non-zero, 1 to 5.
|
|
3335
3936
|
--secret: a secret stored with --jobs, in the run's
|
|
3336
3937
|
environment as ENV_NAME (NAME by default)
|
|
3938
|
+
--image: an image of yours each run starts from
|
|
3939
|
+
--alert-*: when --alert-after runs in a row (1) fail, POST
|
|
3940
|
+
the webhook (signed with the jobs secret NAME) or email
|
|
3941
|
+
you; once more when a run succeeds again
|
|
3942
|
+
--volume: a volume every run attaches, read-write one
|
|
3943
|
+
run at a time (or :snapshot, a read-only copy), so what
|
|
3944
|
+
one run writes there the next one reads; the job runs
|
|
3945
|
+
in the volume's region unless --region says otherwise
|
|
3946
|
+
update <id> [--image <ref> | --standard-image] [--vcpu ..] [--memory ..] [--disk ..] [--timeout ..]
|
|
3947
|
+
[--attempts ..] [--backoff ..] [--secret NAME[=ENV_NAME]]... [--cwd ..] [--max-total-cost <usd>]
|
|
3948
|
+
[--volume <volumeId>:/data[:snapshot]]... [--no-volumes]
|
|
3949
|
+
[--alert-webhook <url> [--alert-secret NAME]] [--alert-email] [--alert-after <runs>] [--no-alert]
|
|
3950
|
+
[-- <command...>] Change it in place: the next run uses it; history stays.
|
|
3951
|
+
--secret and --volume replace the whole list
|
|
3337
3952
|
ls Your jobs: state, schedule and next run
|
|
3338
3953
|
get <id> One job, in full
|
|
3339
|
-
runs <id>
|
|
3954
|
+
runs <id> [--all] Its newest 100 runs (--all: every one, oldest first)
|
|
3340
3955
|
logs <runId|jobId> [-f] [--raw] A run's output (a job id: its latest run); -f follows it;
|
|
3341
3956
|
on a terminal control characters show as \\uXXXX
|
|
3342
3957
|
unless --raw
|
|
@@ -3350,9 +3965,51 @@ const JOB_HELP = `runtime jobs <command>
|
|
|
3350
3965
|
Examples
|
|
3351
3966
|
runtime jobs create nightly --cron "0 3 * * *" --timezone Europe/Berlin -- python3 /workspace/report.py
|
|
3352
3967
|
runtime jobs create once --at now -- bash -lc 'echo hello from a job'
|
|
3968
|
+
runtime jobs create count --cron "0 * * * *" --volume <volumeId>:/data -- bash -lc 'date >> /data/runs'
|
|
3353
3969
|
runtime jobs trigger <jobId> && runtime jobs logs <jobId> -f
|
|
3354
3970
|
runtime jobs reschedule <jobId> --cron "30 4 * * 1-5" --timezone Europe/Berlin
|
|
3971
|
+
runtime jobs update <jobId> --image hub:latest --alert-webhook https://example.com/hook --alert-after 3
|
|
3355
3972
|
`;
|
|
3973
|
+
/** The `--alert-*` flags of create and update, as a job's alert; undefined
|
|
3974
|
+
* when none is given. The secret is a jobs secret, by name. */
|
|
3975
|
+
async function alertFlags(args, runtime) {
|
|
3976
|
+
const url = flag(args, "alert-webhook");
|
|
3977
|
+
const secret = flag(args, "alert-secret");
|
|
3978
|
+
const email = has(args, "alert-email");
|
|
3979
|
+
const after = integer(args, "alert-after");
|
|
3980
|
+
if (secret !== undefined && url === undefined)
|
|
3981
|
+
throw usage("--alert-secret signs the --alert-webhook: give the webhook's URL too.");
|
|
3982
|
+
if (url === undefined && !email) {
|
|
3983
|
+
if (after !== undefined)
|
|
3984
|
+
throw usage("--alert-after says when to alert: give --alert-webhook <url> or --alert-email.");
|
|
3985
|
+
return undefined;
|
|
3986
|
+
}
|
|
3987
|
+
let secretId;
|
|
3988
|
+
if (secret !== undefined) {
|
|
3989
|
+
secretId = (await runtime.secrets.all()).find((s) => s.name === secret)?.jobs?.id;
|
|
3990
|
+
if (!secretId)
|
|
3991
|
+
throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
|
|
3992
|
+
}
|
|
3993
|
+
return {
|
|
3994
|
+
...(after !== undefined ? { afterFailures: after } : {}),
|
|
3995
|
+
...(url !== undefined ? { webhook: { url, ...(secretId ? { secretId } : {}) } } : {}),
|
|
3996
|
+
...(email ? { email: true } : {}),
|
|
3997
|
+
};
|
|
3998
|
+
}
|
|
3999
|
+
/** `--secret NAME[=ENV_NAME]`, each a jobs secret by name. */
|
|
4000
|
+
async function secretFlags(args, runtime) {
|
|
4001
|
+
const bindings = args.flags.get("secret") ?? [];
|
|
4002
|
+
if (!bindings.length)
|
|
4003
|
+
return undefined;
|
|
4004
|
+
const known = await runtime.secrets.all();
|
|
4005
|
+
return bindings.map((binding) => {
|
|
4006
|
+
const [secret, env = secret] = binding.split("=", 2);
|
|
4007
|
+
const found = known.find((s) => s.name === secret)?.jobs;
|
|
4008
|
+
if (!found)
|
|
4009
|
+
throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
|
|
4010
|
+
return { name: env, secretId: found.id };
|
|
4011
|
+
});
|
|
4012
|
+
}
|
|
3356
4013
|
const when = (ms) => (ms == null ? "-" : new Date(ms).toISOString());
|
|
3357
4014
|
function scheduleText(job) {
|
|
3358
4015
|
const s = job.definition.schedule;
|
|
@@ -3382,7 +4039,7 @@ async function jobCommand(argv, env, out) {
|
|
|
3382
4039
|
const api = runtime.jobs;
|
|
3383
4040
|
switch (verb) {
|
|
3384
4041
|
case "create": {
|
|
3385
|
-
const args = parse(rest, [], [
|
|
4042
|
+
const args = parse(rest, ["alert-email"], [
|
|
3386
4043
|
"cron",
|
|
3387
4044
|
"timezone",
|
|
3388
4045
|
"at",
|
|
@@ -3399,6 +4056,11 @@ async function jobCommand(argv, env, out) {
|
|
|
3399
4056
|
"max-cost",
|
|
3400
4057
|
"max-total-cost",
|
|
3401
4058
|
"region",
|
|
4059
|
+
"image",
|
|
4060
|
+
"alert-webhook",
|
|
4061
|
+
"alert-secret",
|
|
4062
|
+
"alert-after",
|
|
4063
|
+
"volume",
|
|
3402
4064
|
], `${me} jobs create <name> --cron "0 3 * * *" -- python3 -c '...'`);
|
|
3403
4065
|
const name = need(args.positional[0], "the job's name, such as nightly-report");
|
|
3404
4066
|
if (args.positional.length > 1)
|
|
@@ -3416,19 +4078,14 @@ async function jobCommand(argv, env, out) {
|
|
|
3416
4078
|
const backoff = integer(args, "backoff");
|
|
3417
4079
|
if (backoff !== undefined && attempts === undefined)
|
|
3418
4080
|
throw usage("--backoff goes with --attempts: the seconds between tries.");
|
|
3419
|
-
const
|
|
3420
|
-
|
|
3421
|
-
if (bindings.length) {
|
|
3422
|
-
const known = await runtime.secrets.all();
|
|
3423
|
-
secrets = bindings.map((binding) => {
|
|
3424
|
-
const [secret, env = secret] = binding.split("=", 2);
|
|
3425
|
-
const found = known.find((s) => s.name === secret)?.jobs;
|
|
3426
|
-
if (!found)
|
|
3427
|
-
throw usage(`${secret} has no copy for jobs. Store one: printf %s "$VALUE" | ${me} secrets set ${secret} --jobs`);
|
|
3428
|
-
return { name: env, secretId: found.id };
|
|
3429
|
-
});
|
|
3430
|
-
}
|
|
4081
|
+
const secrets = await secretFlags(args, runtime);
|
|
4082
|
+
const alert = await alertFlags(args, runtime);
|
|
3431
4083
|
const cwd = flag(args, "cwd");
|
|
4084
|
+
const volumes = (args.flags.get("volume") ?? []).map(attachment);
|
|
4085
|
+
// A run starts on its volumes' server, so the job is in their region
|
|
4086
|
+
// unless --region says otherwise (and the API names the mismatch).
|
|
4087
|
+
const region = flag(args, "region") ??
|
|
4088
|
+
(volumes.length ? (await runtime.volumes.get(volumes[0].volumeId)).region : undefined);
|
|
3432
4089
|
const job = await api.create({
|
|
3433
4090
|
name,
|
|
3434
4091
|
schedule: cron !== undefined
|
|
@@ -3448,10 +4105,11 @@ async function jobCommand(argv, env, out) {
|
|
|
3448
4105
|
...(integer(args, "cpu-floor") !== undefined
|
|
3449
4106
|
? { cpuFloorMillis: integer(args, "cpu-floor") }
|
|
3450
4107
|
: {}),
|
|
3451
|
-
...(
|
|
4108
|
+
...(region ? { region } : {}),
|
|
3452
4109
|
...(usdMicros(args, "max-cost") !== undefined
|
|
3453
4110
|
? { maxCostMicros: usdMicros(args, "max-cost") }
|
|
3454
4111
|
: {}),
|
|
4112
|
+
...(volumes.length ? { volumes } : {}),
|
|
3455
4113
|
},
|
|
3456
4114
|
...(attempts !== undefined
|
|
3457
4115
|
? { retry: { maxAttempts: attempts, backoffSeconds: backoff ?? 30 } }
|
|
@@ -3460,6 +4118,8 @@ async function jobCommand(argv, env, out) {
|
|
|
3460
4118
|
...(usdMicros(args, "max-total-cost") !== undefined
|
|
3461
4119
|
? { maxTotalCostMicros: usdMicros(args, "max-total-cost") }
|
|
3462
4120
|
: {}),
|
|
4121
|
+
...(flag(args, "image") ? { image: flag(args, "image") } : {}),
|
|
4122
|
+
...(alert ? { alert } : {}),
|
|
3463
4123
|
});
|
|
3464
4124
|
if (!out.json)
|
|
3465
4125
|
out.error(`Scheduled ${job.name}: ${scheduleText(job)}. Next run ${when(job.nextRunAt)}. Follow it: ${me} jobs logs ${job.id} -f`);
|
|
@@ -3491,8 +4151,13 @@ async function jobCommand(argv, env, out) {
|
|
|
3491
4151
|
return 0;
|
|
3492
4152
|
}
|
|
3493
4153
|
case "runs": {
|
|
3494
|
-
|
|
3495
|
-
|
|
4154
|
+
// The newest hundred, newest first: a job every minute holds thousands.
|
|
4155
|
+
// --all: every run, oldest first.
|
|
4156
|
+
const args = parse(rest, ["all"], []);
|
|
4157
|
+
const jobId = need(args.positional[0], "a job id");
|
|
4158
|
+
const all = has(args, "all")
|
|
4159
|
+
? await (await api.runs(jobId, { limit: 100 })).toArray(Infinity)
|
|
4160
|
+
: (await api.runs(jobId, { limit: 100, order: "newest" })).data;
|
|
3496
4161
|
print(all.length
|
|
3497
4162
|
? table([
|
|
3498
4163
|
["RUN", "ATTEMPT", "STATE", "EXIT", "SCHEDULED", "STARTED", "ENDED", "REASON"],
|
|
@@ -3526,8 +4191,7 @@ async function jobCommand(argv, env, out) {
|
|
|
3526
4191
|
if (!(error instanceof NotFoundError))
|
|
3527
4192
|
throw error;
|
|
3528
4193
|
// A job's id: its latest run.
|
|
3529
|
-
const
|
|
3530
|
-
const latest = runs.at(-1);
|
|
4194
|
+
const [latest] = (await api.runs(id, { limit: 1, order: "newest" })).data;
|
|
3531
4195
|
if (!latest) {
|
|
3532
4196
|
const job = await api.get(id);
|
|
3533
4197
|
print(`${job.name} has not run yet. Next run ${when(job.nextRunAt)}.`, {
|
|
@@ -3585,6 +4249,86 @@ async function jobCommand(argv, env, out) {
|
|
|
3585
4249
|
print(`${job.name} runs now${job.state === "paused" ? ", once; it stays paused" : ""}. Follow it: ${me} jobs logs ${job.id} -f`, job);
|
|
3586
4250
|
return 0;
|
|
3587
4251
|
}
|
|
4252
|
+
case "update": {
|
|
4253
|
+
const args = parse(rest, ["alert-email", "no-alert", "standard-image", "no-volumes"], [
|
|
4254
|
+
"image",
|
|
4255
|
+
"vcpu",
|
|
4256
|
+
"memory",
|
|
4257
|
+
"disk",
|
|
4258
|
+
"cpu",
|
|
4259
|
+
"cpu-floor",
|
|
4260
|
+
"timeout",
|
|
4261
|
+
"attempts",
|
|
4262
|
+
"backoff",
|
|
4263
|
+
"secret",
|
|
4264
|
+
"cwd",
|
|
4265
|
+
"max-cost",
|
|
4266
|
+
"max-total-cost",
|
|
4267
|
+
"alert-webhook",
|
|
4268
|
+
"alert-secret",
|
|
4269
|
+
"alert-after",
|
|
4270
|
+
"volume",
|
|
4271
|
+
], `${me} jobs update <id> --image hub:latest`);
|
|
4272
|
+
const id = need(args.positional[0], "a job id");
|
|
4273
|
+
if (flag(args, "image") !== undefined && has(args, "standard-image"))
|
|
4274
|
+
throw usage("Give --image <ref> or --standard-image, not both.");
|
|
4275
|
+
const volumes = (args.flags.get("volume") ?? []).map(attachment);
|
|
4276
|
+
if (volumes.length && has(args, "no-volumes"))
|
|
4277
|
+
throw usage("Give --volume to set the volumes or --no-volumes to remove them, not both.");
|
|
4278
|
+
const alert = await alertFlags(args, runtime);
|
|
4279
|
+
if (alert && has(args, "no-alert"))
|
|
4280
|
+
throw usage("Give --alert-* to set the alert or --no-alert to remove it, not both.");
|
|
4281
|
+
const argv = args.rest ?? [];
|
|
4282
|
+
const cwd = flag(args, "cwd");
|
|
4283
|
+
if (cwd !== undefined && !argv.length)
|
|
4284
|
+
throw usage(`--cwd goes with the command: ${me} jobs update ${id} --cwd ${cwd} -- <command...>`);
|
|
4285
|
+
const compute = {
|
|
4286
|
+
...(integer(args, "vcpu") !== undefined ? { vcpu: integer(args, "vcpu") } : {}),
|
|
4287
|
+
...(integer(args, "memory") !== undefined ? { memoryMiB: integer(args, "memory") } : {}),
|
|
4288
|
+
...(integer(args, "disk") !== undefined ? { diskMiB: integer(args, "disk") } : {}),
|
|
4289
|
+
...(flag(args, "cpu")
|
|
4290
|
+
? { cpuMode: oneOf(args, "cpu", ["shared", "reserved"]) }
|
|
4291
|
+
: {}),
|
|
4292
|
+
...(integer(args, "cpu-floor") !== undefined
|
|
4293
|
+
? { cpuFloorMillis: integer(args, "cpu-floor") }
|
|
4294
|
+
: {}),
|
|
4295
|
+
...(usdMicros(args, "max-cost") !== undefined
|
|
4296
|
+
? { maxCostMicros: usdMicros(args, "max-cost") }
|
|
4297
|
+
: {}),
|
|
4298
|
+
...(volumes.length ? { volumes } : {}),
|
|
4299
|
+
...(has(args, "no-volumes") ? { volumes: [] } : {}),
|
|
4300
|
+
};
|
|
4301
|
+
const retry = {
|
|
4302
|
+
...(integer(args, "attempts") !== undefined
|
|
4303
|
+
? { maxAttempts: integer(args, "attempts") }
|
|
4304
|
+
: {}),
|
|
4305
|
+
...(integer(args, "backoff") !== undefined
|
|
4306
|
+
? { backoffSeconds: integer(args, "backoff") }
|
|
4307
|
+
: {}),
|
|
4308
|
+
};
|
|
4309
|
+
const secrets = await secretFlags(args, runtime);
|
|
4310
|
+
const changes = {
|
|
4311
|
+
...(argv.length ? { command: cwd ? { argv, cwd } : argv } : {}),
|
|
4312
|
+
...(flag(args, "image") ? { image: flag(args, "image") } : {}),
|
|
4313
|
+
...(has(args, "standard-image") ? { image: null } : {}),
|
|
4314
|
+
...(Object.keys(compute).length ? { compute } : {}),
|
|
4315
|
+
...(Object.keys(retry).length ? { retry } : {}),
|
|
4316
|
+
...(integer(args, "timeout") !== undefined
|
|
4317
|
+
? { timeoutSeconds: integer(args, "timeout") }
|
|
4318
|
+
: {}),
|
|
4319
|
+
...(secrets ? { secrets } : {}),
|
|
4320
|
+
...(usdMicros(args, "max-total-cost") !== undefined
|
|
4321
|
+
? { maxTotalCostMicros: usdMicros(args, "max-total-cost") }
|
|
4322
|
+
: {}),
|
|
4323
|
+
...(alert ? { alert } : {}),
|
|
4324
|
+
...(has(args, "no-alert") ? { alert: null } : {}),
|
|
4325
|
+
};
|
|
4326
|
+
if (!Object.keys(changes).length)
|
|
4327
|
+
throw usage(`Say what changes: ${me} jobs update ${id} --image hub:latest, --timeout 600, --alert-email, or -- <new command...>`);
|
|
4328
|
+
const job = await api.update(id, changes);
|
|
4329
|
+
print(`${job.name} updated (version ${job.version}); its next run uses it.${job.alert ? ` Alerts after ${job.alert.afterFailures} failing run${job.alert.afterFailures === 1 ? "" : "s"}.` : ""}`, job);
|
|
4330
|
+
return 0;
|
|
4331
|
+
}
|
|
3588
4332
|
case "reschedule": {
|
|
3589
4333
|
const args = parse(rest, [], ["cron", "timezone", "at"], `${me} jobs reschedule <id> --cron "0 3 * * *" [--timezone Europe/Berlin]`);
|
|
3590
4334
|
const id = need(args.positional[0], "a job id");
|
|
@@ -3611,6 +4355,7 @@ async function jobCommand(argv, env, out) {
|
|
|
3611
4355
|
"resume",
|
|
3612
4356
|
"trigger",
|
|
3613
4357
|
"reschedule",
|
|
4358
|
+
"update",
|
|
3614
4359
|
"cancel",
|
|
3615
4360
|
]);
|
|
3616
4361
|
}
|
|
@@ -3991,6 +4736,11 @@ async function execute(sbx, command, args, out, timeout, env,
|
|
|
3991
4736
|
/** `run` stops its whole sandbox on Ctrl-C, so it handles the signal itself. */
|
|
3992
4737
|
ownInterrupt = true) {
|
|
3993
4738
|
const target = command.length === 1 ? command[0] : command;
|
|
4739
|
+
// Only `sandboxes exec` takes --trace-files; `run`, `apps exec` and the
|
|
4740
|
+
// drop-in machines' exec share this runner without it.
|
|
4741
|
+
const traced = !(args.flags instanceof Flags) || args.flags.known.has("trace-files")
|
|
4742
|
+
? flag(args, "trace-files")
|
|
4743
|
+
: undefined;
|
|
3994
4744
|
const extras = await import("./cli-extras.js");
|
|
3995
4745
|
const stdin = await extras.pipedStdin();
|
|
3996
4746
|
const options = {
|
|
@@ -3999,6 +4749,7 @@ ownInterrupt = true) {
|
|
|
3999
4749
|
...(args.flags.has("env") ? { env: envPairs(args, env) } : {}),
|
|
4000
4750
|
...(has(args, "root") ? { user: "root" } : {}),
|
|
4001
4751
|
...(timeout !== undefined ? { timeoutMs: timeout * 1000 } : {}),
|
|
4752
|
+
...(traced ? { traceFiles: true } : {}),
|
|
4002
4753
|
};
|
|
4003
4754
|
// Ctrl-C stops the command in the sandbox too, and says so; without this
|
|
4004
4755
|
// the CLI exited and the command ran on unseen.
|
|
@@ -4026,6 +4777,9 @@ ownInterrupt = true) {
|
|
|
4026
4777
|
finally {
|
|
4027
4778
|
process.off("SIGINT", onInterrupt);
|
|
4028
4779
|
}
|
|
4780
|
+
// What it read and wrote, as JSON, for a cache keyed by what a test read.
|
|
4781
|
+
if (traced)
|
|
4782
|
+
await writeFile(traced, `${JSON.stringify(result.files ?? null)}\n`);
|
|
4029
4783
|
if (out.json)
|
|
4030
4784
|
out.write(JSON.stringify(result));
|
|
4031
4785
|
// Here the output streamed, whose timeout is 24 hours unless one was given
|
|
@@ -4094,11 +4848,20 @@ export function describeError(error, json) {
|
|
|
4094
4848
|
requestId: error.requestId,
|
|
4095
4849
|
idempotencyKey: error.idempotencyKey,
|
|
4096
4850
|
details: error.details,
|
|
4851
|
+
retryable: error.retryable,
|
|
4852
|
+
...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
|
|
4097
4853
|
},
|
|
4098
4854
|
});
|
|
4099
4855
|
// In the command line's words: the option, not the request's field.
|
|
4100
4856
|
const { message, hint } = cliRefusal(error);
|
|
4101
|
-
|
|
4857
|
+
// Said only when the same command, run again, may work: and when, if the
|
|
4858
|
+
// API named it (a daily cap's hour).
|
|
4859
|
+
const again = error.retryable
|
|
4860
|
+
? `\nRetryable: yes${error.retryAfterMs !== undefined
|
|
4861
|
+
? `, after ${Math.max(1, Math.ceil(error.retryAfterMs / 1000))} s`
|
|
4862
|
+
: ""}`
|
|
4863
|
+
: "";
|
|
4864
|
+
return `Error [${error.code}]: ${message}${hint ? `\nHint: ${hint}` : ""}${again}${error.requestId
|
|
4102
4865
|
? // A refusal of what was sent is the caller's to fix; only a failure
|
|
4103
4866
|
// of ours is worth a report.
|
|
4104
4867
|
error.status >= 400 && error.status < 500
|