Compare commits

...
Author SHA1 Message Date
Lang 3e90bbe5cf chg: pkg: migrate from shitty Minio to RustFS #16
Deploy to Production / deploy (push) Successful in 3m9s
2026-10-24 17:45:19 +02:00
Lang 1d8d0ac110 fix: pkg: upgrade PHP due to the security considerations #16 2026-10-24 16:23:40 +02:00
14 changed files with 1501 additions and 905 deletions
+47 -4
View File
@@ -33,12 +33,55 @@ jobs:
cd "${{ vars.PROD_APP_DIR }}" cd "${{ vars.PROD_APP_DIR }}"
docker compose build docker compose build
- name: Start services - name: Migrate legacy MinIO and deploy
env: env:
APP_VERSION: ${{ vars.APP_VERSION }} APP_VERSION: ${{ vars.APP_VERSION }}
run: | run: |
set -euo pipefail
cd "${{ vars.PROD_APP_DIR }}" cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d minio
docker compose rm -sf minio_init MIGRATION_COMPOSE="docker compose --profile storage-migration"
docker compose run --rm minio_init LEGACY_MINIO_CONTAINER=$($MIGRATION_COMPOSE ps -a -q minio)
if [ -n "$LEGACY_MINIO_CONTAINER" ]; then
echo "Legacy MinIO found; pausing writers and migrating its buckets to RustFS."
APP_WAS_RUNNING=$(docker compose ps --status running -q app)
SCHEDULER_WAS_RUNNING=$(docker compose ps --status running -q scheduler)
MIGRATION_SUCCEEDED=0
restart_old_workers_on_failure() {
result=$?
if [ "$result" -ne 0 ] && [ "$MIGRATION_SUCCEEDED" -eq 0 ]; then
# Before a complete copy, restore the old public MinIO endpoint
# before bringing back the old app.
docker compose stop rustfs || true
MINIO_PUBLISHED_API_PORT=9010 MINIO_PUBLISHED_CONSOLE_PORT=9011 $MIGRATION_COMPOSE up -d minio || true
[ -z "$APP_WAS_RUNNING" ] || docker compose start app || true
[ -z "$SCHEDULER_WAS_RUNNING" ] || docker compose start scheduler || true
elif [ "$result" -ne 0 ]; then
echo "Data migration succeeded; leaving RustFS in place for recovery from the app rollout failure." >&2
fi
return "$result"
}
trap restart_old_workers_on_failure EXIT
docker compose stop app scheduler || true
docker compose up -d db mail
# Move the legacy service off the RustFS public port before starting RustFS.
$MIGRATION_COMPOSE up -d minio
docker compose up -d rustfs
$MIGRATION_COMPOSE up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
MIGRATION_SUCCEEDED=1
echo "Migration succeeded; deploying the RustFS-only stack."
docker compose up -d --wait --wait-timeout 180 app scheduler
# Remove only the legacy service containers. Do not use -v: the old
# MinIO volume is retained as a rollback/backup until manually retired.
$MIGRATION_COMPOSE rm -sf minio storage_migrate
trap - EXIT
else
echo "No legacy MinIO container found; deploying the RustFS-only stack."
docker compose up -d docker compose up -d
fi
+4 -1
View File
@@ -18,7 +18,10 @@
header X-Forwarded-Host {host} header X-Forwarded-Host {host}
mercure { mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} transport bolt {
path /data/mercure.db
}
protocol_version_compatibility 8
publisher_jwt {$MERCURE_JWT_SECRET} HS256 publisher_jwt {$MERCURE_JWT_SECRET} HS256
subscriber_jwt {$MERCURE_JWT_SECRET} HS256 subscriber_jwt {$MERCURE_JWT_SECRET} HS256
anonymous anonymous
+28 -14
View File
@@ -4,8 +4,8 @@
help: help:
@echo "Available commands:" @echo "Available commands:"
@echo " make start - Start services and initialize MinIO (uses existing images)" @echo " make start - Start services (storage migration runs automatically once)"
@echo " make start-build - Build, start services, and initialize MinIO" @echo " make start-build - Build and start services (storage migration runs automatically once)"
@echo " make stop - Stop running services" @echo " make stop - Stop running services"
@echo " make build - Build Docker images only" @echo " make build - Build Docker images only"
@echo " make down - Stop and remove containers/networks" @echo " make down - Stop and remove containers/networks"
@@ -19,27 +19,41 @@ help:
@echo " make test - Run PHPUnit tests" @echo " make test - Run PHPUnit tests"
start: start:
docker compose up -d minio docker compose up -d db mail
docker compose rm -sf minio_init @if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
docker compose run --rm minio_init echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose up -d docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d app scheduler
start-build: start-build:
composer i composer i
bun run build bun run build
docker compose up -d --build minio docker compose up -d --build db mail
docker compose rm -sf minio_init @if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
docker compose run --rm minio_init echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose up -d --build docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d --build app scheduler
stop: stop:
docker compose stop docker compose --profile storage-migration stop
build: build:
docker compose build docker compose --profile storage-migration build
down: down:
docker compose down docker compose --profile storage-migration down
prune-everything: prune-everything:
@echo "WARNING: This will remove ALL containers, volumes, networks and images!" @echo "WARNING: This will remove ALL containers, volumes, networks and images!"
@@ -48,7 +62,7 @@ prune-everything:
echo "Aborted."; \ echo "Aborted."; \
exit 1; \ exit 1; \
fi fi
docker compose down -v --rmi all --remove-orphans docker compose --profile storage-migration down -v --rmi all --remove-orphans
mercure-jwt: mercure-jwt:
@php bin/generate-mercure-jwt.php @php bin/generate-mercure-jwt.php
+11 -11
View File
@@ -14,7 +14,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- **Guest & registered play** — jump in anonymously or create an account for stats and history - **Guest & registered play** — jump in anonymously or create an account for stats and history
- **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes - **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes
- **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links - **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links
- **Profile pictures** — uploaded to MinIO object storage, thumbnails generated on-the-fly by LiipImagine - **Profile pictures** — uploaded to RustFS object storage, thumbnails generated on-the-fly by LiipImagine
- **Battle replay sharing** — share a direct link to any finished game - **Battle replay sharing** — share a direct link to any finished game
- **Docker-ready** — single `make start-build` brings up the full production-like stack - **Docker-ready** — single `make start-build` brings up the full production-like stack
@@ -28,7 +28,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
| Frontend | React 19, Vite, MUI, SCSS | | Frontend | React 19, Vite, MUI, SCSS |
| Database | PostgreSQL 18 | | Database | PostgreSQL 18 |
| Real-time | Mercure (built into FrankenPHP / Caddy) | | Real-time | Mercure (built into FrankenPHP / Caddy) |
| File storage | MinIO (S3-compatible) | | File storage | RustFS (S3-compatible; one-time migration from legacy MinIO) |
| Image processing | LiipImagine + Flysystem | | Image processing | LiipImagine + Flysystem |
| Server | FrankenPHP (Caddy + PHP in one binary) | | Server | FrankenPHP (Caddy + PHP in one binary) |
| Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework | | Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework |
@@ -44,7 +44,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- PostgreSQL 18 - PostgreSQL 18
- Caddy with FrankenPHP and the Mercure module - Caddy with FrankenPHP and the Mercure module
- MailHog (or any SMTP server on port 1025) - MailHog (or any SMTP server on port 1025)
- MinIO (API on port 9010, console on port 9011) - RustFS (API on port 9010, console on port 9011); legacy MinIO source on 9012/9013 during migration
### Docker ### Docker
- Docker Engine 24+ - Docker Engine 24+
@@ -75,9 +75,9 @@ Edit `.env` and fill in every value. Key ones:
|---|---| |---|---|
| `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` | | `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` |
| `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials | | `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials |
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials | | `MINIO_ROOT_USER/PASSWORD` | S3 storage credentials (used by RustFS and legacy MinIO during migration) |
| `MINIO_ENDPOINT` | `http://localhost:9010` (bare-metal) | | `MINIO_ENDPOINT` | `http://localhost:9010` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO | | `MINIO_PUBLIC_URL` | Public URL browsers use to reach RustFS (including media and LiipImagine thumbnails) |
| `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) | | `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) |
| `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) | | `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) | | `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
@@ -104,7 +104,7 @@ sudo systemctl reload caddy
make start-build make start-build
``` ```
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker image, and starts all services (`app`, `db`, `mail`, `minio`, `minio_init`). This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker images, and starts all services. RustFS migration runs automatically once before the app and scheduler start.
The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`). The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`).
@@ -119,11 +119,11 @@ bun run dev # Vite dev server with hot-reload
php bin/console doctrine:migrations:migrate --no-interaction php bin/console doctrine:migrations:migrate --no-interaction
``` ```
Start MinIO and MailHog, then open the URL configured in your Caddy vhost. Start RustFS and MailHog, then open the URL configured in your Caddy vhost.
### 5. MinIO bucket setup ### 5. Object storage setup
**Docker** — handled automatically on first start by the `minio_init` service. No action needed. **Docker** — `make start` and `make start-build` run the one-shot migration before starting the app. Production releases pause writers, migrate and verify all MinIO buckets, start the RustFS-backed app, then remove the MinIO container while retaining its volume. See [Object storage migration](docs/MINIO_INITIALIZATION.md).
**Bare-metal** — run once after MinIO is up: **Bare-metal** — run once after MinIO is up:
@@ -217,7 +217,7 @@ POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>" MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://rustfs:9000
MINIO_PUBLIC_URL=https://aws.mineseeker.hu MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0 MAILER_DSN=smtp://mail:25?verify_peer=0
@@ -270,7 +270,7 @@ git tag v2026.01
git push origin v2026.01 git push origin v2026.01
``` ```
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`. This writes `.env`, builds the Docker images, runs database migrations, and—if the legacy MinIO container exists—pauses app writers, copies and verifies all its buckets in RustFS, then removes the MinIO container after the app is running on RustFS. The MinIO data volume is retained.
#### 4. Verify #### 4. Verify
+24 -5
View File
@@ -28,6 +28,12 @@ if [ -z "$DB_USER" ]; then
exit 1 exit 1
fi fi
DB_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_DB=//p')
if [ -z "$DB_NAME" ]; then
echo "Error: Could not read POSTGRES_DB from the db service."
exit 1
fi
echo "Verifying PostgreSQL archive..." echo "Verifying PostgreSQL archive..."
docker exec -i "$CONTAINER_ID" pg_restore --list < "$BACKUP_FILE" > /dev/null docker exec -i "$CONTAINER_ID" pg_restore --list < "$BACKUP_FILE" > /dev/null
echo "Archive OK." echo "Archive OK."
@@ -53,13 +59,26 @@ trap restart_services EXIT
echo "Stopping MineSeeker services that use PostgreSQL..." echo "Stopping MineSeeker services that use PostgreSQL..."
docker compose stop app scheduler docker compose stop app scheduler
echo "Restoring PostgreSQL backup: $BACKUP_FILE" echo "Dropping configured database '$DB_NAME'..."
docker exec "$CONTAINER_ID" dropdb \
--username="$DB_USER" \
--if-exists \
--force \
"$DB_NAME"
echo "Creating '$DB_NAME' owned by '$DB_USER'..."
docker exec "$CONTAINER_ID" createdb \
--username="$DB_USER" \
--owner="$DB_USER" \
--template=template0 \
"$DB_NAME"
echo "Restoring PostgreSQL backup without source ownership/ACLs: $BACKUP_FILE"
docker exec -i "$CONTAINER_ID" pg_restore \ docker exec -i "$CONTAINER_ID" pg_restore \
--username="$DB_USER" \ --username="$DB_USER" \
--dbname=postgres \ --dbname="$DB_NAME" \
--clean \ --no-owner \
--create \ --no-acl \
--if-exists \
--exit-on-error < "$BACKUP_FILE" --exit-on-error < "$BACKUP_FILE"
echo "Restore complete." echo "Restore complete."
+74 -15
View File
@@ -6,7 +6,7 @@ services:
restart: unless-stopped restart: unless-stopped
container_name: '${APP_NAME}-app' container_name: '${APP_NAME}-app'
ports: ports:
- "10080:80" - "${APP_PUBLISHED_PORT:-10080}:80"
environment: environment:
SERVER_NAME: ${SERVER_NAME:-:80} SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod APP_ENV: prod
@@ -32,7 +32,7 @@ services:
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
# IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production. # IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production.
# For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network) # For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network)
@@ -43,11 +43,21 @@ services:
- app_var:/app/var - app_var:/app/var
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ -o /dev/null"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
mail: mail:
condition: service_started condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
scheduler: scheduler:
build: build:
context: . context: .
@@ -77,7 +87,7 @@ services:
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000 MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes: volumes:
@@ -87,17 +97,59 @@ services:
condition: service_healthy condition: service_healthy
mail: mail:
condition: service_started condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
healthcheck: healthcheck:
disable: true disable: true
rustfs:
image: rustfs/rustfs:1.0.3
restart: unless-stopped
container_name: '${APP_NAME}-rustfs'
environment:
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RUSTFS_ADDRESS: ':9000'
RUSTFS_CONSOLE_ADDRESS: ':9001'
RUSTFS_CONSOLE_ENABLE: 'true'
volumes:
- rustfs_data:/data
ports:
- "${RUSTFS_PUBLISHED_API_PORT:-9010}:9000"
- "${RUSTFS_PUBLISHED_CONSOLE_PORT:-9011}:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
storage_init:
build:
context: ./docker/storage-migrate
entrypoint: ["python"]
command: ["/usr/local/bin/storage-init.py"]
restart: "no"
container_name: '${APP_NAME}-storage-init'
depends_on:
rustfs:
condition: service_healthy
environment:
TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
minio: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 profiles: ["storage-migration"]
image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1}
pull_policy: never
restart: unless-stopped restart: unless-stopped
container_name: '${APP_NAME}-minio' container_name: '${APP_NAME}-minio'
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
ports: ports:
- "9010:9000" - "${MINIO_PUBLISHED_API_PORT:-9012}:9000"
- "9011:9001" - "${MINIO_PUBLISHED_CONSOLE_PORT:-9013}:9001"
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
@@ -109,20 +161,25 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 10s start_period: 10s
minio_init: storage_migrate:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 profiles: ["storage-migration"]
profiles: ['bootstrap'] build:
context: ./docker/storage-migrate
restart: "no" restart: "no"
container_name: '${APP_NAME}-minio-init' container_name: '${APP_NAME}-storage-migrate'
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
rustfs:
condition: service_healthy
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER} SOURCE_ENDPOINT: http://minio:9000
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
volumes: volumes:
- ./docker/minio-init.sh:/minio-init.sh:ro - storage_migration_state:/state
entrypoint: ["/bin/sh", "/minio-init.sh"]
mail: mail:
image: boky/postfix:latest image: boky/postfix:latest
restart: unless-stopped restart: unless-stopped
@@ -157,7 +214,7 @@ services:
retries: 10 retries: 10
start_period: 10s start_period: 10s
ports: ports:
- "5452:5432" - "${POSTGRES_PUBLISHED_PORT:-5452}:5432"
volumes: volumes:
app_var: app_var:
postgres_data: postgres_data:
@@ -165,3 +222,5 @@ volumes:
caddy_config: caddy_config:
postfix_spool: postfix_spool:
minio_data: minio_data:
rustfs_data:
storage_migration_state:
+3 -3
View File
@@ -10,7 +10,7 @@
"prefer-stable": true, "prefer-stable": true,
"private": true, "private": true,
"require": { "require": {
"php": ">=8.5", "php": ">=8.5.12",
"ext-gd": "*", "ext-gd": "*",
"ext-iconv": "*", "ext-iconv": "*",
"ext-json": "*", "ext-json": "*",
@@ -33,8 +33,8 @@
"symfony/framework-bundle": "8.1.*", "symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*", "symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*", "symfony/mailer": "8.1.*",
"symfony/mercure": "^0.8", "symfony/mercure": "^0.9",
"symfony/mercure-bundle": "^0.5", "symfony/mercure-bundle": "^0.6",
"symfony/messenger": "8.1.*", "symfony/messenger": "8.1.*",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/scheduler": "8.1.*", "symfony/scheduler": "8.1.*",
Generated
+984 -837
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ mercure:
default: default:
url: '%env(MERCURE_URL)%' url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%' public_url: '%env(MERCURE_PUBLIC_URL)%'
protocol_version: '0.x'
jwt: jwt:
secret: '%env(MERCURE_JWT_SECRET)%' secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*' publish: '*'
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim
RUN pip install --no-cache-dir boto3==1.43.118
COPY migrate.py /usr/local/bin/storage-migrate.py
COPY init.py /usr/local/bin/storage-init.py
ENTRYPOINT ["python", "/usr/local/bin/storage-migrate.py"]
+45
View File
@@ -0,0 +1,45 @@
"""Create the application bucket and public object-read policy on RustFS."""
import json
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
s3 = boto3.client(
"s3",
endpoint_url=os.environ["TARGET_ENDPOINT"],
aws_access_key_id=os.environ["STORAGE_ACCESS_KEY"],
aws_secret_access_key=os.environ["STORAGE_SECRET_KEY"],
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
try:
s3.head_bucket(Bucket=BUCKET)
except ClientError as error:
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
code = error.response.get("Error", {}).get("Code", "")
if status != 404 and code not in {"NoSuchBucket", "404", "NotFound"}:
raise
s3.create_bucket(Bucket=BUCKET)
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{BUCKET}/media/*",
f"arn:aws:s3:::{BUCKET}/cache/*",
],
}
],
}
s3.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))
print(f"RustFS bucket {BUCKET!r} is initialized", flush=True)
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Copy the legacy MinIO bucket into RustFS exactly once, without deleting source data."""
import json
import itertools
import os
import sys
import tempfile
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError, EndpointConnectionError
MARKER = "/state/minio-to-rustfs-v2.json"
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
ACCESS_KEY = os.environ["STORAGE_ACCESS_KEY"]
SECRET_KEY = os.environ["STORAGE_SECRET_KEY"]
def client(endpoint):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def wait_for_service(s3, label):
for attempt in range(60):
try:
s3.list_buckets()
print(f"{label} is ready", flush=True)
return
except (EndpointConnectionError, ClientError) as error:
if attempt == 59:
raise RuntimeError(f"{label} did not become ready: {error}") from error
time.sleep(2)
def bucket_exists(s3, bucket):
try:
s3.head_bucket(Bucket=bucket)
return True
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucket", "404", "NotFound"} or status == 404:
return False
raise
def ensure_bucket(s3, bucket):
if not bucket_exists(s3, bucket):
s3.create_bucket(Bucket=bucket)
def reject_versioned_bucket(source, bucket):
versioning = source.get_bucket_versioning(Bucket=bucket).get("Status")
if versioning in {"Enabled", "Suspended"}:
raise RuntimeError(
f"Bucket {bucket!r} has versioning status {versioning}; "
"refusing to omit historical object versions or delete markers"
)
def copy_bucket(source, target, bucket):
copied = 0
paginator = source.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
key = item["Key"]
response = source.get_object(Bucket=bucket, Key=key)
try:
# Botocore's request checksum calculation needs a seekable body.
# Spool to disk beyond 16 MiB so large objects don't consume RAM.
with tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b") as body:
for chunk in response["Body"].iter_chunks(chunk_size=1024 * 1024):
if chunk:
body.write(chunk)
body.seek(0)
args = {
"Bucket": bucket,
"Key": key,
"Body": body,
"ContentLength": response["ContentLength"],
"Metadata": response.get("Metadata", {}),
}
for field in (
"ContentType",
"ContentEncoding",
"CacheControl",
"ContentDisposition",
"ContentLanguage",
"Expires",
):
if response.get(field) is not None:
args[field] = response[field]
if response.get("WebsiteRedirectLocation") is not None:
args["WebsiteRedirectLocation"] = response["WebsiteRedirectLocation"]
target.put_object(**args)
finally:
response["Body"].close()
copied += 1
if copied % 100 == 0:
print(f"Copied {copied} objects", flush=True)
return copied
def inventory(s3, bucket):
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
yield item["Key"], item["Size"]
def verify_copy(source, target, bucket):
for source_item, target_item in itertools.zip_longest(
inventory(source, bucket), inventory(target, bucket)
):
if source_item != target_item:
raise RuntimeError(
f"Object inventory mismatch in bucket {bucket!r}: "
f"source={source_item!r}, target={target_item!r}"
)
def apply_bucket_policy(source, target, bucket):
if bucket != BUCKET:
try:
response = source.get_bucket_policy(Bucket=bucket)
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucketPolicy", "NoSuchKey", "404", "NotFound"} or status == 404:
return
raise
target.put_bucket_policy(Bucket=bucket, Policy=response["Policy"])
return
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{bucket}/media/*",
f"arn:aws:s3:::{bucket}/cache/*",
],
}
],
}
target.put_bucket_policy(Bucket=bucket, Policy=json.dumps(policy))
def migrate_all_buckets(source, target):
buckets = sorted(bucket["Name"] for bucket in source.list_buckets().get("Buckets", []))
if BUCKET not in buckets:
raise RuntimeError(f"Required source bucket {BUCKET!r} does not exist; refusing to mark migration complete")
totals = {}
for bucket in buckets:
reject_versioned_bucket(source, bucket)
ensure_bucket(target, bucket)
totals[bucket] = copy_bucket(source, target, bucket)
verify_copy(source, target, bucket)
apply_bucket_policy(source, target, bucket)
print(f"Migrated bucket {bucket!r}: {totals[bucket]} objects", flush=True)
return totals
def main():
if os.path.exists(MARKER):
print("Storage migration already completed; skipping", flush=True)
return
source = client(os.environ["SOURCE_ENDPOINT"])
target = client(os.environ["TARGET_ENDPOINT"])
wait_for_service(source, "MinIO")
wait_for_service(target, "RustFS")
totals = migrate_all_buckets(source, target)
# Publish the completion marker only after the data and policy are confirmed.
temporary_marker = f"{MARKER}.tmp"
with open(temporary_marker, "w", encoding="utf-8") as marker:
json.dump({"buckets": totals, "objects_copied": sum(totals.values())}, marker)
marker.write("\n")
marker.flush()
os.fsync(marker.fileno())
os.replace(temporary_marker, MARKER)
print(
f"Migration complete: {sum(totals.values())} objects copied across "
f"{len(totals)} buckets; source data was left untouched",
flush=True,
)
if __name__ == "__main__":
try:
main()
except Exception as error: # Keep the failed container's logs useful to operators.
print(f"Storage migration failed: {error}", file=sys.stderr, flush=True)
raise
+60 -12
View File
@@ -1,19 +1,67 @@
# MinIO Initialization # Object storage migration
The `minio_init` Compose service creates the `mineseeker` bucket and applies RustFS is the normal S3-compatible store. MinIO and `storage_migrate` are gated
its public read policy. It is an idempotent bootstrap task: after the bucket behind the `storage-migration` Compose profile and are not started by a normal
exists, it exits without changing it. `docker compose up -d`.
It belongs to the `bootstrap` Compose profile, so normal `docker compose up` On the first production release with a legacy MinIO container, the deployment
does not create a stopped `mineseeker-minio-init` container. workflow pauses app/scheduler writers, starts both stores, copies every bucket
and its current objects (including `mineseeker` media and LiipImagine cache
thumbnails), verifies each source/target key-and-size inventory, then starts
the app on RustFS. It also carries over bucket policies, while enforcing the
existing public-read policy for `mineseeker/media/*` and `mineseeker/cache/*`.
Versioned or suspended-versioning buckets fail safely instead of silently
losing historical versions/delete markers. Only after successful migration
and app startup does it remove the
MinIO and migration containers. It never removes the named `minio_data` volume;
keep that volume as the rollback/backup until RustFS has been verified and a
separate retirement decision is made. A missing source bucket or inventory
mismatch fails the deployment rather than recording an empty/partial migration.
`make start`, `make start-build`, and the production deployment workflow run RustFS retains the public host ports `9010` (S3 API) and `9011` (console); the
the bootstrap task automatically with `docker compose run --rm minio_init`. legacy MinIO temporarily uses `9012` and `9013`. Confirm the public storage
They also remove any stopped legacy initializer container first. The temporary hostname configured by `MINIO_PUBLIC_URL` (for example, `d.aws...`) proxies to
container is removed as soon as the task succeeds. the RustFS API port before serving the new app. The legacy MinIO uses the local
image named by `MINIO_IMAGE` (defaulting to the previously configured release
tag) and does not try to pull it. If production has another local image tag,
set `MINIO_IMAGE` to that exact reference before deployment.
To run it manually when needed: For future releases, the workflow sees no MinIO container and deploys only the
RustFS stack. Review the release logs and `docker compose logs rustfs` after
rollout; never use `docker compose down -v` as part of migration/retirement.
## Testing a backup in isolation
`bin/restore-minio.sh` erases the MinIO volume belonging to the selected Compose
project before extracting the archive. Test with a separate project name and
`APP_NAME`; Compose then uses separate containers, network, and named volumes.
Use alternate published ports so the test services can run beside the regular
development stack:
```bash ```bash
docker compose run --rm minio_init export COMPOSE_PROJECT_NAME=mine-minio-test
export APP_NAME=mine-minio-test
export MINIO_PUBLISHED_API_PORT=19102
export MINIO_PUBLISHED_CONSOLE_PORT=19103
export RUSTFS_PUBLISHED_API_PORT=19100
export RUSTFS_PUBLISHED_CONSOLE_PORT=19101
export APP_PUBLISHED_PORT=19180
export POSTGRES_PUBLISHED_PORT=15452
export MAILHOG_SMTP_PUBLISHED_PORT=19125
export MAILHOG_UI_PUBLISHED_PORT=19126
docker compose --profile storage-migration up -d minio rustfs
bin/restore-minio.sh bazmeg/mineseeker-minio.2026-10-24.162934.tar.gz
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
docker compose logs storage_migrate
``` ```
The restore confirmation names the test MinIO volume; verify it says
`mine-minio-test_minio_data` before typing `yes`. The migration's state volume
is also isolated. This starts only MinIO and RustFS before the explicit
migration—not the app, scheduler, or database. To start the test app only after
the migration succeeds, restore its PostgreSQL backup, then run
`MINIO_PUBLIC_URL=http://localhost:19100 docker compose up -d app scheduler`
so browser-generated cache URLs reach the isolated RustFS. `docker compose down`
does not delete the test volumes; avoid `down -v` until you intentionally want
to discard the test copy.
+2 -2
View File
@@ -60,8 +60,8 @@ Quick command reference for testing:
### [Scheduled Tasks](./SCHEDULER.md) ### [Scheduled Tasks](./SCHEDULER.md)
Symfony Scheduler tasks and the Docker worker that runs them. Symfony Scheduler tasks and the Docker worker that runs them.
### [MinIO Initialization](./MINIO_INITIALIZATION.md) ### [Object storage migration](./MINIO_INITIALIZATION.md)
Automatic, one-shot setup of the MinIO bucket and its access policy. Automatic, one-shot migration from legacy MinIO to RustFS.
### [CI/CD Integration Guide](./CI_CD.md) ### [CI/CD Integration Guide](./CI_CD.md)
Comprehensive guide for continuous integration and deployment: Comprehensive guide for continuous integration and deployment: