Skip to content

Commit 9f138cf

Browse files
authored
feat: pin pgbouncer EC2 AMI tag, pin uv version in Lambda docker builds (#235)
* fix: pin uv to v0.10.9 in all dockerfiles * feat: pin pgbouncer EC2 AMI to 20260218 image instead of 'current' * chore: clean up some unnecessary links between pgbouncer and the pgstac bootstrapper * fix: fix reference to wrong uv.lock file in stactools-item-generator * chore(ci): add action to open issue for updating AMI tag
1 parent 7749e1f commit 9f138cf

10 files changed

Lines changed: 168 additions & 34 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: Remind to update PgBouncer AMI pin
2+
3+
on:
4+
schedule:
5+
# Run quarterly: 1st of January, April, July, October
6+
- cron: '0 9 1 1,4,7,10 *'
7+
workflow_dispatch:
8+
9+
jobs:
10+
check-ami-pin:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
issues: write
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v6
18+
19+
- name: Check age of pinned AMI date
20+
id: check
21+
run: |
22+
# Extract the pinned date from PgBouncer.ts
23+
PINNED=$(grep -oP '(?<=/noble/stable/)\d{8}(?=/)' lib/database/PgBouncer.ts | head -1)
24+
if [ -z "$PINNED" ]; then
25+
echo "No pinned date found (using 'current' or unrecognised format) — nothing to check."
26+
echo "should_remind=false" >> "$GITHUB_OUTPUT"
27+
exit 0
28+
fi
29+
30+
PINNED_DATE=$(date -d "$PINNED" +%s)
31+
NOW=$(date +%s)
32+
AGE_DAYS=$(( (NOW - PINNED_DATE) / 86400 ))
33+
34+
echo "Pinned date: $PINNED ($AGE_DAYS days ago)"
35+
echo "pinned=$PINNED" >> "$GITHUB_OUTPUT"
36+
echo "age_days=$AGE_DAYS" >> "$GITHUB_OUTPUT"
37+
38+
if [ "$AGE_DAYS" -ge 90 ]; then
39+
echo "should_remind=true" >> "$GITHUB_OUTPUT"
40+
else
41+
echo "should_remind=false" >> "$GITHUB_OUTPUT"
42+
fi
43+
44+
- name: Open reminder issue
45+
if: steps.check.outputs.should_remind == 'true'
46+
uses: actions/github-script@v7
47+
with:
48+
script: |
49+
const pinned = '${{ steps.check.outputs.pinned }}';
50+
const ageDays = '${{ steps.check.outputs.age_days }}';
51+
52+
// Check if a reminder issue is already open
53+
const issues = await github.rest.issues.listForRepo({
54+
owner: context.repo.owner,
55+
repo: context.repo.repo,
56+
state: 'open',
57+
labels: 'maintenance',
58+
});
59+
const existing = issues.data.find(i => i.title.includes('PgBouncer AMI pin'));
60+
if (existing) {
61+
console.log(`Reminder issue already open: ${existing.html_url}`);
62+
return;
63+
}
64+
65+
await github.rest.issues.create({
66+
owner: context.repo.owner,
67+
repo: context.repo.repo,
68+
title: `chore: update PgBouncer AMI pin (currently ${pinned}, ${ageDays} days old)`,
69+
body: [
70+
`The PgBouncer EC2 AMI is pinned to \`${pinned}\`, which is ${ageDays} days old.`,
71+
'',
72+
'To find a more recent date-versioned path in your region:',
73+
'```',
74+
'aws ssm get-parameters-by-path \\',
75+
' --path "/aws/service/canonical/ubuntu/server/noble/stable/" \\',
76+
' --recursive \\',
77+
' --query "Parameters[?ends_with(Name, \'amd64/hvm/ebs-gp3/ami-id\')].Name"',
78+
'```',
79+
'',
80+
'Then update the `DEFAULT_AMI_SSM_PARAMETER` constant in `lib/database/PgBouncer.ts`',
81+
'and the `@default` tag in both `PgBouncer.ts` and `lib/database/index.ts`.',
82+
'',
83+
'See: https://documentation.ubuntu.com/aws/aws-how-to/instances/find-ubuntu-images/',
84+
].join('\n'),
85+
labels: ['maintenance'],
86+
});

lib/database/PgBouncer.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export interface PgBouncerConfigProps {
2525
maxUserConnections?: number;
2626
}
2727

28+
const DEFAULT_AMI_SSM_PARAMETER =
29+
"/aws/service/canonical/ubuntu/server/noble/stable/20260218/amd64/hvm/ebs-gp3/ami-id";
30+
2831
export interface PgBouncerProps {
2932
/**
3033
* VPC to deploy PgBouncer into
@@ -62,10 +65,24 @@ export interface PgBouncerProps {
6265
instanceProps?: Partial<ec2.InstanceProps>;
6366

6467
/**
65-
* Optional reference to the database bootstrapper CustomResource.
66-
* When provided, the health check will re-trigger if the database setup changes.
68+
* SSM parameter path for the PgBouncer EC2 instance machine image (AMI).
69+
*
70+
* Defaults to the latest Ubuntu Noble AMI. For stable deployments where
71+
* EC2 replacement should only happen on explicit intent (not on Canonical
72+
* AMI releases), pin this to a specific date-versioned path:
73+
* /aws/service/canonical/ubuntu/server/noble/stable/YYYYMMDD.X/amd64/hvm/ebs-gp3/ami-id
74+
*
75+
* To list available date-versioned paths in your region:
76+
* aws ssm get-parameters-by-path --path "/aws/service/canonical/ubuntu/server/noble/stable/" --recursive --query "Parameters[?ends_with(Name, 'amd64/hvm/ebs-gp3/ami-id')].Name"
77+
*
78+
* See: https://documentation.ubuntu.com/aws/aws-how-to/instances/find-ubuntu-images/
79+
*
80+
* With addPatchManager: true (default), SSM Patch Manager handles OS
81+
* security updates without requiring instance replacement.
82+
*
83+
* @default /aws/service/canonical/ubuntu/server/noble/stable/20260218/amd64/hvm/ebs-gp3/ami-id
6784
*/
68-
databaseBootstrapper?: CustomResource;
85+
readonly machineImageSsmParameter?: string;
6986
}
7087

7188
export class PgBouncer extends Construct {
@@ -81,7 +98,7 @@ export class PgBouncer extends Construct {
8198
// so we perform this calculation.
8299

83100
private getDefaultPgbouncerConfig(
84-
dbMaxConnections: number
101+
dbMaxConnections: number,
85102
): Required<PgBouncerConfigProps> {
86103
// maxDbConnections (and maxUserConnections) are the only settings that need
87104
// to be responsive to the database size/max_connections setting
@@ -103,7 +120,7 @@ export class PgBouncer extends Construct {
103120
// Set defaults for optional props
104121

105122
const defaultPgbouncerConfig = this.getDefaultPgbouncerConfig(
106-
props.dbMaxConnections
123+
props.dbMaxConnections,
107124
);
108125

109126
// Merge provided config with defaults
@@ -119,10 +136,10 @@ export class PgBouncer extends Construct {
119136
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
120137
managedPolicies: [
121138
iam.ManagedPolicy.fromAwsManagedPolicyName(
122-
"AmazonSSMManagedInstanceCore"
139+
"AmazonSSMManagedInstanceCore",
123140
),
124141
iam.ManagedPolicy.fromAwsManagedPolicyName(
125-
"CloudWatchAgentServerPolicy"
142+
"CloudWatchAgentServerPolicy",
126143
),
127144
],
128145
});
@@ -132,7 +149,7 @@ export class PgBouncer extends Construct {
132149
new iam.PolicyStatement({
133150
actions: ["secretsmanager:GetSecretValue"],
134151
resources: [props.database.secret.secretArn],
135-
})
152+
}),
136153
);
137154

138155
// Create a security group and allow connections from the Lambda IP ranges for this region
@@ -147,16 +164,16 @@ export class PgBouncer extends Construct {
147164
instanceName: "pgbouncer",
148165
instanceType: ec2.InstanceType.of(
149166
ec2.InstanceClass.T3,
150-
ec2.InstanceSize.MICRO
167+
ec2.InstanceSize.MICRO,
151168
),
152169
vpcSubnets: {
153170
subnetType: props.usePublicSubnet
154171
? ec2.SubnetType.PUBLIC
155172
: ec2.SubnetType.PRIVATE_WITH_EGRESS,
156173
},
157174
machineImage: ec2.MachineImage.fromSsmParameter(
158-
"/aws/service/canonical/ubuntu/server/noble/stable/current/amd64/hvm/ebs-gp3/ami-id",
159-
{ os: ec2.OperatingSystemType.LINUX }
175+
props.machineImageSsmParameter ?? DEFAULT_AMI_SSM_PARAMETER,
176+
{ os: ec2.OperatingSystemType.LINUX },
160177
),
161178
blockDevices: [
162179
{
@@ -185,7 +202,7 @@ export class PgBouncer extends Construct {
185202
props.database.connections.allowFrom(
186203
this.instance,
187204
ec2.Port.tcp(5432),
188-
"Allow PgBouncer to connect to RDS"
205+
"Allow PgBouncer to connect to RDS",
189206
);
190207

191208
// Create a new secret for pgbouncer connection credentials
@@ -205,7 +222,7 @@ export class PgBouncer extends Construct {
205222
runtime: lambda.Runtime.NODEJS_LATEST,
206223
handler: "index.handler",
207224
code: lambda.Code.fromAsset(
208-
path.join(__dirname, "lambda/pgbouncer-secret-updater")
225+
path.join(__dirname, "lambda/pgbouncer-secret-updater"),
209226
),
210227
environment: {
211228
SOURCE_SECRET_ARN: props.database.secret.secretArn,
@@ -226,7 +243,7 @@ export class PgBouncer extends Construct {
226243
? this.instance.instancePublicIp
227244
: this.instance.instancePrivateIp,
228245
},
229-
}
246+
},
230247
);
231248

232249
// Add health check custom resource
@@ -238,10 +255,10 @@ export class PgBouncer extends Construct {
238255
handler: "index.handler",
239256
timeout: Duration.minutes(10),
240257
code: lambda.Code.fromAsset(
241-
path.join(__dirname, "lambda/pgbouncer-health-check")
258+
path.join(__dirname, "lambda/pgbouncer-health-check"),
242259
),
243260
description: "PgBouncer health check function",
244-
}
261+
},
245262
);
246263

247264
// Grant SSM permissions for health check
@@ -254,17 +271,13 @@ export class PgBouncer extends Construct {
254271
"ssm:ListCommandInvocations",
255272
],
256273
resources: ["*"],
257-
})
274+
}),
258275
);
259276

