runcloud 0.1.15 → 0.1.17
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/api.js +16 -1
- package/dist/commands/boxAccess.js +61 -2
- package/dist/commands/image.js +66 -21
- package/dist/commands/run-cloud.js +36 -146
- package/dist/commands/sandbox.js +329 -26
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/commands/secrets.js +203 -0
- package/dist/prompt.js +44 -0
- package/dist/run-cloud.js +2 -0
- package/dist/sandboxExec.js +91 -0
- package/dist/shellSession.js +4 -2
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/run-cloud-ios-simulator/SKILL.md +196 -0
- package/skills/run-cloud-ios-simulator/agents/openai.yaml +4 -0
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
|
-
import { InvalidArgumentError } from 'commander';
|
|
1
|
+
import { Help, InvalidArgumentError } from 'commander';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { writeFileSync } from 'node:fs';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { ApiClient, ApiError, friendlyApiError } from '../api.js';
|
|
6
6
|
import { requireCredentials } from '../config.js';
|
|
7
|
+
import { runSandboxCommand, shellCommand } from '../sandboxExec.js';
|
|
7
8
|
import { runSandboxShell } from '../shellSession.js';
|
|
9
|
+
import { printJson, StatusLine, style } from '../terminal.js';
|
|
8
10
|
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
11
|
+
import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
9
12
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
10
13
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
11
14
|
const DEFAULT_BOX_MEMORY_MIB = 24 * 1024;
|
|
15
|
+
const SANDBOX_POLL_INTERVAL_MS = 1_000;
|
|
16
|
+
const SANDBOX_WAIT_TIMEOUT_MS = 20 * 60 * 1_000;
|
|
17
|
+
const READY_STATES = new Set(['running']);
|
|
18
|
+
const FAILED_STATES = new Set(['interrupted', 'stopped', 'destroyed']);
|
|
19
|
+
const METRIC_RANGES = new Set(['1h', '6h', '24h', '7d', '30d', 'all']);
|
|
20
|
+
const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
12
21
|
function runCloudApi() {
|
|
13
22
|
const credentials = requireCredentials();
|
|
14
23
|
return new ApiClient(credentials.apiUrl, credentials.token);
|
|
15
24
|
}
|
|
16
25
|
function print(value, opts) {
|
|
17
26
|
if (opts.json) {
|
|
18
|
-
|
|
27
|
+
printJson(value);
|
|
19
28
|
return;
|
|
20
29
|
}
|
|
21
30
|
if (Array.isArray(value)) {
|
|
@@ -25,6 +34,53 @@ function print(value, opts) {
|
|
|
25
34
|
}
|
|
26
35
|
console.log(formatRecord(value));
|
|
27
36
|
}
|
|
37
|
+
function sandboxStatusMessage(state) {
|
|
38
|
+
if (state === 'building_image')
|
|
39
|
+
return 'Building image';
|
|
40
|
+
if (state === 'pending')
|
|
41
|
+
return 'Waiting for capacity';
|
|
42
|
+
if (state === 'starting')
|
|
43
|
+
return 'Starting sandbox';
|
|
44
|
+
if (state === 'archived')
|
|
45
|
+
return 'Restoring sandbox';
|
|
46
|
+
return `Sandbox is ${state.replaceAll('_', ' ')}`;
|
|
47
|
+
}
|
|
48
|
+
export async function pollSandboxReady(api, id, opts = {}) {
|
|
49
|
+
const intervalMs = opts.intervalMs ?? SANDBOX_POLL_INTERVAL_MS;
|
|
50
|
+
const timeoutMs = opts.timeoutMs ?? SANDBOX_WAIT_TIMEOUT_MS;
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
for (;;) {
|
|
53
|
+
const record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
|
|
54
|
+
const state = record.state ?? 'unknown';
|
|
55
|
+
opts.onState?.(state);
|
|
56
|
+
if (READY_STATES.has(state))
|
|
57
|
+
return record;
|
|
58
|
+
if (FAILED_STATES.has(state)) {
|
|
59
|
+
throw new Error(`sandbox ${id} entered ${state.replaceAll('_', ' ')} state`);
|
|
60
|
+
}
|
|
61
|
+
if (Date.now() > deadline) {
|
|
62
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for sandbox ${id} to start`);
|
|
63
|
+
}
|
|
64
|
+
await new Promise((done) => setTimeout(done, intervalMs));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function prepareSandboxShell(api, id, status = new StatusLine()) {
|
|
68
|
+
let record = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`));
|
|
69
|
+
if (record.state === 'paused' || record.state === 'archived') {
|
|
70
|
+
status.update(`Resuming ${id}`);
|
|
71
|
+
record = (await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/resume`));
|
|
72
|
+
if (record.state !== 'running') {
|
|
73
|
+
record = await pollSandboxReady(api, id, {
|
|
74
|
+
onState: (state) => status.update(sandboxStatusMessage(state)),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
status.succeed(`Resumed ${id}`);
|
|
78
|
+
}
|
|
79
|
+
if (record.state !== 'running') {
|
|
80
|
+
status.clear();
|
|
81
|
+
throw new Error(`sandbox ${id} is ${String(record.state).replaceAll('_', ' ')}; wait until it is running`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
28
84
|
function formatRecord(value) {
|
|
29
85
|
if (!value || typeof value !== 'object')
|
|
30
86
|
return String(value);
|
|
@@ -73,6 +129,20 @@ function parseIdlePauseSeconds(value) {
|
|
|
73
129
|
}
|
|
74
130
|
return n;
|
|
75
131
|
}
|
|
132
|
+
function parseTimeoutSeconds(value) {
|
|
133
|
+
const timeout = Number(value);
|
|
134
|
+
if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 86_400) {
|
|
135
|
+
throw new InvalidArgumentError('timeout must be an integer from 0 to 86400 seconds');
|
|
136
|
+
}
|
|
137
|
+
return timeout;
|
|
138
|
+
}
|
|
139
|
+
function parseExecTimeoutSeconds(value) {
|
|
140
|
+
const timeout = parseTimeoutSeconds(value);
|
|
141
|
+
if (timeout === 0) {
|
|
142
|
+
throw new InvalidArgumentError('command timeout must be between 1 and 86400 seconds');
|
|
143
|
+
}
|
|
144
|
+
return timeout;
|
|
145
|
+
}
|
|
76
146
|
function parsePort(value) {
|
|
77
147
|
const n = Number(value);
|
|
78
148
|
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
@@ -80,6 +150,66 @@ function parsePort(value) {
|
|
|
80
150
|
}
|
|
81
151
|
return n;
|
|
82
152
|
}
|
|
153
|
+
function parseMetricRange(value) {
|
|
154
|
+
if (!METRIC_RANGES.has(value)) {
|
|
155
|
+
throw new InvalidArgumentError('range must be one of 1h, 6h, 24h, 7d, 30d, all');
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
function parseMetricInterval(value) {
|
|
160
|
+
const seconds = Number(value);
|
|
161
|
+
if (!Number.isFinite(seconds) || seconds < 1) {
|
|
162
|
+
throw new InvalidArgumentError('--interval must be at least 1 second');
|
|
163
|
+
}
|
|
164
|
+
return seconds;
|
|
165
|
+
}
|
|
166
|
+
function formatMetricBytes(value) {
|
|
167
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
168
|
+
return '0 B';
|
|
169
|
+
const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
170
|
+
const amount = value / 1024 ** unit;
|
|
171
|
+
return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
|
|
172
|
+
}
|
|
173
|
+
function formatMetricCpu(millicores) {
|
|
174
|
+
return millicores >= 1000
|
|
175
|
+
? `${(millicores / 1000).toFixed(2).replace(/\.?0+$/, '')} cores`
|
|
176
|
+
: `${Math.round(millicores)}m`;
|
|
177
|
+
}
|
|
178
|
+
function metricPercent(used, total) {
|
|
179
|
+
return total > 0 ? `${((used / total) * 100).toFixed(1)}%` : '—';
|
|
180
|
+
}
|
|
181
|
+
function metricSparkline(values) {
|
|
182
|
+
if (values.length === 0)
|
|
183
|
+
return '—';
|
|
184
|
+
const blocks = '▁▂▃▄▅▆▇█';
|
|
185
|
+
const max = Math.max(...values, 1);
|
|
186
|
+
const stride = Math.max(1, Math.ceil(values.length / 48));
|
|
187
|
+
return values
|
|
188
|
+
.filter((_, index) => index % stride === 0 || index === values.length - 1)
|
|
189
|
+
.map((value) => blocks[Math.min(blocks.length - 1, Math.floor((Math.max(0, value) / max) * (blocks.length - 1)))])
|
|
190
|
+
.join('');
|
|
191
|
+
}
|
|
192
|
+
export function renderSandboxMetrics(id, metrics) {
|
|
193
|
+
const point = metrics.current;
|
|
194
|
+
if (!point)
|
|
195
|
+
return `Sandbox metrics · ${id}\nNo samples recorded yet.`;
|
|
196
|
+
const history = metrics.history;
|
|
197
|
+
return [
|
|
198
|
+
`Sandbox metrics · ${id} · ${metrics.range}`,
|
|
199
|
+
`State ${point.state} · ${new Date(point.timestamp).toLocaleString()}`,
|
|
200
|
+
`CPU ${formatMetricCpu(point.cpu_millicores)}`,
|
|
201
|
+
`Memory ${formatMetricBytes(point.memory_bytes)}`,
|
|
202
|
+
`Network ↓ ${formatMetricBytes(point.network_rx_bytes_per_second)}/s · ↑ ${formatMetricBytes(point.network_tx_bytes_per_second)}/s`,
|
|
203
|
+
` totals ↓ ${formatMetricBytes(point.network_rx_bytes)} · ↑ ${formatMetricBytes(point.network_tx_bytes)}`,
|
|
204
|
+
`Disk ${formatMetricBytes(point.disk_used_bytes)} / ${formatMetricBytes(point.disk_total_bytes)} (${metricPercent(point.disk_used_bytes, point.disk_total_bytes)})`,
|
|
205
|
+
`History ${history.length} samples`,
|
|
206
|
+
`CPU ${metricSparkline(history.map((sample) => sample.cpu_millicores))}`,
|
|
207
|
+
`Memory ${metricSparkline(history.map((sample) => sample.memory_bytes))}`,
|
|
208
|
+
`Network ↓ ${metricSparkline(history.map((sample) => sample.network_rx_bytes_per_second))}`,
|
|
209
|
+
`Network ↑ ${metricSparkline(history.map((sample) => sample.network_tx_bytes_per_second))}`,
|
|
210
|
+
`Disk ${metricSparkline(history.map((sample) => sample.disk_used_bytes))}`,
|
|
211
|
+
].join('\n');
|
|
212
|
+
}
|
|
83
213
|
function parseCoordinate(value) {
|
|
84
214
|
const n = Number(value);
|
|
85
215
|
if (!Number.isSafeInteger(n) || n < 0) {
|
|
@@ -105,8 +235,43 @@ async function findBoxForSandbox(api, sandboxId) {
|
|
|
105
235
|
function attachHostname(api, sandboxId, name, port) {
|
|
106
236
|
return api.post('/run-cloud/boxes', { sandboxId, name, port });
|
|
107
237
|
}
|
|
238
|
+
function secretSelector(opts) {
|
|
239
|
+
const ordered = [...(opts.secretGroup ?? []), ...(opts.secret ?? [])];
|
|
240
|
+
const env = {};
|
|
241
|
+
for (const pair of opts.env ?? []) {
|
|
242
|
+
const eq = pair.indexOf('=');
|
|
243
|
+
if (eq <= 0)
|
|
244
|
+
throw new Error(`--env expects NAME=VALUE, got "${pair}"`);
|
|
245
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
246
|
+
}
|
|
247
|
+
if (opts.noSecrets) {
|
|
248
|
+
if (ordered.length > 0) {
|
|
249
|
+
throw new Error('--no-secrets cannot be combined with --secret-group or --secret');
|
|
250
|
+
}
|
|
251
|
+
return Object.keys(env).length > 0 ? { secrets: 'none', env } : { secrets: 'none' };
|
|
252
|
+
}
|
|
253
|
+
const out = {};
|
|
254
|
+
if (ordered.length > 0)
|
|
255
|
+
out.secrets = ordered;
|
|
256
|
+
if (Object.keys(env).length > 0)
|
|
257
|
+
out.env = env;
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function collectRepeatable(value, previous) {
|
|
261
|
+
return [...previous, value];
|
|
262
|
+
}
|
|
108
263
|
export function registerSandbox(program) {
|
|
109
264
|
const sandbox = program.command('sandbox').description('Spawn and control microVM sandboxes');
|
|
265
|
+
const defaultHelp = new Help();
|
|
266
|
+
sandbox.configureHelp({
|
|
267
|
+
subcommandTerm: (command) => {
|
|
268
|
+
const term = defaultHelp.subcommandTerm(command);
|
|
269
|
+
const aliases = command.aliases();
|
|
270
|
+
if (aliases.length < 2)
|
|
271
|
+
return term;
|
|
272
|
+
return term.replace(`${command.name()}|${aliases[0]}`, `${command.name()}|${aliases.join('|')}`);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
110
275
|
withOutput(sandbox
|
|
111
276
|
.command('create')
|
|
112
277
|
.description('Spawn a microVM sandbox')
|
|
@@ -118,8 +283,14 @@ export function registerSandbox(program) {
|
|
|
118
283
|
.option('--memory <mb>', 'memory allocation in MiB', parseMemoryMb)
|
|
119
284
|
.option('--disk <gb>', 'root disk capacity in GiB', parseDiskGb)
|
|
120
285
|
.option('--idle-pause <seconds>', 'pause after inactivity; 0 keeps it running', parseIdlePauseSeconds)
|
|
286
|
+
.option('--timeout <seconds>', 'maximum sandbox lifetime; 0 disables the lifetime timer', parseTimeoutSeconds, 0)
|
|
121
287
|
.option('--persistent', 'never pause when idle (same as --idle-pause 0)', false)
|
|
122
|
-
.option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
|
|
288
|
+
.option('--expose <port>', 'publish a guest port at <name>-box.run.cloud; implies --persistent', parsePort)
|
|
289
|
+
.option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
|
|
290
|
+
.option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
|
|
291
|
+
.option('--env <NAME=VALUE>', 'literal value for this sandbox; applied last, so it wins; repeatable', collectRepeatable, [])
|
|
292
|
+
.option('--no-secrets', 'state explicitly that the sandbox holds no secrets')
|
|
293
|
+
.option('--no-wait', 'return before the sandbox finishes building and starting')).action((opts) => run(async () => {
|
|
123
294
|
const exposed = opts.expose !== undefined;
|
|
124
295
|
if (exposed && !opts.name) {
|
|
125
296
|
throw new Error('--expose needs --name <name>, the hostname prefix for <name>-box.run.cloud');
|
|
@@ -144,20 +315,44 @@ export function registerSandbox(program) {
|
|
|
144
315
|
body.memory = memory;
|
|
145
316
|
if (opts.disk !== undefined)
|
|
146
317
|
body.disk = opts.disk;
|
|
318
|
+
body.timeoutSeconds = opts.timeout;
|
|
147
319
|
if (exposed || opts.persistent)
|
|
148
320
|
body.idlePauseSeconds = 0;
|
|
149
321
|
else if (opts.idlePause !== undefined)
|
|
150
322
|
body.idlePauseSeconds = opts.idlePause;
|
|
323
|
+
Object.assign(body, secretSelector({ ...opts, noSecrets: opts.secrets === false }));
|
|
151
324
|
const api = runCloudApi();
|
|
152
|
-
|
|
325
|
+
let created = (await api.post('/run-cloud/sandboxes', body));
|
|
326
|
+
if (typeof created.id !== 'string') {
|
|
327
|
+
throw new Error('sandbox create response did not include an id');
|
|
328
|
+
}
|
|
329
|
+
if (!opts.json && opts.wait && created.state && !READY_STATES.has(created.state)) {
|
|
330
|
+
const status = new StatusLine();
|
|
331
|
+
status.update(sandboxStatusMessage(created.state));
|
|
332
|
+
try {
|
|
333
|
+
created = await pollSandboxReady(api, created.id, {
|
|
334
|
+
onState: (state) => status.update(sandboxStatusMessage(state)),
|
|
335
|
+
});
|
|
336
|
+
status.clear();
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
status.fail('Sandbox failed to start');
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
153
343
|
if (!exposed) {
|
|
154
|
-
|
|
344
|
+
if (opts.json)
|
|
345
|
+
printJson(created);
|
|
346
|
+
else
|
|
347
|
+
console.log(renderSandbox(created, 'created'));
|
|
155
348
|
return;
|
|
156
349
|
}
|
|
157
|
-
if (typeof created?.id !== 'string')
|
|
158
|
-
throw new Error('sandbox create response did not include an id');
|
|
159
350
|
const box = await attachHostname(api, created.id, opts.name, opts.expose);
|
|
160
|
-
|
|
351
|
+
const exposedRecord = { ...created, box };
|
|
352
|
+
if (opts.json)
|
|
353
|
+
printJson(exposedRecord);
|
|
354
|
+
else
|
|
355
|
+
console.log(renderSandbox(exposedRecord, 'created'));
|
|
161
356
|
}));
|
|
162
357
|
withOutput(sandbox.command('list').description('List sandboxes').option('--state <state>', 'filter by state')).action((opts) => run(async () => {
|
|
163
358
|
const qs = opts.state ? `?state=${encodeURIComponent(opts.state)}` : '';
|
|
@@ -175,7 +370,10 @@ export function registerSandbox(program) {
|
|
|
175
370
|
const box = bySandbox.get(item.id);
|
|
176
371
|
return box ? { ...item, hostname: box.hostname, exposedPort: box.port } : item;
|
|
177
372
|
});
|
|
178
|
-
|
|
373
|
+
if (opts.json)
|
|
374
|
+
printJson(items);
|
|
375
|
+
else
|
|
376
|
+
console.log(renderSandboxList(items));
|
|
179
377
|
}));
|
|
180
378
|
withOutput(sandbox
|
|
181
379
|
.command('get')
|
|
@@ -186,7 +384,43 @@ export function registerSandbox(program) {
|
|
|
186
384
|
api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}`),
|
|
187
385
|
findBoxForSandbox(api, id),
|
|
188
386
|
]);
|
|
189
|
-
|
|
387
|
+
const sandboxRecord = (box ? { ...record, box } : record);
|
|
388
|
+
if (opts.json)
|
|
389
|
+
printJson(sandboxRecord);
|
|
390
|
+
else
|
|
391
|
+
console.log(renderSandbox(sandboxRecord));
|
|
392
|
+
}));
|
|
393
|
+
withOutput(sandbox
|
|
394
|
+
.command('metrics')
|
|
395
|
+
.description('Show current and historical CPU, memory, network, and disk usage')
|
|
396
|
+
.argument('<id>', 'sandbox id')
|
|
397
|
+
.option('--range <range>', 'history range: 1h, 6h, 24h, 7d, 30d, all', parseMetricRange, '24h')
|
|
398
|
+
.option('--watch', 'refresh continuously', false)
|
|
399
|
+
.option('--interval <seconds>', 'watch refresh interval', parseMetricInterval, 5)).action((id, opts) => run(async () => {
|
|
400
|
+
const api = runCloudApi();
|
|
401
|
+
for (;;) {
|
|
402
|
+
const metrics = (await api.get(`/run-cloud/sandboxes/${encodeURIComponent(id)}/metrics?range=${encodeURIComponent(opts.range)}`));
|
|
403
|
+
if (opts.json) {
|
|
404
|
+
console.log(opts.watch ? JSON.stringify(metrics) : JSON.stringify(metrics, null, 2));
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
console.log(renderSandboxMetrics(id, metrics));
|
|
408
|
+
}
|
|
409
|
+
if (!opts.watch)
|
|
410
|
+
return;
|
|
411
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, opts.interval * 1_000));
|
|
412
|
+
}
|
|
413
|
+
}));
|
|
414
|
+
withOutput(sandbox
|
|
415
|
+
.command('secrets')
|
|
416
|
+
.description('Replace the secrets a running sandbox holds (a full replacement, not a merge)')
|
|
417
|
+
.argument('<id>', 'sandbox id')
|
|
418
|
+
.option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
|
|
419
|
+
.option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
|
|
420
|
+
.option('--env <NAME=VALUE>', 'literal value; applied last, so it wins; repeatable', collectRepeatable, [])).action((id, opts) => run(async () => {
|
|
421
|
+
const body = secretSelector(opts);
|
|
422
|
+
await runCloudApi().put(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`, body);
|
|
423
|
+
console.log(`${id}: secrets replaced`);
|
|
190
424
|
}));
|
|
191
425
|
withOutput(sandbox
|
|
192
426
|
.command('expose')
|
|
@@ -227,33 +461,70 @@ export function registerSandbox(program) {
|
|
|
227
461
|
.command('exec')
|
|
228
462
|
.description('Run a command in a sandbox (via /bin/sh -c)')
|
|
229
463
|
.argument('<id>', 'sandbox id')
|
|
230
|
-
.argument('<cmd...>', 'command to run, e.g. npm run build')
|
|
231
|
-
|
|
232
|
-
|
|
464
|
+
.argument('<cmd...>', 'command to run, e.g. npm run build')
|
|
465
|
+
.option('--timeout <seconds>', 'command timeout', parseExecTimeoutSeconds)
|
|
466
|
+
.allowUnknownOption(true)).action((id, cmdParts, opts) => run(async () => {
|
|
467
|
+
const credentials = requireCredentials();
|
|
468
|
+
const r = await runSandboxCommand({
|
|
469
|
+
apiUrl: credentials.apiUrl,
|
|
470
|
+
token: credentials.token,
|
|
471
|
+
sandboxId: id,
|
|
472
|
+
request: {
|
|
473
|
+
cmd: ['/bin/sh', '-c', shellCommand(cmdParts)],
|
|
474
|
+
...(opts.timeout === undefined ? {} : { timeout_seconds: opts.timeout }),
|
|
475
|
+
},
|
|
476
|
+
...(opts.json
|
|
477
|
+
? {}
|
|
478
|
+
: {
|
|
479
|
+
onStdout: (chunk) => process.stdout.write(chunk),
|
|
480
|
+
onStderr: (chunk) => process.stderr.write(chunk),
|
|
481
|
+
}),
|
|
233
482
|
});
|
|
234
483
|
if (opts.json) {
|
|
235
484
|
console.log(JSON.stringify(r, null, 2));
|
|
236
485
|
}
|
|
237
|
-
else {
|
|
238
|
-
if (r.stdout)
|
|
239
|
-
process.stdout.write(r.stdout);
|
|
240
|
-
if (r.stderr)
|
|
241
|
-
process.stderr.write(r.stderr);
|
|
242
|
-
}
|
|
243
486
|
process.exitCode = r.exit_code ?? 0;
|
|
244
487
|
}));
|
|
245
488
|
sandbox
|
|
246
489
|
.command('shell')
|
|
247
|
-
.description('Open an interactive shell
|
|
490
|
+
.description('Open an interactive shell (resumes paused sandboxes)')
|
|
248
491
|
.argument('<id>', 'sandbox id')
|
|
249
492
|
.action((id) => run(async () => {
|
|
250
493
|
const credentials = requireCredentials();
|
|
494
|
+
const api = new ApiClient(credentials.apiUrl, credentials.token);
|
|
495
|
+
await prepareSandboxShell(api, id);
|
|
251
496
|
process.exitCode = await runSandboxShell({
|
|
252
497
|
apiUrl: credentials.apiUrl,
|
|
253
498
|
token: credentials.token,
|
|
254
499
|
sandboxId: id,
|
|
255
500
|
});
|
|
256
501
|
}));
|
|
502
|
+
for (const lifecycle of [
|
|
503
|
+
{
|
|
504
|
+
name: 'pause',
|
|
505
|
+
alias: 'stop',
|
|
506
|
+
description: 'Pause a sandbox so it can be resumed later',
|
|
507
|
+
pastTense: 'Paused',
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: 'resume',
|
|
511
|
+
alias: 'start',
|
|
512
|
+
description: 'Resume a paused sandbox',
|
|
513
|
+
pastTense: 'Resumed',
|
|
514
|
+
},
|
|
515
|
+
]) {
|
|
516
|
+
withOutput(sandbox
|
|
517
|
+
.command(lifecycle.name)
|
|
518
|
+
.alias(lifecycle.alias)
|
|
519
|
+
.description(lifecycle.description)
|
|
520
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
521
|
+
const record = await runCloudApi().post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/${lifecycle.name}`);
|
|
522
|
+
if (opts.json)
|
|
523
|
+
printJson(record);
|
|
524
|
+
else
|
|
525
|
+
console.log(`${style.green('✓')} ${lifecycle.pastTense} ${id}`);
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
257
528
|
withOutput(sandbox
|
|
258
529
|
.command('logs')
|
|
259
530
|
.description('Show recent sandbox logs (one-shot journalctl tail; streaming is a follow-up)')
|
|
@@ -341,7 +612,8 @@ export function registerSandbox(program) {
|
|
|
341
612
|
}));
|
|
342
613
|
withOutput(sandbox
|
|
343
614
|
.command('rm')
|
|
344
|
-
.
|
|
615
|
+
.aliases(['kill', 'terminate'])
|
|
616
|
+
.description('Permanently destroy a sandbox and its public hostname when exposed')
|
|
345
617
|
.argument('<id>', 'sandbox id')).action((id, opts) => run(async () => {
|
|
346
618
|
const api = runCloudApi();
|
|
347
619
|
try {
|
|
@@ -356,18 +628,32 @@ export function registerSandbox(program) {
|
|
|
356
628
|
await api.delete(`/run-cloud/boxes/${encodeURIComponent(box.id)}`);
|
|
357
629
|
}
|
|
358
630
|
if (!opts.json)
|
|
359
|
-
console.log(
|
|
631
|
+
console.log(`${style.green('✓')} Destroyed ${id}`);
|
|
360
632
|
}));
|
|
361
633
|
const snapshot = sandbox.command('snapshot').description('Capture and manage sandbox snapshots');
|
|
362
634
|
withOutput(snapshot
|
|
363
635
|
.command('create')
|
|
364
636
|
.description('Snapshot a sandbox for later restore')
|
|
365
637
|
.argument('<id>', 'sandbox id to snapshot')
|
|
366
|
-
.option('--label <label>', 'human-readable label')
|
|
638
|
+
.option('--label <label>', 'human-readable label')
|
|
639
|
+
.option('--timeout <seconds>', 'maximum time to wait for snapshot completion', parseExecTimeoutSeconds, 3600)).action((id, opts) => run(async () => {
|
|
367
640
|
const body = {};
|
|
368
641
|
if (opts.label)
|
|
369
642
|
body.label = opts.label;
|
|
370
|
-
|
|
643
|
+
const api = runCloudApi();
|
|
644
|
+
let result = await api.post(`/run-cloud/sandboxes/${encodeURIComponent(id)}/snapshots`, body);
|
|
645
|
+
const deadline = Date.now() + opts.timeout * 1000;
|
|
646
|
+
while (result.state === 'pending') {
|
|
647
|
+
if (!result.id)
|
|
648
|
+
throw new Error('snapshot job did not return an id');
|
|
649
|
+
if (Date.now() >= deadline)
|
|
650
|
+
throw new Error(`snapshot ${result.id} did not finish before the timeout`);
|
|
651
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
652
|
+
result = await api.get(`/run-cloud/snapshots/${encodeURIComponent(result.id)}`);
|
|
653
|
+
}
|
|
654
|
+
if (result.state === 'failed')
|
|
655
|
+
throw new Error(`snapshot ${result.id ?? ''} failed`);
|
|
656
|
+
print(result, opts);
|
|
371
657
|
}));
|
|
372
658
|
withOutput(snapshot
|
|
373
659
|
.command('list')
|
|
@@ -387,16 +673,33 @@ export function registerSandbox(program) {
|
|
|
387
673
|
.description('Fork a new sandbox from a snapshot (warm start)')
|
|
388
674
|
.argument('<snapshot-id>', 'snapshot id to restore from')
|
|
389
675
|
.option('--name <name>', 'human-readable name for the new sandbox')
|
|
676
|
+
.option('--size <size>', 'size preset for the restored sandbox')
|
|
677
|
+
.option('--cpu <cores>', 'CPU cores, including fractional values', parseCpuCores)
|
|
678
|
+
.option('--memory <mb>', 'memory in MiB', parseMemoryMb)
|
|
390
679
|
.option('--disk <gb>', 'root disk capacity in GiB (may only grow)', parseDiskGb)
|
|
391
|
-
.option('--region <region>', 'placement region')
|
|
680
|
+
.option('--region <region>', 'placement region')
|
|
681
|
+
.option('--secret-group <group>', 'attach a secret group to the fork; a fork inherits NONE by default (secrets live in memory, so a snapshot cannot carry them)', collectRepeatable, [])
|
|
682
|
+
.option('--secret <group/name>', 'attach one secret out of a group; repeatable, order matters', collectRepeatable, [])
|
|
683
|
+
.option('--env <NAME=VALUE>', 'literal value for the fork; applied last; repeatable', collectRepeatable, [])).action((snapshotId, opts) => run(async () => {
|
|
392
684
|
const body = {};
|
|
393
685
|
if (opts.name)
|
|
394
686
|
body.name = opts.name;
|
|
687
|
+
if (opts.size)
|
|
688
|
+
body.size = opts.size;
|
|
689
|
+
if (opts.cpu !== undefined)
|
|
690
|
+
body.cpu = opts.cpu;
|
|
691
|
+
if (opts.memory !== undefined)
|
|
692
|
+
body.memory = opts.memory;
|
|
395
693
|
if (opts.disk !== undefined)
|
|
396
694
|
body.disk = opts.disk;
|
|
397
695
|
if (opts.region)
|
|
398
696
|
body.region = opts.region;
|
|
399
|
-
|
|
697
|
+
Object.assign(body, secretSelector(opts));
|
|
698
|
+
const restored = await runCloudApi().post(`/run-cloud/snapshots/${encodeURIComponent(snapshotId)}/restore`, body);
|
|
699
|
+
if (opts.json)
|
|
700
|
+
printJson(restored);
|
|
701
|
+
else
|
|
702
|
+
console.log(renderSandbox(restored, 'restored'));
|
|
400
703
|
}));
|
|
401
704
|
registerAccessCommands(sandbox);
|
|
402
705
|
const box = program
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
|
|
2
|
+
function resources(record) {
|
|
3
|
+
const memory = formatMib(record.mem_mb);
|
|
4
|
+
const disk = formatMib(record.disk_mb);
|
|
5
|
+
return [
|
|
6
|
+
formatCpu(record.milli_cpu),
|
|
7
|
+
memory ? `${memory} memory` : undefined,
|
|
8
|
+
disk ? `${disk} disk` : undefined,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(' · ') || undefined;
|
|
12
|
+
}
|
|
13
|
+
function hostname(record) {
|
|
14
|
+
const host = record.hostname ?? record.box?.hostname;
|
|
15
|
+
const port = record.exposedPort ?? record.box?.port;
|
|
16
|
+
return host && port ? `${host}:${port}` : host;
|
|
17
|
+
}
|
|
18
|
+
function title(record, verb) {
|
|
19
|
+
if (verb === 'details')
|
|
20
|
+
return 'Sandbox';
|
|
21
|
+
if (record.state === 'running')
|
|
22
|
+
return `✓ Sandbox ${verb === 'restored' ? 'restored' : 'ready'}`;
|
|
23
|
+
if (record.state === 'building_image')
|
|
24
|
+
return '◐ Sandbox is building its image';
|
|
25
|
+
if (record.state === 'paused' || record.state === 'archived')
|
|
26
|
+
return '○ Sandbox paused';
|
|
27
|
+
return verb === 'restored' ? 'Sandbox restored' : 'Sandbox created';
|
|
28
|
+
}
|
|
29
|
+
export function renderSandbox(record, verb = 'details') {
|
|
30
|
+
const next = verb === 'details'
|
|
31
|
+
? undefined
|
|
32
|
+
: record.state === 'running'
|
|
33
|
+
? `${style.dim('Next:')} runcloud sandbox shell ${record.id}`
|
|
34
|
+
: `${style.dim('Follow:')} runcloud sandbox get ${record.id}`;
|
|
35
|
+
return renderDetails(title(record, verb), [
|
|
36
|
+
{ label: 'ID', value: record.id },
|
|
37
|
+
{ label: 'Name', value: record.name },
|
|
38
|
+
{ label: 'State', value: formatState(record.state) },
|
|
39
|
+
{ label: 'Image', value: record.image },
|
|
40
|
+
{ label: 'Resources', value: resources(record) },
|
|
41
|
+
{ label: 'Idle pause', value: formatSeconds(record.idle_pause_seconds) },
|
|
42
|
+
{ label: 'Timeout', value: formatSeconds(record.timeout_seconds) },
|
|
43
|
+
{ label: 'Hostname', value: hostname(record) },
|
|
44
|
+
{ label: 'Image digest', value: shortDigest(record.image_digest) },
|
|
45
|
+
{ label: 'Created', value: formatAge(record.created_at) },
|
|
46
|
+
{ label: 'Last active', value: verb === 'details' ? formatAge(record.last_active_at) : undefined },
|
|
47
|
+
], next);
|
|
48
|
+
}
|
|
49
|
+
export function renderSandboxList(records) {
|
|
50
|
+
if (records.length === 0) {
|
|
51
|
+
return `${style.bold('Sandboxes')}\n\nNo sandboxes found.\n\n${style.dim('Create one:')} runcloud sandbox create`;
|
|
52
|
+
}
|
|
53
|
+
return renderTable(`Sandboxes (${records.length})`, [
|
|
54
|
+
{ key: 'id', label: 'ID', maxWidth: 24 },
|
|
55
|
+
{ key: 'name', label: 'NAME / HOSTNAME', maxWidth: 20 },
|
|
56
|
+
{ key: 'state', label: 'STATE', maxWidth: 14 },
|
|
57
|
+
{ key: 'image', label: 'IMAGE', maxWidth: 20 },
|
|
58
|
+
{ key: 'resources', label: 'CPU / MEMORY', maxWidth: 18 },
|
|
59
|
+
{ key: 'age', label: 'AGE' },
|
|
60
|
+
], records.map((record) => ({
|
|
61
|
+
id: record.id,
|
|
62
|
+
name: hostname(record) ?? record.name ?? '—',
|
|
63
|
+
state: formatState(record.state),
|
|
64
|
+
image: record.image ?? '—',
|
|
65
|
+
resources: [formatCpu(record.milli_cpu)?.replace(' CPU', ''), formatMib(record.mem_mb)]
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join(' / ') || '—',
|
|
68
|
+
age: formatAge(record.created_at) ?? '—',
|
|
69
|
+
})));
|
|
70
|
+
}
|