@vellumai/cli 0.7.1 → 0.7.3
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/AGENTS.md +3 -11
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +121 -5
- package/src/__tests__/teleport.test.ts +515 -10
- package/src/commands/backup.ts +35 -2
- package/src/commands/client.ts +90 -7
- package/src/commands/exec.ts +13 -4
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +11 -0
- package/src/commands/restore.ts +7 -1
- package/src/commands/rollback.ts +1 -1
- package/src/commands/setup.ts +38 -73
- package/src/commands/teleport.ts +122 -12
- package/src/commands/upgrade.ts +8 -2
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +42 -130
- package/src/index.ts +1 -7
- package/src/lib/__tests__/docker.test.ts +53 -35
- package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
- package/src/lib/__tests__/runtime-url.test.ts +39 -1
- package/src/lib/assistant-client.ts +13 -5
- package/src/lib/assistant-config.ts +0 -25
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/client-identity.ts +9 -5
- package/src/lib/docker.ts +6 -267
- package/src/lib/environments/paths.ts +20 -0
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/local-runtime-client.ts +82 -1
- package/src/lib/local.ts +9 -7
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +100 -1
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +22 -0
- package/src/lib/statefulset.ts +375 -0
- package/src/lib/upgrade-lifecycle.ts +97 -1
- package/src/commands/pair.ts +0 -212
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative StatefulSet spec for the assistant service group.
|
|
3
|
+
*
|
|
4
|
+
* Defines the static topology of all three containers (assistant, gateway,
|
|
5
|
+
* credential-executor), their volumes, ports, and env vars. Used by both
|
|
6
|
+
* the hatch and upgrade flows to build `docker run` argument arrays.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ASSISTANT_INTERNAL_PORT,
|
|
13
|
+
GATEWAY_INTERNAL_PORT,
|
|
14
|
+
} from "./environments/paths.js";
|
|
15
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
16
|
+
|
|
17
|
+
const AVATAR_DEVICE_ENV_VAR = "VELLUM_AVATAR_DEVICE";
|
|
18
|
+
|
|
19
|
+
/** Logical service name used throughout the CLI. */
|
|
20
|
+
export type ServiceName = "assistant" | "gateway" | "credential-executor";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
/**
|
|
24
|
+
* The four fields from `dockerResourceNames()` that the builder actually uses.
|
|
25
|
+
* Container/network names come from here; volume names are generated by the
|
|
26
|
+
* `volumeClaimTemplates` in the spec. `ReturnType<typeof dockerResourceNames>`
|
|
27
|
+
* structurally satisfies this interface (it has all these fields plus more).
|
|
28
|
+
*/
|
|
29
|
+
export interface DockerResourceNames {
|
|
30
|
+
assistantContainer: string;
|
|
31
|
+
cesContainer: string;
|
|
32
|
+
gatewayContainer: string;
|
|
33
|
+
network: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Types
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** A static env var whose value is known at spec-definition time. */
|
|
41
|
+
interface StaticEnv {
|
|
42
|
+
kind: "static";
|
|
43
|
+
name: string;
|
|
44
|
+
value: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* An env var whose value comes from the opts object passed to
|
|
49
|
+
* `buildServiceRunArgs` at container-start time.
|
|
50
|
+
*/
|
|
51
|
+
interface SecretEnv {
|
|
52
|
+
kind: "secret";
|
|
53
|
+
name: string;
|
|
54
|
+
/** Key in `DockerRunSecrets`. */
|
|
55
|
+
secret: keyof DockerRunSecrets;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An env var conditionally forwarded from the host process environment.
|
|
60
|
+
* Only emitted when `process.env[hostVar]` is set.
|
|
61
|
+
* Defaults to `name` for `hostVar` when omitted.
|
|
62
|
+
*/
|
|
63
|
+
interface HostEnv {
|
|
64
|
+
kind: "host";
|
|
65
|
+
name: string;
|
|
66
|
+
hostVar?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type EnvEntry = StaticEnv | SecretEnv | HostEnv;
|
|
70
|
+
|
|
71
|
+
interface VolumeMount {
|
|
72
|
+
/** Logical volume name — maps to a Docker volume via `DockerVolumeClaimTemplate`. */
|
|
73
|
+
volumeName: string;
|
|
74
|
+
mountPath: string;
|
|
75
|
+
readOnly?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PortSpec {
|
|
79
|
+
containerPort: number;
|
|
80
|
+
/**
|
|
81
|
+
* Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"` is
|
|
82
|
+
* a sentinel replaced with the instance-specific gateway port at build time.
|
|
83
|
+
*/
|
|
84
|
+
hostPort?: string;
|
|
85
|
+
description?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DockerContainerSpec {
|
|
89
|
+
/** K8s-style container name (matches `stateful_template.yaml`). */
|
|
90
|
+
name: string;
|
|
91
|
+
/** Internal CLI `ServiceName` key used by `SERVICE_START_ORDER`. */
|
|
92
|
+
internalName: ServiceName;
|
|
93
|
+
/**
|
|
94
|
+
* Network mode:
|
|
95
|
+
* - "bridge": owns the network (assistant only — gateway port published here).
|
|
96
|
+
* - "container": share the assistant container's network namespace.
|
|
97
|
+
*/
|
|
98
|
+
network: "bridge" | "container";
|
|
99
|
+
ports?: PortSpec[];
|
|
100
|
+
env: EnvEntry[];
|
|
101
|
+
volumeMounts: VolumeMount[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DockerVolumeClaimTemplate {
|
|
105
|
+
name: string;
|
|
106
|
+
dockerVolume: (instanceName: string) => string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DockerStatefulSetSpec {
|
|
110
|
+
containers: DockerContainerSpec[];
|
|
111
|
+
startOrder: ServiceName[];
|
|
112
|
+
volumeClaimTemplates: DockerVolumeClaimTemplate[];
|
|
113
|
+
readiness: {
|
|
114
|
+
endpoint: string;
|
|
115
|
+
timeoutMs: number;
|
|
116
|
+
intervalMs: number;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Secrets injected at container-start time (from hatch / upgrade opts). */
|
|
121
|
+
export interface DockerRunSecrets {
|
|
122
|
+
signingKey?: string;
|
|
123
|
+
bootstrapSecret?: string;
|
|
124
|
+
cesServiceToken?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Spec
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
132
|
+
startOrder: ["assistant", "gateway", "credential-executor"],
|
|
133
|
+
|
|
134
|
+
readiness: {
|
|
135
|
+
endpoint: "/readyz",
|
|
136
|
+
timeoutMs: 180_000,
|
|
137
|
+
intervalMs: 1_000,
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
volumeClaimTemplates: [
|
|
141
|
+
{ name: "assistant-workspace", dockerVolume: (n) => `${n}-workspace` },
|
|
142
|
+
{ name: "ces-bootstrap-socket", dockerVolume: (n) => `${n}-socket` },
|
|
143
|
+
{ name: "assistant-ipc-socket", dockerVolume: (n) => `${n}-assistant-ipc` },
|
|
144
|
+
{ name: "gateway-ipc-socket", dockerVolume: (n) => `${n}-gateway-ipc` },
|
|
145
|
+
{ name: "gateway-security", dockerVolume: (n) => `${n}-gateway-sec` },
|
|
146
|
+
{ name: "ces-security", dockerVolume: (n) => `${n}-ces-sec` },
|
|
147
|
+
],
|
|
148
|
+
|
|
149
|
+
containers: [
|
|
150
|
+
{
|
|
151
|
+
name: "assistant-container",
|
|
152
|
+
internalName: "assistant",
|
|
153
|
+
network: "bridge",
|
|
154
|
+
ports: [
|
|
155
|
+
{
|
|
156
|
+
containerPort: GATEWAY_INTERNAL_PORT,
|
|
157
|
+
hostPort: "{{ gatewayPort }}",
|
|
158
|
+
description: "Gateway reverse-proxy (published to host)",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
containerPort: ASSISTANT_INTERNAL_PORT,
|
|
162
|
+
hostPort: `${ASSISTANT_INTERNAL_PORT}`,
|
|
163
|
+
description: "Assistant HTTP API",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
env: [
|
|
167
|
+
{ kind: "static", name: "IS_CONTAINERIZED", value: "true" },
|
|
168
|
+
{ kind: "static", name: "DEBUG_STDOUT_LOGS", value: "1" },
|
|
169
|
+
{ kind: "static", name: "VELLUM_CLOUD", value: "docker" },
|
|
170
|
+
{ kind: "static", name: "RUNTIME_HTTP_HOST", value: "0.0.0.0" },
|
|
171
|
+
{ kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
|
|
172
|
+
{ kind: "static", name: "VELLUM_BACKUP_DIR", value: "/workspace/.backups" },
|
|
173
|
+
{ kind: "static", name: "VELLUM_BACKUP_KEY_PATH", value: "/workspace/.backup.key" },
|
|
174
|
+
{ kind: "static", name: "CES_CREDENTIAL_URL", value: "http://localhost:8090" },
|
|
175
|
+
{ kind: "static", name: "GATEWAY_IPC_SOCKET_DIR", value: "/run/gateway-ipc" },
|
|
176
|
+
{ kind: "static", name: "ASSISTANT_IPC_SOCKET_DIR", value: "/run/assistant-ipc" },
|
|
177
|
+
{ kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
|
|
178
|
+
{ kind: "secret", name: "ACTOR_TOKEN_SIGNING_KEY", secret: "signingKey" },
|
|
179
|
+
{ kind: "secret", name: "GUARDIAN_BOOTSTRAP_SECRET", secret: "bootstrapSecret" },
|
|
180
|
+
// Provider API keys — forwarded from host if set
|
|
181
|
+
...Object.values(PROVIDER_ENV_VAR_NAMES).map(
|
|
182
|
+
(v): HostEnv => ({ kind: "host", name: v }),
|
|
183
|
+
),
|
|
184
|
+
{ kind: "host", name: "VELLUM_ENVIRONMENT" },
|
|
185
|
+
{ kind: "host", name: "VELLUM_PLATFORM_URL" },
|
|
186
|
+
],
|
|
187
|
+
volumeMounts: [
|
|
188
|
+
{ volumeName: "assistant-workspace", mountPath: "/workspace" },
|
|
189
|
+
{ volumeName: "ces-bootstrap-socket", mountPath: "/run/ces-bootstrap" },
|
|
190
|
+
{ volumeName: "assistant-ipc-socket", mountPath: "/run/assistant-ipc" },
|
|
191
|
+
{ volumeName: "gateway-ipc-socket", mountPath: "/run/gateway-ipc" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
name: "gateway-sidecar",
|
|
197
|
+
internalName: "gateway",
|
|
198
|
+
network: "container",
|
|
199
|
+
env: [
|
|
200
|
+
{ kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
|
|
201
|
+
{ kind: "static", name: "GATEWAY_SECURITY_DIR", value: "/gateway-security" },
|
|
202
|
+
{ kind: "static", name: "ASSISTANT_HOST", value: "localhost" },
|
|
203
|
+
{ kind: "static", name: "CES_CREDENTIAL_URL", value: "http://localhost:8090" },
|
|
204
|
+
{ kind: "static", name: "GATEWAY_IPC_SOCKET_DIR", value: "/run/gateway-ipc" },
|
|
205
|
+
{ kind: "static", name: "ASSISTANT_IPC_SOCKET_DIR", value: "/run/assistant-ipc" },
|
|
206
|
+
{ kind: "static", name: "GATEWAY_PORT", value: `${GATEWAY_INTERNAL_PORT}` },
|
|
207
|
+
{ kind: "static", name: "RUNTIME_HTTP_PORT", value: `${ASSISTANT_INTERNAL_PORT}` },
|
|
208
|
+
{ kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
|
|
209
|
+
{ kind: "secret", name: "ACTOR_TOKEN_SIGNING_KEY", secret: "signingKey" },
|
|
210
|
+
{ kind: "secret", name: "GUARDIAN_BOOTSTRAP_SECRET", secret: "bootstrapSecret" },
|
|
211
|
+
{ kind: "host", name: "VELLUM_ENVIRONMENT" },
|
|
212
|
+
{ kind: "host", name: "VELLUM_PLATFORM_URL" },
|
|
213
|
+
{ kind: "host", name: "VELAY_BASE_URL" },
|
|
214
|
+
],
|
|
215
|
+
volumeMounts: [
|
|
216
|
+
{ volumeName: "assistant-workspace", mountPath: "/workspace" },
|
|
217
|
+
{ volumeName: "gateway-security", mountPath: "/gateway-security" },
|
|
218
|
+
{ volumeName: "assistant-ipc-socket", mountPath: "/run/assistant-ipc" },
|
|
219
|
+
{ volumeName: "gateway-ipc-socket", mountPath: "/run/gateway-ipc" },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
{
|
|
224
|
+
name: "credential-executor-sidecar",
|
|
225
|
+
internalName: "credential-executor",
|
|
226
|
+
network: "container",
|
|
227
|
+
env: [
|
|
228
|
+
{ kind: "static", name: "CES_MODE", value: "managed" },
|
|
229
|
+
{ kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
|
|
230
|
+
{ kind: "static", name: "CES_BOOTSTRAP_SOCKET_DIR", value: "/run/ces-bootstrap" },
|
|
231
|
+
{ kind: "static", name: "CREDENTIAL_SECURITY_DIR", value: "/ces-security" },
|
|
232
|
+
{ kind: "secret", name: "CES_SERVICE_TOKEN", secret: "cesServiceToken" },
|
|
233
|
+
],
|
|
234
|
+
volumeMounts: [
|
|
235
|
+
{ volumeName: "assistant-workspace", mountPath: "/workspace", readOnly: true },
|
|
236
|
+
{ volumeName: "ces-bootstrap-socket", mountPath: "/run/ces-bootstrap" },
|
|
237
|
+
{ volumeName: "ces-security", mountPath: "/ces-security" },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Builder
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
|
|
248
|
+
gatewayPort: number;
|
|
249
|
+
imageTags: Record<ServiceName, string>;
|
|
250
|
+
instanceName: string;
|
|
251
|
+
res: DockerResourceNames;
|
|
252
|
+
extraAssistantEnv?: Record<string, string>;
|
|
253
|
+
defaultWorkspaceConfigPath?: string;
|
|
254
|
+
/** Avatar device path, if available. Injected by `docker.ts` after resolving. */
|
|
255
|
+
avatarDevicePath?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveVolume(
|
|
259
|
+
spec: DockerStatefulSetSpec,
|
|
260
|
+
instanceName: string,
|
|
261
|
+
volumeName: string,
|
|
262
|
+
): string {
|
|
263
|
+
const claim = spec.volumeClaimTemplates.find((v) => v.name === volumeName);
|
|
264
|
+
if (!claim) throw new Error(`docker-statefulset: unknown volume "${volumeName}"`);
|
|
265
|
+
return claim.dockerVolume(instanceName);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build `docker run` argument arrays for each container in the StatefulSet.
|
|
270
|
+
* Returns `Record<ServiceName, () => string[]>` — callers evaluate lazily.
|
|
271
|
+
*/
|
|
272
|
+
export function buildServiceRunArgs(
|
|
273
|
+
opts: BuildServiceRunArgsOpts,
|
|
274
|
+
spec = DOCKER_STATEFUL_SET_SPEC,
|
|
275
|
+
): Record<ServiceName, () => string[]> {
|
|
276
|
+
const {
|
|
277
|
+
gatewayPort,
|
|
278
|
+
imageTags,
|
|
279
|
+
instanceName,
|
|
280
|
+
res,
|
|
281
|
+
extraAssistantEnv,
|
|
282
|
+
defaultWorkspaceConfigPath,
|
|
283
|
+
avatarDevicePath,
|
|
284
|
+
} = opts;
|
|
285
|
+
|
|
286
|
+
const result = {} as Record<ServiceName, () => string[]>;
|
|
287
|
+
|
|
288
|
+
for (const container of spec.containers) {
|
|
289
|
+
const svc = container.internalName;
|
|
290
|
+
|
|
291
|
+
result[svc] = () => {
|
|
292
|
+
const args: string[] = ["run", "--init", "-d"];
|
|
293
|
+
|
|
294
|
+
// Container name
|
|
295
|
+
const containerName =
|
|
296
|
+
svc === "assistant"
|
|
297
|
+
? res.assistantContainer
|
|
298
|
+
: svc === "gateway"
|
|
299
|
+
? res.gatewayContainer
|
|
300
|
+
: res.cesContainer;
|
|
301
|
+
args.push("--name", containerName);
|
|
302
|
+
|
|
303
|
+
// Network
|
|
304
|
+
if (container.network === "bridge") {
|
|
305
|
+
args.push(`--network=${res.network}`);
|
|
306
|
+
for (const port of container.ports ?? []) {
|
|
307
|
+
const hostSide =
|
|
308
|
+
port.hostPort === "{{ gatewayPort }}"
|
|
309
|
+
? `${gatewayPort}`
|
|
310
|
+
: port.hostPort;
|
|
311
|
+
if (hostSide !== undefined) {
|
|
312
|
+
args.push("-p", `${hostSide}:${port.containerPort}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
args.push(`--network=container:${res.assistantContainer}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Volume mounts
|
|
320
|
+
for (const mount of container.volumeMounts) {
|
|
321
|
+
const vol = resolveVolume(spec, instanceName, mount.volumeName);
|
|
322
|
+
args.push("-v", mount.readOnly ? `${vol}:${mount.mountPath}:ro` : `${vol}:${mount.mountPath}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Env vars from spec
|
|
326
|
+
for (const entry of container.env) {
|
|
327
|
+
if (entry.kind === "static") {
|
|
328
|
+
args.push("-e", `${entry.name}=${entry.value}`);
|
|
329
|
+
} else if (entry.kind === "secret") {
|
|
330
|
+
const val = opts[entry.secret];
|
|
331
|
+
if (val) args.push("-e", `${entry.name}=${val}`);
|
|
332
|
+
} else {
|
|
333
|
+
const hostVar = entry.hostVar ?? entry.name;
|
|
334
|
+
const val = process.env[hostVar];
|
|
335
|
+
if (val) args.push("-e", `${entry.name}=${val}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Assistant-only computed / optional additions
|
|
340
|
+
if (svc === "assistant") {
|
|
341
|
+
args.push(
|
|
342
|
+
"-e", `VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
343
|
+
"-e", `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (defaultWorkspaceConfigPath) {
|
|
347
|
+
const cPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
348
|
+
args.push(
|
|
349
|
+
"-v", `${defaultWorkspaceConfigPath}:${cPath}:ro`,
|
|
350
|
+
"-e", `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${cPath}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (extraAssistantEnv) {
|
|
355
|
+
for (const [k, v] of Object.entries(extraAssistantEnv)) {
|
|
356
|
+
args.push("-e", `${k}=${v}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (avatarDevicePath && existsSync(avatarDevicePath)) {
|
|
361
|
+
args.push(
|
|
362
|
+
"--device", `${avatarDevicePath}:${avatarDevicePath}`,
|
|
363
|
+
"-e", `${AVATAR_DEVICE_ENV_VAR}=${avatarDevicePath}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Image is always last
|
|
369
|
+
args.push(imageTags[svc]);
|
|
370
|
+
return args;
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
2
5
|
|
|
3
6
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
4
7
|
import { saveAssistantEntry } from "./assistant-config.js";
|
|
@@ -12,12 +15,68 @@ import {
|
|
|
12
15
|
startContainers,
|
|
13
16
|
stopContainers,
|
|
14
17
|
} from "./docker.js";
|
|
18
|
+
import { getStateDir } from "./environments/paths.js";
|
|
19
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
15
20
|
import { loadGuardianToken } from "./guardian-token.js";
|
|
16
21
|
import { getPlatformUrl } from "./platform-client.js";
|
|
17
22
|
import { resolveImageRefs } from "./platform-releases.js";
|
|
18
23
|
import { exec, execOutput } from "./step-runner.js";
|
|
19
24
|
import { compareVersions } from "./version-compat.js";
|
|
20
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Failure log capture
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** XDG-compliant directory for upgrade failure logs, scoped to the current environment. */
|
|
31
|
+
function getUpgradeLogsDir(): string {
|
|
32
|
+
return join(getStateDir(getCurrentEnvironment()), "upgrade-logs");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capture stdout/stderr from all three containers after a readiness failure
|
|
37
|
+
* and write them to an XDG state directory. Returns the directory path so
|
|
38
|
+
* the caller can print it for the user.
|
|
39
|
+
*
|
|
40
|
+
* Runs best-effort — never throws.
|
|
41
|
+
*/
|
|
42
|
+
export async function captureUpgradeFailureLogs(
|
|
43
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
44
|
+
label: string,
|
|
45
|
+
): Promise<string | null> {
|
|
46
|
+
const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
47
|
+
const logDir = join(getUpgradeLogsDir(), `${label}-${isoTimestamp}`);
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(logDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const containers: [string, string][] = [
|
|
52
|
+
[res.assistantContainer, "assistant.log"],
|
|
53
|
+
[res.gatewayContainer, "gateway.log"],
|
|
54
|
+
[res.cesContainer, "credential-executor.log"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const [container, filename] of containers) {
|
|
58
|
+
try {
|
|
59
|
+
// Capture stdout + stderr together so container logs written to either
|
|
60
|
+
// stream (docker logs writes container stdout→stdout, stderr→stderr)
|
|
61
|
+
// are preserved in a single file. spawnSync avoids the execOutput
|
|
62
|
+
// limitation of returning only stdout on success.
|
|
63
|
+
const result = spawnSync("docker", ["logs", "--tail", "500", container], {
|
|
64
|
+
encoding: "utf8",
|
|
65
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
66
|
+
});
|
|
67
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("");
|
|
68
|
+
if (output) writeFileSync(join(logDir, filename), output);
|
|
69
|
+
} catch {
|
|
70
|
+
// Container may not exist or may have already been removed
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return existsSync(logDir) ? logDir : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
21
80
|
// ---------------------------------------------------------------------------
|
|
22
81
|
// Shared constants & builders for upgrade / rollback lifecycle events
|
|
23
82
|
// ---------------------------------------------------------------------------
|
|
@@ -147,6 +206,38 @@ export async function fetchCurrentVersion(
|
|
|
147
206
|
return undefined;
|
|
148
207
|
}
|
|
149
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Best-effort fetch of the assistant's configured public ingress URL from the
|
|
211
|
+
* gateway `integrations/ingress/config` endpoint. Returns `undefined` when
|
|
212
|
+
* the gateway is unreachable, the bearer token is missing, or no public URL
|
|
213
|
+
* is configured.
|
|
214
|
+
*/
|
|
215
|
+
export async function fetchAssistantIngressUrl(
|
|
216
|
+
runtimeUrl: string,
|
|
217
|
+
bearerToken?: string,
|
|
218
|
+
): Promise<string | undefined> {
|
|
219
|
+
if (!bearerToken) return undefined;
|
|
220
|
+
try {
|
|
221
|
+
const resp = await fetch(`${runtimeUrl}/integrations/ingress/config`, {
|
|
222
|
+
headers: { Authorization: `Bearer ${bearerToken}` },
|
|
223
|
+
signal: AbortSignal.timeout(5000),
|
|
224
|
+
});
|
|
225
|
+
if (resp.ok) {
|
|
226
|
+
const body = (await resp.json()) as {
|
|
227
|
+
publicBaseUrl?: string;
|
|
228
|
+
managedCallbacks?: boolean;
|
|
229
|
+
};
|
|
230
|
+
// Ignore managed-callback URLs — those belong to the platform, not the
|
|
231
|
+
// self-hosted assistant's own ingress.
|
|
232
|
+
if (body.managedCallbacks) return undefined;
|
|
233
|
+
return body.publicBaseUrl || undefined;
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Best-effort
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
150
241
|
/**
|
|
151
242
|
* Determine the version that was running before the current one.
|
|
152
243
|
*
|
|
@@ -545,7 +636,7 @@ export async function performDockerRollback(
|
|
|
545
636
|
const signingKey =
|
|
546
637
|
capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
|
|
547
638
|
|
|
548
|
-
// Build extra env vars, excluding keys managed by
|
|
639
|
+
// Build extra env vars, excluding keys managed by buildServiceRunArgs
|
|
549
640
|
const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
|
|
550
641
|
for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
|
|
551
642
|
if (process.env[envVar]) {
|
|
@@ -734,6 +825,11 @@ export async function performDockerRollback(
|
|
|
734
825
|
// Failure path — attempt auto-rollback to original version
|
|
735
826
|
console.error(`\n❌ Containers failed to become ready within the timeout.`);
|
|
736
827
|
|
|
828
|
+
const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-rollback-failure`);
|
|
829
|
+
if (logDir) {
|
|
830
|
+
console.log(`📋 Container logs saved to: ${logDir}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
737
833
|
if (currentImageRefs) {
|
|
738
834
|
await broadcastUpgradeEvent(
|
|
739
835
|
entry.runtimeUrl,
|