Re-point the staging files bucket
AI-generated content
This document was generated by an AI assistant. Verify accuracy before relying on the details.
The staging Mongo database is a restore of a production dump, so every document in adb-files.files carries fileLocation.bucket = prod-files-life-connect-fr-5824. The download path signs its S3 GET against the bucket stored on the document, not against the Worker's configured bucket, so the staging credentials hit the production bucket, S3 answers 403, and adb-files maps that to 502: every GET /files/:id fails on staging. tools/scripts/files-bucket-migrate.mjs copies each object into the staging bucket and then re-points fileLocation.bucket. It is dry-run by default, resumable, idempotent, and never deletes anything.
At a glance
| Fact | Value |
|---|---|
| Script | tools/scripts/files-bucket-migrate.mjs (core + unit tests in tools/scripts/lib / tools/scripts/test) |
| Runs as | an engineer, from a workstation with AWS access — not from a Worker, not from CI |
| Source bucket | prod-files-life-connect-fr-5824 (eu-west-3), opened with a read-only credential |
| Target bucket | stage-files-life-connect-fr-2729 (eu-west-3) — S3_BUCKET_FILES of the staging env in services/files/wrangler.jsonc |
| Target database | staging Atlas, database adb-files, collection files |
| Default mode | dry-run — --apply is the only flag that writes |
| Deletes | none, ever (no delete exists on either port) |
| Blast radius | staging only: the staging bucket and the staging database |
Why the bucket is read from the document
This is faithful behaviour, not a port bug — do not "fix" it by forcing the configured bucket:
- JVM oracle —
adb-files/.../service/impl/FileServiceImpl.javabuilds the read asGetObjectRequest.builder().bucket(location.getBucket())(both the/files/{id}download and the PDF-concat read). - Port —
services/files/src/download.mjscallsgetObjectResponse({ ...cfg, bucket: loc.bucket }, key)and maps a non-404 S3 failure to502.
The object key is the JVM's too: an explicit fileLocation.key wins, otherwise organizations/<organisationId>/files/<fileId> (AwsS3FileLocation.FILE_KEY_PATTERN).
Why the copy carries the object's content-type
The persisted document has no contentType: FileMetaDataEntity (the @Document(collection = "files")) has no such field — contentType lives only on the transport model FileMetaData, where it signs the upload and is then dropped. download.mjs therefore resolves the response type as doc.contentType ?? <the header S3 returns> ?? "application/octet-stream". A copy that forgot the source header would leave every migrated blob as application/octet-stream on staging while production serves application/pdf / image/jpeg — so the migration reads the object with a GET it keeps the headers of, and replays the type on the staging PUT.
S3 user metadata (x-amz-meta-lc_target_id, stamped by the browser POST-policy upload) is not copied: nothing in the JVM or the port ever reads it back. If that ever changes, the copy needs to forward x-amz-meta-* too.
Prerequisites
pnpm installat the repo root (the script needs themongodbdriver andaws4fetch).- A read-only IAM credential on the production bucket (
s3:GetObjectonprod-files-life-connect-fr-5824/*, nothing else). Do not reuse a read-write production key. - A credential allowed to
s3:GetObjectands3:PutObjecton the staging bucket, pluss3:ListBucketon the bucket itself. WithoutListBucket, S3 answers a GET on a missing key with403instead of404; the script refuses to guess and reports every document as failed (staging S3 GET … responded 403). That is deliberate — treating a denied probe as "absent" would re-point documents at objects nobody verified. - A staging Atlas connection string with write access to
adb-files. - Enough local bandwidth for the blobs — every object missing from staging is streamed through the workstation (download from production, upload to staging).
Environment
Credentials and URIs are read only from the environment; nothing sensitive is passed on the command line. Export them in a shell that is not logged:
export PROD_S3_ACCESS_KEY_ID=… # READ-ONLY production credential
export PROD_S3_SECRET_ACCESS_KEY=…
export PROD_S3_REGION=eu-west-3
export PROD_S3_BUCKET=prod-files-life-connect-fr-5824
export STAGE_S3_ACCESS_KEY_ID=…
export STAGE_S3_SECRET_ACCESS_KEY=…
export STAGE_S3_REGION=eu-west-3
export STAGE_S3_BUCKET=stage-files-life-connect-fr-2729
export STAGE_MONGO_URI='mongodb+srv://…' # staging Atlas
# export STAGE_MONGO_DB=adb-files # optional, this is the default
The script refuses to start if PROD_S3_BUCKET and STAGE_S3_BUCKET are the same, and lists every missing variable at once. --check does not need the production credential at all.
Every run opens with a header naming the cluster it will act on, credentials stripped:
mode: dry-run
mongo: mongodb+srv://stage-cluster.abcd.mongodb.net -> adb-files.files
buckets: prod-files-life-connect-fr-5824 -> stage-files-life-connect-fr-2729
Read that mongo: line before typing --apply. The script writes to whatever STAGE_MONGO_URI points at; a production connection string pasted there would be re-pointed at a bucket it cannot read. The dry-run prints the same header, so the check costs nothing.
Steps
1. Census (read-only)
node tools/scripts/files-bucket-migrate.mjs --check
Prints the document count per fileLocation.bucket and, over a bounded sample (--sample, default 20), how many of those objects are already in the staging bucket. Nothing is copied or written.
2. Dry run (the default)
node tools/scripts/files-bucket-migrate.mjs
node tools/scripts/files-bucket-migrate.mjs --limit 20 # a smaller first look
Reports what would be copied and re-pointed. It probes the staging bucket but never reads production, never writes, and never touches the manifest. --limit takes a positive integer — --limit 0 is refused rather than read as "no limit"; omitting the flag is the only way to ask for every document.
3. Apply
Start small, then run the rest:
node tools/scripts/files-bucket-migrate.mjs --apply --limit 20
node tools/scripts/files-bucket-migrate.mjs --apply
Per document: copy the object into staging when it is missing → verify it is readable there → only then $set fileLocation.bucket. Each completed document is appended to the manifest (.files-bucket-migrate.manifest.jsonl by default, --manifest <path> to relocate it). Interrupt it with Ctrl-C and re-run the same command: manifest entries are skipped, already-copied objects are not re-uploaded, and already-re-pointed documents are left alone.
A document that fails (unreadable blob, denied read) is logged, recorded in the manifest as failed, and the walk continues; the process exits 1 if anything failed. Re-running retries exactly those.
4. Verify
node tools/scripts/files-bucket-migrate.mjs --check
Every document should now be counted under the staging bucket. Then, in the staging SPA, open a document (a rent receipt or an agency logo) and confirm GET /files/:id returns 200 instead of 502.
What it deliberately does not do
- No deletes. Production objects stay, staging objects are never removed, no document is dropped. Cleaning up an over-copied staging bucket is a separate, manual decision.
- No writes to production. Production is only ever read, with the read-only credential.
- No whole-
fileLocationrewrite. Only thebucketfield is$set, so_class, an explicitkey, and anything else the dump carries survive. The JVM'sMetaDataUpdateRepositoryImpl.patchreplaces the whole sub-document because it writes a freshly builtAwsS3FileLocation; a migration must not drop fields, so it changes the one field that moves. - No Worker-side override. Nothing in
services/fileschanges; the parity with the JVM read path is preserved.
Open questions
- The
productionenv block ofservices/files/wrangler.jsoncstill namesS3_BUCKET_FILES: files.life-connect.frineu-west-1, which matches neither the audited bucket (prod-files-life-connect-fr-5824) nor its region (eu-west-3). That is a separate cutover decision, flagged in the file itself — this runbook only concerns staging. - Objects whose
fileLocationhas neither akeynororganisationId/fileIdare skipped and reported. If--checkshows a non-zero count there, those documents need an individual look. - The copy is sequential and streams through the workstation. If the full run is too slow for the volume, an S3 server-side copy (
CopyObjectbetween the two buckets, same region) would be the next step — it needs a credential trusted by both buckets, which is why it is not the default.