260277
this.healthCheck = new CustomResource(this, "PgBouncerHealthCheck", {
261278
serviceToken: healthCheckFunction.functionArn,
262279
properties: {
263280
InstanceId: this.instance.instanceId,
264-
// Reference the database bootstrapper to re-trigger on database changes
265-
...(props.databaseBootstrapper && {
266-
DatabaseBootstrapperRef: props.databaseBootstrapper.ref,
267-
}),
268281
},
269282
});
270283

@@ -275,7 +288,7 @@ export class PgBouncer extends Construct {
275288

276289
private loadUserDataScript(
277290
pgBouncerConfig: Required<NonNullable<PgBouncerProps["pgBouncerConfig"]>>,
278-
database: { secret: secretsmanager.ISecret }
291+
database: { secret: secretsmanager.ISecret },
279292
): ec2.UserData {
280293
const userDataScript = ec2.UserData.forLinux();
281294

@@ -292,7 +305,9 @@ export class PgBouncer extends Construct {
292305
pgBouncerConfig.reservePoolTimeout +
293306
'"',
294307
'export MAX_DB_CONNECTIONS="' + pgBouncerConfig.maxDbConnections + '"',
295-
'export MAX_USER_CONNECTIONS="' + pgBouncerConfig.maxUserConnections + '"'
308+
'export MAX_USER_CONNECTIONS="' +
309+
pgBouncerConfig.maxUserConnections +
310+
'"',
296311
);
297312

298313
// Load the startup script

lib/database/bootstrapper_runtime/handler.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,15 @@ def handler(event, context):
171171
"""Lambda Handler."""
172172
print(f"Handling {event}")
173173

174+
physicalResourceId = event.get("PhysicalResourceId") or context.log_stream_name
174175
if event["RequestType"] not in ["Create", "Update"]:
175-
return send(event, context, "SUCCESS", {"msg": "No action to be taken"})
176+
return send(
177+
event,
178+
context,
179+
"SUCCESS",
180+
{"msg": "No action to be taken"},
181+
physicalResourceId=physicalResourceId,
182+
)
176183

177184
try:
178185
params = event["ResourceProperties"]
@@ -261,8 +268,14 @@ def handler(event, context):
261268

262269
except Exception as e:
263270
print(f"Unable to bootstrap database with exception={e}")
264-
send(event, context, "FAILED", {"message": str(e)})
271+
send(
272+
event,
273+
context,
274+
"FAILED",
275+
{"message": str(e)},
276+
physicalResourceId=physicalResourceId,
277+
)
265278
raise e
266279

267280
print("Complete.")
268-
return send(event, context, "SUCCESS", {})
281+
return send(event, context, "SUCCESS", {}, physicalResourceId=physicalResourceId)

lib/database/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export class PgStacDatabase extends Construct {
279279
reservePoolSize: 5,
280280
reservePoolTimeout: 5,
281281
},
282-
databaseBootstrapper: bootstrapper,
282+
machineImageSsmParameter: props.pgbouncerAmiSsmParameter,
283283
});
284284

