Prove the MinIO/S3 decoupling pattern end-to-end on the smallest possible service, so every later migration (up to the GPU workers) follows a known-good recipe. Pilot service: security-scanner (single, read-only uploads bind — smallest blast radius, per docs/minio-storage-migration.md).
Parent epic: #413. This ticket is the first concrete step under it.
Prerequisite (step 0): actually deploy MinIO
docs/minio-storage-migration.md says "MinIO deployed" but it is not running — no minio service in the swarm and minio.spikersoft.com is not a Traefik router. So first:
Export root creds and deploy the committed stack:
export MINIO_ROOT_USER=spikersoft-admin
export MINIO_ROOT_PASSWORD=<generated 16+ char secret> # store as a secret, not in the stack
docker stack deploy -c minio/docker-stack.yml minio
Confirm S3 API https://minio.spikersoft.com (path-style) and console https://minio-console.spikersoft.com are up via Traefik.
MinIO stays pinned to SERVER (/mnt/fusionio/minio) — that's fine; consumers reach it over the network.
Pilot: migrate security-scanner
Follow the per-service recipe from the migration doc:
Bucket + scoped creds:uploads bucket already needed; create a read-only access key limited to it (mc admin user add + mc admin policy create/attach). One key per service; never root creds.
App change (backend repo): swap the filesystem read for AWSSDK.S3 (ForcePathStyle = true, ServiceURL = https://minio.spikersoft.com); config via Storage:Endpoint/Storage:Bucket + key/secret from env. Keep path-based code behind a flag for dual-run.
Backfill:mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads on SERVER (re-run before cutover).
Dual-run: deploy with S3 enabled, bind still mounted; watch Seq/Jaeger for S3 errors a few days.
Cut over: remove the bind mount, relax placement to node.labels.spikersoft-app == true, redeploy; verify a task can land on 4090 (burst runbook).
Retire the ro binding once done (the shared uploads writers migrate later as a batch — see doc's ordering constraint).
Definition of done
MinIO deployed + fronted.
security-scanner reads objects from the uploads bucket, no /mnt/fusionio bind, and a task successfully schedules on 4090.
Recipe confirmed repeatable for the next service (metadata-extractor).
Explicitly out of scope
The art_pipe GPU workers (see #413 comment) — those contain Python venvs workers exec into and are a separate "bake venvs into the image + sync weights from ai-models" workstream, not a path→S3 swap.
## Goal
Prove the MinIO/S3 decoupling pattern end-to-end on the smallest possible service, so every later migration (up to the GPU workers) follows a known-good recipe. Pilot service: **security-scanner** (single, read-only `uploads` bind — smallest blast radius, per `docs/minio-storage-migration.md`).
Parent epic: #413. This ticket is the first concrete step under it.
## Prerequisite (step 0): actually deploy MinIO
`docs/minio-storage-migration.md` says "MinIO deployed" but it is **not running** — no `minio` service in the swarm and `minio.spikersoft.com` is not a Traefik router. So first:
1. Export root creds and deploy the committed stack:
```
export MINIO_ROOT_USER=spikersoft-admin
export MINIO_ROOT_PASSWORD=<generated 16+ char secret> # store as a secret, not in the stack
docker stack deploy -c minio/docker-stack.yml minio
```
2. Confirm S3 API `https://minio.spikersoft.com` (path-style) and console `https://minio-console.spikersoft.com` are up via Traefik.
3. MinIO stays pinned to SERVER (`/mnt/fusionio/minio`) — that's fine; consumers reach it over the network.
## Pilot: migrate security-scanner
Follow the per-service recipe from the migration doc:
1. **Bucket + scoped creds:** `uploads` bucket already needed; create a read-only access key limited to it (`mc admin user add` + `mc admin policy create/attach`). One key per service; never root creds.
2. **App change (backend repo):** swap the filesystem read for AWSSDK.S3 (`ForcePathStyle = true`, `ServiceURL = https://minio.spikersoft.com`); config via `Storage:Endpoint`/`Storage:Bucket` + key/secret from env. Keep path-based code behind a flag for dual-run.
3. **Backfill:** `mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads` on SERVER (re-run before cutover).
4. **Dual-run:** deploy with S3 enabled, bind still mounted; watch Seq/Jaeger for S3 errors a few days.
5. **Cut over:** remove the bind mount, relax placement to `node.labels.spikersoft-app == true`, redeploy; verify a task can land on 4090 (burst runbook).
6. **Retire** the ro binding once done (the shared `uploads` writers migrate later as a batch — see doc's ordering constraint).
## Definition of done
- MinIO deployed + fronted.
- security-scanner reads objects from the `uploads` bucket, no `/mnt/fusionio` bind, and a task successfully schedules on 4090.
- Recipe confirmed repeatable for the next service (metadata-extractor).
## Explicitly out of scope
The art_pipe GPU workers (see #413 comment) — those contain Python venvs workers `exec` into and are a separate "bake venvs into the image + sync weights from `ai-models`" workstream, not a path→S3 swap.
Step 2 (backend dual-run code) is up: spikersoft-backend PR feat/scanner-s3-dual-run-493 — IStagedObjectStore seam, filesystem default + S3 impl (path-style MinIO, filesystem-shaped StagingPaths mapped to uploads-bucket keys with boundary/traversal hardening), selected by Storage:UseS3 (default false → merging is a no-op until dual-run). 12 new xUnit tests. Steps 0/1/3-6 are swarm-side and stay on this ticket: deploy the committed minio stack, scoped ro key, mc mirror backfill, flag flip for dual-run, then bind removal + spikersoft-app placement and a 4090 scheduling check.
Step 2 (backend dual-run code) is up: spikersoft-backend PR feat/scanner-s3-dual-run-493 — IStagedObjectStore seam, filesystem default + S3 impl (path-style MinIO, filesystem-shaped StagingPaths mapped to uploads-bucket keys with boundary/traversal hardening), selected by Storage:UseS3 (default false → merging is a no-op until dual-run). 12 new xUnit tests. Steps 0/1/3-6 are swarm-side and stay on this ticket: deploy the committed minio stack, scoped ro key, mc mirror backfill, flag flip for dual-run, then bind removal + spikersoft-app placement and a 4090 scheduling check.
Step 0 DONE and externally verified (2026-07-11 ~21:55): https://minio.spikersoft.com/minio/health/live → 200, API root → 403 (auth enforced, as it should be), https://minio-console.spikersoft.com → 200, valid LE cert via Traefik. Running the -cpuv1 pin from infra PR #31. Remaining checklist: (1) merge backend PR #215 (scanner dual-run code — no-op until the flag flips); (2) in the console or via mc: create the uploads bucket + a scoped READ-ONLY access key for the scanner (mc admin user add + policy attach — never root creds); (3) backfill: mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads on SERVER; (4) dual-run: set Storage__UseS3=true + Storage__AccessKey/Storage__SecretKey env on the spikersoft-security-scanner stack (bind still mounted) and watch Seq for S3 errors; (5) cutover: drop the ro bind, relax placement to node.labels.spikersoft-app == true, redeploy, and confirm a scanner task schedules on the 4090 — that's the pilot's definition of done.
Step 0 DONE and externally verified (2026-07-11 ~21:55): https://minio.spikersoft.com/minio/health/live → 200, API root → 403 (auth enforced, as it should be), https://minio-console.spikersoft.com → 200, valid LE cert via Traefik. Running the -cpuv1 pin from infra PR #31. Remaining checklist: (1) merge backend PR #215 (scanner dual-run code — no-op until the flag flips); (2) in the console or via mc: create the uploads bucket + a scoped READ-ONLY access key for the scanner (mc admin user add + policy attach — never root creds); (3) backfill: mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads on SERVER; (4) dual-run: set Storage__UseS3=true + Storage__AccessKey/Storage__SecretKey env on the spikersoft-security-scanner stack (bind still mounted) and watch Seq for S3 errors; (5) cutover: drop the ro bind, relax placement to node.labels.spikersoft-app == true, redeploy, and confirm a scanner task schedules on the 4090 — that's the pilot's definition of done.
Scoped service user:scanner-svc created with a dedicated scanner-readonly policy — s3:GetObject on uploads/* + s3:GetBucketLocation on uploads only. No write, delete, or cross-bucket access.
Enforcement verified with the scoped key: stat/cat on an uploads object succeed; cp (PUT) is rejected with Insufficient permissions; bucket listing shows only uploads/ (no other buckets visible). Test probe object removed afterward — bucket left empty.
The scoped keypair (access key scanner-svc + secret) was handed to Joey privately for the scanner stack env — not posted here. It goes in as Storage__AccessKey / Storage__SecretKey alongside Storage__UseS3=true.
Remaining for cutover (SERVER-side, Joey):
Backfill existing files: mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads on SERVER.
Set Storage__UseS3=true + the two scoped-cred env vars on the security-scanner stack and redeploy (dual-run: reads shift to S3, filesystem bind still present as fallback).
Cutover / definition-of-done: drop the /mnt/fusionio read-only bind, relax placement from node.hostname == SERVER to node.labels.spikersoft-app == true, confirm the scanner task can schedule on 4090.
Follow-up reminder: root credentials should be rotated now that setup is complete. Rotating root means redeploying the minio stack with new MINIO_ROOT_USER/MINIO_ROOT_PASSWORD env — logging it here so it isn't forgotten. The scoped scanner-svc key is independent of root and survives rotation.
### MinIO admin setup done — scanner is unblocked
Completed the object-storage side of the pilot against `minio.spikersoft.com` (root creds provided by Joey, out-of-band):
- **Bucket:** `uploads` created, confirmed **private** (`mc anonymous get` → `private`; anon HTTP already returns 403).
- **Scoped service user:** `scanner-svc` created with a dedicated `scanner-readonly` policy — `s3:GetObject` on `uploads/*` + `s3:GetBucketLocation` on `uploads` only. **No** write, delete, or cross-bucket access.
- **Enforcement verified** with the scoped key: `stat`/`cat` on an `uploads` object succeed; `cp` (PUT) is rejected with *Insufficient permissions*; bucket listing shows only `uploads/` (no other buckets visible). Test probe object removed afterward — bucket left empty.
The scoped keypair (access key `scanner-svc` + secret) was handed to Joey privately for the scanner stack env — **not** posted here. It goes in as `Storage__AccessKey` / `Storage__SecretKey` alongside `Storage__UseS3=true`.
**Remaining for cutover (SERVER-side, Joey):**
1. Backfill existing files: `mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads` on SERVER.
2. Set `Storage__UseS3=true` + the two scoped-cred env vars on the security-scanner stack and redeploy (dual-run: reads shift to S3, filesystem bind still present as fallback).
3. Cutover / definition-of-done: drop the `/mnt/fusionio` read-only bind, relax placement from `node.hostname == SERVER` to `node.labels.spikersoft-app == true`, confirm the scanner task can schedule on `4090`.
**Follow-up reminder:** root credentials should be rotated now that setup is complete. Rotating root means redeploying the `minio` stack with new `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` env — logging it here so it isn't forgotten. The scoped `scanner-svc` key is independent of root and survives rotation.
Pilot re-scope: the reader-only pilot can't land alone — first WRITER migrated
Finding (verified live tonight): Joey's book upload traced end-to-end in Seq — the scanner scanned the staging PDF from the filesystem, metadata-extractor wrote covers/pages/images to /app/ebooks/books/SPIKER6924601/, file-movement moved the PDF there, and MinIO saw zero S3 traffic. Two structural reasons:
Writer/reader split:#215 gave the scanner S3 reads, but every writer (API staging, coordinator, extractor, file-movement) still writes /mnt/fusionio. Flipping the scanner alone would make it look in the bucket for files that only exist on disk → every new upload quarantined. The one-time mc mirror backfill can't cover new writes.
Serving dependency: Angular's nginx serves covers/pages from the fusionio root bind — any S3-only writer would break the site.
Direction: dual-write, writer-first. spikersoft-backend PR #222 (merged) gives metadata-extractor an IEbookArtifactMirror — after each extraction, the finished books/<ISBN>/ tree is ALSO uploaded to the new ebooks bucket (created, private). Filesystem stays authoritative; the mirror never throws (failures degrade to filesystem-only). 13 new xUnit tests.
To activate (in order):
Joey: create the metadata-svc MinIO key — policy staged (rw ebooks/*, ro uploads/*); needs explicit authorization for me to mint credentials.
Add METADATA_S3_SECRET_KEY Actions secret (I'll do it once the key exists).
Merge backend #223 (deploy secret plumbing + secrets docs re-land) and infra #35 (stack env) → pull /mnt/infrastructure → dispatch the Metadata Extractor workflow.
Verify: upload a book → mc ls --recursive spiker/ebooks shows books/<ISBN>/covers|pages|images/….
Scanner status: dual-run reads stay OFF (correct — it would break on fresh uploads until the staging writers also dual-write). Sequencing: ebooks writers first (extractor done, file-movement + coordinator next), then staging, then the scanner flip + serving cutover. Hardening PRs backend #219 / infra #33 (the ${VAR:?} footgun) still open.
Also noting for posterity: two commits got stranded by the merge-fast race (branch merged+deleted before a second push — #217's docs, #222's workflow commit). Both re-landed via #223. If a PR gets a second push from me, hold merging for ~a minute.
### Pilot re-scope: the reader-only pilot can't land alone — first WRITER migrated
**Finding (verified live tonight):** Joey's book upload traced end-to-end in Seq — the scanner scanned the staging PDF from the filesystem, metadata-extractor wrote covers/pages/images to `/app/ebooks/books/SPIKER6924601/`, file-movement moved the PDF there, and **MinIO saw zero S3 traffic**. Two structural reasons:
1. **Writer/reader split:** #215 gave the scanner S3 *reads*, but every *writer* (API staging, coordinator, extractor, file-movement) still writes `/mnt/fusionio`. Flipping the scanner alone would make it look in the bucket for files that only exist on disk → every new upload quarantined. The one-time `mc mirror` backfill can't cover new writes.
2. **Serving dependency:** Angular's nginx serves covers/pages from the fusionio root bind — any S3-*only* writer would break the site.
**Direction: dual-write, writer-first.** spikersoft-backend PR #222 (merged) gives metadata-extractor an `IEbookArtifactMirror` — after each extraction, the finished `books/<ISBN>/` tree is ALSO uploaded to the new **`ebooks` bucket** (created, private). Filesystem stays authoritative; the mirror never throws (failures degrade to filesystem-only). 13 new xUnit tests.
**To activate (in order):**
1. **Joey:** create the `metadata-svc` MinIO key — policy staged (rw `ebooks/*`, ro `uploads/*`); needs explicit authorization for me to mint credentials.
2. Add `METADATA_S3_SECRET_KEY` Actions secret (I'll do it once the key exists).
3. Merge backend #223 (deploy secret plumbing + secrets docs re-land) and infra #35 (stack env) → pull /mnt/infrastructure → dispatch the Metadata Extractor workflow.
4. Verify: upload a book → `mc ls --recursive spiker/ebooks` shows `books/<ISBN>/covers|pages|images/…`.
**Scanner status:** dual-run reads stay OFF (correct — it would break on fresh uploads until the *staging* writers also dual-write). Sequencing: ebooks writers first (extractor done, file-movement + coordinator next), then staging, then the scanner flip + serving cutover. Hardening PRs backend #219 / infra #33 (the ${VAR:?} footgun) still open.
Also noting for posterity: two commits got stranded by the merge-fast race (branch merged+deleted before a second push — #217's docs, #222's workflow commit). Both re-landed via #223. If a PR gets a second push from me, hold merging for ~a minute.
✅Dual-write verified in production. Book upload 8809a6e6 (ISBN SPIKER4684262, 37 pages):
Seq 04:49:15 — Ebook mirror: uploaded 75/75 artifacts from /app/ebooks/books/SPIKER4684262 to bucket ebooks
mc du spiker/ebooks — 151MiB, 75 objects (covers png+avif, 37 page PDFs, page images)
Keys mirror the filesystem layout exactly (books/<ISBN>/covers|pages|images/…), so the serving cutover is a prefix swap when we get there. Filesystem untouched/authoritative; pipeline completed normally (book+quiz records, file moved, orchestrator fanned out 36 image-description requests).
The last blocker was subtle: MetadataExtractionConsumer constructs the extraction service directly (for its Rabbit channel), bypassing the DI factory the mirror was registered in — fixed in backend PR #224 by injecting IEbookArtifactMirror into that construction.
#493 remaining, in order: (1) migrate the other ebooks writers the same way (file-movement's final PDF move, coordinator/API staging writes); (2) staging (uploads bucket) dual-write; (3) THEN flip the scanner's S3 reads (safe once writers dual-write); (4) serving cutover + drop binds + relax SERVER pins. Plus the ai-models bucket direction from #497.
✅ **Dual-write verified in production.** Book upload 8809a6e6 (ISBN SPIKER4684262, 37 pages):
Seq 04:49:15 — Ebook mirror: uploaded 75/75 artifacts from /app/ebooks/books/SPIKER4684262 to bucket ebooks
mc du spiker/ebooks — 151MiB, 75 objects (covers png+avif, 37 page PDFs, page images)
Keys mirror the filesystem layout exactly (`books/<ISBN>/covers|pages|images/…`), so the serving cutover is a prefix swap when we get there. Filesystem untouched/authoritative; pipeline completed normally (book+quiz records, file moved, orchestrator fanned out 36 image-description requests).
The last blocker was subtle: MetadataExtractionConsumer constructs the extraction service directly (for its Rabbit channel), bypassing the DI factory the mirror was registered in — fixed in backend PR #224 by injecting IEbookArtifactMirror into that construction.
**#493 remaining, in order:** (1) migrate the other ebooks writers the same way (file-movement's final PDF move, coordinator/API staging writes); (2) staging (uploads bucket) dual-write; (3) THEN flip the scanner's S3 reads (safe once writers dual-write); (4) serving cutover + drop binds + relax SERVER pins. Plus the ai-models bucket direction from #497.
Progress sweep: #500/#502/#508/#506/#497 all closed today with production evidence — captions flow from the 4090 with images AND model sourced from MinIO (110 completed captions in Mongo). New: PR #235 adds the file-movement dual-write so the moved book file itself joins its covers/pages in the ebooks bucket — each book's bucket footprint is now complete. Remaining #493 phases: staging (uploads) writers dual-write → scanner S3 flip → serving cutover → drop binds/pins.
Progress sweep: **#500/#502/#508/#506/#497 all closed today with production evidence** — captions flow from the 4090 with images AND model sourced from MinIO (110 completed captions in Mongo). New: **PR #235** adds the file-movement dual-write so the moved book file itself joins its covers/pages in the `ebooks` bucket — each book's bucket footprint is now complete. Remaining #493 phases: staging (`uploads`) writers dual-write → scanner S3 flip → serving cutover → drop binds/pins.
Scanner-flip preflight complete — plus a sequencing hazard to defuse:
✅security.scan.requested has exactly ONE publisher (UploadOrchestrator) — and merged PR #236 mirrors the staging file immediately BEFORE that publish. Once the mirror deploys, every scanned file is bucket-guaranteed → the scanner flip is safe by construction.
⚠️ Hazard: the scanner stack has carried Storage__UseS3=true on master since infra #32, and scanner CI redeploys on any merge touching Common/**/Data/** — a redeploy before the mirror is live flips the scanner early → new uploads fail scans fail-closed. Defusal: merge infra #43 → pull /mnt/infrastructure → redeploy upload-coordinator → verify one upload lands in mc ls spiker/uploads/ebooks/ — after that the scanner's next redeploy is the safe, intended flip. Then serving cutover closes this ticket.
(Comment delayed by the laptop-server disk-full incident — gitea's package-blob cleanup cron hadn't run since before the registry wipe; triggered now.)
**Scanner-flip preflight complete — plus a sequencing hazard to defuse:**
✅ `security.scan.requested` has exactly ONE publisher (UploadOrchestrator) — and merged PR #236 mirrors the staging file immediately BEFORE that publish. Once the mirror deploys, every scanned file is bucket-guaranteed → the scanner flip is safe by construction.
⚠️ Hazard: the scanner stack has carried `Storage__UseS3=true` on master since infra #32, and scanner CI redeploys on any merge touching `Common/**`/`Data/**` — a redeploy before the mirror is live flips the scanner early → new uploads fail scans fail-closed. **Defusal: merge infra #43 → pull /mnt/infrastructure → redeploy upload-coordinator → verify one upload lands in `mc ls spiker/uploads/ebooks/`** — after that the scanner's next redeploy is the safe, intended flip. Then serving cutover closes this ticket.
(Comment delayed by the laptop-server disk-full incident — gitea's package-blob cleanup cron hadn't run since before the registry wipe; triggered now.)
Staging mirror verified live in production (the gate this ticket was waiting on). Two real uploads today:
19:52:52 UTC — S3StagingMirror: Staging mirror: uploaded uploads/ebooks/spikerj_1783885962_...pdf (Seq) + object in the bucket (mc)
20:04:12 UTC — same for spikerj_1783886641_...pdf
Full pipeline trace on the first: staging save → mirror → SecurityScanConsumer scan (clean, 4s after mirror) → metadata extraction → file movement. The mirror precedes the scan request exactly as designed, so the scanner S3 flip is safe whenever the scanner (re)deploys with its long-standing Storage__UseS3=true env.
Remaining to close: (1) one-time confirmation the scanner actually reads from S3 (its boot log line or docker service inspect env on the running task), (2) then drop the staging bind-mount from the scanner stack. Serving-side cutover for book artifacts is already proven (captions/embeddings/quiz consumed MinIO-hosted artifacts end-to-end).
**Staging mirror verified live in production** (the gate this ticket was waiting on). Two real uploads today:
- 19:52:52 UTC — `S3StagingMirror: Staging mirror: uploaded uploads/ebooks/spikerj_1783885962_...pdf` (Seq) + object in the bucket (mc)
- 20:04:12 UTC — same for `spikerj_1783886641_...pdf`
Full pipeline trace on the first: staging save → mirror → SecurityScanConsumer scan (clean, 4s after mirror) → metadata extraction → file movement. The mirror precedes the scan request exactly as designed, so **the scanner S3 flip is safe** whenever the scanner (re)deploys with its long-standing `Storage__UseS3=true` env.
Remaining to close: (1) one-time confirmation the scanner actually reads from S3 (its boot log line or `docker service inspect` env on the running task), (2) then drop the staging bind-mount from the scanner stack. Serving-side cutover for book artifacts is already proven (captions/embeddings/quiz consumed MinIO-hosted artifacts end-to-end).
Status check while filing the #413 child tree (2026-07-12): metadata-extractor is already dual-running in prod (Storage__UseS3=true + metadata-svc key in its stack file), and the S3 code paths exist in four services (UploadCoordinator StagingMirror, MetadataExtractor EbookArtifactMirror, FileMovement MovedFileMirror/BookArtifactPurger, SecurityScanner S3StagedObjectStore). Remaining for this ticket: security-scanner cutover steps 4-6 (dual-run watch → remove ro bind → relax placement → verify a task lands on 4090). Follow-on tickets for everything else are listed on #413.
Status check while filing the #413 child tree (2026-07-12): metadata-extractor is already dual-running in prod (Storage__UseS3=true + metadata-svc key in its stack file), and the S3 code paths exist in four services (UploadCoordinator StagingMirror, MetadataExtractor EbookArtifactMirror, FileMovement MovedFileMirror/BookArtifactPurger, SecurityScanner S3StagedObjectStore). Remaining for this ticket: security-scanner cutover steps 4-6 (dual-run watch → remove ro bind → relax placement → verify a task lands on 4090). Follow-on tickets for everything else are listed on #413.
Pilot status: MinIO deployed+hardened (infra #54/#55), scanner dual-read code live since the flag flip, recipe proven and generalized into SpikerSoft.Storage (#533) — every later migration followed it. Remaining DoD item: drop the ro bind + schedule a scanner task on 4090, which is #529 phase 2 (runbook merged-pending in infra #60). Suggest closing this into #529 once phase 2 verifies.
Pilot status: MinIO deployed+hardened (infra #54/#55), scanner dual-read code live since the flag flip, recipe proven and generalized into SpikerSoft.Storage (#533) — every later migration followed it. Remaining DoD item: drop the ro bind + schedule a scanner task on 4090, which is #529 phase 2 (runbook merged-pending in infra #60). Suggest closing this into #529 once phase 2 verifies.
security-scanner dual-read live: Storage__UseS3=true + scanner-svc scoped key on the stack (spikersoft-security-scanner/docker-stack.yml), S3StagedObjectStore on backend master
The recipe generalized into SpikerSoft.Storage (#533, closed) and was followed by every later migration — 10 stacks now carry Storage__UseS3=true
Staging writer dual-write verified live in prod (2026-07-12 comments above), making the scanner flip safe by construction
The only unfinished DoD line — drop the :ro bind + relax node.hostname == SERVER + see a task land on 4090 — is literally #529 phase 2 for the whole 7-service batch (the scanner is row 3 of the runbook's table). Keeping two tickets open for one bind-drop is what made this group inaccurate; consolidating into #529, exactly as the 2026-07-13 comment recommended.
**Epic #413 accuracy pass (2026-07-18) — closing: the pilot's goal is achieved; the one residual step is #529's, not this ticket's.**
What this ticket set out to prove is proven, verified against current masters:
- MinIO deployed, fronted, hardened (infra #31/#54/#55, drive-aware healthcheck, `-cpuv1` pin)
- security-scanner dual-read live: `Storage__UseS3=true` + `scanner-svc` scoped key on the stack (`spikersoft-security-scanner/docker-stack.yml`), `S3StagedObjectStore` on backend master
- The recipe generalized into `SpikerSoft.Storage` (#533, closed) and was followed by **every** later migration — 10 stacks now carry `Storage__UseS3=true`
- Staging writer dual-write verified live in prod (2026-07-12 comments above), making the scanner flip safe by construction
The only unfinished DoD line — *drop the `:ro` bind + relax `node.hostname == SERVER` + see a task land on 4090* — is literally **#529 phase 2** for the whole 7-service batch (the scanner is row 3 of the runbook's table). Keeping two tickets open for one bind-drop is what made this group inaccurate; consolidating into #529, exactly as the 2026-07-13 comment recommended.
Residual tracked in: #529 (phase 2 bind drop + placement relax + 4090 verification).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Goal
Prove the MinIO/S3 decoupling pattern end-to-end on the smallest possible service, so every later migration (up to the GPU workers) follows a known-good recipe. Pilot service: security-scanner (single, read-only
uploadsbind — smallest blast radius, perdocs/minio-storage-migration.md).Parent epic: #413. This ticket is the first concrete step under it.
Prerequisite (step 0): actually deploy MinIO
docs/minio-storage-migration.mdsays "MinIO deployed" but it is not running — nominioservice in the swarm andminio.spikersoft.comis not a Traefik router. So first:https://minio.spikersoft.com(path-style) and consolehttps://minio-console.spikersoft.comare up via Traefik./mnt/fusionio/minio) — that's fine; consumers reach it over the network.Pilot: migrate security-scanner
Follow the per-service recipe from the migration doc:
uploadsbucket already needed; create a read-only access key limited to it (mc admin user add+mc admin policy create/attach). One key per service; never root creds.ForcePathStyle = true,ServiceURL = https://minio.spikersoft.com); config viaStorage:Endpoint/Storage:Bucket+ key/secret from env. Keep path-based code behind a flag for dual-run.mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploadson SERVER (re-run before cutover).node.labels.spikersoft-app == true, redeploy; verify a task can land on 4090 (burst runbook).uploadswriters migrate later as a batch — see doc's ordering constraint).Definition of done
uploadsbucket, no/mnt/fusioniobind, and a task successfully schedules on 4090.Explicitly out of scope
The art_pipe GPU workers (see #413 comment) — those contain Python venvs workers
execinto and are a separate "bake venvs into the image + sync weights fromai-models" workstream, not a path→S3 swap.Step 2 (backend dual-run code) is up: spikersoft-backend PR feat/scanner-s3-dual-run-493 — IStagedObjectStore seam, filesystem default + S3 impl (path-style MinIO, filesystem-shaped StagingPaths mapped to uploads-bucket keys with boundary/traversal hardening), selected by Storage:UseS3 (default false → merging is a no-op until dual-run). 12 new xUnit tests. Steps 0/1/3-6 are swarm-side and stay on this ticket: deploy the committed minio stack, scoped ro key, mc mirror backfill, flag flip for dual-run, then bind removal + spikersoft-app placement and a 4090 scheduling check.
Step 0 DONE and externally verified (2026-07-11 ~21:55): https://minio.spikersoft.com/minio/health/live → 200, API root → 403 (auth enforced, as it should be), https://minio-console.spikersoft.com → 200, valid LE cert via Traefik. Running the -cpuv1 pin from infra PR #31. Remaining checklist: (1) merge backend PR #215 (scanner dual-run code — no-op until the flag flips); (2) in the console or via mc: create the uploads bucket + a scoped READ-ONLY access key for the scanner (mc admin user add + policy attach — never root creds); (3) backfill: mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploads on SERVER; (4) dual-run: set Storage__UseS3=true + Storage__AccessKey/Storage__SecretKey env on the spikersoft-security-scanner stack (bind still mounted) and watch Seq for S3 errors; (5) cutover: drop the ro bind, relax placement to node.labels.spikersoft-app == true, redeploy, and confirm a scanner task schedules on the 4090 — that's the pilot's definition of done.
MinIO admin setup done — scanner is unblocked
Completed the object-storage side of the pilot against
minio.spikersoft.com(root creds provided by Joey, out-of-band):uploadscreated, confirmed private (mc anonymous get→private; anon HTTP already returns 403).scanner-svccreated with a dedicatedscanner-readonlypolicy —s3:GetObjectonuploads/*+s3:GetBucketLocationonuploadsonly. No write, delete, or cross-bucket access.stat/caton anuploadsobject succeed;cp(PUT) is rejected with Insufficient permissions; bucket listing shows onlyuploads/(no other buckets visible). Test probe object removed afterward — bucket left empty.The scoped keypair (access key
scanner-svc+ secret) was handed to Joey privately for the scanner stack env — not posted here. It goes in asStorage__AccessKey/Storage__SecretKeyalongsideStorage__UseS3=true.Remaining for cutover (SERVER-side, Joey):
mc mirror /mnt/fusionio/spikersoft/uploads spiker/uploadson SERVER.Storage__UseS3=true+ the two scoped-cred env vars on the security-scanner stack and redeploy (dual-run: reads shift to S3, filesystem bind still present as fallback)./mnt/fusionioread-only bind, relax placement fromnode.hostname == SERVERtonode.labels.spikersoft-app == true, confirm the scanner task can schedule on4090.Follow-up reminder: root credentials should be rotated now that setup is complete. Rotating root means redeploying the
miniostack with newMINIO_ROOT_USER/MINIO_ROOT_PASSWORDenv — logging it here so it isn't forgotten. The scopedscanner-svckey is independent of root and survives rotation.Pilot re-scope: the reader-only pilot can't land alone — first WRITER migrated
Finding (verified live tonight): Joey's book upload traced end-to-end in Seq — the scanner scanned the staging PDF from the filesystem, metadata-extractor wrote covers/pages/images to
/app/ebooks/books/SPIKER6924601/, file-movement moved the PDF there, and MinIO saw zero S3 traffic. Two structural reasons:/mnt/fusionio. Flipping the scanner alone would make it look in the bucket for files that only exist on disk → every new upload quarantined. The one-timemc mirrorbackfill can't cover new writes.Direction: dual-write, writer-first. spikersoft-backend PR #222 (merged) gives metadata-extractor an
IEbookArtifactMirror— after each extraction, the finishedbooks/<ISBN>/tree is ALSO uploaded to the newebooksbucket (created, private). Filesystem stays authoritative; the mirror never throws (failures degrade to filesystem-only). 13 new xUnit tests.To activate (in order):
metadata-svcMinIO key — policy staged (rwebooks/*, rouploads/*); needs explicit authorization for me to mint credentials.METADATA_S3_SECRET_KEYActions secret (I'll do it once the key exists).mc ls --recursive spiker/ebooksshowsbooks/<ISBN>/covers|pages|images/….Scanner status: dual-run reads stay OFF (correct — it would break on fresh uploads until the staging writers also dual-write). Sequencing: ebooks writers first (extractor done, file-movement + coordinator next), then staging, then the scanner flip + serving cutover. Hardening PRs backend #219 / infra #33 (the ${VAR:?} footgun) still open.
Also noting for posterity: two commits got stranded by the merge-fast race (branch merged+deleted before a second push — #217's docs, #222's workflow commit). Both re-landed via #223. If a PR gets a second push from me, hold merging for ~a minute.
✅ Dual-write verified in production. Book upload 8809a6e6 (ISBN SPIKER4684262, 37 pages):
Keys mirror the filesystem layout exactly (
books/<ISBN>/covers|pages|images/…), so the serving cutover is a prefix swap when we get there. Filesystem untouched/authoritative; pipeline completed normally (book+quiz records, file moved, orchestrator fanned out 36 image-description requests).The last blocker was subtle: MetadataExtractionConsumer constructs the extraction service directly (for its Rabbit channel), bypassing the DI factory the mirror was registered in — fixed in backend PR #224 by injecting IEbookArtifactMirror into that construction.
#493 remaining, in order: (1) migrate the other ebooks writers the same way (file-movement's final PDF move, coordinator/API staging writes); (2) staging (uploads bucket) dual-write; (3) THEN flip the scanner's S3 reads (safe once writers dual-write); (4) serving cutover + drop binds + relax SERVER pins. Plus the ai-models bucket direction from #497.
Progress sweep: #500/#502/#508/#506/#497 all closed today with production evidence — captions flow from the 4090 with images AND model sourced from MinIO (110 completed captions in Mongo). New: PR #235 adds the file-movement dual-write so the moved book file itself joins its covers/pages in the
ebooksbucket — each book's bucket footprint is now complete. Remaining #493 phases: staging (uploads) writers dual-write → scanner S3 flip → serving cutover → drop binds/pins.Scanner-flip preflight complete — plus a sequencing hazard to defuse:
✅
security.scan.requestedhas exactly ONE publisher (UploadOrchestrator) — and merged PR #236 mirrors the staging file immediately BEFORE that publish. Once the mirror deploys, every scanned file is bucket-guaranteed → the scanner flip is safe by construction.⚠️ Hazard: the scanner stack has carried
Storage__UseS3=trueon master since infra #32, and scanner CI redeploys on any merge touchingCommon/**/Data/**— a redeploy before the mirror is live flips the scanner early → new uploads fail scans fail-closed. Defusal: merge infra #43 → pull /mnt/infrastructure → redeploy upload-coordinator → verify one upload lands inmc ls spiker/uploads/ebooks/— after that the scanner's next redeploy is the safe, intended flip. Then serving cutover closes this ticket.(Comment delayed by the laptop-server disk-full incident — gitea's package-blob cleanup cron hadn't run since before the registry wipe; triggered now.)
Staging mirror verified live in production (the gate this ticket was waiting on). Two real uploads today:
S3StagingMirror: Staging mirror: uploaded uploads/ebooks/spikerj_1783885962_...pdf(Seq) + object in the bucket (mc)spikerj_1783886641_...pdfFull pipeline trace on the first: staging save → mirror → SecurityScanConsumer scan (clean, 4s after mirror) → metadata extraction → file movement. The mirror precedes the scan request exactly as designed, so the scanner S3 flip is safe whenever the scanner (re)deploys with its long-standing
Storage__UseS3=trueenv.Remaining to close: (1) one-time confirmation the scanner actually reads from S3 (its boot log line or
docker service inspectenv on the running task), (2) then drop the staging bind-mount from the scanner stack. Serving-side cutover for book artifacts is already proven (captions/embeddings/quiz consumed MinIO-hosted artifacts end-to-end).Status check while filing the #413 child tree (2026-07-12): metadata-extractor is already dual-running in prod (Storage__UseS3=true + metadata-svc key in its stack file), and the S3 code paths exist in four services (UploadCoordinator StagingMirror, MetadataExtractor EbookArtifactMirror, FileMovement MovedFileMirror/BookArtifactPurger, SecurityScanner S3StagedObjectStore). Remaining for this ticket: security-scanner cutover steps 4-6 (dual-run watch → remove ro bind → relax placement → verify a task lands on 4090). Follow-on tickets for everything else are listed on #413.
Pilot status: MinIO deployed+hardened (infra #54/#55), scanner dual-read code live since the flag flip, recipe proven and generalized into SpikerSoft.Storage (#533) — every later migration followed it. Remaining DoD item: drop the ro bind + schedule a scanner task on 4090, which is #529 phase 2 (runbook merged-pending in infra #60). Suggest closing this into #529 once phase 2 verifies.
Epic #413 accuracy pass (2026-07-18) — closing: the pilot's goal is achieved; the one residual step is #529's, not this ticket's.
What this ticket set out to prove is proven, verified against current masters:
-cpuv1pin)Storage__UseS3=true+scanner-svcscoped key on the stack (spikersoft-security-scanner/docker-stack.yml),S3StagedObjectStoreon backend masterSpikerSoft.Storage(#533, closed) and was followed by every later migration — 10 stacks now carryStorage__UseS3=trueThe only unfinished DoD line — drop the
:robind + relaxnode.hostname == SERVER+ see a task land on 4090 — is literally #529 phase 2 for the whole 7-service batch (the scanner is row 3 of the runbook's table). Keeping two tickets open for one bind-drop is what made this group inaccurate; consolidating into #529, exactly as the 2026-07-13 comment recommended.Residual tracked in: #529 (phase 2 bind drop + placement relax + 4090 verification).