@vellumai/cli 0.1.1 → 0.1.2
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 +97 -0
- package/package.json +1 -1
- package/src/adapters/openclaw.ts +7 -0
- package/src/commands/hatch.ts +119 -120
- package/src/commands/retire.ts +139 -0
- package/src/index.ts +4 -1
- package/src/lib/assistant-config.ts +95 -0
- package/src/lib/aws.ts +590 -0
- package/src/lib/gcp.ts +71 -15
package/src/lib/aws.ts
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { spawn as spawnChild } from "child_process";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir, tmpdir, userInfo } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
import { buildStartupScript, watchHatching } from "../commands/hatch";
|
|
8
|
+
import type { PollResult } from "../commands/hatch";
|
|
9
|
+
import { saveAssistantEntry } from "./assistant-config";
|
|
10
|
+
import type { AssistantEntry } from "./assistant-config";
|
|
11
|
+
import { GATEWAY_PORT } from "./constants";
|
|
12
|
+
import type { Species } from "./constants";
|
|
13
|
+
import { generateRandomSuffix } from "./random-name";
|
|
14
|
+
import { exec, execOutput } from "./step-runner";
|
|
15
|
+
|
|
16
|
+
const KEY_PAIR_NAME = "vellum-assistant";
|
|
17
|
+
const DEFAULT_SSH_USER = "admin";
|
|
18
|
+
const AWS_INSTANCE_TYPE = "t3.xlarge";
|
|
19
|
+
const AWS_DEFAULT_REGION = "us-east-1";
|
|
20
|
+
|
|
21
|
+
export async function getActiveRegion(): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
const output = await execOutput("aws", ["configure", "get", "region"]);
|
|
24
|
+
const region = output.trim();
|
|
25
|
+
if (region) return region;
|
|
26
|
+
} catch {}
|
|
27
|
+
throw new Error(
|
|
28
|
+
"No active AWS region. Set AWS_REGION or run `aws configure set region <region>` first.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getDefaultVpcId(region: string): Promise<string> {
|
|
33
|
+
const output = await execOutput("aws", [
|
|
34
|
+
"ec2",
|
|
35
|
+
"describe-vpcs",
|
|
36
|
+
"--filters",
|
|
37
|
+
"Name=isDefault,Values=true",
|
|
38
|
+
"--query",
|
|
39
|
+
"Vpcs[0].VpcId",
|
|
40
|
+
"--output",
|
|
41
|
+
"text",
|
|
42
|
+
"--region",
|
|
43
|
+
region,
|
|
44
|
+
]);
|
|
45
|
+
const vpcId = output.trim();
|
|
46
|
+
if (!vpcId || vpcId === "None") {
|
|
47
|
+
throw new Error("No default VPC found. Please create a default VPC or specify one.");
|
|
48
|
+
}
|
|
49
|
+
return vpcId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function ensureSecurityGroup(
|
|
53
|
+
groupName: string,
|
|
54
|
+
vpcId: string,
|
|
55
|
+
gatewayPort: number,
|
|
56
|
+
region: string,
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
try {
|
|
59
|
+
const output = await execOutput("aws", [
|
|
60
|
+
"ec2",
|
|
61
|
+
"describe-security-groups",
|
|
62
|
+
"--filters",
|
|
63
|
+
`Name=group-name,Values=${groupName}`,
|
|
64
|
+
`Name=vpc-id,Values=${vpcId}`,
|
|
65
|
+
"--query",
|
|
66
|
+
"SecurityGroups[0].GroupId",
|
|
67
|
+
"--output",
|
|
68
|
+
"text",
|
|
69
|
+
"--region",
|
|
70
|
+
region,
|
|
71
|
+
]);
|
|
72
|
+
const groupId = output.trim();
|
|
73
|
+
if (groupId && groupId !== "None") return groupId;
|
|
74
|
+
} catch {}
|
|
75
|
+
|
|
76
|
+
const createOutput = await execOutput("aws", [
|
|
77
|
+
"ec2",
|
|
78
|
+
"create-security-group",
|
|
79
|
+
"--group-name",
|
|
80
|
+
groupName,
|
|
81
|
+
"--description",
|
|
82
|
+
"Security group for vellum-assistant instances",
|
|
83
|
+
"--vpc-id",
|
|
84
|
+
vpcId,
|
|
85
|
+
"--query",
|
|
86
|
+
"GroupId",
|
|
87
|
+
"--output",
|
|
88
|
+
"text",
|
|
89
|
+
"--region",
|
|
90
|
+
region,
|
|
91
|
+
]);
|
|
92
|
+
const groupId = createOutput.trim();
|
|
93
|
+
|
|
94
|
+
await exec("aws", [
|
|
95
|
+
"ec2",
|
|
96
|
+
"authorize-security-group-ingress",
|
|
97
|
+
"--group-id",
|
|
98
|
+
groupId,
|
|
99
|
+
"--protocol",
|
|
100
|
+
"tcp",
|
|
101
|
+
"--port",
|
|
102
|
+
String(gatewayPort),
|
|
103
|
+
"--cidr",
|
|
104
|
+
"0.0.0.0/0",
|
|
105
|
+
"--region",
|
|
106
|
+
region,
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
await exec("aws", [
|
|
110
|
+
"ec2",
|
|
111
|
+
"authorize-security-group-ingress",
|
|
112
|
+
"--group-id",
|
|
113
|
+
groupId,
|
|
114
|
+
"--protocol",
|
|
115
|
+
"tcp",
|
|
116
|
+
"--port",
|
|
117
|
+
"22",
|
|
118
|
+
"--cidr",
|
|
119
|
+
"0.0.0.0/0",
|
|
120
|
+
"--region",
|
|
121
|
+
region,
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
return groupId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function ensureKeyPair(region: string): Promise<string> {
|
|
128
|
+
const sshDir = join(homedir(), ".ssh");
|
|
129
|
+
const keyPath = join(sshDir, `${KEY_PAIR_NAME}.pem`);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await execOutput("aws", [
|
|
133
|
+
"ec2",
|
|
134
|
+
"describe-key-pairs",
|
|
135
|
+
"--key-names",
|
|
136
|
+
KEY_PAIR_NAME,
|
|
137
|
+
"--region",
|
|
138
|
+
region,
|
|
139
|
+
]);
|
|
140
|
+
if (!existsSync(keyPath)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Key pair '${KEY_PAIR_NAME}' exists in AWS but private key not found at ${keyPath}. ` +
|
|
143
|
+
`Delete it with: aws ec2 delete-key-pair --key-name ${KEY_PAIR_NAME} --region ${region}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return keyPath;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof Error && error.message.includes("not found at")) {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!existsSync(sshDir)) {
|
|
154
|
+
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
}
|
|
156
|
+
const output = await execOutput("aws", [
|
|
157
|
+
"ec2",
|
|
158
|
+
"create-key-pair",
|
|
159
|
+
"--key-name",
|
|
160
|
+
KEY_PAIR_NAME,
|
|
161
|
+
"--query",
|
|
162
|
+
"KeyMaterial",
|
|
163
|
+
"--output",
|
|
164
|
+
"text",
|
|
165
|
+
"--region",
|
|
166
|
+
region,
|
|
167
|
+
]);
|
|
168
|
+
writeFileSync(keyPath, output.trim() + "\n", { mode: 0o600 });
|
|
169
|
+
return keyPath;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function getLatestDebianAmi(region: string): Promise<string> {
|
|
173
|
+
const output = await execOutput("aws", [
|
|
174
|
+
"ec2",
|
|
175
|
+
"describe-images",
|
|
176
|
+
"--owners",
|
|
177
|
+
"136693071363",
|
|
178
|
+
"--filters",
|
|
179
|
+
"Name=name,Values=debian-11-amd64-*",
|
|
180
|
+
"Name=state,Values=available",
|
|
181
|
+
"--query",
|
|
182
|
+
"sort_by(Images, &CreationDate)[-1].ImageId",
|
|
183
|
+
"--output",
|
|
184
|
+
"text",
|
|
185
|
+
"--region",
|
|
186
|
+
region,
|
|
187
|
+
]);
|
|
188
|
+
const amiId = output.trim();
|
|
189
|
+
if (!amiId || amiId === "None") {
|
|
190
|
+
throw new Error("Could not find a Debian 11 AMI in this region.");
|
|
191
|
+
}
|
|
192
|
+
return amiId;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function instanceExistsByName(
|
|
196
|
+
name: string,
|
|
197
|
+
region: string,
|
|
198
|
+
): Promise<boolean> {
|
|
199
|
+
try {
|
|
200
|
+
const output = await execOutput("aws", [
|
|
201
|
+
"ec2",
|
|
202
|
+
"describe-instances",
|
|
203
|
+
"--filters",
|
|
204
|
+
`Name=tag:Name,Values=${name}`,
|
|
205
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
206
|
+
"--query",
|
|
207
|
+
"Reservations[0].Instances[0].InstanceId",
|
|
208
|
+
"--output",
|
|
209
|
+
"text",
|
|
210
|
+
"--region",
|
|
211
|
+
region,
|
|
212
|
+
]);
|
|
213
|
+
return output.trim() !== "" && output.trim() !== "None";
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function launchInstance(
|
|
220
|
+
name: string,
|
|
221
|
+
amiId: string,
|
|
222
|
+
instanceType: string,
|
|
223
|
+
securityGroupId: string,
|
|
224
|
+
userDataPath: string,
|
|
225
|
+
species: string,
|
|
226
|
+
region: string,
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
const blockDeviceMappings = JSON.stringify([
|
|
229
|
+
{
|
|
230
|
+
DeviceName: "/dev/xvda",
|
|
231
|
+
Ebs: { VolumeSize: 50, VolumeType: "gp3" },
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
const tagSpecifications = JSON.stringify([
|
|
235
|
+
{
|
|
236
|
+
ResourceType: "instance",
|
|
237
|
+
Tags: [
|
|
238
|
+
{ Key: "Name", Value: name },
|
|
239
|
+
{ Key: "vellum-assistant", Value: "true" },
|
|
240
|
+
{ Key: "species", Value: species },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
const output = await execOutput("aws", [
|
|
246
|
+
"ec2",
|
|
247
|
+
"run-instances",
|
|
248
|
+
"--image-id",
|
|
249
|
+
amiId,
|
|
250
|
+
"--instance-type",
|
|
251
|
+
instanceType,
|
|
252
|
+
"--key-name",
|
|
253
|
+
KEY_PAIR_NAME,
|
|
254
|
+
"--security-group-ids",
|
|
255
|
+
securityGroupId,
|
|
256
|
+
"--user-data",
|
|
257
|
+
`file://${userDataPath}`,
|
|
258
|
+
"--block-device-mappings",
|
|
259
|
+
blockDeviceMappings,
|
|
260
|
+
"--tag-specifications",
|
|
261
|
+
tagSpecifications,
|
|
262
|
+
"--query",
|
|
263
|
+
"Instances[0].InstanceId",
|
|
264
|
+
"--output",
|
|
265
|
+
"text",
|
|
266
|
+
"--region",
|
|
267
|
+
region,
|
|
268
|
+
]);
|
|
269
|
+
return output.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function waitForInstanceRunning(
|
|
273
|
+
instanceId: string,
|
|
274
|
+
region: string,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
await exec("aws", [
|
|
277
|
+
"ec2",
|
|
278
|
+
"wait",
|
|
279
|
+
"instance-running",
|
|
280
|
+
"--instance-ids",
|
|
281
|
+
instanceId,
|
|
282
|
+
"--region",
|
|
283
|
+
region,
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function getInstancePublicIp(
|
|
288
|
+
instanceId: string,
|
|
289
|
+
region: string,
|
|
290
|
+
): Promise<string | null> {
|
|
291
|
+
const output = await execOutput("aws", [
|
|
292
|
+
"ec2",
|
|
293
|
+
"describe-instances",
|
|
294
|
+
"--instance-ids",
|
|
295
|
+
instanceId,
|
|
296
|
+
"--query",
|
|
297
|
+
"Reservations[0].Instances[0].PublicIpAddress",
|
|
298
|
+
"--output",
|
|
299
|
+
"text",
|
|
300
|
+
"--region",
|
|
301
|
+
region,
|
|
302
|
+
]);
|
|
303
|
+
const ip = output.trim();
|
|
304
|
+
return ip && ip !== "None" ? ip : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function awsSshExec(
|
|
308
|
+
ip: string,
|
|
309
|
+
keyPath: string,
|
|
310
|
+
command: string,
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
return execOutput("ssh", [
|
|
313
|
+
"-i",
|
|
314
|
+
keyPath,
|
|
315
|
+
"-o",
|
|
316
|
+
"StrictHostKeyChecking=no",
|
|
317
|
+
"-o",
|
|
318
|
+
"UserKnownHostsFile=/dev/null",
|
|
319
|
+
"-o",
|
|
320
|
+
"ConnectTimeout=10",
|
|
321
|
+
"-o",
|
|
322
|
+
"LogLevel=ERROR",
|
|
323
|
+
`${DEFAULT_SSH_USER}@${ip}`,
|
|
324
|
+
command,
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function pollAwsInstance(
|
|
329
|
+
ip: string,
|
|
330
|
+
keyPath: string,
|
|
331
|
+
): Promise<PollResult> {
|
|
332
|
+
try {
|
|
333
|
+
const remoteCmd =
|
|
334
|
+
"L=$(tail -1 /var/log/startup-script.log 2>/dev/null || true); " +
|
|
335
|
+
"S=$(cloud-init status 2>/dev/null | awk '/status:/{print $2}' || echo unknown); " +
|
|
336
|
+
"E=$(cat /var/log/startup-error 2>/dev/null || true); " +
|
|
337
|
+
'printf "%s\\n===HATCH_SEP===\\n%s\\n===HATCH_ERR===\\n%s" "$L" "$S" "$E"';
|
|
338
|
+
const output = await awsSshExec(ip, keyPath, remoteCmd);
|
|
339
|
+
const sepIdx = output.indexOf("===HATCH_SEP===");
|
|
340
|
+
if (sepIdx === -1) {
|
|
341
|
+
return { lastLine: output.trim() || null, done: false, failed: false };
|
|
342
|
+
}
|
|
343
|
+
const errIdx = output.indexOf("===HATCH_ERR===");
|
|
344
|
+
const lastLine = output.substring(0, sepIdx).trim() || null;
|
|
345
|
+
const statusEnd = errIdx === -1 ? undefined : errIdx;
|
|
346
|
+
const status = output.substring(sepIdx + "===HATCH_SEP===".length, statusEnd).trim();
|
|
347
|
+
const errorContent =
|
|
348
|
+
errIdx === -1 ? "" : output.substring(errIdx + "===HATCH_ERR===".length).trim();
|
|
349
|
+
const done = lastLine !== null && status !== "running" && status !== "pending";
|
|
350
|
+
const failed = errorContent.length > 0 || status === "error";
|
|
351
|
+
return { lastLine, done, failed };
|
|
352
|
+
} catch {
|
|
353
|
+
return { lastLine: null, done: false, failed: false };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function hatchAws(
|
|
358
|
+
species: Species,
|
|
359
|
+
detached: boolean,
|
|
360
|
+
name: string | null,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
const startTime = Date.now();
|
|
363
|
+
try {
|
|
364
|
+
const region =
|
|
365
|
+
process.env.AWS_REGION ??
|
|
366
|
+
process.env.AWS_DEFAULT_REGION ??
|
|
367
|
+
(await getActiveRegion().catch(() => AWS_DEFAULT_REGION));
|
|
368
|
+
let instanceName: string;
|
|
369
|
+
|
|
370
|
+
if (name) {
|
|
371
|
+
instanceName = name;
|
|
372
|
+
} else {
|
|
373
|
+
const suffix = generateRandomSuffix();
|
|
374
|
+
instanceName = `${species}-${suffix}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
console.log(`\u{1F95A} Creating new assistant: ${instanceName}`);
|
|
378
|
+
console.log(` Species: ${species}`);
|
|
379
|
+
console.log(` Cloud: AWS`);
|
|
380
|
+
console.log(` Region: ${region}`);
|
|
381
|
+
console.log(` Instance type: ${AWS_INSTANCE_TYPE}`);
|
|
382
|
+
console.log("");
|
|
383
|
+
|
|
384
|
+
if (name) {
|
|
385
|
+
if (await instanceExistsByName(name, region)) {
|
|
386
|
+
console.error(
|
|
387
|
+
`Error: Instance name '${name}' is already taken. Please choose a different name.`,
|
|
388
|
+
);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
while (await instanceExistsByName(instanceName, region)) {
|
|
393
|
+
console.log(`\u26a0\ufe0f Instance name ${instanceName} already exists, generating a new name...`);
|
|
394
|
+
const suffix = generateRandomSuffix();
|
|
395
|
+
instanceName = `${species}-${suffix}`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const sshUser = userInfo().username;
|
|
400
|
+
const bearerToken = randomBytes(32).toString("hex");
|
|
401
|
+
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
402
|
+
if (!anthropicApiKey) {
|
|
403
|
+
console.error("Error: ANTHROPIC_API_KEY environment variable is not set.");
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const vpcId = await getDefaultVpcId(region);
|
|
408
|
+
|
|
409
|
+
console.log("\u{1F512} Ensuring security group...");
|
|
410
|
+
const securityGroupId = await ensureSecurityGroup(
|
|
411
|
+
"vellum-assistant",
|
|
412
|
+
vpcId,
|
|
413
|
+
GATEWAY_PORT,
|
|
414
|
+
region,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
console.log("\u{1F511} Ensuring SSH key pair...");
|
|
418
|
+
const keyPath = await ensureKeyPair(region);
|
|
419
|
+
|
|
420
|
+
console.log("\u{1F50D} Finding latest Debian AMI...");
|
|
421
|
+
const amiId = await getLatestDebianAmi(region);
|
|
422
|
+
|
|
423
|
+
const startupScript = buildStartupScript(species, bearerToken, sshUser, anthropicApiKey);
|
|
424
|
+
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
425
|
+
writeFileSync(startupScriptPath, startupScript);
|
|
426
|
+
|
|
427
|
+
console.log("\u{1F528} Launching instance...");
|
|
428
|
+
let instanceId: string;
|
|
429
|
+
try {
|
|
430
|
+
instanceId = await launchInstance(
|
|
431
|
+
instanceName,
|
|
432
|
+
amiId,
|
|
433
|
+
AWS_INSTANCE_TYPE,
|
|
434
|
+
securityGroupId,
|
|
435
|
+
startupScriptPath,
|
|
436
|
+
species,
|
|
437
|
+
region,
|
|
438
|
+
);
|
|
439
|
+
} finally {
|
|
440
|
+
try {
|
|
441
|
+
unlinkSync(startupScriptPath);
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
console.log(`\u2705 Instance ${instanceName} (${instanceId}) launched\n`);
|
|
446
|
+
|
|
447
|
+
console.log("\u23f3 Waiting for instance to be running...");
|
|
448
|
+
await waitForInstanceRunning(instanceId, region);
|
|
449
|
+
|
|
450
|
+
let externalIp: string | null = null;
|
|
451
|
+
try {
|
|
452
|
+
externalIp = await getInstancePublicIp(instanceId, region);
|
|
453
|
+
} catch {
|
|
454
|
+
console.log("\u26a0\ufe0f Could not retrieve external IP yet (instance may still be starting)");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const runtimeUrl = externalIp
|
|
458
|
+
? `http://${externalIp}:${GATEWAY_PORT}`
|
|
459
|
+
: `http://${instanceName}:${GATEWAY_PORT}`;
|
|
460
|
+
const awsEntry: AssistantEntry = {
|
|
461
|
+
assistantId: instanceName,
|
|
462
|
+
runtimeUrl,
|
|
463
|
+
bearerToken,
|
|
464
|
+
cloud: "aws",
|
|
465
|
+
instanceId,
|
|
466
|
+
region,
|
|
467
|
+
species,
|
|
468
|
+
sshUser,
|
|
469
|
+
hatchedAt: new Date().toISOString(),
|
|
470
|
+
};
|
|
471
|
+
saveAssistantEntry(awsEntry);
|
|
472
|
+
|
|
473
|
+
if (detached) {
|
|
474
|
+
console.log("\u{1F680} Startup script is running on the instance...");
|
|
475
|
+
console.log("");
|
|
476
|
+
console.log("\u2705 Assistant is hatching!\n");
|
|
477
|
+
console.log("Instance details:");
|
|
478
|
+
console.log(` Name: ${instanceName}`);
|
|
479
|
+
console.log(` Instance ID: ${instanceId}`);
|
|
480
|
+
console.log(` Region: ${region}`);
|
|
481
|
+
if (externalIp) {
|
|
482
|
+
console.log(` External IP: ${externalIp}`);
|
|
483
|
+
}
|
|
484
|
+
console.log("");
|
|
485
|
+
} else {
|
|
486
|
+
console.log(" Press Ctrl+C to detach (instance will keep running)");
|
|
487
|
+
console.log("");
|
|
488
|
+
|
|
489
|
+
if (externalIp) {
|
|
490
|
+
const ip = externalIp;
|
|
491
|
+
const success = await watchHatching(
|
|
492
|
+
() => pollAwsInstance(ip, keyPath),
|
|
493
|
+
instanceName,
|
|
494
|
+
startTime,
|
|
495
|
+
species,
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
if (!success) {
|
|
499
|
+
console.log("");
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
console.log("\u26a0\ufe0f No external IP available for monitoring. Instance is still running.");
|
|
504
|
+
console.log(` Monitor with: vel logs ${instanceName}`);
|
|
505
|
+
console.log("");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
console.log("Instance details:");
|
|
509
|
+
console.log(` Name: ${instanceName}`);
|
|
510
|
+
console.log(` Instance ID: ${instanceId}`);
|
|
511
|
+
console.log(` Region: ${region}`);
|
|
512
|
+
if (externalIp) {
|
|
513
|
+
console.log(` External IP: ${externalIp}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} catch (error) {
|
|
517
|
+
console.error("\u274c Error:", error instanceof Error ? error.message : error);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function getInstanceIdByName(
|
|
523
|
+
name: string,
|
|
524
|
+
region: string,
|
|
525
|
+
): Promise<string | null> {
|
|
526
|
+
try {
|
|
527
|
+
const output = await execOutput("aws", [
|
|
528
|
+
"ec2",
|
|
529
|
+
"describe-instances",
|
|
530
|
+
"--filters",
|
|
531
|
+
`Name=tag:Name,Values=${name}`,
|
|
532
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
533
|
+
"--query",
|
|
534
|
+
"Reservations[0].Instances[0].InstanceId",
|
|
535
|
+
"--output",
|
|
536
|
+
"text",
|
|
537
|
+
"--region",
|
|
538
|
+
region,
|
|
539
|
+
]);
|
|
540
|
+
const id = output.trim();
|
|
541
|
+
return id && id !== "None" ? id : null;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function retireInstance(
|
|
548
|
+
name: string,
|
|
549
|
+
region: string,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
const instanceId = await getInstanceIdByName(name, region);
|
|
552
|
+
if (!instanceId) {
|
|
553
|
+
console.warn(
|
|
554
|
+
`\u26a0\ufe0f Instance ${name} not found in AWS (region=${region}).`,
|
|
555
|
+
);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
console.log(`\u{1F5D1}\ufe0f Terminating AWS instance ${name} (${instanceId})\n`);
|
|
560
|
+
|
|
561
|
+
const child = spawnChild(
|
|
562
|
+
"aws",
|
|
563
|
+
[
|
|
564
|
+
"ec2",
|
|
565
|
+
"terminate-instances",
|
|
566
|
+
"--instance-ids",
|
|
567
|
+
instanceId,
|
|
568
|
+
"--region",
|
|
569
|
+
region,
|
|
570
|
+
],
|
|
571
|
+
{ stdio: "inherit" },
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
await new Promise<void>((resolve, reject) => {
|
|
575
|
+
child.on("close", (code) => {
|
|
576
|
+
if (code === 0) {
|
|
577
|
+
resolve();
|
|
578
|
+
} else {
|
|
579
|
+
reject(
|
|
580
|
+
new Error(
|
|
581
|
+
`aws ec2 terminate-instances exited with code ${code}`,
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
child.on("error", reject);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
console.log(`\u2705 Instance ${name} (${instanceId}) terminated.`);
|
|
590
|
+
}
|