Add AMI artifact management tool
This commit is contained in:
@@ -60,3 +60,20 @@ The final AMI is tagged with:
|
|||||||
|
|
||||||
Do not deregister the AMI or delete either snapshot while runtime deployments
|
Do not deregister the AMI or delete either snapshot while runtime deployments
|
||||||
use it.
|
use it.
|
||||||
|
|
||||||
|
## Artifact Cleanup
|
||||||
|
|
||||||
|
List published AMIs and cross-reference qx-deploy runtime registry records:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run ./tofu/ami-builder#ami-artifacts -- list
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete an unused AMI and its root and `/nix` snapshots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run ./tofu/ami-builder#ami-artifacts -- delete --ami-id ami-...
|
||||||
|
```
|
||||||
|
|
||||||
|
The delete command refuses to delete AMIs referenced by runtime deployment
|
||||||
|
registry records unless you pass `--force`.
|
||||||
|
|||||||
@@ -26,11 +26,65 @@
|
|||||||
|
|
||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
config = self.nixosConfigurations."baseline-${system}".config;
|
config = self.nixosConfigurations."baseline-${system}".config;
|
||||||
|
ami-artifacts-script = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "ami-artifacts-script";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./manage-amis.ts;
|
||||||
|
nativeBuildInputs = [ pkgs.esbuild ];
|
||||||
|
dontUnpack = true;
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
esbuild "$src" \
|
||||||
|
--bundle \
|
||||||
|
--platform=node \
|
||||||
|
--target=node24 \
|
||||||
|
--format=esm \
|
||||||
|
--outfile=manage-amis.mjs
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
install -Dm755 manage-amis.mjs "$out/libexec/ami-artifacts/manage-amis.mjs"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ami-artifacts = pkgs.writeShellApplication {
|
||||||
|
name = "ami-artifacts";
|
||||||
|
runtimeInputs = [
|
||||||
|
pkgs.awscli2
|
||||||
|
pkgs.git
|
||||||
|
pkgs.nodejs_24
|
||||||
|
pkgs.opentofu
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
git_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
|
if [ -d "$git_root/tofu/ami-builder" ]; then
|
||||||
|
repo_root="$git_root"
|
||||||
|
ami_builder_dir="$git_root/tofu/ami-builder"
|
||||||
|
else
|
||||||
|
repo_root="$(cd "$git_root/../.." && pwd)"
|
||||||
|
ami_builder_dir="$git_root"
|
||||||
|
fi
|
||||||
|
export QUIXOS_REPO_ROOT="$repo_root"
|
||||||
|
export QUIXOS_AMI_BUILDER_DIR="$ami_builder_dir"
|
||||||
|
exec ${pkgs.nodejs_24}/bin/node ${ami-artifacts-script}/libexec/ami-artifacts/manage-amis.mjs "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
rootImage = config.system.build.images.amazon;
|
rootImage = config.system.build.images.amazon;
|
||||||
systemClosure = config.system.build.toplevel;
|
systemClosure = config.system.build.toplevel;
|
||||||
|
ami-artifacts = ami-artifacts;
|
||||||
|
default = ami-artifacts;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
apps = forAllSystems (system: {
|
||||||
|
ami-artifacts = {
|
||||||
|
type = "app";
|
||||||
|
program = "${self.packages.${system}.ami-artifacts}/bin/ami-artifacts";
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
#!/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);
|
||||||
|
}
|
||||||
@@ -261,6 +261,7 @@ aws ec2 create-tags \
|
|||||||
--tags \
|
--tags \
|
||||||
"Key=Name,Value=${ami_name}" \
|
"Key=Name,Value=${ami_name}" \
|
||||||
"Key=Project,Value=${name}" \
|
"Key=Project,Value=${name}" \
|
||||||
|
"Key=Role,Value=published-ami" \
|
||||||
"Key=Architecture,Value=${architecture}" \
|
"Key=Architecture,Value=${architecture}" \
|
||||||
"Key=System,Value=${system}" \
|
"Key=System,Value=${system}" \
|
||||||
"Key=InputHash,Value=${input_hash}" \
|
"Key=InputHash,Value=${input_hash}" \
|
||||||
|
|||||||
Reference in New Issue
Block a user