@runuai/host 0.9.81 → 0.9.83
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 +214 -9
- package/package.json +1 -1
- package/src/cli.ts +242 -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
|
: []),
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
// runs far too late.)
|
|
21
21
|
import "./load-env";
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
spawn,
|
|
25
|
+
spawnSync,
|
|
26
|
+
type SpawnSyncReturns,
|
|
27
|
+
} from "node:child_process";
|
|
24
28
|
import { createHash, randomBytes } from "node:crypto";
|
|
25
29
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
30
|
import { homedir, hostname } from "node:os";
|
|
@@ -139,6 +143,11 @@ async function main(): Promise<void> {
|
|
|
139
143
|
return cmdRuntime(rest);
|
|
140
144
|
case "tune":
|
|
141
145
|
return cmdTune();
|
|
146
|
+
case "aws":
|
|
147
|
+
if (rest[0] === "setup") return cmdAwsSetup(rest.slice(1));
|
|
148
|
+
console.error(red("usage: uai-host aws setup [--region <region>] [--rotate]"));
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
142
151
|
case "update":
|
|
143
152
|
return cmdUpdate(rest);
|
|
144
153
|
case "rollback":
|
|
@@ -500,6 +509,234 @@ async function boundedResponseJson(
|
|
|
500
509
|
|
|
501
510
|
// --- runtime ---------------------------------------------------------------
|
|
502
511
|
|
|
512
|
+
// --- aws setup (ADR-125 BYO-AWS: mint the host's scoped AWS identity) --------
|
|
513
|
+
|
|
514
|
+
/** Mirrors scripts/machine-ami/byo-aws-quickcreate.yaml — keep in sync. */
|
|
515
|
+
const AWS_MACHINE_POLICY = {
|
|
516
|
+
Version: "2012-10-17",
|
|
517
|
+
Statement: [
|
|
518
|
+
{
|
|
519
|
+
Sid: "UaiMachines",
|
|
520
|
+
Effect: "Allow",
|
|
521
|
+
Action: [
|
|
522
|
+
"ec2:RunInstances",
|
|
523
|
+
"ec2:StartInstances",
|
|
524
|
+
"ec2:StopInstances",
|
|
525
|
+
"ec2:TerminateInstances",
|
|
526
|
+
"ec2:CreateTags",
|
|
527
|
+
"ec2:DescribeInstances",
|
|
528
|
+
"ec2:DescribeImages",
|
|
529
|
+
"ec2:DescribeSubnets",
|
|
530
|
+
"ec2:DescribeVpcs",
|
|
531
|
+
"ec2:DescribeSecurityGroups",
|
|
532
|
+
"ec2:CreateSecurityGroup",
|
|
533
|
+
"ec2:AuthorizeSecurityGroupIngress",
|
|
534
|
+
"ec2:CreateSnapshot",
|
|
535
|
+
"ec2:DeleteSnapshot",
|
|
536
|
+
"ec2:CreateVolume",
|
|
537
|
+
"ec2:DescribeSnapshots",
|
|
538
|
+
"ec2:DescribeVolumes",
|
|
539
|
+
],
|
|
540
|
+
Resource: "*",
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
};
|
|
544
|
+
const AWS_SETUP_USER = "uai-host";
|
|
545
|
+
|
|
546
|
+
/** Run the aws CLI with the operator's AMBIENT (admin) credentials: any
|
|
547
|
+
* AWS_* that load-env pulled from .env.local is a previously-minted SCOPED
|
|
548
|
+
* key that cannot administer IAM, and letting it shadow the shell's admin
|
|
549
|
+
* credentials makes every failure read as a permissions mystery. */
|
|
550
|
+
function awsAdmin(
|
|
551
|
+
args: string[],
|
|
552
|
+
region: string | null,
|
|
553
|
+
): SpawnSyncReturns<string> {
|
|
554
|
+
const env = { ...process.env };
|
|
555
|
+
const fromFile = (() => {
|
|
556
|
+
try {
|
|
557
|
+
return parseDotenv(readFileSync(envLocalPath(), "utf8"));
|
|
558
|
+
} catch {
|
|
559
|
+
return {} as Record<string, string>;
|
|
560
|
+
}
|
|
561
|
+
})();
|
|
562
|
+
if (
|
|
563
|
+
env.AWS_ACCESS_KEY_ID &&
|
|
564
|
+
env.AWS_ACCESS_KEY_ID === fromFile.AWS_ACCESS_KEY_ID
|
|
565
|
+
) {
|
|
566
|
+
delete env.AWS_ACCESS_KEY_ID;
|
|
567
|
+
delete env.AWS_SECRET_ACCESS_KEY;
|
|
568
|
+
delete env.AWS_SESSION_TOKEN;
|
|
569
|
+
}
|
|
570
|
+
return spawnSync(
|
|
571
|
+
"aws",
|
|
572
|
+
[...(region ? ["--region", region] : []), "--output", "json", ...args],
|
|
573
|
+
{ encoding: "utf8", env },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function cmdAwsSetup(rest: string[]): Promise<void> {
|
|
578
|
+
const rotate = rest.includes("--rotate");
|
|
579
|
+
const regionFlagAt = rest.indexOf("--region");
|
|
580
|
+
let region = regionFlagAt >= 0 ? (rest[regionFlagAt + 1] ?? "") : "";
|
|
581
|
+
|
|
582
|
+
const version = spawnSync("aws", ["--version"], { encoding: "utf8" });
|
|
583
|
+
if (version.status !== 0) {
|
|
584
|
+
console.error(red("the aws CLI is not installed (or not on PATH)"));
|
|
585
|
+
console.error("install it: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html");
|
|
586
|
+
process.exitCode = 1;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (!region) {
|
|
591
|
+
const configured = spawnSync("aws", ["configure", "get", "region"], {
|
|
592
|
+
encoding: "utf8",
|
|
593
|
+
});
|
|
594
|
+
region = (configured.stdout ?? "").trim();
|
|
595
|
+
}
|
|
596
|
+
if (!region) {
|
|
597
|
+
console.error(red("no AWS region — pass --region <region> (e.g. --region us-west-2)"));
|
|
598
|
+
process.exitCode = 1;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const identity = awsAdmin(["sts", "get-caller-identity"], region);
|
|
603
|
+
if (identity.status !== 0) {
|
|
604
|
+
console.error(red("no working AWS admin credentials in this shell"));
|
|
605
|
+
console.error(
|
|
606
|
+
"sign in first (aws configure, aws sso login, or exported AWS_* vars), then re-run",
|
|
607
|
+
);
|
|
608
|
+
console.error(dim((identity.stderr ?? "").trim().slice(0, 200)));
|
|
609
|
+
process.exitCode = 1;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const account = (() => {
|
|
613
|
+
try {
|
|
614
|
+
return (JSON.parse(identity.stdout) as { Account?: string }).Account ?? "?";
|
|
615
|
+
} catch {
|
|
616
|
+
return "?";
|
|
617
|
+
}
|
|
618
|
+
})();
|
|
619
|
+
|
|
620
|
+
const existingEnv = (() => {
|
|
621
|
+
try {
|
|
622
|
+
return parseDotenv(readFileSync(envLocalPath(), "utf8"));
|
|
623
|
+
} catch {
|
|
624
|
+
return {} as Record<string, string>;
|
|
625
|
+
}
|
|
626
|
+
})();
|
|
627
|
+
if (existingEnv.AWS_ACCESS_KEY_ID && !rotate) {
|
|
628
|
+
console.log(
|
|
629
|
+
`already configured (key ${existingEnv.AWS_ACCESS_KEY_ID.slice(0, 8)}…, region ${existingEnv.UAI_AWS_REGION ?? "?"})`,
|
|
630
|
+
);
|
|
631
|
+
console.log(`re-run with ${bold("--rotate")} to mint a fresh key`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
console.log(`account ${account}, region ${region} — creating the ${AWS_SETUP_USER} identity`);
|
|
636
|
+
|
|
637
|
+
const created = awsAdmin(
|
|
638
|
+
["iam", "create-user", "--user-name", AWS_SETUP_USER],
|
|
639
|
+
region,
|
|
640
|
+
);
|
|
641
|
+
if (
|
|
642
|
+
created.status !== 0 &&
|
|
643
|
+
!(created.stderr ?? "").includes("EntityAlreadyExists")
|
|
644
|
+
) {
|
|
645
|
+
console.error(red(`could not create the ${AWS_SETUP_USER} IAM user: ${(created.stderr ?? "").trim().slice(0, 300)}`));
|
|
646
|
+
process.exitCode = 1;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// put-user-policy overwrites: re-running upgrades the policy in place.
|
|
651
|
+
const policy = awsAdmin(
|
|
652
|
+
[
|
|
653
|
+
"iam",
|
|
654
|
+
"put-user-policy",
|
|
655
|
+
"--user-name",
|
|
656
|
+
AWS_SETUP_USER,
|
|
657
|
+
"--policy-name",
|
|
658
|
+
"uai-machines",
|
|
659
|
+
"--policy-document",
|
|
660
|
+
JSON.stringify(AWS_MACHINE_POLICY),
|
|
661
|
+
],
|
|
662
|
+
region,
|
|
663
|
+
);
|
|
664
|
+
if (policy.status !== 0) {
|
|
665
|
+
console.error(red(`could not attach the machine policy: ${(policy.stderr ?? "").trim().slice(0, 300)}`));
|
|
666
|
+
process.exitCode = 1;
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const keyResult = awsAdmin(
|
|
671
|
+
["iam", "create-access-key", "--user-name", AWS_SETUP_USER],
|
|
672
|
+
region,
|
|
673
|
+
);
|
|
674
|
+
if (keyResult.status !== 0) {
|
|
675
|
+
console.error(red(`could not create an access key: ${(keyResult.stderr ?? "").trim().slice(0, 300)}`));
|
|
676
|
+
if ((keyResult.stderr ?? "").includes("LimitExceeded")) {
|
|
677
|
+
console.error(
|
|
678
|
+
`the user already has two keys — list them with: aws iam list-access-keys --user-name ${AWS_SETUP_USER}\n` +
|
|
679
|
+
`and delete one with: aws iam delete-access-key --user-name ${AWS_SETUP_USER} --access-key-id <id>`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
process.exitCode = 1;
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
let keyId = "";
|
|
686
|
+
let secret = "";
|
|
687
|
+
try {
|
|
688
|
+
const parsed = JSON.parse(keyResult.stdout) as {
|
|
689
|
+
AccessKey?: { AccessKeyId?: string; SecretAccessKey?: string };
|
|
690
|
+
};
|
|
691
|
+
keyId = parsed.AccessKey?.AccessKeyId ?? "";
|
|
692
|
+
secret = parsed.AccessKey?.SecretAccessKey ?? "";
|
|
693
|
+
} catch {
|
|
694
|
+
// handled below
|
|
695
|
+
}
|
|
696
|
+
if (!keyId || !secret) {
|
|
697
|
+
console.error(red("create-access-key returned no key material"));
|
|
698
|
+
process.exitCode = 1;
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
writeEnvValuesAtomic(envLocalPath(), {
|
|
703
|
+
UAI_MACHINE_TASKS: "1",
|
|
704
|
+
UAI_MACHINE_PROVIDER: "aws",
|
|
705
|
+
UAI_AWS_REGION: region,
|
|
706
|
+
AWS_ACCESS_KEY_ID: keyId,
|
|
707
|
+
AWS_SECRET_ACCESS_KEY: secret,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Rotation hygiene: the key the env file carried before this run belongs
|
|
711
|
+
// to the scoped user; a minted replacement leaves it orphaned AND holding
|
|
712
|
+
// the 2-key limit. Best-effort delete, loudly skippable.
|
|
713
|
+
const previousKey = existingEnv.AWS_ACCESS_KEY_ID;
|
|
714
|
+
if (rotate && previousKey && previousKey !== keyId) {
|
|
715
|
+
const deleted = awsAdmin(
|
|
716
|
+
[
|
|
717
|
+
"iam",
|
|
718
|
+
"delete-access-key",
|
|
719
|
+
"--user-name",
|
|
720
|
+
AWS_SETUP_USER,
|
|
721
|
+
"--access-key-id",
|
|
722
|
+
previousKey,
|
|
723
|
+
],
|
|
724
|
+
region,
|
|
725
|
+
);
|
|
726
|
+
if (deleted.status === 0) {
|
|
727
|
+
console.log(`rotated: deleted the previous key ${previousKey.slice(0, 8)}…`);
|
|
728
|
+
} else {
|
|
729
|
+
console.log(
|
|
730
|
+
yellow(`minted a new key but could not delete ${previousKey.slice(0, 8)}… — remove it manually if unused`),
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
console.log(`${bold("done")} — scoped credentials written to ${envLocalPath()}`);
|
|
736
|
+
console.log(` UAI_MACHINE_PROVIDER=aws UAI_AWS_REGION=${region} (machine tasks enabled)`);
|
|
737
|
+
console.log(`restart the host to activate: ${cyan("uai-host restart")}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
503
740
|
// --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
|
|
504
741
|
|
|
505
742
|
const TUNE_DAEMON_LABEL = "com.runuai.tune";
|
|
@@ -2019,6 +2256,10 @@ function printHelp(): void {
|
|
|
2019
2256
|
runtime recheck probe Docker again and refresh cloud capabilities
|
|
2020
2257
|
tune (macOS, sudo) raise kernel vnode/file-table ceilings for
|
|
2021
2258
|
apple-container task VMs and persist them across boots
|
|
2259
|
+
aws setup [--region <r>] [--rotate]
|
|
2260
|
+
enable machine-backed tasks in YOUR AWS account: uses
|
|
2261
|
+
the admin credentials already in this shell to mint a
|
|
2262
|
+
scoped uai-host IAM key and writes the host config
|
|
2022
2263
|
setup --cloud <wss-url> --enroll <token>
|
|
2023
2264
|
claim this machine via an enrollment token from the web
|
|
2024
2265
|
app (mints + writes the host credential); on an
|