runcloud 0.1.120 → 0.1.122
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/commands/run-cloud.js +3 -1
- package/dist/commands/sandbox.js +36 -6
- package/dist/commands/sandboxOutput.js +20 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runcloud-0.1.122.tgz +0 -0
- package/skills/run-cloud-sandboxes/SKILL.md +86 -16
- package/runcloud-0.1.120.tgz +0 -0
|
@@ -1305,13 +1305,15 @@ async function action(fn, opts = {}) {
|
|
|
1305
1305
|
}
|
|
1306
1306
|
function configureMachineReadableParseErrors(program) {
|
|
1307
1307
|
const output = program.configureOutput();
|
|
1308
|
+
const writeErrorLine = output.outputError ??
|
|
1309
|
+
((value, destination) => destination(value));
|
|
1308
1310
|
program.configureOutput({
|
|
1309
1311
|
...output,
|
|
1310
1312
|
outputError: (message, write) => {
|
|
1311
1313
|
const rawArgs = program.rawArgs ?? [];
|
|
1312
1314
|
const argumentsSeen = [...process.argv, ...rawArgs, ...program.args];
|
|
1313
1315
|
if (!argumentsSeen.includes('--json')) {
|
|
1314
|
-
(
|
|
1316
|
+
writeErrorLine(message, write);
|
|
1315
1317
|
return;
|
|
1316
1318
|
}
|
|
1317
1319
|
write(`${JSON.stringify({
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -8,7 +8,7 @@ import { runSandboxCommand, shellCommand } from '../sandboxExec.js';
|
|
|
8
8
|
import { runSandboxShell } from '../shellSession.js';
|
|
9
9
|
import { printJson, StatusLine, style } from '../terminal.js';
|
|
10
10
|
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
11
|
-
import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
11
|
+
import { renderHeldSecrets, renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
12
12
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
13
13
|
const DEFAULT_SANDBOX_TIMEOUT_SECONDS = 300;
|
|
14
14
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
@@ -260,6 +260,15 @@ function secretSelector(opts) {
|
|
|
260
260
|
out.env = env;
|
|
261
261
|
return out;
|
|
262
262
|
}
|
|
263
|
+
async function replaceSandboxSecrets(id, opts) {
|
|
264
|
+
const body = secretSelector(opts);
|
|
265
|
+
await runCloudApi().put(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`, body);
|
|
266
|
+
if (opts.json) {
|
|
267
|
+
printJson({ id, revoked: opts.noSecrets === true, ...body });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.log(opts.noSecrets ? `${id}: all secrets revoked` : `${id}: secrets replaced`);
|
|
271
|
+
}
|
|
263
272
|
function collectRepeatable(value, previous) {
|
|
264
273
|
return [...previous, value];
|
|
265
274
|
}
|
|
@@ -472,17 +481,38 @@ export function registerSandbox(program) {
|
|
|
472
481
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, opts.interval * 1_000));
|
|
473
482
|
}
|
|
474
483
|
}));
|
|
475
|
-
withOutput(sandbox
|
|
484
|
+
const secrets = withOutput(sandbox
|
|
476
485
|
.command('secrets')
|
|
477
|
-
.description('
|
|
486
|
+
.description('Show what secrets a sandbox holds (names and origins, never values)')
|
|
487
|
+
.argument('[id]', 'sandbox id'))
|
|
488
|
+
.showHelpAfterError("hint: `runcloud sandbox secrets set <id> …` changes what a sandbox holds; the bare form only reads it");
|
|
489
|
+
const wantsJson = (opts) => Boolean(secrets.opts().json) || Boolean(opts.json);
|
|
490
|
+
secrets.action((id, opts) => run(async () => {
|
|
491
|
+
if (!id)
|
|
492
|
+
throw new Error('which sandbox? usage: runcloud sandbox secrets <id>');
|
|
493
|
+
const { held } = (await runCloudApi().get(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`));
|
|
494
|
+
if (opts.json) {
|
|
495
|
+
printJson({ held });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
console.log(renderHeldSecrets(id, held));
|
|
499
|
+
}));
|
|
500
|
+
withOutput(secrets
|
|
501
|
+
.command('set')
|
|
502
|
+
.description('Replace what a sandbox holds (a full replacement, not a merge)')
|
|
478
503
|
.argument('<id>', 'sandbox id')
|
|
479
504
|
.option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
|
|
480
505
|
.option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
|
|
481
506
|
.option('--env <NAME=VALUE>', 'literal value; applied last, so it wins; repeatable', collectRepeatable, [])).action((id, opts) => run(async () => {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
507
|
+
if (!opts.secretGroup.length && !opts.secret.length && !opts.env.length) {
|
|
508
|
+
throw new Error('name what the sandbox should hold with --secret-group/--secret/--env, or run `runcloud sandbox secrets revoke <id>` to take everything back');
|
|
509
|
+
}
|
|
510
|
+
await replaceSandboxSecrets(id, { ...opts, json: wantsJson(opts) });
|
|
485
511
|
}));
|
|
512
|
+
withOutput(secrets
|
|
513
|
+
.command('revoke')
|
|
514
|
+
.description('Take every secret back from a sandbox')
|
|
515
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(() => replaceSandboxSecrets(id, { noSecrets: true, json: wantsJson(opts) })));
|
|
486
516
|
withOutput(sandbox
|
|
487
517
|
.command('expose')
|
|
488
518
|
.description('Publish a sandbox guest port at a stable hostname, or change the published port')
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
|
|
2
|
+
export function renderHeldSecrets(id, held) {
|
|
3
|
+
const heading = `Secrets held by ${id}`;
|
|
4
|
+
if (held === null) {
|
|
5
|
+
return `${style.bold(heading)}\n\nUnknown — nothing was recorded for this sandbox, so what it\nholds cannot be answered from here.\n\n${style.dim('Check the environment inside it, or run `runcloud sandbox secrets set`,')}\n${style.dim('which records what it pushes.')}`;
|
|
6
|
+
}
|
|
7
|
+
if (held.length === 0) {
|
|
8
|
+
return `${style.bold(heading)}\n\nNothing. This sandbox holds no secrets.\n\n${style.dim('Attach some:')} runcloud sandbox secrets set ${id} --secret-group <group>`;
|
|
9
|
+
}
|
|
10
|
+
return renderTable(`${heading} (${held.length})`, [
|
|
11
|
+
{ key: 'name', label: 'NAME', maxWidth: 32 },
|
|
12
|
+
{ key: 'kind', label: 'KIND' },
|
|
13
|
+
{ key: 'source', label: 'FROM', maxWidth: 28 },
|
|
14
|
+
{ key: 'digest', label: 'DIGEST' },
|
|
15
|
+
], held.map((secret) => ({
|
|
16
|
+
name: secret.name,
|
|
17
|
+
kind: secret.kind,
|
|
18
|
+
source: secret.source,
|
|
19
|
+
digest: secret.digest,
|
|
20
|
+
})));
|
|
21
|
+
}
|
|
2
22
|
function resources(record) {
|
|
3
23
|
const memory = formatMib(record.mem_mb);
|
|
4
24
|
const disk = formatMib(record.disk_mb);
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = '0.1.
|
|
1
|
+
export const CLI_VERSION = '0.1.122';
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -190,9 +190,20 @@ immediately.
|
|
|
190
190
|
- Attach only the required groups or names with repeatable `--secret-group` and
|
|
191
191
|
`--secret`. Later selectors win on name collisions; `--env` is applied last.
|
|
192
192
|
- Use `--no-secrets` to state explicitly that a new sandbox needs none.
|
|
193
|
-
-
|
|
194
|
-
-
|
|
195
|
-
|
|
193
|
+
- Three verbs, because two of them change things:
|
|
194
|
+
- `runcloud sandbox secrets <id>` reads. Names, where each one resolved from,
|
|
195
|
+
and a digest of its value.
|
|
196
|
+
- `runcloud sandbox secrets set <id> ...` replaces. It is a
|
|
197
|
+
full replacement, not a merge: the sandbox ends up holding exactly what
|
|
198
|
+
that one command names.
|
|
199
|
+
- `runcloud sandbox secrets revoke <id>` takes everything back.
|
|
200
|
+
- A listing of `Unknown` is not `Nothing`. The inventory is recorded when a
|
|
201
|
+
delivery is accepted, so a sandbox created before inventories existed has no
|
|
202
|
+
record — its secrets still work. Only `Nothing` means the sandbox is empty.
|
|
203
|
+
- Values never come back, from any command. The digest exists so you can tell
|
|
204
|
+
whether the value in a sandbox is still the one its group holds; it is not
|
|
205
|
+
reversible and there is no masked form.
|
|
206
|
+
- Snapshots do not contain secrets, so a restored sandbox starts with none.
|
|
196
207
|
|
|
197
208
|
Inspect `runcloud secret-group --help` and `runcloud secrets --help` for the
|
|
198
209
|
current non-plaintext input forms.
|
|
@@ -295,14 +306,42 @@ Three limits that change what you can conclude from it:
|
|
|
295
306
|
early output is gone, not merely paged out. An empty-looking start is a trim,
|
|
296
307
|
not proof the process printed nothing.
|
|
297
308
|
- `systemd-run` output goes to the journal, not the console, so those units never
|
|
298
|
-
appear here at all.
|
|
309
|
+
appear here at all. `journalctl -u <unit>` reaches it, but only from inside a
|
|
310
|
+
running sandbox, so that output is unrecoverable once the sandbox is destroyed
|
|
311
|
+
and needs a resume on a paused one. A workload whose output has to survive a
|
|
312
|
+
post-mortem should write to stdout rather than a unit.
|
|
313
|
+
|
|
314
|
+
Two details about reading the file: the `created` marker is appended to a shell
|
|
315
|
+
prompt line rather than starting one, so match `--- sandbox` anywhere in the line
|
|
316
|
+
rather than anchoring to the start, and boot output dwarfs everything else, so
|
|
317
|
+
reach for the tail before the head.
|
|
318
|
+
|
|
319
|
+
### What the metrics actually settle
|
|
320
|
+
|
|
321
|
+
`runcloud sandbox metrics <id> --json` carries typed counters, and they are how
|
|
322
|
+
you refute a theory instead of arguing about it. The natural wrong answer on a
|
|
323
|
+
128 MiB sandbox is always "it OOMed":
|
|
324
|
+
|
|
325
|
+
| field | what a zero proves |
|
|
326
|
+
| --- | --- |
|
|
327
|
+
| `memory_oom_kills`, `memory_oom_events` | nothing was OOM-killed, so a dead process exited on its own |
|
|
328
|
+
| `cpu_throttled_periods`, `cpu_throttled_millicores` | the CPU reservation was not starving it |
|
|
329
|
+
| lifetime `network` totals | at ~0 bytes in, no request ever arrived, so the fault is upstream of the guest |
|
|
330
|
+
|
|
331
|
+
Each has a `*_valid` companion; when that is false the counter is unavailable
|
|
332
|
+
rather than zero, and a zero you cannot trust proves nothing.
|
|
333
|
+
|
|
334
|
+
`memory_bytes` is the exception and it misleads. It is measured host-side and
|
|
335
|
+
includes page cache, so it routinely reads several times the sandbox's `mem_mb`
|
|
336
|
+
reservation. That is not a leak and not an impending OOM. Believe
|
|
337
|
+
`memory_oom_kills` over it, every time.
|
|
299
338
|
|
|
300
339
|
### Lifecycle markers
|
|
301
340
|
|
|
302
341
|
The host writes a marker into the console on every transition:
|
|
303
342
|
|
|
304
343
|
```
|
|
305
|
-
--- sandbox
|
|
344
|
+
--- sandbox paused: timeout-sweep at 2026-08-05T11:02:11Z ---
|
|
306
345
|
```
|
|
307
346
|
|
|
308
347
|
The events are `created`, `paused`, `resumed`, `stopped`, and `destroyed`. The
|
|
@@ -310,22 +349,53 @@ token after the colon is the control plane's actor, written verbatim so it can b
|
|
|
310
349
|
matched rather than parsed; the sentence around it is not a contract. Only stops
|
|
311
350
|
carry a reason, so `created` and `resumed` appear without one.
|
|
312
351
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
|
318
|
-
|
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
352
|
+
Read the **event** before the actor. A paused sandbox still exists and its disk is
|
|
353
|
+
intact; a destroyed one is gone. Telling someone their work is lost when it is
|
|
354
|
+
warm-parked and resumable is the most expensive mistake available here.
|
|
355
|
+
|
|
356
|
+
| actor | ends up | what it means |
|
|
357
|
+
| --- | --- | --- |
|
|
358
|
+
| `timeout-sweep` | paused | hit its `--timeout` lifetime cap. Not a crash, and the work is still there |
|
|
359
|
+
| `idle-sweep` | paused | idle-paused after `--idle-pause` seconds. Not a failure at all |
|
|
360
|
+
| `retention-sweep` | destroyed | the 48h parked window expired and it was reaped for real |
|
|
361
|
+
| `pause` `resume` `stop` `archive` `restore` | as asked | a caller requested exactly this |
|
|
362
|
+
| `api` `api-force` | destroyed | a caller destroyed it. `api-force` bypassed a wedged guest |
|
|
363
|
+
| `health-sweep` | interrupted | the platform judged it unhealthy |
|
|
364
|
+
| `host-state-reconcile` | paused | the host had already parked it and the control plane caught up. The pause happened earlier than this event says |
|
|
365
|
+
| `host-reconcile` `reap-sweep` | destroyed | the platform reclaimed it |
|
|
366
|
+
| `boot-failure` `placement-timeout` `request-timeout` `scheduler` | never ran | capacity or image, not the workload |
|
|
367
|
+
| `image-built` `image-build-failure` | varies | an async image build finished or failed |
|
|
368
|
+
| `startup-recover` `volume-lease-invalid` `replica-fork-resume-failure` | varies | platform recovery paths |
|
|
369
|
+
|
|
370
|
+
**Getting a parked sandbox back.** `timeout-sweep` and `idle-sweep` leave a full
|
|
371
|
+
snapshot, so `runcloud sandbox resume <id>` restores it warm. You have 48h before
|
|
372
|
+
`retention-sweep` destroys it for real. Resuming restarts the lifetime window from
|
|
373
|
+
now rather than clearing it, so a sandbox parked by `timeout-sweep` will park again
|
|
374
|
+
after the same interval; raise the cap first with the SDK's
|
|
375
|
+
`cloud.sandboxes.setTimeout(id, seconds)`, which has no CLI equivalent.
|
|
376
|
+
|
|
377
|
+
**The `--timeout` trap.** `--timeout` is a wall-clock lifetime cap, wholly separate
|
|
378
|
+
from `--idle-pause`. It fires on a fully busy sandbox, and `--persistent` /
|
|
379
|
+
`--idle-pause 0` do **not** hold it off, despite `--persistent` reading as "never
|
|
380
|
+
pause when idle". Default is 300s, ceiling 24h.
|
|
324
381
|
|
|
325
382
|
A sandbox that failed before it ever reached a host has no console, because the
|
|
326
383
|
file is created at boot. Those carry a `Last error` row on
|
|
327
384
|
`runcloud sandbox get` instead, which is then the only record of the cause.
|
|
328
385
|
|
|
386
|
+
### "Not reachable" is usually not a network problem
|
|
387
|
+
|
|
388
|
+
Exposure is a separate record, not a field on the sandbox, so `sandbox get` omits
|
|
389
|
+
it entirely when none exists rather than showing it as empty. Absence of a
|
|
390
|
+
`Hostname` row therefore means **no public route exists**, not that the CLI
|
|
391
|
+
declined to print one. `runcloud sandbox domain list <id>` states it outright and
|
|
392
|
+
names the fix. A sandbox created without `--expose` is unreachable from outside
|
|
393
|
+
however healthy the workload is.
|
|
394
|
+
|
|
395
|
+
When a route does exist and the port still refuses, the guest is usually bound to
|
|
396
|
+
`127.0.0.1` rather than `0.0.0.0`, which is invisible from outside and looks
|
|
397
|
+
identical to a dead app.
|
|
398
|
+
|
|
329
399
|
## Guardrails
|
|
330
400
|
|
|
331
401
|
- Destroy every sandbox created during a task unless the user explicitly asks
|
package/runcloud-0.1.120.tgz
DELETED
|
Binary file
|