@runuai/host 0.9.69 → 0.9.70
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/env-schema.ts +101 -0
- package/lib/machine-exec.ts +89 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-provider-aws.ts +437 -0
- package/lib/machine-provider-local.ts +58 -7
- package/lib/machine-provider.ts +10 -0
- package/lib/task-environment/apple-container.ts +21 -0
- package/lib/task-environment/docker.ts +21 -0
- package/lib/task-environment/machine.ts +728 -0
- package/lib/task-environment/types.ts +32 -0
- package/lib/task-environment/workspace-files.ts +51 -0
- package/package.json +1 -1
- package/src/index.ts +88 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-122 phase 1: a minimal, OWNED parser for the env-spec subset Uai
|
|
3
|
+
* reads — key names and the @required / @sensitive / @type decorators from
|
|
4
|
+
* comment lines above each declaration in a committed `.env.schema`.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately dependency-free: this phase must stand alone even if the
|
|
7
|
+
* upstream tooling vanishes (the graduated-adoption ladder in ADR-122).
|
|
8
|
+
* Unknown decorators are preserved verbatim but unused; values in the
|
|
9
|
+
* schema file are IGNORED — a schema declares, it never supplies.
|
|
10
|
+
*
|
|
11
|
+
* First consumer: the post-task-up validation note. A project that
|
|
12
|
+
* declares `@required DATABASE_URL` with no value stored on this host gets
|
|
13
|
+
* one visible system note instead of a blank 500 hours later (the
|
|
14
|
+
* 2026-08-25 incident this phase exists to retire).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface EnvSchemaEntry {
|
|
18
|
+
name: string;
|
|
19
|
+
required: boolean;
|
|
20
|
+
sensitive: boolean;
|
|
21
|
+
type?: string;
|
|
22
|
+
/** Decorators Uai does not interpret, kept for diagnostics. */
|
|
23
|
+
otherDecorators: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
27
|
+
const DECORATOR = /@([A-Za-z][A-Za-z0-9]*)(?:=((?:[^\s(]+)?(?:\([^)]*\))?))?/g;
|
|
28
|
+
|
|
29
|
+
export function parseEnvSchema(content: string): EnvSchemaEntry[] {
|
|
30
|
+
const entries: EnvSchemaEntry[] = [];
|
|
31
|
+
let pending: {
|
|
32
|
+
required: boolean;
|
|
33
|
+
sensitive: boolean;
|
|
34
|
+
type?: string;
|
|
35
|
+
other: string[];
|
|
36
|
+
} = { required: false, sensitive: false, other: [] };
|
|
37
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
38
|
+
const line = rawLine.trim();
|
|
39
|
+
if (line.startsWith("#")) {
|
|
40
|
+
for (const match of line.matchAll(DECORATOR)) {
|
|
41
|
+
const name = match[1]!;
|
|
42
|
+
const value = match[2];
|
|
43
|
+
if (name === "required") pending.required = true;
|
|
44
|
+
else if (name === "sensitive") pending.sensitive = true;
|
|
45
|
+
else if (name === "type" && value) pending.type = value;
|
|
46
|
+
else pending.other.push(value ? `${name}=${value}` : name);
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (line === "") {
|
|
51
|
+
// A blank line ends a decorator block that never reached a key —
|
|
52
|
+
// env-spec decorators attach to the declaration directly below them.
|
|
53
|
+
pending = { required: false, sensitive: false, other: [] };
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const key = KEY_LINE.exec(line);
|
|
57
|
+
if (key) {
|
|
58
|
+
entries.push({
|
|
59
|
+
name: key[1]!,
|
|
60
|
+
required: pending.required,
|
|
61
|
+
sensitive: pending.sensitive,
|
|
62
|
+
...(pending.type !== undefined ? { type: pending.type } : {}),
|
|
63
|
+
otherDecorators: pending.other,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
pending = { required: false, sensitive: false, other: [] };
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Required keys the provided value set does not cover. Presence is a
|
|
72
|
+
* KEY-level fact — an empty string is still a provided value; deciding
|
|
73
|
+
* emptiness policy belongs to later phases, not this one. */
|
|
74
|
+
export function missingRequiredKeys(
|
|
75
|
+
entries: EnvSchemaEntry[],
|
|
76
|
+
providedKeys: ReadonlySet<string>,
|
|
77
|
+
): string[] {
|
|
78
|
+
return entries
|
|
79
|
+
.filter((entry) => entry.required && !providedKeys.has(entry.name))
|
|
80
|
+
.map((entry) => entry.name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Compose the one visible note for a task whose projects declare required
|
|
84
|
+
* keys this host has no values for. Null when everything is satisfied. */
|
|
85
|
+
export function envSchemaNote(
|
|
86
|
+
perProject: Array<{ slug: string; missing: string[] }>,
|
|
87
|
+
): string | null {
|
|
88
|
+
const affected = perProject.filter((project) => project.missing.length > 0);
|
|
89
|
+
if (affected.length === 0) return null;
|
|
90
|
+
const lines = affected.map(
|
|
91
|
+
(project) =>
|
|
92
|
+
`${project.slug}: ${project.missing.join(", ")}`,
|
|
93
|
+
);
|
|
94
|
+
return (
|
|
95
|
+
`This task's project${affected.length > 1 ? "s" : ""} declare required ` +
|
|
96
|
+
`env keys with no value set on this host — ` +
|
|
97
|
+
lines.join("; ") +
|
|
98
|
+
`. The app may fail until they are set on the project page; ` +
|
|
99
|
+
`Resume the task after setting them.`
|
|
100
|
+
);
|
|
101
|
+
}
|
package/lib/machine-exec.ts
CHANGED
|
@@ -42,6 +42,59 @@ export interface MachineExecOptions {
|
|
|
42
42
|
maxOutputBytes?: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** POSIX single-quote escaping. The ONLY safe way to carry an arbitrary
|
|
46
|
+
* argv element through the remote login shell ssh interposes: everything
|
|
47
|
+
* between single quotes is literal, and an embedded quote becomes '\''. */
|
|
48
|
+
export function shellQuote(value: string): string {
|
|
49
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the remote command for a TaskEnvironment-style request. SSH has no
|
|
54
|
+
* --workdir/--env/--user flags — cwd and env must be materialized INTO the
|
|
55
|
+
* remote command, each element quoted, and the user rides the ssh login
|
|
56
|
+
* instead (`-l`). Env values therefore appear in the machine-side process
|
|
57
|
+
* table; the machine is a single-task trust domain, and hardening to sshd
|
|
58
|
+
* SetEnv acceptlists is recorded ADR-121 follow-up work, not silently
|
|
59
|
+
* skipped.
|
|
60
|
+
*/
|
|
61
|
+
export function buildRemoteCommand(request: {
|
|
62
|
+
argv: readonly string[];
|
|
63
|
+
cwd?: string;
|
|
64
|
+
inheritEnv?: readonly string[];
|
|
65
|
+
env?: Readonly<Record<string, string>>;
|
|
66
|
+
detached?: boolean;
|
|
67
|
+
}): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
if (request.cwd) {
|
|
70
|
+
parts.push(`cd ${shellQuote(request.cwd)} &&`);
|
|
71
|
+
}
|
|
72
|
+
const env: string[] = [];
|
|
73
|
+
for (const name of request.inheritEnv ?? []) {
|
|
74
|
+
const value = process.env[name];
|
|
75
|
+
if (value !== undefined) env.push(`${name}=${value}`);
|
|
76
|
+
}
|
|
77
|
+
for (const [name, value] of Object.entries(request.env ?? {}).sort(
|
|
78
|
+
([left], [right]) => left.localeCompare(right),
|
|
79
|
+
)) {
|
|
80
|
+
env.push(`${name}=${value}`);
|
|
81
|
+
}
|
|
82
|
+
const envPrefix =
|
|
83
|
+
env.length > 0 ? `env ${env.map(shellQuote).join(" ")} ` : "";
|
|
84
|
+
const command = request.argv.map(shellQuote).join(" ");
|
|
85
|
+
if (request.detached) {
|
|
86
|
+
// Detached durable sessions manage their own transcript IO (runner.mjs);
|
|
87
|
+
// the launch just needs the process to survive this ssh connection:
|
|
88
|
+
// setsid detaches the controlling terminal, streams are severed, and the
|
|
89
|
+
// remote shell exits immediately with the launch verdict.
|
|
90
|
+
parts.push(`${envPrefix}setsid ${command} </dev/null >/dev/null 2>&1 &`);
|
|
91
|
+
parts.push("exit 0");
|
|
92
|
+
return parts.join(" ");
|
|
93
|
+
}
|
|
94
|
+
parts.push(`exec ${envPrefix}${command}`);
|
|
95
|
+
return parts.join(" ");
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
export function machineSshArgs(
|
|
46
99
|
target: MachineExecTarget,
|
|
47
100
|
argv: string[],
|
|
@@ -71,6 +124,42 @@ export function machineExec(
|
|
|
71
124
|
return runSsh(machineSshArgs(target, argv), opts);
|
|
72
125
|
}
|
|
73
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Full ssh argv for a TaskEnvironment-style request: base transport options,
|
|
129
|
+
* the request's user as the ssh login, and the quoted remote command as the
|
|
130
|
+
* single trailing argument. Shared by captured, streaming, and detached
|
|
131
|
+
* paths so quoting can never diverge between them.
|
|
132
|
+
*/
|
|
133
|
+
export function machineEnvironmentSshArgs(
|
|
134
|
+
target: Omit<MachineExecTarget, "user">,
|
|
135
|
+
request: {
|
|
136
|
+
argv: readonly string[];
|
|
137
|
+
cwd?: string;
|
|
138
|
+
user?: string;
|
|
139
|
+
inheritEnv?: readonly string[];
|
|
140
|
+
env?: Readonly<Record<string, string>>;
|
|
141
|
+
},
|
|
142
|
+
mode: "interactive" | "detached",
|
|
143
|
+
): string[] {
|
|
144
|
+
return [
|
|
145
|
+
"-o",
|
|
146
|
+
"BatchMode=yes",
|
|
147
|
+
"-o",
|
|
148
|
+
"StrictHostKeyChecking=accept-new",
|
|
149
|
+
"-o",
|
|
150
|
+
"ConnectTimeout=10",
|
|
151
|
+
"-i",
|
|
152
|
+
target.keyPath,
|
|
153
|
+
"-p",
|
|
154
|
+
String(target.port ?? 22),
|
|
155
|
+
"-l",
|
|
156
|
+
request.user ?? "node",
|
|
157
|
+
target.address,
|
|
158
|
+
"--",
|
|
159
|
+
buildRemoteCommand({ ...request, detached: mode === "detached" }),
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
74
163
|
function runSsh(
|
|
75
164
|
args: string[],
|
|
76
165
|
opts: MachineExecOptions,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: per-machine SSH identity. Minted host-side at provision, the
|
|
3
|
+
* public half rides the machine spec (user-data / entrypoint env), the
|
|
4
|
+
* private half lives in the task's control tree — task lifetime, deleted
|
|
5
|
+
* with the task dir, never shared between tasks. `ssh-keygen` is used
|
|
6
|
+
* rather than a JS implementation: the host already shells out for every
|
|
7
|
+
* other crypto-bearing operation, and OpenSSH's own keygen is the one
|
|
8
|
+
* implementation `ssh` is guaranteed to agree with.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
export interface MachineKeyPair {
|
|
16
|
+
privateKeyPath: string;
|
|
17
|
+
publicKey: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function machineKeyDir(taskControlDir: string): string {
|
|
21
|
+
return resolve(taskControlDir, "machine");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mint (or reuse) the task's machine keypair. Reuse is deliberate: a
|
|
26
|
+
* re-provision after a crash must produce the same identity the persisted
|
|
27
|
+
* locator's machine may already authorize.
|
|
28
|
+
*/
|
|
29
|
+
export async function ensureMachineKeyPair(
|
|
30
|
+
taskControlDir: string,
|
|
31
|
+
): Promise<MachineKeyPair> {
|
|
32
|
+
const dir = machineKeyDir(taskControlDir);
|
|
33
|
+
const privateKeyPath = resolve(dir, "id_ed25519");
|
|
34
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
35
|
+
if (existsSync(privateKeyPath) && existsSync(publicKeyPath)) {
|
|
36
|
+
return {
|
|
37
|
+
privateKeyPath,
|
|
38
|
+
publicKey: readFileSync(publicKeyPath, "utf8").trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
rmSync(privateKeyPath, { force: true });
|
|
43
|
+
rmSync(publicKeyPath, { force: true });
|
|
44
|
+
await new Promise<void>((resolveKeygen, reject) => {
|
|
45
|
+
const child = spawn(
|
|
46
|
+
"ssh-keygen",
|
|
47
|
+
["-t", "ed25519", "-N", "", "-C", "uai-machine", "-f", privateKeyPath],
|
|
48
|
+
{ stdio: ["ignore", "ignore", "pipe"] },
|
|
49
|
+
);
|
|
50
|
+
let stderr = "";
|
|
51
|
+
child.stderr.setEncoding("utf8");
|
|
52
|
+
child.stderr.on("data", (chunk: string) => {
|
|
53
|
+
stderr += chunk;
|
|
54
|
+
});
|
|
55
|
+
child.once("error", (error) => reject(error));
|
|
56
|
+
child.once("close", (code) => {
|
|
57
|
+
if (code === 0) resolveKeygen();
|
|
58
|
+
else reject(new Error(`ssh-keygen failed (${code}): ${stderr.trim()}`));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
privateKeyPath,
|
|
63
|
+
publicKey: readFileSync(publicKeyPath, "utf8").trim(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the `aws` MachineProvider — task machines as EC2 instances.
|
|
3
|
+
*
|
|
4
|
+
* Shell-out to the `aws` CLI with strict JSON parsing, matching every other
|
|
5
|
+
* backend integration (docker, container, ssh): no SDK dependency rides the
|
|
6
|
+
* OTA channel, and tests inject the runner.
|
|
7
|
+
*
|
|
8
|
+
* Identity model: EC2 invents instance ids, but the environment layer's
|
|
9
|
+
* locator-first invariant needs a DERIVED id. The machine id is therefore
|
|
10
|
+
* the logical `uai-machine-<taskId>`; the provider resolves it internally
|
|
11
|
+
* through the `com.uai.machine` tag on every call, and `--client-token`
|
|
12
|
+
* makes launch idempotent so a crashed provision retried cannot mint a
|
|
13
|
+
* second instance. Two live instances carrying one machine's tag is a
|
|
14
|
+
* confusing answer and maps to `unknown` — never to either instance.
|
|
15
|
+
*
|
|
16
|
+
* Absence: a SUCCESSFUL tag query with zero non-terminated matches proves
|
|
17
|
+
* absence (the filter asked AWS directly and AWS answered "none");
|
|
18
|
+
* `terminated` counts as absent — the id is unreachable and its disk is
|
|
19
|
+
* gone. A failed query proves nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
import type { DockerResult } from "./docker-exec";
|
|
25
|
+
import type {
|
|
26
|
+
MachineInfo,
|
|
27
|
+
MachineProvider,
|
|
28
|
+
MachineSpec,
|
|
29
|
+
MachineState,
|
|
30
|
+
} from "./machine-provider";
|
|
31
|
+
|
|
32
|
+
export const AWS_MACHINE_TAG = "com.uai.machine";
|
|
33
|
+
|
|
34
|
+
export interface AwsMachineConfig {
|
|
35
|
+
region: string;
|
|
36
|
+
/** AMI id used when the spec's image is not already an ami-*. */
|
|
37
|
+
subnetId?: string;
|
|
38
|
+
securityGroupId?: string;
|
|
39
|
+
iamInstanceProfileArn?: string;
|
|
40
|
+
/** Override the size ladder; the default picks the smallest Graviton
|
|
41
|
+
* type satisfying both cpu and memory. */
|
|
42
|
+
instanceType?: (spec: MachineSpec) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Runner = (args: string[]) => Promise<DockerResult>;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
48
|
+
|
|
49
|
+
function defaultRunner(region: string): Runner {
|
|
50
|
+
return (args) =>
|
|
51
|
+
new Promise<DockerResult>((resolve) => {
|
|
52
|
+
let stdout = "";
|
|
53
|
+
let stderr = "";
|
|
54
|
+
let settled = false;
|
|
55
|
+
const child = spawn(
|
|
56
|
+
"aws",
|
|
57
|
+
["--region", region, "--output", "json", ...args],
|
|
58
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
59
|
+
);
|
|
60
|
+
const settle = (status: number | null): void => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve({ status, stdout, stderr });
|
|
65
|
+
};
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
child.kill("SIGKILL");
|
|
68
|
+
const grace = setTimeout(() => settle(null), 2_000);
|
|
69
|
+
grace.unref?.();
|
|
70
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
71
|
+
timer.unref?.();
|
|
72
|
+
child.stdout.setEncoding("utf8");
|
|
73
|
+
child.stderr.setEncoding("utf8");
|
|
74
|
+
child.stdout.on("data", (chunk: string) => (stdout += chunk));
|
|
75
|
+
child.stderr.on("data", (chunk: string) => (stderr += chunk));
|
|
76
|
+
child.once("error", () => settle(null));
|
|
77
|
+
child.once("close", (code) => settle(code));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function awsMachineName(taskId: string): string {
|
|
82
|
+
return `uai-machine-${taskId.toLowerCase()}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Smallest Graviton type satisfying both dimensions. */
|
|
86
|
+
export function defaultInstanceType(spec: MachineSpec): string {
|
|
87
|
+
const ladder: Array<[string, number, number]> = [
|
|
88
|
+
["m7g.medium", 1, 4096],
|
|
89
|
+
["m7g.large", 2, 8192],
|
|
90
|
+
["m7g.xlarge", 4, 16384],
|
|
91
|
+
["m7g.2xlarge", 8, 32768],
|
|
92
|
+
["m7g.4xlarge", 16, 65536],
|
|
93
|
+
];
|
|
94
|
+
for (const [type, cpus, memoryMiB] of ladder) {
|
|
95
|
+
if (spec.cpus <= cpus && spec.memoryMiB <= memoryMiB) return type;
|
|
96
|
+
}
|
|
97
|
+
return "m7g.4xlarge";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** cloud-init: install the orchestrator key for node (and root for the
|
|
101
|
+
* provisioning path), creating the node user when the AMI lacks it. */
|
|
102
|
+
export function awsUserData(authorizedPublicKey: string): string {
|
|
103
|
+
const script = [
|
|
104
|
+
"#!/bin/sh",
|
|
105
|
+
"set -e",
|
|
106
|
+
"id node >/dev/null 2>&1 || useradd -m -u 1000 -s /bin/bash node",
|
|
107
|
+
"for account in node root; do",
|
|
108
|
+
' home=$(getent passwd "$account" | cut -d: -f6)',
|
|
109
|
+
' mkdir -p "$home/.ssh"',
|
|
110
|
+
` printf '%s\\n' '${authorizedPublicKey.replace(/'/g, "")}' > "$home/.ssh/authorized_keys"`,
|
|
111
|
+
' chown -R "$account" "$home/.ssh"',
|
|
112
|
+
' chmod 700 "$home/.ssh"',
|
|
113
|
+
' chmod 600 "$home/.ssh/authorized_keys"',
|
|
114
|
+
"done",
|
|
115
|
+
"",
|
|
116
|
+
].join("\n");
|
|
117
|
+
return Buffer.from(script, "utf8").toString("base64");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type LiveInstance = {
|
|
121
|
+
instanceId: string;
|
|
122
|
+
state: string;
|
|
123
|
+
privateIp: string | null;
|
|
124
|
+
taskLabel: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Strict reservations parse. Any malformed instance voids the answer. */
|
|
128
|
+
function parseReservations(stdout: string): LiveInstance[] | null {
|
|
129
|
+
let parsed: unknown;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(stdout);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const reservations = (parsed as { Reservations?: unknown }).Reservations;
|
|
136
|
+
if (!Array.isArray(reservations)) return null;
|
|
137
|
+
const instances: LiveInstance[] = [];
|
|
138
|
+
for (const reservation of reservations) {
|
|
139
|
+
const list = (reservation as { Instances?: unknown }).Instances;
|
|
140
|
+
if (!Array.isArray(list)) return null;
|
|
141
|
+
for (const instance of list) {
|
|
142
|
+
if (typeof instance !== "object" || instance === null) return null;
|
|
143
|
+
const record = instance as {
|
|
144
|
+
InstanceId?: unknown;
|
|
145
|
+
State?: { Name?: unknown };
|
|
146
|
+
PrivateIpAddress?: unknown;
|
|
147
|
+
Tags?: Array<{ Key?: unknown; Value?: unknown }>;
|
|
148
|
+
};
|
|
149
|
+
if (typeof record.InstanceId !== "string") return null;
|
|
150
|
+
const state = record.State?.Name;
|
|
151
|
+
if (typeof state !== "string") return null;
|
|
152
|
+
let taskLabel: string | null = null;
|
|
153
|
+
if (record.Tags !== undefined) {
|
|
154
|
+
if (!Array.isArray(record.Tags)) return null;
|
|
155
|
+
for (const tag of record.Tags) {
|
|
156
|
+
if (tag?.Key === AWS_MACHINE_TAG) {
|
|
157
|
+
if (typeof tag.Value !== "string") return null;
|
|
158
|
+
taskLabel = tag.Value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
instances.push({
|
|
163
|
+
instanceId: record.InstanceId,
|
|
164
|
+
state,
|
|
165
|
+
privateIp:
|
|
166
|
+
typeof record.PrivateIpAddress === "string"
|
|
167
|
+
? record.PrivateIpAddress
|
|
168
|
+
: null,
|
|
169
|
+
taskLabel,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return instances;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stateFromAws(state: string): MachineState {
|
|
177
|
+
switch (state) {
|
|
178
|
+
case "running":
|
|
179
|
+
return "running";
|
|
180
|
+
case "pending":
|
|
181
|
+
return "pending";
|
|
182
|
+
case "stopping":
|
|
183
|
+
case "stopped":
|
|
184
|
+
return "stopped";
|
|
185
|
+
case "shutting-down":
|
|
186
|
+
case "terminated":
|
|
187
|
+
return "absent";
|
|
188
|
+
default:
|
|
189
|
+
return "unknown";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function unknown(id: string, detail: string): MachineInfo {
|
|
194
|
+
return { id, taskLabel: null, state: "unknown", address: null, detail };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function createAwsMachineProvider(
|
|
198
|
+
config: AwsMachineConfig,
|
|
199
|
+
runner: Runner = defaultRunner(config.region),
|
|
200
|
+
): MachineProvider {
|
|
201
|
+
const taskIdOf = (machineId: string): string => {
|
|
202
|
+
if (!machineId.startsWith("uai-machine-")) {
|
|
203
|
+
throw new Error(`not a uai machine id: ${machineId}`);
|
|
204
|
+
}
|
|
205
|
+
return machineId.slice("uai-machine-".length);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Resolve the logical machine id to its live instances via the tag. */
|
|
209
|
+
async function resolveLive(
|
|
210
|
+
machineId: string,
|
|
211
|
+
): Promise<{ instances: LiveInstance[] } | { failure: string }> {
|
|
212
|
+
const res = await runner([
|
|
213
|
+
"ec2",
|
|
214
|
+
"describe-instances",
|
|
215
|
+
"--filters",
|
|
216
|
+
`Name=tag:${AWS_MACHINE_TAG},Values=${taskIdOf(machineId)}`,
|
|
217
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
218
|
+
]);
|
|
219
|
+
if (res.status !== 0) {
|
|
220
|
+
return {
|
|
221
|
+
failure: res.stderr.trim() || `describe exited ${res.status ?? "killed"}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const instances = parseReservations(res.stdout);
|
|
225
|
+
if (instances === null) {
|
|
226
|
+
return { failure: "describe answered with a confusing shape" };
|
|
227
|
+
}
|
|
228
|
+
return { instances };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function describe(machineId: string): Promise<MachineInfo> {
|
|
232
|
+
let resolved: Awaited<ReturnType<typeof resolveLive>>;
|
|
233
|
+
try {
|
|
234
|
+
resolved = await resolveLive(machineId);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return unknown(
|
|
237
|
+
machineId,
|
|
238
|
+
error instanceof Error ? error.message : String(error),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if ("failure" in resolved) return unknown(machineId, resolved.failure);
|
|
242
|
+
if (resolved.instances.length === 0) {
|
|
243
|
+
// A successful tag query answering "none live" IS the absence proof.
|
|
244
|
+
return { id: machineId, taskLabel: null, state: "absent", address: null };
|
|
245
|
+
}
|
|
246
|
+
if (resolved.instances.length > 1) {
|
|
247
|
+
return unknown(
|
|
248
|
+
machineId,
|
|
249
|
+
`multiple live instances carry this machine's tag`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const instance = resolved.instances[0]!;
|
|
253
|
+
const state = stateFromAws(instance.state);
|
|
254
|
+
return {
|
|
255
|
+
id: machineId,
|
|
256
|
+
taskLabel: instance.taskLabel,
|
|
257
|
+
state,
|
|
258
|
+
address: state === "running" ? instance.privateIp : null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
kind: "aws",
|
|
264
|
+
|
|
265
|
+
async launch(spec: MachineSpec): Promise<MachineInfo> {
|
|
266
|
+
const machineId = awsMachineName(spec.taskId);
|
|
267
|
+
const type = (config.instanceType ?? defaultInstanceType)(spec);
|
|
268
|
+
const args = [
|
|
269
|
+
"ec2",
|
|
270
|
+
"run-instances",
|
|
271
|
+
"--image-id",
|
|
272
|
+
spec.image,
|
|
273
|
+
"--instance-type",
|
|
274
|
+
type,
|
|
275
|
+
"--count",
|
|
276
|
+
"1",
|
|
277
|
+
// Idempotency across crashed/retried provisions: the same token can
|
|
278
|
+
// never mint a second instance.
|
|
279
|
+
"--client-token",
|
|
280
|
+
machineId,
|
|
281
|
+
"--tag-specifications",
|
|
282
|
+
`ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
|
|
283
|
+
...(spec.authorizedPublicKey
|
|
284
|
+
? ["--user-data", awsUserData(spec.authorizedPublicKey)]
|
|
285
|
+
: []),
|
|
286
|
+
...(config.subnetId ? ["--subnet-id", config.subnetId] : []),
|
|
287
|
+
...(config.securityGroupId
|
|
288
|
+
? ["--security-group-ids", config.securityGroupId]
|
|
289
|
+
: []),
|
|
290
|
+
...(config.iamInstanceProfileArn
|
|
291
|
+
? ["--iam-instance-profile", `Arn=${config.iamInstanceProfileArn}`]
|
|
292
|
+
: []),
|
|
293
|
+
];
|
|
294
|
+
const res = await runner(args);
|
|
295
|
+
if (res.status !== 0) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`machine launch failed for ${machineId}: ${
|
|
298
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
299
|
+
}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const info = await describe(machineId);
|
|
303
|
+
if (info.state === "absent" || info.state === "unknown") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`machine ${machineId} launched but could not be described (${info.state}${
|
|
306
|
+
info.detail ? `: ${info.detail}` : ""
|
|
307
|
+
})`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return info;
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async stop(machineId: string): Promise<void> {
|
|
314
|
+
const instance = await requireSingleLive(machineId);
|
|
315
|
+
const res = await runner([
|
|
316
|
+
"ec2",
|
|
317
|
+
"stop-instances",
|
|
318
|
+
"--instance-ids",
|
|
319
|
+
instance.instanceId,
|
|
320
|
+
]);
|
|
321
|
+
if (res.status !== 0) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`machine stop failed for ${machineId}: ${
|
|
324
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
325
|
+
}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
async start(machineId: string): Promise<MachineInfo> {
|
|
331
|
+
const instance = await requireSingleLive(machineId);
|
|
332
|
+
const res = await runner([
|
|
333
|
+
"ec2",
|
|
334
|
+
"start-instances",
|
|
335
|
+
"--instance-ids",
|
|
336
|
+
instance.instanceId,
|
|
337
|
+
]);
|
|
338
|
+
if (res.status !== 0) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`machine start failed for ${machineId}: ${
|
|
341
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
342
|
+
}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return describe(machineId);
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async terminate(machineId: string): Promise<void> {
|
|
349
|
+
const resolved = await resolveLive(machineId);
|
|
350
|
+
if ("failure" in resolved) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`machine ${machineId} could not be resolved for terminate: ${resolved.failure}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
// Terminate EVERY live instance carrying the tag: a duplicate from a
|
|
356
|
+
// pathological double-launch must not survive its sibling's teardown.
|
|
357
|
+
for (const instance of resolved.instances) {
|
|
358
|
+
const res = await runner([
|
|
359
|
+
"ec2",
|
|
360
|
+
"terminate-instances",
|
|
361
|
+
"--instance-ids",
|
|
362
|
+
instance.instanceId,
|
|
363
|
+
]);
|
|
364
|
+
if (res.status !== 0) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`machine terminate failed for ${machineId} (${instance.instanceId}): ${
|
|
367
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
368
|
+
}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Absence is not best-effort: poll until the tag query answers "none
|
|
373
|
+
// live" (shutting-down/terminated fall out of the live filter).
|
|
374
|
+
const deadline = Date.now() + 120_000;
|
|
375
|
+
for (;;) {
|
|
376
|
+
const after = await describe(machineId);
|
|
377
|
+
if (after.state === "absent") return;
|
|
378
|
+
if (Date.now() >= deadline) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`machine ${machineId} could not be proven absent after terminate (${after.state}${
|
|
381
|
+
after.detail ? `: ${after.detail}` : ""
|
|
382
|
+
})`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5_000));
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
describe,
|
|
390
|
+
|
|
391
|
+
async list(): Promise<MachineInfo[]> {
|
|
392
|
+
const res = await runner([
|
|
393
|
+
"ec2",
|
|
394
|
+
"describe-instances",
|
|
395
|
+
"--filters",
|
|
396
|
+
`Name=tag-key,Values=${AWS_MACHINE_TAG}`,
|
|
397
|
+
"Name=instance-state-name,Values=pending,running,stopping,stopped",
|
|
398
|
+
]);
|
|
399
|
+
if (res.status !== 0) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`machine listing failed: ${
|
|
402
|
+
res.stderr.trim() || `exit ${res.status ?? "killed"}`
|
|
403
|
+
}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const instances = parseReservations(res.stdout);
|
|
407
|
+
if (instances === null) {
|
|
408
|
+
throw new Error("machine inventory was unparseable");
|
|
409
|
+
}
|
|
410
|
+
return instances.map((instance) => {
|
|
411
|
+
const state = stateFromAws(instance.state);
|
|
412
|
+
return {
|
|
413
|
+
id:
|
|
414
|
+
instance.taskLabel !== null
|
|
415
|
+
? awsMachineName(instance.taskLabel)
|
|
416
|
+
: instance.instanceId,
|
|
417
|
+
taskLabel: instance.taskLabel,
|
|
418
|
+
state,
|
|
419
|
+
address: state === "running" ? instance.privateIp : null,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
async function requireSingleLive(machineId: string): Promise<LiveInstance> {
|
|
426
|
+
const resolved = await resolveLive(machineId);
|
|
427
|
+
if ("failure" in resolved) {
|
|
428
|
+
throw new Error(`machine ${machineId} could not be resolved: ${resolved.failure}`);
|
|
429
|
+
}
|
|
430
|
+
if (resolved.instances.length !== 1) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`machine ${machineId} resolution expected one live instance, found ${resolved.instances.length}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return resolved.instances[0]!;
|
|
436
|
+
}
|
|
437
|
+
}
|