commit b2e4b7fcaad5c7c9bd5c09dc221528b32e1d1e22 Author: Timothy J. Aveni Date: Sat Jun 20 11:28:38 2026 -0700 Add NixOS ZFS EC2 AMI builder diff --git a/README.md b/README.md new file mode 100644 index 0000000..1df1848 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# NixOS ZFS EC2 AMI Builder + +This project publishes a matched EC2 AMI artifact: + +- root volume: bootable NixOS EC2 image +- secondary volume: preseeded ZFS pool mounted at `/nix` + +The root snapshot and `/nix` snapshot are registered together in one AMI block +device mapping. Runtime deployments should consume the AMI ID directly and +should not independently choose a `/nix` snapshot. + +The image is intentionally project-agnostic. It provides NixOS, ZFS, SSM, +flakes, and systemd ordering so the Nix daemon waits for `/nix`. + +## Publish + +The smallest supported flow is: + +```bash +cd tofu/ami-builder +nix build .#packages.aarch64-linux.rootImage +./publish-ami.sh --bucket s3://my-vm-import-bucket +``` + +`publish-ami.sh` builds/imports the declarative NixOS root image, boots a +temporary seeder instance once to create/export the ZFS `/nix` volume, creates +the final AMI from the stopped instance, and writes `artifact.json`. + +The OpenTofu project in this directory owns only the final AMI registration +metadata. Its state is separate from any runtime Quixos deployment state. + +## Artifact Metadata + +The final AMI is tagged with: + +- `Architecture` +- `InputHash` +- `BuilderGitRev` +- `FlakeLockRev` +- `RootSnapshotId` +- `NixSnapshotId` +- `NixosSystemClosure` + +Do not deregister the AMI or delete either snapshot while runtime deployments +use it. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0ee7307 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3a010d6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "Project-agnostic NixOS EC2 AMI with ZFS /nix support"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs, ... }: + let + supportedSystems = [ "aarch64-linux" "x86_64-linux" ]; + forAllSystems = f: + nixpkgs.lib.genAttrs supportedSystems (system: f system); + mkConfig = system: + nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + ./nixos/baseline.nix + ]; + }; + in + { + nixosConfigurations = { + baseline-aarch64-linux = mkConfig "aarch64-linux"; + baseline-x86_64-linux = mkConfig "x86_64-linux"; + }; + + packages = forAllSystems (system: + let + config = self.nixosConfigurations."baseline-${system}".config; + in + { + rootImage = config.system.build.images.amazon; + systemClosure = config.system.build.toplevel; + }); + }; +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..c0aaac7 --- /dev/null +++ b/main.tf @@ -0,0 +1,45 @@ +locals { + resource_name = "${var.name}-${var.architecture}-${substr(var.input_hash, 0, 12)}" + tags = { + Name = local.resource_name + Project = var.name + Architecture = var.architecture + InputHash = var.input_hash + BuilderGitRev = var.builder_git_rev + FlakeLockRev = var.flake_lock_rev + RootSnapshotId = var.root_snapshot_id + NixSnapshotId = var.nix_snapshot_id + NixosSystemClosure = var.nixos_system_closure + } +} + +resource "aws_ami" "matched" { + name = local.resource_name + description = "NixOS EC2 AMI with matched root and ZFS /nix snapshots" + architecture = var.architecture + virtualization_type = "hvm" + root_device_name = var.root_device_name + ena_support = true + + ebs_block_device { + device_name = var.root_device_name + snapshot_id = var.root_snapshot_id + volume_size = var.root_volume_size_gb + volume_type = "gp3" + delete_on_termination = true + } + + ebs_block_device { + device_name = var.nix_device_name + snapshot_id = var.nix_snapshot_id + volume_size = var.nix_volume_size_gb + volume_type = "gp3" + delete_on_termination = true + } + + tags = local.tags + + lifecycle { + prevent_destroy = true + } +} diff --git a/nixos/baseline.nix b/nixos/baseline.nix new file mode 100644 index 0000000..e994de8 --- /dev/null +++ b/nixos/baseline.nix @@ -0,0 +1,49 @@ +{ config, lib, modulesPath, pkgs, ... }: + +let + nixPool = "nixos-zfs-nix"; +in +{ + imports = [ + "${modulesPath}/virtualisation/amazon-image.nix" + ]; + + networking.hostName = "nixos-zfs-ec2"; + networking.hostId = "6e69787a"; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + environment.systemPackages = [ + pkgs.amazon-ssm-agent + pkgs.git + pkgs.zfs + ]; + + services.amazon-ssm-agent.enable = true; + + boot.supportedFilesystems = [ "zfs" ]; + boot.zfs.extraPools = [ nixPool ]; + boot.zfs.forceImportRoot = false; + + fileSystems."/nix" = { + device = "${nixPool}/nix"; + fsType = "zfs"; + neededForBoot = true; + options = [ + "nofail" + "x-systemd.device-timeout=30s" + ]; + }; + + systemd.sockets.nix-daemon = { + after = [ "nix.mount" ]; + requires = [ "nix.mount" ]; + }; + + systemd.services.nix-daemon = { + after = [ "nix.mount" ]; + requires = [ "nix.mount" ]; + }; + + system.stateVersion = "25.05"; +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..083282e --- /dev/null +++ b/outputs.tf @@ -0,0 +1,31 @@ +output "ami_id" { + value = aws_ami.matched.id +} + +output "architecture" { + value = var.architecture +} + +output "input_hash" { + value = var.input_hash +} + +output "builder_git_rev" { + value = var.builder_git_rev +} + +output "flake_lock_rev" { + value = var.flake_lock_rev +} + +output "root_snapshot_id" { + value = var.root_snapshot_id +} + +output "nix_snapshot_id" { + value = var.nix_snapshot_id +} + +output "nixos_system_closure" { + value = var.nixos_system_closure +} diff --git a/publish-ami.sh b/publish-ami.sh new file mode 100755 index 0000000..a173b15 --- /dev/null +++ b/publish-ami.sh @@ -0,0 +1,363 @@ +#!/usr/bin/env bash +set -euo pipefail + +aws_region="${AWS_REGION:-us-west-2}" +architecture="arm64" +bucket="" +subnet_id="" +security_group_id="" +instance_profile_name="" +instance_type="t4g.small" +nix_volume_size_gb="50" +name="nixos-zfs-ec2" + +usage() { + cat >&2 <<'EOF' +Usage: publish-ami.sh --bucket s3://bucket[/prefix] [options] + +Options: + --architecture AMI architecture (default: arm64) + --aws-region AWS region (default: AWS_REGION or us-west-2) + --bucket S3 URI used by EC2 VM import + --subnet-id Subnet for the temporary seeder instance + --security-group-id Security group for the temporary seeder instance + --instance-profile-name Optional IAM instance profile for the seeder + --instance-type Seeder instance type (default: t4g.small) + --nix-volume-size-gb /nix volume size (default: 50) + --name AMI name prefix +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --architecture) + architecture="$2" + shift 2 + ;; + --aws-region) + aws_region="$2" + shift 2 + ;; + --bucket) + bucket="$2" + shift 2 + ;; + --subnet-id) + subnet_id="$2" + shift 2 + ;; + --security-group-id) + security_group_id="$2" + shift 2 + ;; + --instance-profile-name) + instance_profile_name="$2" + shift 2 + ;; + --instance-type) + instance_type="$2" + shift 2 + ;; + --nix-volume-size-gb) + nix_volume_size_gb="$2" + shift 2 + ;; + --name) + name="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [ -z "$bucket" ]; then + usage + exit 1 +fi + +if [ -z "$instance_profile_name" ]; then + echo "Missing required --instance-profile-name for SSM seeding" >&2 + usage + exit 1 +fi + +case "$architecture" in + arm64) + nix_system="aarch64-linux" + ;; + x86_64) + nix_system="x86_64-linux" + ;; + *) + echo "Unsupported architecture: $architecture" >&2 + exit 1 + ;; +esac + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +require aws +require git +require jq +require nix +require sha256sum +require tofu + +repo_root="$(git rev-parse --show-toplevel)" +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "The AMI builder git tree must be clean before publishing." >&2 + exit 1 +fi + +builder_git_rev="$(git rev-parse HEAD)" +flake_lock_rev="$(jq -r '.nodes.nixpkgs.locked.rev // .nodes.nixpkgs.locked.narHash // "unknown"' "$repo_root/flake.lock")" +input_hash="$(printf '%s\n%s\n%s\n' "$builder_git_rev" "$flake_lock_rev" "$architecture" | sha256sum | awk '{ print $1 }')" + +root_image="$(nix build --print-out-paths ".#packages.${nix_system}.rootImage")" +nixos_system_closure="$(nix build --print-out-paths ".#packages.${nix_system}.systemClosure")" + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +bucket_no_scheme="${bucket#s3://}" +bucket_name="${bucket_no_scheme%%/*}" +bucket_prefix="${bucket_no_scheme#"$bucket_name"}" +bucket_prefix="${bucket_prefix#/}" +s3_key_prefix="${bucket_prefix:+$bucket_prefix/}${name}/${architecture}/${input_hash}" + +image_file="$(find "$root_image" -type f \( -name '*.vhd' -o -name '*.raw' \) | head -n 1)" +if [ -z "$image_file" ]; then + echo "Could not locate built root image under $root_image" >&2 + exit 1 +fi + +case "$image_file" in + *.vhd) + import_format="vhd" + ;; + *.raw) + import_format="raw" + ;; + *) + echo "Unsupported root image format: $image_file" >&2 + exit 1 + ;; +esac + +aws s3 cp "$image_file" "s3://${bucket_name}/${s3_key_prefix}/root-image" + +container_json="$tmpdir/container.json" +jq -n \ + --arg bucket "$bucket_name" \ + --arg key "${s3_key_prefix}/root-image" \ + --arg format "$import_format" \ + '[{Description:"nixos-zfs-ec2-root",Format:$format,UserBucket:{S3Bucket:$bucket,S3Key:$key}}]' \ + > "$container_json" + +import_task_id="$( + aws ec2 import-image \ + --region "$aws_region" \ + --architecture "$architecture" \ + --platform Linux \ + --disk-containers "file://$container_json" \ + --query ImportTaskId \ + --output text +)" + +while true; do + import_json="$(aws ec2 describe-import-image-tasks --region "$aws_region" --import-task-ids "$import_task_id")" + status="$(jq -r '.ImportImageTasks[0].Status' <<<"$import_json")" + case "$status" in + completed) + root_ami_id="$(jq -r '.ImportImageTasks[0].ImageId' <<<"$import_json")" + break + ;; + deleted|deleting) + echo "$import_json" >&2 + exit 1 + ;; + *) + status_message="$(jq -r '.ImportImageTasks[0].StatusMessage // ""' <<<"$import_json")" + echo "import-image: $status $status_message" >&2 + sleep 20 + ;; + esac +done + +root_snapshot_id="$( + aws ec2 describe-images \ + --region "$aws_region" \ + --image-ids "$root_ami_id" \ + --query 'Images[0].BlockDeviceMappings[?Ebs != `null`][0].Ebs.SnapshotId' \ + --output text +)" + +run_args=( + --region "$aws_region" + --image-id "$root_ami_id" + --instance-type "$instance_type" + --block-device-mappings "DeviceName=/dev/sdf,Ebs={VolumeSize=${nix_volume_size_gb},VolumeType=gp3,DeleteOnTermination=true}" + --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=${name}-seed-${input_hash:0:12}}]" + --query 'Instances[0].InstanceId' + --output text +) + +if [ -n "$subnet_id" ]; then + run_args+=(--subnet-id "$subnet_id") +fi +if [ -n "$security_group_id" ]; then + run_args+=(--security-group-ids "$security_group_id") +fi +if [ -n "$instance_profile_name" ]; then + run_args+=(--iam-instance-profile "Name=$instance_profile_name") +fi + +instance_id="$(aws ec2 run-instances "${run_args[@]}")" +aws ec2 wait instance-running --region "$aws_region" --instance-ids "$instance_id" +aws ec2 wait instance-status-ok --region "$aws_region" --instance-ids "$instance_id" + +cat > "$tmpdir/seed-nix.sh" <<'EOS' +#!/usr/bin/env bash +set -euo pipefail + +pool="nixos-zfs-nix" +device="" +for candidate in /dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_*; do + if [ -e "$candidate" ] && ! findmnt -n -S "$candidate" >/dev/null 2>&1; then + device="$candidate" + fi +done + +if [ -z "$device" ]; then + echo "Unable to find unattached EBS device for /nix seed" >&2 + exit 1 +fi + +zpool create \ + -f \ + -o ashift=12 \ + -O acltype=posixacl \ + -O atime=off \ + -O compression=zstd \ + -O mountpoint=none \ + -O xattr=sa \ + "$pool" "$device" + +zfs create -o mountpoint=legacy "$pool/nix" +mkdir -p /mnt/nixos-zfs-nix +mount -t zfs "$pool/nix" /mnt/nixos-zfs-nix +rsync -aHAX --numeric-ids \ + --exclude='/var/nix/daemon-socket/*' \ + /nix/ /mnt/nixos-zfs-nix/ +rm -f /mnt/nixos-zfs-nix/var/nix/daemon-socket/socket +umount /mnt/nixos-zfs-nix +zpool export "$pool" +EOS + +jq -n --rawfile commands "$tmpdir/seed-nix.sh" '{commands: [$commands]}' > "$tmpdir/ssm-parameters.json" + +command_id="$( + aws ssm send-command \ + --region "$aws_region" \ + --instance-ids "$instance_id" \ + --document-name AWS-RunShellScript \ + --parameters "file://$tmpdir/ssm-parameters.json" \ + --query Command.CommandId \ + --output text +)" + +while true; do + invocation_json="$(aws ssm get-command-invocation --region "$aws_region" --command-id "$command_id" --instance-id "$instance_id" 2>/dev/null || true)" + status="$(jq -r '.Status // "Pending"' <<<"$invocation_json")" + case "$status" in + Success) + break + ;; + Failed|Cancelled|TimedOut) + echo "$invocation_json" >&2 + exit 1 + ;; + *) + echo "seed /nix: $status" >&2 + sleep 10 + ;; + esac +done + +aws ec2 stop-instances --region "$aws_region" --instance-ids "$instance_id" >/dev/null +aws ec2 wait instance-stopped --region "$aws_region" --instance-ids "$instance_id" + +nix_volume_id="$( + aws ec2 describe-instances \ + --region "$aws_region" \ + --instance-ids "$instance_id" \ + --query 'Reservations[0].Instances[0].BlockDeviceMappings[?DeviceName==`/dev/sdf`].Ebs.VolumeId | [0]' \ + --output text +)" + +nix_snapshot_id="$( + aws ec2 create-snapshot \ + --region "$aws_region" \ + --volume-id "$nix_volume_id" \ + --description "${name} ${architecture} ZFS /nix ${input_hash}" \ + --tag-specifications "ResourceType=snapshot,Tags=[{Key=Name,Value=${name}-nix-${architecture}-${input_hash:0:12}},{Key=InputHash,Value=${input_hash}},{Key=BuilderGitRev,Value=${builder_git_rev}}]" \ + --query SnapshotId \ + --output text +)" +aws ec2 wait snapshot-completed --region "$aws_region" --snapshot-ids "$nix_snapshot_id" + +tofu init +tofu apply \ + -var "aws_region=$aws_region" \ + -var "name=$name" \ + -var "architecture=$architecture" \ + -var "root_snapshot_id=$root_snapshot_id" \ + -var "nix_snapshot_id=$nix_snapshot_id" \ + -var "input_hash=$input_hash" \ + -var "builder_git_rev=$builder_git_rev" \ + -var "flake_lock_rev=$flake_lock_rev" \ + -var "nixos_system_closure=$nixos_system_closure" \ + -var "nix_volume_size_gb=$nix_volume_size_gb" + +ami_id="$(tofu output -raw ami_id)" + +aws ec2 terminate-instances --region "$aws_region" --instance-ids "$instance_id" >/dev/null +aws ec2 deregister-image --region "$aws_region" --image-id "$root_ami_id" >/dev/null || true + +jq -n \ + --arg architecture "$architecture" \ + --arg inputHash "$input_hash" \ + --arg builderGitRev "$builder_git_rev" \ + --arg flakeLockRev "$flake_lock_rev" \ + --arg amiId "$ami_id" \ + --arg rootSnapshotId "$root_snapshot_id" \ + --arg nixSnapshotId "$nix_snapshot_id" \ + --arg nixosSystemClosure "$nixos_system_closure" \ + '{ + architecture: $architecture, + inputHash: $inputHash, + builderGitRev: $builderGitRev, + flakeLockRev: $flakeLockRev, + amiId: $amiId, + rootSnapshotId: $rootSnapshotId, + nixSnapshotId: $nixSnapshotId, + nixosSystemClosure: $nixosSystemClosure + }' > artifact.json + +echo "Published AMI: $ami_id" diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..d4691e1 --- /dev/null +++ b/variables.tf @@ -0,0 +1,71 @@ +variable "aws_region" { + type = string + default = "us-west-2" + description = "AWS region." +} + +variable "name" { + type = string + default = "nixos-zfs-ec2" + description = "Name prefix for the published AMI." +} + +variable "architecture" { + type = string + description = "AMI architecture, such as arm64 or x86_64." +} + +variable "root_snapshot_id" { + type = string + description = "Snapshot ID for the bootable NixOS root volume." +} + +variable "nix_snapshot_id" { + type = string + description = "Snapshot ID for the preseeded ZFS /nix volume." +} + +variable "input_hash" { + type = string + description = "Stable hash of the AMI builder inputs." +} + +variable "builder_git_rev" { + type = string + description = "Git revision of this AMI builder." +} + +variable "flake_lock_rev" { + type = string + description = "Revision or hash representing flake.lock inputs." +} + +variable "nixos_system_closure" { + type = string + default = "" + description = "NixOS system closure path used to build the root image." +} + +variable "root_device_name" { + type = string + default = "/dev/xvda" + description = "Root block device name used for AMI registration." +} + +variable "nix_device_name" { + type = string + default = "/dev/sdf" + description = "AMI block device name for the /nix EBS snapshot. Runtime imports by ZFS pool identity, not this device name." +} + +variable "root_volume_size_gb" { + type = number + default = 30 + description = "Root EBS volume size." +} + +variable "nix_volume_size_gb" { + type = number + default = 50 + description = "/nix EBS volume size." +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..f7d6bc1 --- /dev/null +++ b/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.8.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +}