@runuai/host 0.9.74 → 0.9.76
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 +79 -6
- package/package.json +1 -1
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
23
|
|
|
24
24
|
import type { DockerResult } from "./docker-exec";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
|
|
25
27
|
import type {
|
|
26
28
|
MachineInfo,
|
|
27
29
|
MachineProvider,
|
|
@@ -103,7 +105,12 @@ export function awsUserData(authorizedPublicKey: string): string {
|
|
|
103
105
|
const script = [
|
|
104
106
|
"#!/bin/sh",
|
|
105
107
|
"set -e",
|
|
106
|
-
|
|
108
|
+
// uid 1000 is often TAKEN on stock cloud images (Debian's `admin`) —
|
|
109
|
+
// insisting on it aborted the whole script under set -e and left no key
|
|
110
|
+
// installed for anyone (live 2026-08-27, first EC2 smoke). Prefer 1000,
|
|
111
|
+
// fall back to auto: on a machine the task owns the whole filesystem,
|
|
112
|
+
// so the exact uid is convention, not correctness.
|
|
113
|
+
"id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node 2>/dev/null || useradd -m -s /bin/bash node",
|
|
107
114
|
"for account in node root; do",
|
|
108
115
|
' home=$(getent passwd "$account" | cut -d: -f6)',
|
|
109
116
|
' mkdir -p "$home/.ssh"',
|
|
@@ -114,7 +121,12 @@ export function awsUserData(authorizedPublicKey: string): string {
|
|
|
114
121
|
"done",
|
|
115
122
|
"",
|
|
116
123
|
].join("\n");
|
|
117
|
-
|
|
124
|
+
// RAW script, not base64: AWS CLI v2 base64-encodes --user-data ITSELF.
|
|
125
|
+
// Pre-encoding double-encoded it, cloud-init saw garbage and silently ran
|
|
126
|
+
// nothing — no key installed for anyone (live 2026-08-27, second EC2
|
|
127
|
+
// smoke failure; the uid-1000 collision fixed just before was real too,
|
|
128
|
+
// but masked by this).
|
|
129
|
+
return script;
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
type LiveInstance = {
|
|
@@ -144,6 +156,7 @@ function parseReservations(stdout: string): LiveInstance[] | null {
|
|
|
144
156
|
InstanceId?: unknown;
|
|
145
157
|
State?: { Name?: unknown };
|
|
146
158
|
PrivateIpAddress?: unknown;
|
|
159
|
+
PublicIpAddress?: unknown;
|
|
147
160
|
Tags?: Array<{ Key?: unknown; Value?: unknown }>;
|
|
148
161
|
};
|
|
149
162
|
if (typeof record.InstanceId !== "string") return null;
|
|
@@ -162,10 +175,16 @@ function parseReservations(stdout: string): LiveInstance[] | null {
|
|
|
162
175
|
instances.push({
|
|
163
176
|
instanceId: record.InstanceId,
|
|
164
177
|
state,
|
|
178
|
+
// Prefer the public address when the subnet assigns one: an
|
|
179
|
+
// orchestrator OUTSIDE the VPC (the local smoke, a dev box) can only
|
|
180
|
+
// dial that, while the production in-VPC orchestrator sees no public
|
|
181
|
+
// IP on its machines and keeps dialing private, unchanged.
|
|
165
182
|
privateIp:
|
|
166
|
-
typeof record.
|
|
167
|
-
? record.
|
|
168
|
-
:
|
|
183
|
+
typeof record.PublicIpAddress === "string"
|
|
184
|
+
? record.PublicIpAddress
|
|
185
|
+
: typeof record.PrivateIpAddress === "string"
|
|
186
|
+
? record.PrivateIpAddress
|
|
187
|
+
: null,
|
|
169
188
|
taskLabel,
|
|
170
189
|
});
|
|
171
190
|
}
|
|
@@ -265,6 +284,28 @@ export function createAwsMachineProvider(
|
|
|
265
284
|
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
266
285
|
const machineId = awsMachineName(spec.taskId);
|
|
267
286
|
const type = (config.instanceType ?? defaultInstanceType)(spec);
|
|
287
|
+
// The idempotency token binds the machine id TO these exact launch
|
|
288
|
+
// arguments: EC2 remembers a client token's arguments for ~24h (even
|
|
289
|
+
// past termination) and rejects reuse with different ones
|
|
290
|
+
// (IdempotentParameterMismatch — live 2026-08-27: a Resume after a
|
|
291
|
+
// provider fix changed the user-data and the retry was refused). An
|
|
292
|
+
// identical retry still cannot mint a second instance; a CHANGED
|
|
293
|
+
// launch legitimately gets a fresh token, and the resume-aware
|
|
294
|
+
// provision's describe-first step remains the guard against doubling
|
|
295
|
+
// a live machine.
|
|
296
|
+
const launchFingerprint = createHash("sha256")
|
|
297
|
+
.update(
|
|
298
|
+
JSON.stringify([
|
|
299
|
+
spec.image,
|
|
300
|
+
type,
|
|
301
|
+
spec.authorizedPublicKey ?? null,
|
|
302
|
+
config.subnetId ?? null,
|
|
303
|
+
config.securityGroupId ?? null,
|
|
304
|
+
config.iamInstanceProfileArn ?? null,
|
|
305
|
+
]),
|
|
306
|
+
)
|
|
307
|
+
.digest("hex")
|
|
308
|
+
.slice(0, 16);
|
|
268
309
|
const args = [
|
|
269
310
|
"ec2",
|
|
270
311
|
"run-instances",
|
|
@@ -277,7 +318,7 @@ export function createAwsMachineProvider(
|
|
|
277
318
|
// Idempotency across crashed/retried provisions: the same token can
|
|
278
319
|
// never mint a second instance.
|
|
279
320
|
"--client-token",
|
|
280
|
-
machineId,
|
|
321
|
+
`${machineId}-${launchFingerprint}`.slice(0, 64),
|
|
281
322
|
"--tag-specifications",
|
|
282
323
|
`ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
|
|
283
324
|
...(spec.authorizedPublicKey
|
|
@@ -325,6 +366,38 @@ export function createAwsMachineProvider(
|
|
|
325
366
|
}`,
|
|
326
367
|
);
|
|
327
368
|
}
|
|
369
|
+
// EC2 sits in `stopping` for tens of seconds, and StartInstances
|
|
370
|
+
// refuses it with IncorrectInstanceState — an immediate stop→resume
|
|
371
|
+
// (a user pressing Resume right after Stop; the live 2026-08-27
|
|
372
|
+
// smoke) failed exactly there because our state map folds `stopping`
|
|
373
|
+
// into "stopped". Stop resolves only once the instance can actually
|
|
374
|
+
// be started again, mirroring terminate's poll-to-absent.
|
|
375
|
+
const deadline = Date.now() + 180_000;
|
|
376
|
+
for (;;) {
|
|
377
|
+
const raw = await runner([
|
|
378
|
+
"ec2",
|
|
379
|
+
"describe-instances",
|
|
380
|
+
"--instance-ids",
|
|
381
|
+
instance.instanceId,
|
|
382
|
+
"--query",
|
|
383
|
+
"Reservations[].Instances[].State.Name",
|
|
384
|
+
"--output",
|
|
385
|
+
"text",
|
|
386
|
+
]);
|
|
387
|
+
const state = raw.status === 0 ? raw.stdout.trim() : "";
|
|
388
|
+
if (state === "stopped") return;
|
|
389
|
+
if (state === "terminated" || state === "shutting-down") {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`machine ${machineId} terminated while stopping (${state})`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (Date.now() >= deadline) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`machine ${machineId} did not reach stopped within the stop deadline (${state || "unknown"})`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
|
|
400
|
+
}
|
|
328
401
|
},
|
|
329
402
|
|
|
330
403
|
async start(machineId: string): Promise<MachineInfo> {
|