Skip to content

Commit 47e3da8

Browse files
committed
feat(nix): add stage 1 AMI build caching based on input hash
Implement content-based caching for stage 1 AMI builds by computing a hash from all input sources. Then the build-ami script checks for existing AMIs with matching input hash before building.
1 parent 36ff81c commit 47e3da8

File tree

5 files changed

+226
-37
lines changed

5 files changed

+226
-37
lines changed

.github/workflows/testinfra-ami-build.yml

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,6 @@ jobs:
9191
with:
9292
endpoint: builders
9393

94-
- name: Generate random string
95-
id: random
96-
run: echo "random_string=$(openssl rand -hex 8)" >> $GITHUB_OUTPUT
97-
9894
- name: Set PostgreSQL version environment variable
9995
run: |
10096
echo "POSTGRES_MAJOR_VERSION=${{ matrix.postgres_version }}" >> $GITHUB_ENV
@@ -107,29 +103,48 @@ jobs:
107103
echo 'postgres-version = "'$PG_VERSION'"' > common-nix.vars.pkr.hcl
108104
# Ensure there's a newline at the end of the file
109105
echo "" >> common-nix.vars.pkr.hcl
106+
git add -f common-nix.vars.pkr.hcl
110107
111108
- name: Build AMI stage 1
112109
env:
113110
AWS_MAX_ATTEMPTS: 10
114111
AWS_RETRY_MODE: adaptive
115112
run: |
116113
GIT_SHA=${{github.sha}}
117-
nix run github:supabase/postgres/${GIT_SHA}#packer -- init amazon-arm64-nix.pkr.hcl
118-
nix run github:supabase/postgres/${GIT_SHA}#packer -- build -var "git-head-version=${GIT_SHA}" -var "packer-execution-id=${EXECUTION_ID}" -var-file="development-arm.vars.pkr.hcl" -var-file="common-nix.vars.pkr.hcl" -var "ansible_arguments=" -var "postgres-version=${{ steps.random.outputs.random_string }}" -var "region=ap-southeast-1" -var 'ami_regions=["ap-southeast-1"]' -var "force-deregister=true" -var "ansible_arguments=-e postgresql_major=${POSTGRES_MAJOR_VERSION}" amazon-arm64-nix.pkr.hcl
114+
nix run .#build-ami -- stage1 \
115+
-var "git-head-version=${GIT_SHA}" \
116+
-var "packer-execution-id=${EXECUTION_ID}" \
117+
-var "ansible_arguments=-e postgresql_major=${POSTGRES_MAJOR_VERSION}" \
118+
-var "region=ap-southeast-1" \
119+
-var 'ami_regions=["ap-southeast-1"]' \
120+
-var-file="development-arm.vars.pkr.hcl" \
121+
-var-file="common-nix.vars.pkr.hcl" \
122+
amazon-arm64-nix.pkr.hcl
119123
120124
- name: Build AMI stage 2
125+
id: build-stage2
121126
env:
122127
AWS_MAX_ATTEMPTS: 10
123128
AWS_RETRY_MODE: adaptive
129+
PACKER_EXECUTION_ID: ${{ env.EXECUTION_ID }}
124130
run: |
125131
GIT_SHA=${{github.sha}}
126-
nix run github:supabase/postgres/${GIT_SHA}#packer -- init stage2-nix-psql.pkr.hcl
127-
nix run github:supabase/postgres/${GIT_SHA}#packer -- build -var "git-head-version=${GIT_SHA}" -var "packer-execution-id=${EXECUTION_ID}" -var "postgres_major_version=${POSTGRES_MAJOR_VERSION}" -var-file="development-arm.vars.pkr.hcl" -var-file="common-nix.vars.pkr.hcl" -var "postgres-version=${{ steps.random.outputs.random_string }}" -var "region=ap-southeast-1" -var 'ami_regions=["ap-southeast-1"]' -var "force-deregister=true" -var "git_sha=${GITHUB_SHA}" stage2-nix-psql.pkr.hcl
132+
nix run .#build-ami -- stage2 \
133+
-var "git-head-version=${GIT_SHA}" \
134+
-var "packer-execution-id=${EXECUTION_ID}" \
135+
-var "postgres_major_version=${POSTGRES_MAJOR_VERSION}" \
136+
-var-file="development-arm.vars.pkr.hcl" \
137+
-var-file="common-nix.vars.pkr.hcl" \
138+
-var "postgres-version=${EXECUTION_ID}" \
139+
-var 'ami_regions=["ap-southeast-1"]' \
140+
-var "force-deregister=true" \
141+
-var "git_sha=${GITHUB_SHA}" \
142+
stage2-nix-psql.pkr.hcl
128143
129144
- name: Run tests
130145
timeout-minutes: 10
131146
env:
132-
AMI_NAME: "supabase-postgres-${{ steps.random.outputs.random_string }}"
147+
AMI_NAME: ${{ steps.build-stage2.outputs.stage2_ami_id }}
133148
run: |
134149
# TODO: use poetry for pkg mgmt
135150
pip3 install boto3 boto3-stubs[essential] docker ec2instanceconnectcli pytest pytest-testinfra[paramiko,docker] requests
@@ -138,30 +153,40 @@ jobs:
138153
- name: Cleanup resources on build cancellation
139154
if: ${{ cancelled() }}
140155
run: |
141-
aws ec2 --region ap-southeast-1 describe-instances --filters "Name=tag:packerExecutionId,Values=${EXECUTION_ID}" --query "Reservations[].Instances[].InstanceId" --output text | xargs -r aws ec2 terminate-instances --region ap-southeast-1 --instance-ids
156+
INSTANCE_IDS=$(aws ec2 --region ap-southeast-1 describe-instances --filters "Name=tag:packerExecutionId,Values=${EXECUTION_ID}" --query "Reservations[].Instances[].InstanceId" --output text)
157+
if [ -n "$INSTANCE_IDS" ]; then
158+
echo "Terminating packer build instances: $INSTANCE_IDS"
159+
echo "$INSTANCE_IDS" | xargs -r aws ec2 terminate-instances --region ap-southeast-1 --instance-ids
160+
else
161+
echo "No packer build instances to clean up"
162+
fi
142163
143164
- name: Cleanup resources after build
144165
if: ${{ always() }}
145166
run: |
146-
aws ec2 --region ap-southeast-1 describe-instances --filters "Name=tag:testinfra-run-id,Values=${EXECUTION_ID}" --query "Reservations[].Instances[].InstanceId" --output text | xargs -r aws ec2 terminate-instances --region ap-southeast-1 --instance-ids || true
167+
INSTANCE_IDS=$(aws ec2 --region ap-southeast-1 describe-instances --filters "Name=tag:testinfra-run-id,Values=${EXECUTION_ID}" --query "Reservations[].Instances[].InstanceId" --output text)
168+
if [ -n "$INSTANCE_IDS" ]; then
169+
echo "Terminating testinfra instances: $INSTANCE_IDS"
170+
echo "$INSTANCE_IDS" | xargs -r aws ec2 terminate-instances --region ap-southeast-1 --instance-ids || true
171+
else
172+
echo "No testinfra instances to clean up"
173+
fi
147174
148-
- name: Cleanup AMIs
175+
- name: Cleanup stage 2 AMI
149176
if: always()
150177
run: |
151-
# Define AMI name patterns
152-
STAGE1_AMI_NAME="supabase-postgres-ci-ami-test-stage-1"
153-
STAGE2_AMI_NAME="${{ steps.random.outputs.random_string }}"
154-
155-
# Function to deregister AMIs by name pattern
156-
deregister_ami_by_name() {
157-
local ami_name_pattern=$1
158-
local ami_ids=$(aws ec2 describe-images --region ap-southeast-1 --owners self --filters "Name=name,Values=${ami_name_pattern}" --query 'Images[*].ImageId' --output text)
159-
for ami_id in $ami_ids; do
160-
echo "Deregistering AMI: $ami_id"
161-
aws ec2 deregister-image --region ap-southeast-1 --image-id $ami_id
178+
STAGE2_AMI_IDS=$(aws ec2 describe-images \
179+
--region ap-southeast-1 \
180+
--owners self \
181+
--filters "Name=tag:packerExecutionId,Values=${EXECUTION_ID}" \
182+
--query 'Images[*].ImageId' \
183+
--output text)
184+
185+
if [ -n "$STAGE2_AMI_IDS" ]; then
186+
for ami_id in $STAGE2_AMI_IDS; do
187+
echo "Deregistering stage 2 AMI: $ami_id"
188+
aws ec2 deregister-image --region ap-southeast-1 --image-id $ami_id || true
162189
done
163-
}
164-
165-
# Deregister AMIs
166-
deregister_ami_by_name "$STAGE1_AMI_NAME"
167-
deregister_ami_by_name "$STAGE2_AMI_NAME"
190+
else
191+
echo "No stage 2 AMI to clean up"
192+
fi

