Storage & data ops¶
This page documents the storage and data-lifecycle utilities that live in the
sibling repository platform-utils.
These tools deal with S3 sizing/costing and MySQL data archival, plus a couple of
one-off, developer-local working sets that are checked in (or left) under src/ but are
not built or deployed.
Four components are covered:
| Component | Language | Built image? | Deployed? | Cadence |
|---|---|---|---|---|
s3-bucket-size |
Bash | Yes (ghcr.io/example-org/s3-bucket-size) |
Yes — CronJob | Monthly |
s3-sums |
Python | No | No | Local analysis only |
mysql-archival |
Bash | Yes (ghcr.io/example-org/mysql-archival) |
Yes — CronJob | Monthly (prod), monthly dry-run (staging) |
client-c-migration |
n/a (docs + data) | No | No | One-off working set (untracked) |
Two of these are not container components
Only s3-bucket-size and mysql-archival are real deployable utilities with a
Dockerfile, a docker-bake.hcl target, and Kubernetes manifests. s3-sums
and client-c-migration are developer-local working directories that happen to sit under
src/. s3-sums has no committed source at all (only a __pycache__/ and a
venv/), and client-c-migration is entirely untracked (git status shows ?? src/client-c-migration/).
They are documented here for completeness so a new owner knows what they are and why
nothing deploys them.
s3-bucket-size¶
A Bash utility that walks every S3 bucket in an AWS account and reports each bucket's total
size and object count to a CSV, with special recursive per-prefix handling for the very large
example-prod-media bucket. It can be run locally against a named AWS profile, or on a schedule as a
Kubernetes CronJob for periodic storage audits.
- Language: Bash (single script)
- Source:
src/s3-bucket-size/s3-bucket-size.sh,README.md - Key tooling:
awsCLI (s3 ls,s3api list-objects-v2),jq,awk
What it does¶
- Lists all buckets (
aws s3 ls) and, for each, runsaws s3 ls --recursive --human-readable --summarizeto extract Total Objects and Total Size, appending a row toallregions-buckets-s3-sizes.csv(headerBucket/Path,Objects,Size). - Special-cases
example-prod-media: instead of sizing the whole (huge) bucket, it marks itSKIPPEDand recurses into its prefixes withanalyze_subdirectories(), usings3api list-objects-v2 --delimiter '/'to discover common prefixes and descending up to 3 levels deep (if [ ${depth} -lt 3 ]). - Writes a live progress log
s3_calculation_progress.log(tail-able while running) and prints formatted result boxes per prefix. - Cleans up and recreates the CSV/progress files on each run.
The AWS profile is controlled by PROFILE (defaults to default); locally you run e.g.
PROFILE=prod ./s3-bucket-size.sh.
Dockerfile & image¶
- Base image:
ubuntu:22.04(Dockerfile) - Installs
awscli,jq,bash,curl,ca-certificates,tzdata; runs as non-root users3user(uid 1000);ENTRYPOINTis the script. - Built image:
ghcr.io/example-org/s3-bucket-size(tags:${VERSION}and:latest), from thes3-bucket-sizebake target indocker-bake.hcl(contextsrc/s3-bucket-size). It is part of the default build group, so it builds with the rest of the fleet.
Kubernetes deployment¶
Deployed as a CronJob in the misc namespace.
- Base manifest:
k8s/base/s3-bucket-size/cronjob.yaml - Schedule:
0 2 1 * *— monthly, 1st of the month at 02:00 UTC - Job policy:
concurrencyPolicy: Forbid,activeDeadlineSeconds: 14400(4h cap for large scans),backoffLimit: 2,ttlSecondsAfterFinished: 86400,restartPolicy: Never, history limits 3/3. - Hardened pod:
runAsNonRoot, uid 1000,readOnlyRootFilesystem: true,allowPrivilegeEscalation: false, drops all capabilities; anemptyDiris mounted at/app/output.
Config & credentials:
- A
ConfigMaps3-bucket-size-configsuppliesAWS_REGION: us-east-1(injected as envAWS_REGION). -
AWS auth is via IRSA — no static keys in-cluster. The
s3-bucket-sizeServiceAccount is annotated:
README vs. actual deployment
The component README shows an optional variant using static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
env vars and even an amazon/aws-cli base image. The committed, deployed setup does neither: the real
Dockerfile is Ubuntu-based, and the real CronJob authenticates with the IRSA role above and only needs
S3 read permissions (s3:ListAllMyBuckets, s3:ListBucket, s3:GetBucketLocation).
Output is ephemeral
The CSV and progress log are written into the container filesystem / emptyDir and are not shipped
anywhere (no S3 upload, no Slack). In the CronJob you read results from the pod logs. If you need durable
or notified output, that has to be added.
Prod overlay: k8s/overlays/prod/s3-bucket-size/kustomization.yaml
pins the image to newTag: v0.1.0. There is also a staging overlay
(k8s/overlays/staging/s3-bucket-size/).
Flux / platform-infra wiring¶
The CronJob image line and the overlay newTag both carry the marker
# {"$imagepolicy": "flux-system:s3-bucket-size:tag"}, so Flux image automation updates the pinned tag as new
images are published. platform-infra drives this via its Flux GitRepository on platform-utils and a
Kustomization pointing at the prod overlay path.
s3-sums¶
A Python S3 storage-cost analysis / modelling toolkit. It projects and compares the cost of holding capture/camera-image data across S3 storage classes over a 24-month horizon and emits a report with charts.
Not deployed, and source is not committed
src/s3-sums/ contains only a __pycache__/ (compiled *.cpython-314.pyc) and a local venv/.
There is no committed .py source, no Dockerfile, no docker-bake.hcl target, and no k8s/ manifests.
It is an analyst's local working directory. Everything below is reconstructed from the compiled modules and
is here so a new owner understands what the folder is — treat it as an offline spreadsheet-style analysis,
not a running service.
What it does (from the compiled modules)¶
The toolkit is organised as a set of cooperating modules:
main.py— orchestrator ("S3 STORAGE COST ANALYSIS / the platform Systems"). Reads CSV files from adata/directory (S3 billing exports), optionally runs a sampling pass (--sample), projects costs, and drives chart/report generation.calculations.py— "Centralized S3 Cost Calculations Module", the single source of truth for af-south-1 pricing constants (STANDARD_STORAGE_RATE, tiered..._50TB/_450TB/_500TB,GLACIER_RATE,GLACIER_RETRIEVAL,IT_DEEP_ARCHIVE_RATE,TIER1/2/4_RATE,MIN_SIZE_GLACIER_IR_KB, etc.) and cost↔GB-month conversion helpers.generate_report.py— writes aREPORT.mdwith sections and links to discovered graphs (executive summary, cumulative costs, cost-per-GB, cost-per-million-objects, break-even, IT-vs-Glacier crossover…).hybrid_migration.py/hybrid_glacier.py— model "keep old Intelligent-Tiering data static, send new data to S3 Standard / Glacier IR from month N" scenarios.direct_upload_comparison.py,simple_comparison.py,analyze_it_breakeven.py— additional scenarios comparing S3 Standard, Intelligent-Tiering (+ Deep Archive), and Glacier Instant Retrieval, and analysing what access rate would make IT cheaper than Glacier IR.
Inputs/outputs: input is CSV billing-export data in a data/ directory; output is charts plus a generated
REPORT.md. There are no AWS credentials or secrets involved — it is pure offline cost arithmetic.
mysql-archival¶
A containerised maintenance job that archives old MySQL rows to S3 and then prunes them from the database, reclaiming space in the capture admin-portal database. Runs as a monthly CronJob (with a dry-run staging variant) and ships a companion rollback script for restoring archived data from S3.
- Language: Bash
- Source:
src/mysql-archival/—scripts/db_archival.sh(main),scripts/db_rollback.sh(restore),scripts/healthcheck.sh,README.md,ROLLBACK_PLAN.md
Archival flow¶
The db_archival.sh script (set -e, structured logging to /tmp/db_archival_<ts>.log) archives data older
than 18 months (ARCHIVE_DATE = now − 18 months, computed per-OS for Linux/macOS):
- Prerequisite checks —
DB_PASSWORDmust be set (no default, for safety); verifies MySQL connectivity (SELECT 1), the AWS CLI, and S3 bucket access before touching anything. - Export each target table's older-than-18-months rows to a
.csv.gz, plus a.sql.gzschema dump. - Upload to S3 under
database_archives/<DB_NAME>/<table>/…and verify the upload before deleting. - Delete archived rows from the DB in batches (
BATCH_SIZE, default10000). - Optimize tables to reclaim disk space.
Tables archived (per the README): up_down_status, voi_item_log, search_reason, search_log,
user_log.
Archive layout in S3 (from ROLLBACK_PLAN.md):
s3://example-prod-backups/database_archives/capture_admin_portal/
├── up_down_status/ <table>_archive_<YYYYMMDD_HHMMSS>.csv.gz + _schema_...sql.gz
├── search_log/ ...
├── search_reason/ ...
├── voi_item_log/ ...
└── logs/ db_archival_<ts>.log
Safety features: dry-run mode, S3-upload verification before any delete, batched deletes, signal handling for graceful interruption, and retrying health checks (S3 read/write with exponential backoff, DB connectivity).
Rollback flow¶
db_rollback.sh restores archived data from S3 back into MySQL. See
ROLLBACK_PLAN.md
for the full runbook. Key commands:
./scripts/db_rollback.sh --list # list available archives
./scripts/db_rollback.sh --table up_down_status # restore latest for a table
./scripts/db_rollback.sh --table up_down_status --date 20250806 # point-in-time
./scripts/db_rollback.sh --dry-run --table search_log # preview
Restoration uses LOAD DATA LOCAL INFILE (tab-separated, ignore header) with INSERT IGNORE semantics to
tolerate overlapping data.
Dockerfile & image¶
- Base image:
ubuntu:22.04(Dockerfile) - Installs
mysql-client,awscli,gzip,curl,ca-certificates,tzdata; runs as non-rootdbarchiver(uid 1000). Has a containerHEALTHCHECKrunning/app/healthcheck.sh;ENTRYPOINTisdb_archival.sh. - Built image:
ghcr.io/example-org/mysql-archival(:${VERSION}and:latest), from themysql-archivalbake target indocker-bake.hcl(contextsrc/mysql-archival). Part of the default build group.
Kubernetes deployment¶
Deployed as CronJob mysql-db-archival in the misc namespace
(k8s/base/mysql-archival/cronjob.yaml).
- Prod schedule:
0 2 1 * *— monthly, 1st at 02:00 UTC,DRY_RUN=false. - Staging schedule:
0 3 15 * *— monthly, 15th at 03:00 UTC, patched toDRY_RUN=true(see staging overlay). - Job policy:
concurrencyPolicy: Forbid,activeDeadlineSeconds: 14400(4h cap),backoffLimit: 1,ttlSecondsAfterFinished: 86400,restartPolicy: Never. - Hardened pod: non-root uid 1000,
readOnlyRootFilesystem: true, no privilege escalation, all caps dropped; anemptyDirmounted at/tmpfor the working files.
Config (ConfigMap mysql-archival-config):
| Key | Value |
|---|---|
DB_HOST |
db-proxy.internal.prod.cpt.aws.example.net |
DB_USER |
captureadmin |
DB_NAME |
capture_admin_portal |
S3_BUCKET |
example-prod-backups |
BATCH_SIZE |
10000 |
DRY_RUN |
false (patched to true in staging) |
Secrets (Secret mysql-archival-secret, injected as env): DB_PASSWORD, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY. The base manifest ships placeholder values
(REPLACE_WITH_ACTUAL_*) — real values are supplied out-of-band (the README notes SOPS encryption).
Auth model differs from s3-bucket-size
Unlike s3-bucket-size (IRSA role), mysql-archival uses static AWS access keys from a Kubernetes
Secret. Per the README these belong to an IAM user db-archival-service-prod writing to the
example-prod-backups bucket, whose S3 lifecycle policy transitions archives Standard → Standard-IA →
Glacier → Deep Archive over time.
Overlays: prod
and staging
both pin newTag: v0.1.0 and carry the # {"$imagepolicy": "flux-system:mysql-archival:tag"} marker for Flux
image automation, driven from platform-infra's Flux GitRepository/Kustomization the same way as
s3-bucket-size.
client-c-migration¶
A one-off, developer-local working set for a City of Cape Town (Client C) capture migration / VPN-outage
investigation. It is not a container component: it has no Dockerfile, no docker-bake.hcl target, and no
k8s/ manifests, and the entire directory is untracked in git (git status reports ?? src/client-c-migration/).
It is documented here only so an owner knows what the folder contains and that nothing builds or deploys it.
Contents on disk (src/client-c-migration/):
RCA-tunnel2-ipsec-phase2.md— a Root Cause Analysis (dated 2026-07-02, by the platform engineer) of a ~7-hour Client C capture-event outage caused by a RouterOS 7.23 IPsec phase-2 regression on the Client C TMC MikroTik CCR2004 terminating the AWS Site-to-Site VPN. (Cross-refers the broader AWS↔Client C VPN topology.)reachability.log,reachability2.log— probe/ping output captured during that investigation.kubeconfigs— cluster access material used during the work.camera_profile.csv— an export of the Client C camera fleet (~314 Client C capture cameras): device id, name/location, host/port/protocol, coordinates, model/make/serial/firmware, VPN and reachability flags, NTP config, etc..env— a read-only Mongo connection string (MONGO_URI_READ_ONLY) used to query the Client C capture data.client_c_readonly— a read-only access credential/key.
Sensitive, untracked, and not a deployable
This directory holds live-looking operational data and credentials (a Mongo URI, a client_c_readonly key, and
a camera CSV that includes per-camera hostnames and login/password columns). It is deliberately not committed
and must never be baked into an image or checked in. There is nothing to "run" here — it is investigation
scratch and reference data tied to the Client C migration/outage work, distinct from the recurring utilities
above. If you are cleaning up src/, treat this as data to preserve/secure or remove locally, not as code to
maintain.