285285
this._pgBouncerServer.node.addDependency(bootstrapper);
@@ -387,6 +387,26 @@ export interface PgStacDatabaseProps extends rds.DatabaseInstanceProps {
387387
*/
388388
readonly pgbouncerInstanceProps?: ec2.InstanceProps | any;
389389

390+
/**
391+
* SSM parameter path for the PgBouncer EC2 instance machine image (AMI).
392+
*
393+
* Defaults to the latest Ubuntu Noble AMI (`current`). For stable deployments
394+
* where EC2 replacement should only happen on explicit intent, pin this to a
395+
* specific date-versioned path:
396+
* /aws/service/canonical/ubuntu/server/noble/stable/YYYYMMDD.X/amd64/hvm/ebs-gp3/ami-id
397+
*
398+
* To list available date-versioned paths in your region:
399+
* aws ssm get-parameters-by-path --path "/aws/service/canonical/ubuntu/server/noble/stable/" --recursive --query "Parameters[?ends_with(Name, 'amd64/hvm/ebs-gp3/ami-id')].Name"
400+
*
401+
* See: https://documentation.ubuntu.com/aws/aws-how-to/instances/find-ubuntu-images/
402+
*
403+
* With addPatchManager: true (default), SSM Patch Manager handles OS security
404+
* updates without requiring instance replacement.
405+
*
406+
* @default /aws/service/canonical/ubuntu/server/noble/stable/20260218/amd64/hvm/ebs-gp3/ami-id
407+
*/
408+
readonly pgbouncerAmiSsmParameter?: string;
409+
390410
/**
391411
* Add patching system using AWS SSM for pgbouncer instance maintenance
392412
* `addPgbouncer` must be true for this to have an effect

lib/stac-api/runtime/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION
22
FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
WORKDIR /tmp
66
COPY stac-api/runtime/uv.lock stac-api/runtime/pyproject.toml ./

lib/stac-auth-proxy/runtime/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION
22
FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
WORKDIR /tmp
66
COPY stac-auth-proxy/runtime/uv.lock stac-auth-proxy/runtime/pyproject.toml ./

lib/stac-loader/runtime/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION=3.12
22
FROM public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
ENV UV_COMPILE_BYTECODE=1
66
ENV PYTHONUNBUFFERED=1

lib/stactools-item-generator/runtime/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION=3.12
22
FROM public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
ENV UV_CACHE_DIR=/tmp/uv-cache/
66
ENV UV_COMPILE_BYTECODE=1
@@ -10,7 +10,7 @@ ENV PATH=/tmp/.local/bin:$PATH
1010

1111
WORKDIR /tmp
1212

13-
COPY stactools-item-generator/runtime/pyproject.toml stac-loader/runtime/uv.lock ./
13+
COPY stactools-item-generator/runtime/pyproject.toml stactools-item-generator/runtime/uv.lock ./
1414
COPY stactools-item-generator/runtime/src/ ./src/
1515

1616
RUN <<EOF

lib/tipg-api/runtime/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION
22
FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
WORKDIR /tmp
66
COPY tipg-api/runtime/uv.lock tipg-api/runtime/pyproject.toml ./

lib/titiler-pgstac-api/runtime/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
ARG PYTHON_VERSION
22
FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION}
3-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
44

55
WORKDIR /tmp
66
COPY titiler-pgstac-api/runtime/uv.lock titiler-pgstac-api/runtime/pyproject.toml ./

0 commit comments

Comments
 (0)