amazon-arm64-nix.pkr.hcl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ variable "force-deregister" {
9292
default = false
9393
}
9494

95+
variable "input-hash" {
96+
type = string
97+
default = ""
98+
description = "Content hash of all input sources"
99+
}
100+
95101
packer {
96102
required_plugins {
97103
amazon = {
@@ -172,6 +178,7 @@ source "amazon-ebssurrogate" "source" {
172178
appType = "postgres"
173179
postgresVersion = "${var.postgres-version}-stage1"
174180
sourceSha = "${var.git-head-version}"
181+
inputHash = "${var.input-hash}"
175182
}
176183

177184
communicator = "ssh"

nix/packages/build-ami.nix

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
{
2+
lib,
3+
stdenv,
4+
writeShellApplication,
5+
packer,
6+
awscli2,
7+
jq,
8+
...
9+
}:
10+
11+
let
12+
root = ../..;
13+
packerSources = stdenv.mkDerivation {
14+
name = "packer-sources";
15+
src = lib.fileset.toSource {
16+
inherit root;
17+
fileset = lib.fileset.unions [
18+
(root + "/ebssurrogate")
19+
(root + "/ansible")
20+
(root + "/migrations")
21+
(root + "/scripts")
22+
(root + "/amazon-arm64-nix.pkr.hcl")
23+
(root + "/development-arm.vars.pkr.hcl")
24+
(lib.fileset.maybeMissing (root + "/common-nix.vars.pkr.hcl"))
25+
];
26+
};
27+
28+
phases = [
29+
"unpackPhase"
30+
"installPhase"
31+
];
32+
installPhase = ''
33+
mkdir -p $out
34+
cp -r . $out/
35+
'';
36+
};
37+
in
38+
writeShellApplication {
39+
name = "build-ami";
40+
41+
runtimeInputs = [
42+
packer
43+
awscli2
44+
jq
45+
];
46+
47+
text = ''
48+
set -euo pipefail
49+
50+
set -x
51+
52+
# Parse stage parameter
53+
STAGE="''${1:-stage1}"
54+
shift || true # Remove first arg, ignore error if no args
55+
56+
REGION="''${AWS_REGION:-ap-southeast-1}"
57+
POSTGRES_VERSION="''${POSTGRES_MAJOR_VERSION:-15}"
58+
PACKER_SOURCES="${packerSources}"
59+
INPUT_HASH=$(basename "$PACKER_SOURCES" | cut -d- -f1)
60+
61+
find_stage1_ami() {
62+
set +e
63+
local ami_output
64+
ami_output=$(aws ec2 describe-images \
65+
--region "$REGION" \
66+
--owners self \
67+
--filters \
68+
"Name=tag:inputHash,Values=$INPUT_HASH" \
69+
"Name=tag:postgresVersion,Values=$POSTGRES_VERSION-stage1" \
70+
"Name=state,Values=available" \
71+
--query 'Images[0].ImageId' \
72+
--output text 2>&1)
73+
local exit_code=$?
74+
set -e
75+
76+
if [ $exit_code -ne 0 ] && [ $exit_code -ne 255 ]; then
77+
echo "Error querying AWS: $ami_output"
78+
exit 1
79+
fi
80+
81+
if [ "$ami_output" = "None" ] || [ -z "$ami_output" ]; then
82+
echo ""
83+
else
84+
echo "$ami_output"
85+
fi
86+
}
87+
88+
if [ "$STAGE" = "stage1" ]; then
89+
echo "Building stage 1..."
90+
echo "Checking for existing AMI..."
91+
92+
AMI_ID=$(find_stage1_ami)
93+
if [ -n "$AMI_ID" ]; then
94+
echo "Found existing AMI: $AMI_ID"
95+
echo "STAGE1_AMI_ID=$AMI_ID"
96+
exit 0
97+
fi
98+
99+
echo "No cached AMI found"
100+
101+
cd "$PACKER_SOURCES"
102+
packer init amazon-arm64-nix.pkr.hcl
103+
packer build \
104+
-var "input-hash=$INPUT_HASH" \
105+
-var "postgres-version=$POSTGRES_VERSION" \
106+
-var "region=$REGION" \
107+
"$@"
108+
elif [ "$STAGE" = "stage2" ]; then
109+
echo "Building stage 2..."
110+
111+
STAGE1_AMI_ID=$(find_stage1_ami)
112+
if [ -z "$STAGE1_AMI_ID" ]; then
113+
echo "Error: Stage 1 AMI not found. Please build stage 1 first."
114+
exit 1
115+
fi
116+
117+
echo "Found stage 1 AMI: $STAGE1_AMI_ID"
118+
119+
packer init stage2-nix-psql.pkr.hcl
120+
packer build \
121+
-var "source_ami=$STAGE1_AMI_ID" \
122+
-var "region=$REGION" \
123+
"$@"
124+
125+
if [ -n "''${PACKER_EXECUTION_ID:-}" ]; then
126+
STAGE2_AMI_ID=$(aws ec2 describe-images \
127+
--region "$REGION" \
128+
--owners self \
129+
--filters \
130+
"Name=tag:packerExecutionId,Values=''${PACKER_EXECUTION_ID}" \
131+
"Name=state,Values=available" \
132+
--query 'Images[0].ImageId' \
133+
--output text)
134+
135+
if [ -n "$STAGE2_AMI_ID" ] && [ "$STAGE2_AMI_ID" != "None" ]; then
136+
echo "STAGE2_AMI_ID=$STAGE2_AMI_ID"
137+
138+
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
139+
echo "stage2_ami_id=$STAGE2_AMI_ID" >> "$GITHUB_OUTPUT"
140+
fi
141+
fi
142+
fi
143+
else
144+
echo "Error: Invalid stage '$STAGE'. Must be 'stage1' or 'stage2'"
145+
exit 1
146+
fi
147+
'';
148+
149+
meta = {
150+
description = "Build AMI if not cached based on input hash";
151+
longDescription = ''
152+
The input hash is computed from all source files that affect the build.
153+
Before building, we verify the existence of an AMI with the same hash.
154+
If found, the build is skipped. Otherwise, a new AMI is created and
155+
tagged with the input hash for future cache hits.
156+
'';
157+
};
158+
}

nix/packages/default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
{
3030
packages = (
3131
{
32+
build-ami = pkgs.callPackage ./build-ami.nix { packer = self'.packages.packer; };
3233
build-test-ami = pkgs.callPackage ./build-test-ami.nix { };
3334
cleanup-ami = pkgs.callPackage ./cleanup-ami.nix { };
3435
dbmate-tool = pkgs.callPackage ./dbmate-tool.nix { inherit (self.supabase) defaults; };

stage2-nix-psql.pkr.hcl

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ variable "postgres_major_version" {
5151
default = ""
5252
}
5353

54+
variable "source_ami" {
55+
type = string
56+
description = "Source AMI ID from stage 1"
57+
}
58+
5459
packer {
5560
required_plugins {
5661
amazon = {
@@ -64,15 +69,7 @@ source "amazon-ebs" "ubuntu" {
6469
ami_name = "${var.ami_name}-${var.postgres-version}"
6570
instance_type = "c6g.4xlarge"
6671
region = "${var.region}"
67-
source_ami_filter {
68-
filters = {
69-
name = "${var.ami_name}-${var.postgres-version}-stage-1"
70-
root-device-type = "ebs"
71-
virtualization-type = "hvm"
72-
}
73-
most_recent = true
74-
owners = ["amazon", "self"]
75-
}
72+
source_ami = "${var.source_ami}"
7673

7774
communicator = "ssh"
7875
ssh_pty = true
@@ -107,6 +104,7 @@ source "amazon-ebs" "ubuntu" {
107104
appType = "postgres"
108105
postgresVersion = "${var.postgres-version}"
109106
sourceSha = "${var.git-head-version}"
107+
packerExecutionId = "${var.packer-execution-id}"
110108
}
111109
}
112110

0 commit comments

Comments
 (0)