@vellumai/cli 0.5.4 ā 0.5.6
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 +12 -0
- package/knip.json +2 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +46 -0
- package/src/__tests__/health-check.test.ts +26 -1
- package/src/adapters/openclaw.ts +4 -2
- package/src/commands/backup.ts +151 -0
- package/src/commands/hatch.ts +14 -4
- package/src/commands/ps.ts +6 -1
- package/src/commands/restore.ts +330 -0
- package/src/commands/rollback.ts +280 -0
- package/src/commands/upgrade.ts +171 -2
- package/src/commands/wake.ts +8 -0
- package/src/index.ts +11 -0
- package/src/lib/assistant-config.ts +57 -0
- package/src/lib/aws.ts +13 -5
- package/src/lib/constants.ts +12 -0
- package/src/lib/docker.ts +299 -18
- package/src/lib/gcp.ts +18 -6
- package/src/lib/guardian-token.ts +46 -1
- package/src/lib/health-check.ts +4 -0
- package/src/lib/platform-client.ts +3 -2
- package/src/lib/version-compat.ts +45 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
import { findAssistantByName } from "../lib/assistant-config.js";
|
|
4
|
+
import {
|
|
5
|
+
loadGuardianToken,
|
|
6
|
+
leaseGuardianToken,
|
|
7
|
+
} from "../lib/guardian-token.js";
|
|
8
|
+
|
|
9
|
+
function printUsage(): void {
|
|
10
|
+
console.log("Usage: vellum restore <name> --from <path> [--dry-run]");
|
|
11
|
+
console.log("");
|
|
12
|
+
console.log("Restore a .vbundle backup into a running assistant.");
|
|
13
|
+
console.log("");
|
|
14
|
+
console.log("Arguments:");
|
|
15
|
+
console.log(" <name> Name of the assistant to restore into");
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log("Options:");
|
|
18
|
+
console.log(
|
|
19
|
+
" --from <path> Path to the .vbundle file to restore (required)",
|
|
20
|
+
);
|
|
21
|
+
console.log(" --dry-run Show what would change without applying");
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log("Examples:");
|
|
24
|
+
console.log(" vellum restore my-assistant --from ~/Desktop/backup.vbundle");
|
|
25
|
+
console.log(
|
|
26
|
+
" vellum restore my-assistant --from ~/Desktop/backup.vbundle --dry-run",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv: string[]): {
|
|
31
|
+
name: string | undefined;
|
|
32
|
+
fromPath: string | undefined;
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
help: boolean;
|
|
35
|
+
} {
|
|
36
|
+
const args = argv.slice(3);
|
|
37
|
+
|
|
38
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
39
|
+
return { name: undefined, fromPath: undefined, dryRun: false, help: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let fromPath: string | undefined;
|
|
43
|
+
const dryRun = args.includes("--dry-run");
|
|
44
|
+
const positionals: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
if (args[i] === "--from" && args[i + 1]) {
|
|
48
|
+
fromPath = args[i + 1];
|
|
49
|
+
i++; // skip the value
|
|
50
|
+
} else if (args[i] === "--dry-run") {
|
|
51
|
+
// already handled above
|
|
52
|
+
} else if (!args[i].startsWith("-")) {
|
|
53
|
+
positionals.push(args[i]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { name: positionals[0], fromPath, dryRun, help: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function getAccessToken(
|
|
61
|
+
runtimeUrl: string,
|
|
62
|
+
assistantId: string,
|
|
63
|
+
displayName: string,
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
66
|
+
|
|
67
|
+
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
68
|
+
return tokenData.accessToken;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
|
|
73
|
+
return freshToken.accessToken;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
77
|
+
console.error(
|
|
78
|
+
`Error: Could not connect to assistant '${displayName}'. Is it running?`,
|
|
79
|
+
);
|
|
80
|
+
console.error(`Try: vellum wake ${displayName}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface PreflightFileEntry {
|
|
88
|
+
path: string;
|
|
89
|
+
action: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface StructuredError {
|
|
93
|
+
code: string;
|
|
94
|
+
message: string;
|
|
95
|
+
path?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface PreflightResponse {
|
|
99
|
+
can_import: boolean;
|
|
100
|
+
validation?: {
|
|
101
|
+
is_valid: false;
|
|
102
|
+
errors: StructuredError[];
|
|
103
|
+
};
|
|
104
|
+
files?: PreflightFileEntry[];
|
|
105
|
+
summary?: {
|
|
106
|
+
files_to_create: number;
|
|
107
|
+
files_to_overwrite: number;
|
|
108
|
+
files_unchanged: number;
|
|
109
|
+
total_files: number;
|
|
110
|
+
};
|
|
111
|
+
conflicts?: StructuredError[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ImportResponse {
|
|
115
|
+
success: boolean;
|
|
116
|
+
reason?: string;
|
|
117
|
+
errors?: StructuredError[];
|
|
118
|
+
message?: string;
|
|
119
|
+
warnings?: string[];
|
|
120
|
+
summary?: {
|
|
121
|
+
total_files: number;
|
|
122
|
+
files_created: number;
|
|
123
|
+
files_overwritten: number;
|
|
124
|
+
files_skipped: number;
|
|
125
|
+
backups_created: number;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function restore(): Promise<void> {
|
|
130
|
+
const { name, fromPath, dryRun, help } = parseArgs(process.argv);
|
|
131
|
+
|
|
132
|
+
if (help) {
|
|
133
|
+
printUsage();
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!name || !fromPath) {
|
|
138
|
+
console.error("Error: Both <name> and --from <path> are required.");
|
|
139
|
+
console.error("");
|
|
140
|
+
printUsage();
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Look up the instance
|
|
145
|
+
const entry = findAssistantByName(name);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
console.error(`Error: No assistant found with name '${name}'.`);
|
|
148
|
+
console.error("Run 'vellum ps' to see available assistants.");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify .vbundle file exists
|
|
153
|
+
if (!existsSync(fromPath)) {
|
|
154
|
+
console.error(`Error: File not found: ${fromPath}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Read the .vbundle file
|
|
159
|
+
const bundleData = readFileSync(fromPath);
|
|
160
|
+
const sizeMB = (bundleData.byteLength / (1024 * 1024)).toFixed(2);
|
|
161
|
+
console.log(`Reading ${fromPath} (${sizeMB} MB)...`);
|
|
162
|
+
|
|
163
|
+
// Obtain auth token
|
|
164
|
+
const accessToken = await getAccessToken(
|
|
165
|
+
entry.runtimeUrl,
|
|
166
|
+
entry.assistantId,
|
|
167
|
+
name,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (dryRun) {
|
|
171
|
+
// Preflight check
|
|
172
|
+
console.log("Running preflight analysis...\n");
|
|
173
|
+
|
|
174
|
+
let response: Response;
|
|
175
|
+
try {
|
|
176
|
+
response = await fetch(
|
|
177
|
+
`${entry.runtimeUrl}/v1/migrations/import-preflight`,
|
|
178
|
+
{
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${accessToken}`,
|
|
182
|
+
"Content-Type": "application/octet-stream",
|
|
183
|
+
},
|
|
184
|
+
body: bundleData,
|
|
185
|
+
signal: AbortSignal.timeout(120_000),
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
190
|
+
console.error("Error: Preflight request timed out after 2 minutes.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
195
|
+
console.error(
|
|
196
|
+
`Error: Could not connect to assistant '${name}'. Is it running?`,
|
|
197
|
+
);
|
|
198
|
+
console.error(`Try: vellum wake ${name}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const body = await response.text();
|
|
206
|
+
console.error(
|
|
207
|
+
`Error: Preflight check failed (${response.status}): ${body}`,
|
|
208
|
+
);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const result = (await response.json()) as PreflightResponse;
|
|
213
|
+
|
|
214
|
+
if (!result.can_import) {
|
|
215
|
+
if (result.validation?.errors?.length) {
|
|
216
|
+
console.error("Import blocked by validation errors:");
|
|
217
|
+
for (const err of result.validation.errors) {
|
|
218
|
+
console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (result.conflicts?.length) {
|
|
222
|
+
console.error("Import blocked by conflicts:");
|
|
223
|
+
for (const conflict of result.conflicts) {
|
|
224
|
+
console.error(` - ${conflict.message}${conflict.path ? ` (${conflict.path})` : ""}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Print summary table
|
|
231
|
+
const summary = result.summary ?? {
|
|
232
|
+
files_to_create: 0,
|
|
233
|
+
files_to_overwrite: 0,
|
|
234
|
+
files_unchanged: 0,
|
|
235
|
+
total_files: 0,
|
|
236
|
+
};
|
|
237
|
+
console.log("Preflight analysis:");
|
|
238
|
+
console.log(` Files to create: ${summary.files_to_create}`);
|
|
239
|
+
console.log(` Files to overwrite: ${summary.files_to_overwrite}`);
|
|
240
|
+
console.log(` Files unchanged: ${summary.files_unchanged}`);
|
|
241
|
+
console.log(` Total: ${summary.total_files}`);
|
|
242
|
+
console.log("");
|
|
243
|
+
|
|
244
|
+
const conflicts = result.conflicts ?? [];
|
|
245
|
+
console.log(
|
|
246
|
+
`Conflicts: ${conflicts.length > 0 ? conflicts.map((c) => c.message).join(", ") : "none"}`,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// List individual files with their action
|
|
250
|
+
if (result.files && result.files.length > 0) {
|
|
251
|
+
console.log("");
|
|
252
|
+
console.log("Files:");
|
|
253
|
+
for (const file of result.files) {
|
|
254
|
+
console.log(` [${file.action}] ${file.path}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
// Full import
|
|
259
|
+
console.log("Importing backup...\n");
|
|
260
|
+
|
|
261
|
+
let response: Response;
|
|
262
|
+
try {
|
|
263
|
+
response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${accessToken}`,
|
|
267
|
+
"Content-Type": "application/octet-stream",
|
|
268
|
+
},
|
|
269
|
+
body: bundleData,
|
|
270
|
+
signal: AbortSignal.timeout(120_000),
|
|
271
|
+
});
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
274
|
+
console.error("Error: Import request timed out after 2 minutes.");
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
279
|
+
console.error(
|
|
280
|
+
`Error: Could not connect to assistant '${name}'. Is it running?`,
|
|
281
|
+
);
|
|
282
|
+
console.error(`Try: vellum wake ${name}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
const body = await response.text();
|
|
290
|
+
console.error(`Error: Import failed (${response.status}): ${body}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const result = (await response.json()) as ImportResponse;
|
|
295
|
+
|
|
296
|
+
if (!result.success) {
|
|
297
|
+
console.error(
|
|
298
|
+
`Error: Import failed ā ${result.message ?? result.reason ?? "unknown reason"}`,
|
|
299
|
+
);
|
|
300
|
+
for (const err of result.errors ?? []) {
|
|
301
|
+
console.error(` - ${err.message}${err.path ? ` (${err.path})` : ""}`);
|
|
302
|
+
}
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Print import report
|
|
307
|
+
const summary = result.summary ?? {
|
|
308
|
+
total_files: 0,
|
|
309
|
+
files_created: 0,
|
|
310
|
+
files_overwritten: 0,
|
|
311
|
+
files_skipped: 0,
|
|
312
|
+
backups_created: 0,
|
|
313
|
+
};
|
|
314
|
+
console.log("ā
Restore complete.");
|
|
315
|
+
console.log(` Files created: ${summary.files_created}`);
|
|
316
|
+
console.log(` Files overwritten: ${summary.files_overwritten}`);
|
|
317
|
+
console.log(` Files skipped: ${summary.files_skipped}`);
|
|
318
|
+
console.log(` Backups created: ${summary.backups_created}`);
|
|
319
|
+
|
|
320
|
+
// Print warnings if any
|
|
321
|
+
const warnings = result.warnings ?? [];
|
|
322
|
+
if (warnings.length > 0) {
|
|
323
|
+
console.log("");
|
|
324
|
+
console.log("Warnings:");
|
|
325
|
+
for (const warning of warnings) {
|
|
326
|
+
console.log(` ā ļø ${warning}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findAssistantByName,
|
|
5
|
+
getActiveAssistant,
|
|
6
|
+
loadAllAssistants,
|
|
7
|
+
saveAssistantEntry,
|
|
8
|
+
} from "../lib/assistant-config";
|
|
9
|
+
import type { AssistantEntry } from "../lib/assistant-config";
|
|
10
|
+
import {
|
|
11
|
+
captureImageRefs,
|
|
12
|
+
clearSigningKeyBootstrapLock,
|
|
13
|
+
GATEWAY_INTERNAL_PORT,
|
|
14
|
+
dockerResourceNames,
|
|
15
|
+
migrateCesSecurityFiles,
|
|
16
|
+
migrateGatewaySecurityFiles,
|
|
17
|
+
startContainers,
|
|
18
|
+
stopContainers,
|
|
19
|
+
} from "../lib/docker";
|
|
20
|
+
import type { ServiceName } from "../lib/docker";
|
|
21
|
+
import { loadBootstrapSecret } from "../lib/guardian-token";
|
|
22
|
+
import {
|
|
23
|
+
broadcastUpgradeEvent,
|
|
24
|
+
captureContainerEnv,
|
|
25
|
+
waitForReady,
|
|
26
|
+
} from "./upgrade";
|
|
27
|
+
|
|
28
|
+
function parseArgs(): { name: string | null } {
|
|
29
|
+
const args = process.argv.slice(3);
|
|
30
|
+
let name: string | null = null;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
const arg = args[i];
|
|
34
|
+
if (arg === "--help" || arg === "-h") {
|
|
35
|
+
console.log("Usage: vellum rollback [<name>]");
|
|
36
|
+
console.log("");
|
|
37
|
+
console.log("Roll back a Docker assistant to the previous version.");
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log("Arguments:");
|
|
40
|
+
console.log(
|
|
41
|
+
" <name> Name of the assistant to roll back (default: active or only assistant)",
|
|
42
|
+
);
|
|
43
|
+
console.log("");
|
|
44
|
+
console.log("Examples:");
|
|
45
|
+
console.log(
|
|
46
|
+
" vellum rollback # Roll back the active assistant",
|
|
47
|
+
);
|
|
48
|
+
console.log(
|
|
49
|
+
" vellum rollback my-assistant # Roll back a specific assistant by name",
|
|
50
|
+
);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} else if (!arg.startsWith("-")) {
|
|
53
|
+
name = arg;
|
|
54
|
+
} else {
|
|
55
|
+
console.error(`Error: Unknown option '${arg}'.`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { name };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveCloud(entry: AssistantEntry): string {
|
|
64
|
+
if (entry.cloud) {
|
|
65
|
+
return entry.cloud;
|
|
66
|
+
}
|
|
67
|
+
if (entry.project) {
|
|
68
|
+
return "gcp";
|
|
69
|
+
}
|
|
70
|
+
if (entry.sshUser) {
|
|
71
|
+
return "custom";
|
|
72
|
+
}
|
|
73
|
+
return "local";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve which assistant to target for the rollback command. Priority:
|
|
78
|
+
* 1. Explicit name argument
|
|
79
|
+
* 2. Active assistant set via `vellum use`
|
|
80
|
+
* 3. Sole assistant (when exactly one exists)
|
|
81
|
+
*/
|
|
82
|
+
function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
|
|
83
|
+
if (nameArg) {
|
|
84
|
+
const entry = findAssistantByName(nameArg);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
console.error(`No assistant found with name '${nameArg}'.`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const active = getActiveAssistant();
|
|
93
|
+
if (active) {
|
|
94
|
+
const entry = findAssistantByName(active);
|
|
95
|
+
if (entry) return entry;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const all = loadAllAssistants();
|
|
99
|
+
if (all.length === 1) return all[0];
|
|
100
|
+
|
|
101
|
+
if (all.length === 0) {
|
|
102
|
+
console.error("No assistants found. Run 'vellum hatch' first.");
|
|
103
|
+
} else {
|
|
104
|
+
console.error(
|
|
105
|
+
"Multiple assistants found. Specify a name or set an active assistant with 'vellum use <name>'.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function rollback(): Promise<void> {
|
|
112
|
+
const { name } = parseArgs();
|
|
113
|
+
const entry = resolveTargetAssistant(name);
|
|
114
|
+
const cloud = resolveCloud(entry);
|
|
115
|
+
|
|
116
|
+
// Only Docker assistants support rollback
|
|
117
|
+
if (cloud !== "docker") {
|
|
118
|
+
console.error(
|
|
119
|
+
"Rollback is only supported for Docker assistants. For managed assistants, use the version picker to upgrade to the previous version.",
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Verify rollback state exists
|
|
125
|
+
if (!entry.previousServiceGroupVersion || !entry.previousContainerInfo) {
|
|
126
|
+
console.error(
|
|
127
|
+
"No rollback state available. Run `vellum upgrade` first to create a rollback point.",
|
|
128
|
+
);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Verify all three digest fields are present
|
|
133
|
+
const prev = entry.previousContainerInfo;
|
|
134
|
+
if (!prev.assistantDigest || !prev.gatewayDigest || !prev.cesDigest) {
|
|
135
|
+
console.error(
|
|
136
|
+
"Incomplete rollback state. Previous container digests are missing.",
|
|
137
|
+
);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Build image refs from the previous digests
|
|
142
|
+
const previousImageRefs: Record<ServiceName, string> = {
|
|
143
|
+
assistant: prev.assistantDigest,
|
|
144
|
+
"credential-executor": prev.cesDigest,
|
|
145
|
+
gateway: prev.gatewayDigest,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const instanceName = entry.assistantId;
|
|
149
|
+
const res = dockerResourceNames(instanceName);
|
|
150
|
+
|
|
151
|
+
console.log(
|
|
152
|
+
`š Rolling back Docker assistant '${instanceName}' to ${entry.previousServiceGroupVersion}...\n`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Capture current container env
|
|
156
|
+
console.log("š¾ Capturing existing container environment...");
|
|
157
|
+
const capturedEnv = await captureContainerEnv(res.assistantContainer);
|
|
158
|
+
console.log(
|
|
159
|
+
` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Extract CES_SERVICE_TOKEN from captured env, or generate fresh one
|
|
163
|
+
const cesServiceToken =
|
|
164
|
+
capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
|
|
165
|
+
|
|
166
|
+
// Retrieve or generate a bootstrap secret for the gateway.
|
|
167
|
+
const bootstrapSecret =
|
|
168
|
+
loadBootstrapSecret(instanceName) || randomBytes(32).toString("hex");
|
|
169
|
+
|
|
170
|
+
// Build extra env vars, excluding keys managed by serviceDockerRunArgs
|
|
171
|
+
const envKeysSetByRunArgs = new Set([
|
|
172
|
+
"CES_SERVICE_TOKEN",
|
|
173
|
+
"VELLUM_ASSISTANT_NAME",
|
|
174
|
+
"RUNTIME_HTTP_HOST",
|
|
175
|
+
"PATH",
|
|
176
|
+
]);
|
|
177
|
+
for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
|
|
178
|
+
if (process.env[envVar]) {
|
|
179
|
+
envKeysSetByRunArgs.add(envVar);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const extraAssistantEnv: Record<string, string> = {};
|
|
183
|
+
for (const [key, value] of Object.entries(capturedEnv)) {
|
|
184
|
+
if (!envKeysSetByRunArgs.has(key)) {
|
|
185
|
+
extraAssistantEnv[key] = value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Parse gateway port from entry's runtimeUrl, fall back to default
|
|
190
|
+
let gatewayPort = GATEWAY_INTERNAL_PORT;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = new URL(entry.runtimeUrl);
|
|
193
|
+
const port = parseInt(parsed.port, 10);
|
|
194
|
+
if (!isNaN(port)) {
|
|
195
|
+
gatewayPort = port;
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// use default
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Notify connected clients that a rollback is about to begin (best-effort)
|
|
202
|
+
console.log("š¢ Notifying connected clients...");
|
|
203
|
+
await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
|
|
204
|
+
type: "starting",
|
|
205
|
+
targetVersion: entry.previousServiceGroupVersion,
|
|
206
|
+
expectedDowntimeSeconds: 60,
|
|
207
|
+
});
|
|
208
|
+
// Brief pause to allow SSE delivery before containers stop.
|
|
209
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
210
|
+
|
|
211
|
+
console.log("š Stopping existing containers...");
|
|
212
|
+
await stopContainers(res);
|
|
213
|
+
console.log("ā
Containers stopped\n");
|
|
214
|
+
|
|
215
|
+
// Run security file migrations and signing key cleanup
|
|
216
|
+
console.log("š Migrating security files to gateway volume...");
|
|
217
|
+
await migrateGatewaySecurityFiles(res, (msg) => console.log(msg));
|
|
218
|
+
|
|
219
|
+
console.log("š Migrating credential files to CES security volume...");
|
|
220
|
+
await migrateCesSecurityFiles(res, (msg) => console.log(msg));
|
|
221
|
+
|
|
222
|
+
console.log("š Clearing signing key bootstrap lock...");
|
|
223
|
+
await clearSigningKeyBootstrapLock(res);
|
|
224
|
+
|
|
225
|
+
console.log("š Starting containers with previous version...");
|
|
226
|
+
await startContainers(
|
|
227
|
+
{
|
|
228
|
+
bootstrapSecret,
|
|
229
|
+
cesServiceToken,
|
|
230
|
+
extraAssistantEnv,
|
|
231
|
+
gatewayPort,
|
|
232
|
+
imageTags: previousImageRefs,
|
|
233
|
+
instanceName,
|
|
234
|
+
res,
|
|
235
|
+
},
|
|
236
|
+
(msg) => console.log(msg),
|
|
237
|
+
);
|
|
238
|
+
console.log("ā
Containers started\n");
|
|
239
|
+
|
|
240
|
+
console.log("Waiting for assistant to become ready...");
|
|
241
|
+
const ready = await waitForReady(entry.runtimeUrl);
|
|
242
|
+
|
|
243
|
+
if (ready) {
|
|
244
|
+
// Capture new digests from the rolled-back containers
|
|
245
|
+
const newDigests = await captureImageRefs(res);
|
|
246
|
+
|
|
247
|
+
// Swap current/previous state to enable "rollback the rollback"
|
|
248
|
+
const updatedEntry: AssistantEntry = {
|
|
249
|
+
...entry,
|
|
250
|
+
serviceGroupVersion: entry.previousServiceGroupVersion,
|
|
251
|
+
containerInfo: {
|
|
252
|
+
assistantImage: prev.assistantImage ?? previousImageRefs.assistant,
|
|
253
|
+
gatewayImage: prev.gatewayImage ?? previousImageRefs.gateway,
|
|
254
|
+
cesImage: prev.cesImage ?? previousImageRefs["credential-executor"],
|
|
255
|
+
assistantDigest: newDigests?.assistant,
|
|
256
|
+
gatewayDigest: newDigests?.gateway,
|
|
257
|
+
cesDigest: newDigests?.["credential-executor"],
|
|
258
|
+
networkName: res.network,
|
|
259
|
+
},
|
|
260
|
+
previousServiceGroupVersion: entry.serviceGroupVersion,
|
|
261
|
+
previousContainerInfo: entry.containerInfo,
|
|
262
|
+
};
|
|
263
|
+
saveAssistantEntry(updatedEntry);
|
|
264
|
+
|
|
265
|
+
// Notify clients that the rollback succeeded
|
|
266
|
+
await broadcastUpgradeEvent(entry.runtimeUrl, entry.assistantId, {
|
|
267
|
+
type: "complete",
|
|
268
|
+
installedVersion: entry.previousServiceGroupVersion,
|
|
269
|
+
success: true,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
console.log(
|
|
273
|
+
`\nā
Docker assistant '${instanceName}' rolled back to ${entry.previousServiceGroupVersion}.`,
|
|
274
|
+
);
|
|
275
|
+
} else {
|
|
276
|
+
console.error(`\nā Containers failed to become ready within the timeout.`);
|
|
277
|
+
console.log(` Check logs with: docker logs -f ${res.assistantContainer}`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
}
|