Skip to content

Commit 4952163

Browse files
arielr-ltAriel Rolfo
andauthored
Add production DB table dump workflow via K8s Job (#1016)
Introduces a manually-triggered GitHub Actions workflow that spins up a K8s Job in credreg-prod to pg_dump a specified table, uploads the result to a dedicated private S3 bucket, and notifies Slack with a 1-hour presigned download URL. - New terraform module: db_dumps_s3 (private bucket, AES-256, 7-day expiry) - IRSA application policy extended with PutObject on cer-db-dumps-prod - github-oidc-widget gets s3:GetObject for presigned URL generation - K8s Job template: postgres:16-alpine, uses existing app-secrets for DB creds - GH Actions workflow: validates table name, creates job, waits, presigns, notifies Slack Co-authored-by: Ariel Rolfo <arielr-lt+username@users.noreply.github.com>
1 parent 17e1be1 commit 4952163

7 files changed

Lines changed: 279 additions & 2 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: Production DB table dump
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
table_name:
7+
description: "Table name to dump (e.g. envelopes)"
8+
type: string
9+
required: true
10+
11+
permissions:
12+
id-token: write
13+
contents: read
14+
15+
env:
16+
AWS_REGION: us-east-1
17+
EKS_CLUSTER: ce-registry-eks
18+
S3_DUMP_BUCKET: cer-db-dumps-prod
19+
PRESIGNED_URL_TTL: 3600
20+
21+
jobs:
22+
dump:
23+
if: ${{ github.repository_owner == 'CredentialEngine' }}
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout code
27+
uses: actions/checkout@v4
28+
29+
- name: Configure AWS credentials
30+
uses: aws-actions/configure-aws-credentials@v4
31+
with:
32+
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT }}:role/github-oidc-widget
33+
aws-region: ${{ env.AWS_REGION }}
34+
35+
- name: Install kubectl
36+
uses: azure/setup-kubectl@v4
37+
with:
38+
version: v1.29.6
39+
40+
- name: Update kubeconfig
41+
run: aws eks update-kubeconfig --name "${{ env.EKS_CLUSTER }}" --region "${{ env.AWS_REGION }}"
42+
43+
- name: Validate table name
44+
run: |
45+
TABLE="${{ inputs.table_name }}"
46+
if [[ ! "$TABLE" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
47+
echo "Invalid table name: '$TABLE'. Only alphanumeric characters and underscores allowed." >&2
48+
exit 1
49+
fi
50+
51+
- name: Create dump job
52+
id: create_job
53+
run: |
54+
TABLE="${{ inputs.table_name }}"
55+
JOB_NAME=$(
56+
sed "s/__TABLE_NAME__/${TABLE}/" \
57+
terraform/environments/eks/k8s-manifests-prod/db-table-dump-job.yaml |
58+
kubectl -n credreg-prod create -f - -o name | sed 's|job.batch/||'
59+
)
60+
echo "job_name=${JOB_NAME}" >> "$GITHUB_OUTPUT"
61+
echo "Launched job: ${JOB_NAME}"
62+
63+
- name: Wait for job completion
64+
run: |
65+
kubectl -n credreg-prod wait \
66+
--for=condition=complete \
67+
"job/${{ steps.create_job.outputs.job_name }}" \
68+
--timeout=30m
69+
70+
- name: Get presigned URL
71+
id: presign
72+
run: |
73+
S3_KEY=$(
74+
kubectl -n credreg-prod logs "job/${{ steps.create_job.outputs.job_name }}" |
75+
grep "^S3_KEY=" | tail -1 | cut -d= -f2-
76+
)
77+
if [ -z "$S3_KEY" ]; then
78+
echo "Could not parse S3_KEY from job logs" >&2
79+
exit 1
80+
fi
81+
PRESIGNED_URL=$(
82+
aws s3 presign "s3://${{ env.S3_DUMP_BUCKET }}/${S3_KEY}" \
83+
--expires-in "${{ env.PRESIGNED_URL_TTL }}" \
84+
--region "${{ env.AWS_REGION }}"
85+
)
86+
echo "presigned_url=${PRESIGNED_URL}" >> "$GITHUB_OUTPUT"
87+
88+
- name: Notify Slack
89+
if: always()
90+
env:
91+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
92+
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
93+
run: |
94+
if [ -z "${SLACK_WEBHOOK_URL}" ]; then
95+
echo "SLACK_WEBHOOK_URL not set; skipping"
96+
exit 0
97+
fi
98+
99+
STATUS="${{ job.status }}"
100+
TABLE="${{ inputs.table_name }}"
101+
ACTOR="${{ github.actor }}"
102+
103+
if [ "$STATUS" = "success" ]; then
104+
PRESIGNED_URL="${{ steps.presign.outputs.presigned_url }}"
105+
MSG=":white_check_mark: *DB dump ready* | table: \`${TABLE}\` | triggered by: ${ACTOR}\nDownload (expires in 1h): ${PRESIGNED_URL}\n${RUN_URL}"
106+
else
107+
MSG=":x: *DB dump failed* | table: \`${TABLE}\` | triggered by: ${ACTOR}\n${RUN_URL}"
108+
fi
109+
110+
payload=$(jq -nc --arg text "$MSG" '{text:$text}')
111+
curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" || true
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
apiVersion: batch/v1
2+
kind: Job
3+
metadata:
4+
generateName: db-table-dump-
5+
namespace: credreg-prod
6+
labels:
7+
app: db-table-dump
8+
spec:
9+
backoffLimit: 0
10+
activeDeadlineSeconds: 1800
11+
ttlSecondsAfterFinished: 300
12+
template:
13+
spec:
14+
serviceAccountName: main-app-service-account
15+
restartPolicy: Never
16+
containers:
17+
- name: pg-dump
18+
image: postgres:16-alpine
19+
command: ["/bin/sh", "-c"]
20+
args:
21+
- |
22+
set -euo pipefail
23+
24+
apk add --no-cache aws-cli >/dev/null 2>&1
25+
26+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
27+
DUMP_FILE="/tmp/${TABLE_NAME}_${TIMESTAMP}.dump"
28+
S3_KEY="table-dumps/${TABLE_NAME}/${TIMESTAMP}.dump"
29+
30+
echo "Dumping table '${TABLE_NAME}' from ${POSTGRESQL_DATABASE}..."
31+
PGPASSWORD="${POSTGRESQL_PASSWORD}" pg_dump \
32+
-h "${POSTGRESQL_ADDRESS}" \
33+
-U "${POSTGRESQL_USERNAME}" \
34+
-d "${POSTGRESQL_DATABASE}" \
35+
-p "${POSTGRESQL_PORT:-5432}" \
36+
--table "${TABLE_NAME}" \
37+
--no-owner --no-acl \
38+
-Fc \
39+
-f "${DUMP_FILE}"
40+
41+
echo "Uploading to s3://${S3_DUMP_BUCKET}/${S3_KEY}"
42+
aws s3 cp "${DUMP_FILE}" "s3://${S3_DUMP_BUCKET}/${S3_KEY}" --region us-east-1
43+
44+
echo "S3_KEY=${S3_KEY}"
45+
env:
46+
- name: TABLE_NAME
47+
value: "__TABLE_NAME__"
48+
- name: S3_DUMP_BUCKET
49+
value: "cer-db-dumps-prod"
50+
envFrom:
51+
- secretRef:
52+
name: app-secrets

terraform/environments/eks/main.tf

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,41 @@ output "cer_envelope_graphs_bucket_name_prod" {
225225
description = "Production S3 bucket name for envelope graphs"
226226
}
227227

228+
## DB Dumps S3 bucket
229+
module "db_dumps_s3" {
230+
source = "../../modules/db_dumps_s3"
231+
bucket_name = "cer-db-dumps-prod"
232+
expiration_days = 7
233+
common_tags = local.common_tags
234+
}
235+
236+
output "db_dumps_bucket_name" {
237+
value = module.db_dumps_s3.bucket_name
238+
description = "S3 bucket name for production DB table dumps"
239+
}
240+
241+
# Allow the GitHub OIDC role to generate presigned URLs for dump objects
242+
data "aws_iam_role" "github_oidc" {
243+
name = "github-oidc-widget"
244+
}
245+
246+
resource "aws_iam_role_policy" "github_oidc_db_dumps" {
247+
name = "db-dumps-presign"
248+
role = data.aws_iam_role.github_oidc.id
249+
250+
policy = jsonencode({
251+
Version = "2012-10-17"
252+
Statement = [
253+
{
254+
Sid = "DbDumpsPresign"
255+
Effect = "Allow"
256+
Action = ["s3:GetObject"]
257+
Resource = "${module.db_dumps_s3.bucket_arn}/*"
258+
}
259+
]
260+
})
261+
}
262+
228263
## CloudWatch Log Forwarding to Slack
229264
module "cloudwatch_slack_forwarder" {
230265
source = "../../modules/cloudwatch_slack_forwarder"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
locals {
2+
tags = merge(var.common_tags, {
3+
Name = var.bucket_name
4+
purpose = "db-table-dumps"
5+
})
6+
}
7+
8+
resource "aws_s3_bucket" "this" {
9+
bucket = var.bucket_name
10+
tags = local.tags
11+
}
12+
13+
resource "aws_s3_bucket_ownership_controls" "this" {
14+
bucket = aws_s3_bucket.this.id
15+
rule {
16+
object_ownership = "BucketOwnerEnforced"
17+
}
18+
}
19+
20+
resource "aws_s3_bucket_public_access_block" "this" {
21+
bucket = aws_s3_bucket.this.id
22+
23+
block_public_acls = true
24+
block_public_policy = true
25+
ignore_public_acls = true
26+
restrict_public_buckets = true
27+
}
28+
29+
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
30+
bucket = aws_s3_bucket.this.id
31+
32+
rule {
33+
apply_server_side_encryption_by_default {
34+
sse_algorithm = "AES256"
35+
}
36+
}
37+
}
38+
39+
resource "aws_s3_bucket_lifecycle_configuration" "this" {
40+
bucket = aws_s3_bucket.this.id
41+
42+
rule {
43+
id = "expire-dumps"
44+
status = "Enabled"
45+
46+
filter {}
47+
48+
expiration {
49+
days = var.expiration_days
50+
}
51+
}
52+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
output "bucket_name" {
2+
value = aws_s3_bucket.this.bucket
3+
description = "S3 bucket name for DB dumps"
4+
}
5+
6+
output "bucket_arn" {
7+
value = aws_s3_bucket.this.arn
8+
description = "S3 bucket ARN for DB dumps"
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
variable "bucket_name" {
2+
description = "Name of the S3 bucket for DB dumps"
3+
type = string
4+
}
5+
6+
variable "expiration_days" {
7+
description = "Number of days after which dump objects are automatically deleted"
8+
type = number
9+
default = 7
10+
}
11+
12+
variable "common_tags" {
13+
description = "Common tags to apply to resources"
14+
type = map(string)
15+
default = {}
16+
}

terraform/modules/eks/irsa-iam-policy-and-role.tf

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ resource "aws_iam_policy" "application_policy" {
127127
"arn:aws:s3:::cer-envelope-graphs-prod-us-east-1/*",
128128
"arn:aws:s3:::cer-envelope-downloads/*",
129129
"arn:aws:s3:::ocn-exports/*",
130-
"arn:aws:s3:::cer-resources*/*"
130+
"arn:aws:s3:::cer-resources*/*",
131+
"arn:aws:s3:::cer-db-dumps-prod/*"
131132
]
132133
},
133134
{
@@ -147,7 +148,8 @@ resource "aws_iam_policy" "application_policy" {
147148
"arn:aws:s3:::cer-envelope-graphs-prod-us-east-1",
148149
"arn:aws:s3:::cer-envelope-downloads",
149150
"arn:aws:s3:::ocn-exports",
150-
"arn:aws:s3:::cer-resources*"
151+
"arn:aws:s3:::cer-resources*",
152+
"arn:aws:s3:::cer-db-dumps-prod"
151153
]
152154
}
153155
]

0 commit comments

Comments
 (0)