warpmetal 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +1 -1
- package/skills/warpmetal/references/cli-reference.md +1 -1
- package/skills/warpmetal/references/runtime.md +49 -0
- package/src/api.js +5 -1
- package/src/cli.js +20 -3
- package/src/errors.js +5 -1
- package/src/installer.js +78 -3
package/README.md
CHANGED
|
@@ -296,6 +296,11 @@ compatible external signer.
|
|
|
296
296
|
- Sandboxes use the fixed runtime image and fixed sizes. Persistent is the
|
|
297
297
|
default; temporary sandboxes require explicit confirmation and permanently
|
|
298
298
|
delete their workspace after 15 minutes to 24 hours.
|
|
299
|
+
- `sandbox action --action refresh_image --confirm refresh_image --wait`
|
|
300
|
+
explicitly replaces one sandbox's root filesystem with the current immutable
|
|
301
|
+
production image. It briefly disconnects active sessions but preserves the
|
|
302
|
+
external workspace, lifetime, and start time. The wait completes only when
|
|
303
|
+
the observed digest and generation both match the accepted target.
|
|
299
304
|
- Guarded reload powers the server off first. Runtime-enabled reload requires a
|
|
300
305
|
second acknowledgment, after which the CLI guides supervisor reinstall and
|
|
301
306
|
pinned connection-profile refresh. Post-reload owner SSH host keys must be
|
|
@@ -319,12 +324,30 @@ warpmetal sandbox create \
|
|
|
319
324
|
--size small \
|
|
320
325
|
--wait \
|
|
321
326
|
--json
|
|
327
|
+
warpmetal sandbox action \
|
|
328
|
+
--server <serverId> \
|
|
329
|
+
--sandbox <sandboxId> \
|
|
330
|
+
--action refresh_image \
|
|
331
|
+
--confirm refresh_image \
|
|
332
|
+
--wait \
|
|
333
|
+
--json
|
|
322
334
|
warpmetal sandbox access keygen \
|
|
323
335
|
--output ~/.ssh/warpmetal-planner \
|
|
324
336
|
--confirm GENERATE \
|
|
325
337
|
--json
|
|
326
338
|
```
|
|
327
339
|
|
|
340
|
+
Installation gives pre-existing Docker containers exact liveness checks and
|
|
341
|
+
tracks common container-runtime processes without collecting application
|
|
342
|
+
configuration. The signed installer uses `crun` for its private rootless Podman
|
|
343
|
+
engine, refuses package removals or changes to an installed container stack,
|
|
344
|
+
and checks minimal liveness metadata after package installation and again
|
|
345
|
+
immediately before registration. Safety refusals have no force bypass; review
|
|
346
|
+
the reported `runtime_*` code instead of changing or restarting a third-party
|
|
347
|
+
runtime automatically. With `--json`, that code is returned as structured
|
|
348
|
+
`error.code`. A pending reboot and legacy preview Podman state are separate
|
|
349
|
+
maintenance operations, not implicit install steps.
|
|
350
|
+
|
|
328
351
|
See `skills/warpmetal/references/runtime.md` for the complete lifecycle,
|
|
329
352
|
cleanup, access-grant, and strict host-key connection workflow.
|
|
330
353
|
|
package/package.json
CHANGED
|
@@ -215,7 +215,7 @@ warpmetal sandbox list --server <serverId> --json
|
|
|
215
215
|
warpmetal sandbox get --server <serverId> --sandbox <sandboxId> [--wait] --json
|
|
216
216
|
warpmetal sandbox action \
|
|
217
217
|
--server <serverId> --sandbox <sandboxId> \
|
|
218
|
-
--action <start|stop|restart|make_persistent> --confirm <same-action> \
|
|
218
|
+
--action <start|stop|restart|make_persistent|refresh_image> --confirm <same-action> \
|
|
219
219
|
[--wait] --json
|
|
220
220
|
warpmetal sandbox delete \
|
|
221
221
|
--server <serverId> --sandbox <sandboxId> --confirm DELETE [--wait] --json
|
|
@@ -68,6 +68,36 @@ The CLI holds the one-time bootstrap only in memory, verifies the signed
|
|
|
68
68
|
artifact, uploads it through OpenSSH without a shell-enabled local spawn, and
|
|
69
69
|
does not print or store the bootstrap.
|
|
70
70
|
|
|
71
|
+
The signed installer is designed to preserve container workloads already
|
|
72
|
+
running on a supported host. It selects `crun` for WarpMetal's private rootless
|
|
73
|
+
Podman service, refuses APT removals and DNF erasures, protects installed
|
|
74
|
+
Docker/containerd/Podman and package-manager versions, and compares a minimal
|
|
75
|
+
root-only liveness snapshot after the guarded package action and immediately
|
|
76
|
+
before registration. The snapshot contains recognized runtime-process start
|
|
77
|
+
metadata and, when Docker is active, container IDs, init PIDs, and start
|
|
78
|
+
timestamps. Docker receives container-level checks; other recognized engines
|
|
79
|
+
receive process-level checks and need separate certification for broader
|
|
80
|
+
coexistence claims. The snapshot never collects names, images, environment,
|
|
81
|
+
mounts, or logs.
|
|
82
|
+
|
|
83
|
+
Treat these refusal codes as safety gates, not prompts to force or repair the
|
|
84
|
+
host automatically:
|
|
85
|
+
|
|
86
|
+
- `runtime_package_plan_unsafe`: the proposed package transaction was refused;
|
|
87
|
+
- `runtime_workload_state_unverifiable`: an active Docker workload could not be
|
|
88
|
+
inspected safely;
|
|
89
|
+
- `runtime_workload_drift_detected`: a pre-existing runtime process or Docker
|
|
90
|
+
container changed during the installation window;
|
|
91
|
+
- `runtime_package_postcondition_failed`: a protected container package changed
|
|
92
|
+
unexpectedly; stop and review the host package logs;
|
|
93
|
+
- `runtime_legacy_migration_required`: preview Podman state needs a separate,
|
|
94
|
+
explicitly reviewed migration and was not reset.
|
|
95
|
+
|
|
96
|
+
There is no force bypass. If `runtime_reboot_required` is returned, schedule
|
|
97
|
+
the reboot as a separate maintenance action and retry only after the host and
|
|
98
|
+
its existing workloads are healthy. The ordinary installer never installs a
|
|
99
|
+
kernel or runs `podman system reset`.
|
|
100
|
+
|
|
71
101
|
## Sizes and lifetime
|
|
72
102
|
|
|
73
103
|
Use `small`, `medium`, `large`, or `xlarge` exactly as the live catalog
|
|
@@ -109,6 +139,25 @@ Use `sandbox list`, `sandbox get --wait`, and guarded `sandbox action` commands
|
|
|
109
139
|
to observe and change desired state. HTTP 202 and CLI exit 8 mean accepted or
|
|
110
140
|
pending, not complete.
|
|
111
141
|
|
|
142
|
+
Refresh an existing sandbox to the current immutable production image only
|
|
143
|
+
after the owner approves the brief connection interruption:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
warpmetal sandbox action \
|
|
147
|
+
--server <serverId> \
|
|
148
|
+
--sandbox <sandboxId> \
|
|
149
|
+
--action refresh_image \
|
|
150
|
+
--confirm refresh_image \
|
|
151
|
+
--wait \
|
|
152
|
+
--json
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The supervisor must already support image refresh. The action pre-pulls and
|
|
156
|
+
replaces only the container root filesystem while retaining the external
|
|
157
|
+
workspace, sandbox lifetime, and original start time. `--wait` requires both
|
|
158
|
+
the observed digest and generation to match the accepted target. A change to
|
|
159
|
+
the global production image does not refresh existing sandboxes implicitly.
|
|
160
|
+
|
|
112
161
|
Manual deletion is irreversible:
|
|
113
162
|
|
|
114
163
|
```sh
|
package/src/api.js
CHANGED
|
@@ -347,10 +347,14 @@ export class WarpMetalClient {
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
sandboxAction(serverId, sandboxId, action, token, idempotencyKey) {
|
|
350
|
+
const body =
|
|
351
|
+
action === "refresh_image"
|
|
352
|
+
? { action, confirm: "refresh_image" }
|
|
353
|
+
: { action };
|
|
350
354
|
return this.request(
|
|
351
355
|
"POST",
|
|
352
356
|
`/servers/${encodeURIComponent(serverId)}/sandboxes/${encodeURIComponent(sandboxId)}/actions`,
|
|
353
|
-
{ body
|
|
357
|
+
{ body, token, idempotencyKey },
|
|
354
358
|
);
|
|
355
359
|
}
|
|
356
360
|
|
package/src/cli.js
CHANGED
|
@@ -114,6 +114,8 @@ Usage:
|
|
|
114
114
|
warpmetal sandbox create --server <serverId> --name <name> --size <size>
|
|
115
115
|
[--lifetime temporary] [--expires-in-seconds <n>] [--confirm TEMPORARY] [--wait]
|
|
116
116
|
warpmetal sandbox create --server <serverId> --file <batch.json> [--confirm TEMPORARY]
|
|
117
|
+
warpmetal sandbox action --server <serverId> --sandbox <sandboxId>
|
|
118
|
+
--action <start|stop|restart|make_persistent|refresh_image> --confirm <same-action> [--wait]
|
|
117
119
|
warpmetal sandbox list|get|action|delete ...
|
|
118
120
|
warpmetal sandbox access keygen --output <private-key-path> --confirm GENERATE
|
|
119
121
|
warpmetal sandbox access grant|list|get|revoke ...
|
|
@@ -2205,9 +2207,17 @@ async function handleSandboxAction(client, store, options, context) {
|
|
|
2205
2207
|
const serverId = stringOption(options, "server", { required: true });
|
|
2206
2208
|
const sandboxId = stringOption(options, "sandbox", { required: true });
|
|
2207
2209
|
const action = stringOption(options, "action", { required: true });
|
|
2208
|
-
if (
|
|
2210
|
+
if (
|
|
2211
|
+
![
|
|
2212
|
+
"start",
|
|
2213
|
+
"stop",
|
|
2214
|
+
"restart",
|
|
2215
|
+
"make_persistent",
|
|
2216
|
+
"refresh_image",
|
|
2217
|
+
].includes(action)
|
|
2218
|
+
) {
|
|
2209
2219
|
throw new CliError(
|
|
2210
|
-
"--action must be start, stop, restart, or
|
|
2220
|
+
"--action must be start, stop, restart, make_persistent, or refresh_image.",
|
|
2211
2221
|
{
|
|
2212
2222
|
exitCode: 2,
|
|
2213
2223
|
},
|
|
@@ -2231,6 +2241,11 @@ async function handleSandboxAction(client, store, options, context) {
|
|
|
2231
2241
|
);
|
|
2232
2242
|
if (booleanOption(options, "wait")) {
|
|
2233
2243
|
const accepted = result.data.sandbox;
|
|
2244
|
+
if (action === "refresh_image" && !accepted.desiredImageDigest) {
|
|
2245
|
+
throw new CliError(
|
|
2246
|
+
"WarpMetal accepted image refresh without an immutable desired image digest.",
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2234
2249
|
const expectedState =
|
|
2235
2250
|
accepted.desiredState === "stopped" ? "stopped" : "running";
|
|
2236
2251
|
result = await pollSandbox(
|
|
@@ -2241,7 +2256,9 @@ async function handleSandboxAction(client, store, options, context) {
|
|
|
2241
2256
|
integerOption(options, "timeout-seconds", 900),
|
|
2242
2257
|
(sandbox) =>
|
|
2243
2258
|
sandbox.observedState === expectedState &&
|
|
2244
|
-
Number(sandbox.observedGeneration) >= Number(accepted.generation)
|
|
2259
|
+
Number(sandbox.observedGeneration) >= Number(accepted.generation) &&
|
|
2260
|
+
(action !== "refresh_image" ||
|
|
2261
|
+
sandbox.imageDigest === accepted.desiredImageDigest),
|
|
2245
2262
|
);
|
|
2246
2263
|
}
|
|
2247
2264
|
await store.saveSandboxes(serverId, [result.data.sandbox]);
|
package/src/errors.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export class CliError extends Error {
|
|
2
|
-
constructor(
|
|
2
|
+
constructor(
|
|
3
|
+
message,
|
|
4
|
+
{ exitCode = 1, details = undefined, code = undefined } = {},
|
|
5
|
+
) {
|
|
3
6
|
super(message);
|
|
4
7
|
this.name = "CliError";
|
|
5
8
|
this.exitCode = exitCode;
|
|
6
9
|
this.details = details;
|
|
10
|
+
this.code = code ?? details?.code;
|
|
7
11
|
}
|
|
8
12
|
}
|
|
9
13
|
|
package/src/installer.js
CHANGED
|
@@ -24,6 +24,71 @@ const REQUIRED_FILES = [
|
|
|
24
24
|
"warpmetal-sandbox.conf",
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
const INSTALLER_ERROR_MESSAGES = new Map([
|
|
28
|
+
[
|
|
29
|
+
"runtime_install_lock_unavailable",
|
|
30
|
+
"The server cannot create the Agent Runtime installation lock.",
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
"runtime_install_state_unavailable",
|
|
34
|
+
"The server cannot create private temporary state for the installation checks.",
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
"runtime_install_in_progress",
|
|
38
|
+
"Another Agent Runtime installation is already running on this server.",
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"runtime_reboot_required",
|
|
42
|
+
"The server already requires a reboot. Reboot it as a separate maintenance action, then retry.",
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"runtime_package_index_failed",
|
|
46
|
+
"The server package index could not be refreshed; no Agent Runtime services were changed.",
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
"runtime_package_plan_unsafe",
|
|
50
|
+
"Installation refused a package transaction that could change the existing container stack.",
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
"runtime_package_apply_failed",
|
|
54
|
+
"The guarded dependency transaction failed; the Agent Runtime supervisor was not registered.",
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
"runtime_package_postcondition_failed",
|
|
58
|
+
"An installed container package changed unexpectedly. Do not retry until the host package state is reviewed.",
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
"runtime_workload_state_unverifiable",
|
|
62
|
+
"Installation could not safely inspect the existing container workload state.",
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
"runtime_workload_drift_detected",
|
|
66
|
+
"An existing container workload changed during installation. Do not retry until the host runtime is reviewed.",
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"runtime_oci_runtime_unavailable",
|
|
70
|
+
"The certified crun OCI runtime is unavailable on this server.",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
"runtime_podman_unavailable",
|
|
74
|
+
"The private Podman service did not become available.",
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"runtime_legacy_migration_required",
|
|
78
|
+
"Existing preview Agent Runtime state requires a separately reviewed migration; it was not reset.",
|
|
79
|
+
],
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function mappedInstallerError(result) {
|
|
83
|
+
const lines = `${result.stderr}\n${result.stdout}`
|
|
84
|
+
.split(/\r?\n/)
|
|
85
|
+
.map((line) => line.trim());
|
|
86
|
+
for (const [code, message] of INSTALLER_ERROR_MESSAGES) {
|
|
87
|
+
if (lines.includes(code)) return { code, message: `${message} (${code})` };
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
27
92
|
function validateArtifact(artifact) {
|
|
28
93
|
if (
|
|
29
94
|
!artifact ||
|
|
@@ -130,7 +195,7 @@ function appendBounded(current, chunk) {
|
|
|
130
195
|
async function spawnChecked(
|
|
131
196
|
command,
|
|
132
197
|
args,
|
|
133
|
-
{ stdin, spawnImpl = nodeSpawn, redact = [] } = {},
|
|
198
|
+
{ stdin, spawnImpl = nodeSpawn, redact = [], mapError } = {},
|
|
134
199
|
) {
|
|
135
200
|
const result = await new Promise((resolvePromise, reject) => {
|
|
136
201
|
const child = spawnImpl(command, args, {
|
|
@@ -151,13 +216,18 @@ async function spawnChecked(
|
|
|
151
216
|
else child.stdin?.end();
|
|
152
217
|
});
|
|
153
218
|
if (result.status !== 0) {
|
|
219
|
+
const mappedError = mapError?.(result);
|
|
154
220
|
let message =
|
|
221
|
+
mappedError?.message ||
|
|
155
222
|
result.stderr.trim() ||
|
|
156
223
|
result.stdout.trim() ||
|
|
157
224
|
`${command} exited unsuccessfully`;
|
|
158
225
|
for (const secret of redact)
|
|
159
226
|
if (secret) message = message.split(secret).join("[redacted]");
|
|
160
|
-
throw new CliError(message.slice(0, 500), {
|
|
227
|
+
throw new CliError(message.slice(0, 500), {
|
|
228
|
+
exitCode: 4,
|
|
229
|
+
code: mappedError?.code,
|
|
230
|
+
});
|
|
161
231
|
}
|
|
162
232
|
return result;
|
|
163
233
|
}
|
|
@@ -286,7 +356,12 @@ export async function installRuntime({
|
|
|
286
356
|
"--bundle",
|
|
287
357
|
remoteBundle,
|
|
288
358
|
],
|
|
289
|
-
{
|
|
359
|
+
{
|
|
360
|
+
stdin: `${bootstrapToken}\n`,
|
|
361
|
+
spawnImpl,
|
|
362
|
+
redact: [bootstrapToken],
|
|
363
|
+
mapError: mappedInstallerError,
|
|
364
|
+
},
|
|
290
365
|
);
|
|
291
366
|
await spawnChecked(
|
|
292
367
|
"ssh",
|