mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
201 lines
5.9 KiB
JavaScript
201 lines
5.9 KiB
JavaScript
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { join } from "node:path";
|
|
import { connect, account, region } from "./client.mjs";
|
|
import { infra, name } from "./templates.mjs";
|
|
import { guard } from "./guard.mjs";
|
|
const root = fileURLToPath(new URL("../../.ow-aws/", import.meta.url));
|
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
const { aws } = await connect(),
|
|
action = process.argv[2] || "status";
|
|
const path = join(root, "plan.json");
|
|
const stack = async (n) => {
|
|
try {
|
|
return (await aws(["cloudformation", "describe-stacks", "--stack-name", n]))
|
|
.Stacks[0];
|
|
} catch (e) {
|
|
if (String(e.stderr).includes("does not exist")) return null;
|
|
throw e;
|
|
}
|
|
};
|
|
const assertOwned = (s) => {
|
|
if (s && !s.Tags?.some((t) => t.Key === "Project" && t.Value === name))
|
|
throw Error("Refusing unowned stack");
|
|
};
|
|
if (action === "plan") {
|
|
let plan;
|
|
try {
|
|
plan = JSON.parse(await readFile(path, "utf8"));
|
|
} catch (e) {
|
|
if (e.code !== "ENOENT") throw e;
|
|
}
|
|
if (!plan) {
|
|
const operatorIp = (
|
|
await (
|
|
await fetch("https://checkip.amazonaws.com", {
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
).text()
|
|
).trim();
|
|
const addon = async (name) => {
|
|
const d = await aws([
|
|
"eks",
|
|
"describe-addon-versions",
|
|
"--kubernetes-version",
|
|
"1.36",
|
|
"--addon-name",
|
|
name,
|
|
]);
|
|
const versions = d.addons[0]?.addonVersions || [];
|
|
const v = versions.find(
|
|
(v) =>
|
|
v.architecture.includes("arm64") &&
|
|
v.compatibilities.some((c) => c.defaultVersion),
|
|
);
|
|
if (!v) throw Error("No default ARM64 add-on " + name);
|
|
return v.addonVersion;
|
|
};
|
|
const pg = await aws([
|
|
"rds",
|
|
"describe-db-engine-versions",
|
|
"--engine",
|
|
"postgres",
|
|
"--engine-version",
|
|
"16",
|
|
"--query",
|
|
"DBEngineVersions[].EngineVersion",
|
|
]);
|
|
const pgVersion = pg
|
|
.filter((v) => /^16\.\d+$/.test(v))
|
|
.sort((a, b) => Number(b.split(".")[1]) - Number(a.split(".")[1]))[0];
|
|
if (!pgVersion) throw Error("No PostgreSQL 16 version");
|
|
plan = {
|
|
account,
|
|
region,
|
|
name,
|
|
created: new Date().toISOString(),
|
|
expiry: new Date(Date.now() + 24 * 3600000).toISOString(),
|
|
budget: 100,
|
|
operatorCidr: operatorIp + "/32",
|
|
cniVersion: await addon("vpc-cni"),
|
|
podIdentityVersion: await addon("eks-pod-identity-agent"),
|
|
ebsVersion: await addon("aws-ebs-csi-driver"),
|
|
pgVersion,
|
|
};
|
|
await writeFile(path, JSON.stringify(plan, null, 2), {
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
}
|
|
const code = await readFile(new URL("cleanup.cjs", import.meta.url), "utf8");
|
|
for (const [file, value] of [
|
|
["infra.json", infra(plan)],
|
|
["guard.json", guard({ ...plan, code })],
|
|
]) {
|
|
const target = join(root, file);
|
|
await writeFile(target, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
await aws([
|
|
"cloudformation",
|
|
"validate-template",
|
|
"--template-body",
|
|
"file://" + target,
|
|
]);
|
|
}
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
...plan,
|
|
operatorCidr: "[single current operator IPv4 /32]",
|
|
validated: true,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} else if (["guard", "launch"].includes(action)) {
|
|
const plan = JSON.parse(await readFile(path, "utf8"));
|
|
if (Date.parse(plan.expiry) - Date.now() < 2 * 3600000)
|
|
throw Error("Too little time before immutable experiment deadline");
|
|
if (
|
|
plan.account !== account ||
|
|
plan.region !== region ||
|
|
plan.budget !== 100 ||
|
|
plan.name !== name
|
|
)
|
|
throw Error("Experiment scope mismatch");
|
|
const target = action === "guard" ? name + "-guard" : name;
|
|
const current = await stack(target);
|
|
assertOwned(current);
|
|
if (current) {
|
|
console.log(JSON.stringify({ stack: target, status: current.StackStatus }));
|
|
process.exit(0);
|
|
}
|
|
if (action === "launch") {
|
|
const guardStack = await stack(name + "-guard");
|
|
assertOwned(guardStack);
|
|
if (guardStack?.StackStatus !== "CREATE_COMPLETE")
|
|
throw Error("Cloud-side expiry guard must be ready first");
|
|
const fn = await aws([
|
|
"lambda",
|
|
"get-function-configuration",
|
|
"--function-name",
|
|
name + "-cleanup",
|
|
]);
|
|
if (
|
|
fn.Environment?.Variables?.EXPIRY !== plan.expiry ||
|
|
fn.State !== "Active"
|
|
)
|
|
throw Error("Cleanup expiry/function mismatch");
|
|
const rule = await aws([
|
|
"events",
|
|
"describe-rule",
|
|
"--name",
|
|
name + "-cleanup",
|
|
]);
|
|
if (rule.State !== "ENABLED") throw Error("Cleanup schedule not enabled");
|
|
}
|
|
const result = await aws([
|
|
"cloudformation",
|
|
"create-stack",
|
|
"--stack-name",
|
|
target,
|
|
"--template-body",
|
|
"file://" + join(root, action === "guard" ? "guard.json" : "infra.json"),
|
|
"--capabilities",
|
|
"CAPABILITY_NAMED_IAM",
|
|
"--tags",
|
|
`Key=Project,Value=${name}`,
|
|
`Key=ExpiresAt,Value=${plan.expiry}`,
|
|
"--on-failure",
|
|
"ROLLBACK",
|
|
]);
|
|
console.log(
|
|
JSON.stringify({ stack: target, id: result.StackId, expiry: plan.expiry }),
|
|
);
|
|
} else if (action === "status") {
|
|
for (const n of [name + "-guard", name]) {
|
|
const s = await stack(n);
|
|
assertOwned(s);
|
|
console.log(
|
|
JSON.stringify({ stack: n, status: s?.StackStatus || "absent" }),
|
|
);
|
|
if (s) {
|
|
const events = await aws([
|
|
"cloudformation",
|
|
"describe-stack-events",
|
|
"--stack-name",
|
|
n,
|
|
"--query",
|
|
"StackEvents[:8].{resource:LogicalResourceId,status:ResourceStatus,reason:ResourceStatusReason}",
|
|
]);
|
|
console.log(JSON.stringify(events));
|
|
if (s.Outputs)
|
|
await writeFile(
|
|
join(root, n + "-outputs.json"),
|
|
JSON.stringify(s.Outputs, null, 2),
|
|
{ mode: 0o600 },
|
|
);
|
|
}
|
|
}
|
|
} else throw Error("Use plan | guard | launch | status");
|