305 lines
8.7 KiB
JavaScript
305 lines
8.7 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const sourceScriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = process.env.QUIXOS_REPO_ROOT ?? path.resolve(sourceScriptDir, "../..");
|
|
const amiBuilderDir = process.env.QUIXOS_AMI_BUILDER_DIR ?? path.resolve(repoRoot, "tofu/ami-builder");
|
|
const centralDir = path.resolve(amiBuilderDir, "../central");
|
|
|
|
const usage = `Usage: nix run ./tofu/ami-builder#ami-artifacts -- <list|delete> [options]
|
|
|
|
Commands:
|
|
list List published AMI artifacts and deployment references
|
|
delete --ami-id <ami> Deregister an AMI and delete its root and /nix snapshots
|
|
|
|
Options:
|
|
--ami-id <ami> AMI to delete
|
|
--aws-region <region> AWS region (default: AWS_REGION or us-west-2)
|
|
--name <name> AMI project tag (default: nixos-zfs-ec2)
|
|
--architecture <arm64|x86_64> Filter by architecture
|
|
--runtime-state-bucket <name> S3 bucket with qx-deploy registry objects
|
|
--force Delete even when registry deployments reference the AMI
|
|
-h, --help Show this help
|
|
`;
|
|
|
|
const run = (command, args, options = {}) => {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd ?? repoRoot,
|
|
env: options.env ?? process.env,
|
|
encoding: "utf8",
|
|
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
});
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (!options.allowFailure && result.status !== 0) {
|
|
const suffix = options.capture && result.stderr ? `\n${result.stderr.trimEnd()}` : "";
|
|
throw new Error(`Command failed: ${command} ${args.join(" ")}${suffix}`);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const capture = (command, args, options = {}) =>
|
|
run(command, args, { ...options, capture: true }).stdout.trim();
|
|
|
|
const captureMaybe = (command, args, options = {}) =>
|
|
run(command, args, { ...options, capture: true, allowFailure: true });
|
|
|
|
const requireCommand = (command) => {
|
|
const result = captureMaybe(command, ["--version"]);
|
|
if (result.status !== 0) {
|
|
throw new Error(`Missing required command: ${command}`);
|
|
}
|
|
};
|
|
|
|
const parseArgs = (argv) => {
|
|
const command = argv[0] ?? "";
|
|
if (!command || command === "-h" || command === "--help") {
|
|
console.error(usage.trimEnd());
|
|
process.exit(command ? 0 : 1);
|
|
}
|
|
if (command !== "list" && command !== "delete") {
|
|
throw new Error(`Unknown command: ${command}\n\n${usage}`);
|
|
}
|
|
|
|
const state = {
|
|
command,
|
|
amiId: "",
|
|
awsRegion: process.env.AWS_REGION ?? "us-west-2",
|
|
name: "nixos-zfs-ec2",
|
|
architecture: "",
|
|
runtimeStateBucket: process.env.QUIXOS_RUNTIME_STATE_BUCKET ?? "",
|
|
force: false,
|
|
};
|
|
|
|
const takeValue = (args, index, flag) => {
|
|
const value = args[index + 1];
|
|
if (!value) {
|
|
throw new Error(`Missing value for ${flag}`);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
for (let i = 1; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
switch (arg) {
|
|
case "--ami-id":
|
|
state.amiId = takeValue(argv, i, arg);
|
|
i += 1;
|
|
break;
|
|
case "--aws-region":
|
|
state.awsRegion = takeValue(argv, i, arg);
|
|
i += 1;
|
|
break;
|
|
case "--name":
|
|
state.name = takeValue(argv, i, arg);
|
|
i += 1;
|
|
break;
|
|
case "--architecture":
|
|
state.architecture = takeValue(argv, i, arg);
|
|
i += 1;
|
|
break;
|
|
case "--runtime-state-bucket":
|
|
state.runtimeStateBucket = takeValue(argv, i, arg);
|
|
i += 1;
|
|
break;
|
|
case "--force":
|
|
state.force = true;
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
console.error(usage.trimEnd());
|
|
process.exit(0);
|
|
default:
|
|
throw new Error(`Unknown argument: ${arg}\n\n${usage}`);
|
|
}
|
|
}
|
|
|
|
return state;
|
|
};
|
|
|
|
const resolveRuntimeStateBucket = (state) => {
|
|
if (state.runtimeStateBucket) {
|
|
return;
|
|
}
|
|
if (!fs.existsSync(centralDir)) {
|
|
return;
|
|
}
|
|
const result = captureMaybe("tofu", ["-chdir=" + centralDir, "output", "-raw", "runtime_state_bucket_name"]);
|
|
if (result.status === 0 && /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(result.stdout.trim())) {
|
|
state.runtimeStateBucket = result.stdout.trim();
|
|
}
|
|
};
|
|
|
|
const awsJson = (args) => {
|
|
const text = capture("aws", [...args, "--output", "json"]);
|
|
return text ? JSON.parse(text) : null;
|
|
};
|
|
|
|
const amiTags = (image) => Object.fromEntries((image.Tags ?? []).map((tag) => [tag.Key, tag.Value]));
|
|
|
|
const listImages = (state) => {
|
|
const filters = [
|
|
"Name=state,Values=available",
|
|
`Name=tag:Project,Values=${state.name}`,
|
|
];
|
|
if (state.architecture) {
|
|
filters.push(`Name=tag:Architecture,Values=${state.architecture}`);
|
|
}
|
|
|
|
const result = awsJson([
|
|
"ec2",
|
|
"describe-images",
|
|
"--region",
|
|
state.awsRegion,
|
|
"--owners",
|
|
"self",
|
|
"--filters",
|
|
...filters,
|
|
]);
|
|
|
|
return (result?.Images ?? [])
|
|
.filter((image) => {
|
|
const tags = amiTags(image);
|
|
return tags.Role === "published-ami" || Boolean(tags.RootSnapshotId || tags.NixSnapshotId);
|
|
})
|
|
.sort((a, b) => String(b.CreationDate ?? "").localeCompare(String(a.CreationDate ?? "")));
|
|
};
|
|
|
|
const deploymentRegistry = (state) => {
|
|
if (!state.runtimeStateBucket) {
|
|
return [];
|
|
}
|
|
|
|
const listed = captureMaybe("aws", [
|
|
"s3api",
|
|
"list-objects-v2",
|
|
"--region",
|
|
state.awsRegion,
|
|
"--bucket",
|
|
state.runtimeStateBucket,
|
|
"--prefix",
|
|
"registry/deployments/",
|
|
"--query",
|
|
"Contents[].Key",
|
|
"--output",
|
|
"text",
|
|
]);
|
|
if (listed.status !== 0 || !listed.stdout.trim() || listed.stdout.trim() === "None") {
|
|
return [];
|
|
}
|
|
|
|
const records = [];
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quixos-ami-registry."));
|
|
try {
|
|
for (const key of listed.stdout.trim().split(/\s+/)) {
|
|
if (!key.endsWith(".json")) {
|
|
continue;
|
|
}
|
|
const result = captureMaybe("aws", ["s3", "cp", `s3://${state.runtimeStateBucket}/${key}`, "-"]);
|
|
if (result.status === 0 && result.stdout.trim()) {
|
|
records.push(JSON.parse(result.stdout));
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
return records;
|
|
};
|
|
|
|
const listArtifacts = (state) => {
|
|
const registry = deploymentRegistry(state);
|
|
const references = new Map();
|
|
for (const record of registry) {
|
|
const amiId = record.config?.amiId;
|
|
if (!amiId) {
|
|
continue;
|
|
}
|
|
const entries = references.get(amiId) ?? [];
|
|
entries.push(record.id);
|
|
references.set(amiId, entries);
|
|
}
|
|
|
|
for (const image of listImages(state)) {
|
|
const tags = amiTags(image);
|
|
const refs = references.get(image.ImageId)?.join(",") ?? "-";
|
|
console.log([
|
|
image.ImageId,
|
|
image.Name ?? "",
|
|
image.CreationDate ?? "",
|
|
tags.Architecture ?? image.Architecture ?? "",
|
|
tags.BuilderGitRev ?? "",
|
|
tags.RootSnapshotId ?? "",
|
|
tags.NixSnapshotId ?? "",
|
|
refs,
|
|
].join("\t"));
|
|
}
|
|
};
|
|
|
|
const deleteArtifact = (state) => {
|
|
if (!state.amiId) {
|
|
throw new Error("Missing --ami-id");
|
|
}
|
|
|
|
const registry = deploymentRegistry(state);
|
|
const refs = registry
|
|
.filter((record) => record.config?.amiId === state.amiId)
|
|
.map((record) => record.id);
|
|
if (refs.length > 0 && !state.force) {
|
|
throw new Error(`Refusing to delete ${state.amiId}; referenced by deployments: ${refs.join(", ")}\nPass --force after those deployments are destroyed or intentionally orphaned.`);
|
|
}
|
|
|
|
const result = awsJson([
|
|
"ec2",
|
|
"describe-images",
|
|
"--region",
|
|
state.awsRegion,
|
|
"--owners",
|
|
"self",
|
|
"--image-ids",
|
|
state.amiId,
|
|
]);
|
|
const image = result?.Images?.[0];
|
|
if (!image) {
|
|
throw new Error(`AMI not found: ${state.amiId}`);
|
|
}
|
|
const tags = amiTags(image);
|
|
const rootSnapshot = tags.RootSnapshotId ?? "";
|
|
const nixSnapshot = tags.NixSnapshotId ?? "";
|
|
|
|
console.error(`Deregistering AMI: ${state.amiId}`);
|
|
run("aws", ["ec2", "deregister-image", "--region", state.awsRegion, "--image-id", state.amiId]);
|
|
|
|
if (rootSnapshot) {
|
|
console.error(`Deleting root snapshot: ${rootSnapshot}`);
|
|
run("aws", ["ec2", "delete-snapshot", "--region", state.awsRegion, "--snapshot-id", rootSnapshot]);
|
|
}
|
|
if (nixSnapshot) {
|
|
console.error(`Deleting /nix snapshot: ${nixSnapshot}`);
|
|
run("aws", ["ec2", "delete-snapshot", "--region", state.awsRegion, "--snapshot-id", nixSnapshot]);
|
|
}
|
|
};
|
|
|
|
const main = () => {
|
|
const state = parseArgs(process.argv.slice(2));
|
|
requireCommand("aws");
|
|
resolveRuntimeStateBucket(state);
|
|
|
|
if (state.command === "list") {
|
|
listArtifacts(state);
|
|
return;
|
|
}
|
|
deleteArtifact(state);
|
|
};
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|