@runuai/host 0.9.80 → 0.9.82
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/lib/machine-provider-aws.ts +228 -10
- package/package.json +1 -1
|
@@ -33,6 +33,17 @@ import type {
|
|
|
33
33
|
|
|
34
34
|
export const AWS_MACHINE_TAG = "com.uai.machine";
|
|
35
35
|
|
|
36
|
+
/** ADR-125 BYO-AWS: the Uai account that publishes the public payload AMIs,
|
|
37
|
+
* and the name prefix build-ami.sh stamps on them. A host with no
|
|
38
|
+
* UAI_MACHINE_IMAGE resolves the newest published payload in its region. */
|
|
39
|
+
export const PAYLOAD_AMI_OWNER = "277057415208";
|
|
40
|
+
export const PAYLOAD_AMI_NAME_PREFIX = "uai-machine-payload";
|
|
41
|
+
/** Name of the security group auto-created in the default VPC when the host
|
|
42
|
+
* config names none. Inbound: all TCP from the HOST's egress /32 only —
|
|
43
|
+
* code-server runs auth-none and preview ports are plain HTTP, so exposure
|
|
44
|
+
* wider than the orchestrator would hand the editor to the internet. */
|
|
45
|
+
const MANAGED_SECURITY_GROUP = "uai-machines";
|
|
46
|
+
|
|
36
47
|
export interface AwsMachineConfig {
|
|
37
48
|
region: string;
|
|
38
49
|
/** AMI id used when the spec's image is not already an ami-*. */
|
|
@@ -219,6 +230,30 @@ function unknown(id: string, detail: string): MachineInfo {
|
|
|
219
230
|
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
/** The host's public egress address, for scoping the auto-created security
|
|
234
|
+
* group. Only consulted when the group must be CREATED — an existing group
|
|
235
|
+
* (explicit config or a prior run) never re-resolves. Overridable for hosts
|
|
236
|
+
* whose egress the checker cannot see (e.g. VPN'd). */
|
|
237
|
+
async function hostEgressIp(): Promise<string> {
|
|
238
|
+
const configured = process.env.UAI_HOST_EGRESS_IP;
|
|
239
|
+
if (configured && /^\d{1,3}(\.\d{1,3}){3}$/.test(configured)) {
|
|
240
|
+
return configured;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch("https://checkip.amazonaws.com", {
|
|
244
|
+
signal: AbortSignal.timeout(5_000),
|
|
245
|
+
});
|
|
246
|
+
const ip = (await res.text()).trim();
|
|
247
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip;
|
|
248
|
+
} catch {
|
|
249
|
+
// fall through to the instruction below
|
|
250
|
+
}
|
|
251
|
+
throw new Error(
|
|
252
|
+
"could not determine this host's public address to scope the machine " +
|
|
253
|
+
"security group — set UAI_HOST_EGRESS_IP or UAI_AWS_SECURITY_GROUP_ID",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
222
257
|
export function createAwsMachineProvider(
|
|
223
258
|
config: AwsMachineConfig,
|
|
224
259
|
runner: Runner = defaultRunner(config.region),
|
|
@@ -265,6 +300,171 @@ export function createAwsMachineProvider(
|
|
|
265
300
|
return device;
|
|
266
301
|
}
|
|
267
302
|
|
|
303
|
+
// -------------------------------------------------------------------------
|
|
304
|
+
// ADR-125 BYO-AWS zero-config discovery. Every resolver prefers explicit
|
|
305
|
+
// host config, resolves once per process, and fails with an instruction
|
|
306
|
+
// rather than a guess — a wrong subnet or security group is a machine the
|
|
307
|
+
// orchestrator can never reach.
|
|
308
|
+
// -------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
let resolvedImage: string | null = null;
|
|
311
|
+
async function resolveImage(specImage: string): Promise<string> {
|
|
312
|
+
if (specImage.startsWith("ami-")) return specImage;
|
|
313
|
+
if (resolvedImage) return resolvedImage;
|
|
314
|
+
const res = await runner([
|
|
315
|
+
"ec2",
|
|
316
|
+
"describe-images",
|
|
317
|
+
"--owners",
|
|
318
|
+
PAYLOAD_AMI_OWNER,
|
|
319
|
+
"--filters",
|
|
320
|
+
`Name=name,Values=${PAYLOAD_AMI_NAME_PREFIX}-*`,
|
|
321
|
+
"Name=state,Values=available",
|
|
322
|
+
"--query",
|
|
323
|
+
"sort_by(Images, &CreationDate)[-1].ImageId",
|
|
324
|
+
]);
|
|
325
|
+
let image: unknown = null;
|
|
326
|
+
if (res.status === 0) {
|
|
327
|
+
try {
|
|
328
|
+
image = JSON.parse(res.stdout);
|
|
329
|
+
} catch {
|
|
330
|
+
image = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (typeof image !== "string" || !image.startsWith("ami-")) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`no uai machine payload AMI is published in ${config.region} — set ` +
|
|
336
|
+
"UAI_MACHINE_IMAGE to an AMI id, or ask Uai to publish this region",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
resolvedImage = image;
|
|
340
|
+
return image;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let resolvedSubnet: string | null = null;
|
|
344
|
+
async function resolveSubnet(): Promise<string> {
|
|
345
|
+
if (config.subnetId) return config.subnetId;
|
|
346
|
+
if (resolvedSubnet) return resolvedSubnet;
|
|
347
|
+
const res = await runner([
|
|
348
|
+
"ec2",
|
|
349
|
+
"describe-subnets",
|
|
350
|
+
"--filters",
|
|
351
|
+
"Name=default-for-az,Values=true",
|
|
352
|
+
"--query",
|
|
353
|
+
"sort_by(Subnets, &AvailabilityZone)[].{id: SubnetId, public: MapPublicIpOnLaunch, vpc: VpcId}",
|
|
354
|
+
]);
|
|
355
|
+
let subnets: Array<{ id?: unknown; public?: unknown }> = [];
|
|
356
|
+
if (res.status === 0) {
|
|
357
|
+
try {
|
|
358
|
+
const parsed = JSON.parse(res.stdout);
|
|
359
|
+
if (Array.isArray(parsed)) subnets = parsed;
|
|
360
|
+
} catch {
|
|
361
|
+
subnets = [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const chosen =
|
|
365
|
+
subnets.find((s) => s.public === true && typeof s.id === "string") ??
|
|
366
|
+
subnets.find((s) => typeof s.id === "string");
|
|
367
|
+
if (!chosen || typeof chosen.id !== "string") {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`no default-VPC subnet found in ${config.region} — set ` +
|
|
370
|
+
"UAI_AWS_SUBNET_ID to a public subnet",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
resolvedSubnet = chosen.id;
|
|
374
|
+
return chosen.id;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let resolvedSecurityGroup: string | null = null;
|
|
378
|
+
async function resolveSecurityGroup(subnetId: string): Promise<string> {
|
|
379
|
+
if (config.securityGroupId) return config.securityGroupId;
|
|
380
|
+
if (resolvedSecurityGroup) return resolvedSecurityGroup;
|
|
381
|
+
const vpcRes = await runner([
|
|
382
|
+
"ec2",
|
|
383
|
+
"describe-subnets",
|
|
384
|
+
"--subnet-ids",
|
|
385
|
+
subnetId,
|
|
386
|
+
"--query",
|
|
387
|
+
"Subnets[0].VpcId",
|
|
388
|
+
]);
|
|
389
|
+
const vpcId = vpcRes.status === 0 ? JSON.parse(vpcRes.stdout) : null;
|
|
390
|
+
if (typeof vpcId !== "string") {
|
|
391
|
+
throw new Error(`could not resolve the VPC of subnet ${subnetId}`);
|
|
392
|
+
}
|
|
393
|
+
const found = await runner([
|
|
394
|
+
"ec2",
|
|
395
|
+
"describe-security-groups",
|
|
396
|
+
"--filters",
|
|
397
|
+
`Name=group-name,Values=${MANAGED_SECURITY_GROUP}`,
|
|
398
|
+
`Name=vpc-id,Values=${vpcId}`,
|
|
399
|
+
"--query",
|
|
400
|
+
"SecurityGroups[0].GroupId",
|
|
401
|
+
]);
|
|
402
|
+
if (found.status === 0) {
|
|
403
|
+
try {
|
|
404
|
+
const existing = JSON.parse(found.stdout);
|
|
405
|
+
if (typeof existing === "string" && existing.startsWith("sg-")) {
|
|
406
|
+
resolvedSecurityGroup = existing;
|
|
407
|
+
return existing;
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// fall through to creation
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const egress = await hostEgressIp();
|
|
414
|
+
const created = await runner([
|
|
415
|
+
"ec2",
|
|
416
|
+
"create-security-group",
|
|
417
|
+
"--group-name",
|
|
418
|
+
MANAGED_SECURITY_GROUP,
|
|
419
|
+
"--description",
|
|
420
|
+
"uai task machines: reachable only from the orchestrating host",
|
|
421
|
+
"--vpc-id",
|
|
422
|
+
vpcId,
|
|
423
|
+
"--query",
|
|
424
|
+
"GroupId",
|
|
425
|
+
]);
|
|
426
|
+
const groupId = created.status === 0 ? JSON.parse(created.stdout) : null;
|
|
427
|
+
if (typeof groupId !== "string") {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`could not create the ${MANAGED_SECURITY_GROUP} security group: ${
|
|
430
|
+
created.stderr.trim() || `exit ${created.status ?? "killed"}`
|
|
431
|
+
}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const authorized = await runner([
|
|
435
|
+
"ec2",
|
|
436
|
+
"authorize-security-group-ingress",
|
|
437
|
+
"--group-id",
|
|
438
|
+
groupId,
|
|
439
|
+
"--ip-permissions",
|
|
440
|
+
JSON.stringify([
|
|
441
|
+
{
|
|
442
|
+
IpProtocol: "tcp",
|
|
443
|
+
FromPort: 0,
|
|
444
|
+
ToPort: 65535,
|
|
445
|
+
IpRanges: [
|
|
446
|
+
{
|
|
447
|
+
CidrIp: `${egress}/32`,
|
|
448
|
+
Description: "uai orchestrating host",
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
]),
|
|
453
|
+
]);
|
|
454
|
+
if (authorized.status !== 0) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`created ${groupId} but could not authorize the host's address: ${
|
|
457
|
+
authorized.stderr.trim() || `exit ${authorized.status ?? "killed"}`
|
|
458
|
+
}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
console.log(
|
|
462
|
+
`[machine-aws] created security group ${groupId} (${MANAGED_SECURITY_GROUP}) allowing ${egress}/32`,
|
|
463
|
+
);
|
|
464
|
+
resolvedSecurityGroup = groupId;
|
|
465
|
+
return groupId;
|
|
466
|
+
}
|
|
467
|
+
|
|
268
468
|
/** Resolve the logical machine id to its live instances via the tag. */
|
|
269
469
|
async function resolveLive(
|
|
270
470
|
machineId: string,
|
|
@@ -338,16 +538,21 @@ export function createAwsMachineProvider(
|
|
|
338
538
|
// name the AMI's actual root device — a wrong name would ADD a blank
|
|
339
539
|
// volume while the root stays small — so resolve it per image.
|
|
340
540
|
const diskGiB = config.diskGiB ?? DEFAULT_DISK_GIB;
|
|
341
|
-
|
|
541
|
+
// ADR-125 BYO-AWS: explicit config wins; otherwise resolve the newest
|
|
542
|
+
// published payload AMI and the default-VPC network per process.
|
|
543
|
+
const image = await resolveImage(spec.image);
|
|
544
|
+
const subnetId = await resolveSubnet();
|
|
545
|
+
const securityGroupId = await resolveSecurityGroup(subnetId);
|
|
546
|
+
const rootDevice = await rootDeviceName(image);
|
|
342
547
|
// a live machine.
|
|
343
548
|
const launchFingerprint = createHash("sha256")
|
|
344
549
|
.update(
|
|
345
550
|
JSON.stringify([
|
|
346
|
-
|
|
551
|
+
image,
|
|
347
552
|
type,
|
|
348
553
|
spec.authorizedPublicKey ?? null,
|
|
349
|
-
|
|
350
|
-
|
|
554
|
+
subnetId,
|
|
555
|
+
securityGroupId,
|
|
351
556
|
config.iamInstanceProfileArn ?? null,
|
|
352
557
|
diskGiB,
|
|
353
558
|
]),
|
|
@@ -358,7 +563,7 @@ export function createAwsMachineProvider(
|
|
|
358
563
|
"ec2",
|
|
359
564
|
"run-instances",
|
|
360
565
|
"--image-id",
|
|
361
|
-
|
|
566
|
+
image,
|
|
362
567
|
"--instance-type",
|
|
363
568
|
type,
|
|
364
569
|
"--count",
|
|
@@ -383,10 +588,10 @@ export function createAwsMachineProvider(
|
|
|
383
588
|
...(spec.authorizedPublicKey
|
|
384
589
|
? ["--user-data", awsUserData(spec.authorizedPublicKey)]
|
|
385
590
|
: []),
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
591
|
+
"--subnet-id",
|
|
592
|
+
subnetId,
|
|
593
|
+
"--security-group-ids",
|
|
594
|
+
securityGroupId,
|
|
390
595
|
...(config.iamInstanceProfileArn
|
|
391
596
|
? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
|
|
392
597
|
: []),
|
|
@@ -399,7 +604,20 @@ export function createAwsMachineProvider(
|
|
|
399
604
|
}`,
|
|
400
605
|
);
|
|
401
606
|
}
|
|
402
|
-
|
|
607
|
+
// DescribeInstances TAG FILTERS are eventually consistent: the query
|
|
608
|
+
// can miss an instance RunInstances just created (live 2026-08-28 —
|
|
609
|
+
// the machine was running the whole time while launch reported
|
|
610
|
+
// "absent"). Absence immediately after a successful launch is
|
|
611
|
+
// therefore not evidence yet; give the filter a bounded window.
|
|
612
|
+
let info = await describe(machineId);
|
|
613
|
+
for (
|
|
614
|
+
let attempt = 0;
|
|
615
|
+
(info.state === "absent" || info.state === "unknown") && attempt < 5;
|
|
616
|
+
attempt += 1
|
|
617
|
+
) {
|
|
618
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
|
619
|
+
info = await describe(machineId);
|
|
620
|
+
}
|
|
403
621
|
if (info.state === "absent" || info.state === "unknown") {
|
|
404
622
|
throw new Error(
|
|
405
623
|
`machine ${machineId} launched but could not be described (${info.state}${
|