Private
Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721eada72f | ||
|
|
7de1514cd9 |
@@ -32,12 +32,12 @@ CAP_API_ENDPOINT=http://localhost:3000/changethis/
|
|||||||
CAP_SECRET_KEY=changethis
|
CAP_SECRET_KEY=changethis
|
||||||
###< trycap.dev/cap ###
|
###< trycap.dev/cap ###
|
||||||
|
|
||||||
###> minio/minio ###
|
###> object-storage ###
|
||||||
MINIO_ROOT_USER=changethis
|
STORAGE_ACCESS_KEY=changethis
|
||||||
MINIO_ROOT_PASSWORD=changethis
|
STORAGE_SECRET_KEY=changethis
|
||||||
MINIO_ENDPOINT=http://localhost:9010
|
STORAGE_ENDPOINT=http://localhost:9010
|
||||||
MINIO_PUBLIC_URL=https://your-minio-subdomain.example.com
|
STORAGE_PUBLIC_URL=https://your-storage-subdomain.example.com
|
||||||
###< minio/minio ###
|
###< object-storage ###
|
||||||
|
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=smtp://localhost:1025
|
MAILER_DSN=smtp://localhost:1025
|
||||||
|
|||||||
@@ -33,55 +33,11 @@ jobs:
|
|||||||
cd "${{ vars.PROD_APP_DIR }}"
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
- name: Migrate legacy MinIO and deploy
|
- name: Deploy
|
||||||
env:
|
env:
|
||||||
APP_VERSION: ${{ vars.APP_VERSION }}
|
APP_VERSION: ${{ vars.APP_VERSION }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "${{ vars.PROD_APP_DIR }}"
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
# --remove-orphans removes retired service containers, not named volumes.
|
||||||
MIGRATION_COMPOSE="docker compose --profile storage-migration"
|
docker compose up -d --wait --wait-timeout 180 --remove-orphans
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This document provides guidelines and context for AI coding agents working on th
|
|||||||
- **Backend:** Symfony 7.2 (PHP 8.3)
|
- **Backend:** Symfony 7.2 (PHP 8.3)
|
||||||
- **Frontend:** React 18, Vite 6
|
- **Frontend:** React 18, Vite 6
|
||||||
- **Database:** PostgreSQL 17 with materialized views
|
- **Database:** PostgreSQL 17 with materialized views
|
||||||
- **Storage:** MinIO (S3-compatible)
|
- **Storage:** RustFS (S3-compatible)
|
||||||
- **Real-time:** Mercure (Server-Sent Events)
|
- **Real-time:** Mercure (Server-Sent Events)
|
||||||
- **Styling:** SCSS, MUI (Material-UI), Emotion
|
- **Styling:** SCSS, MUI (Material-UI), Emotion
|
||||||
- **Fonts:** @fontsource packages (web), Carlito-Bold.ttf (server-side images)
|
- **Fonts:** @fontsource packages (web), Carlito-Bold.ttf (server-side images)
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v2026.4.0-0 (2026-10-24)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
* Migrate from shitty Minio to RustFS #16. [Lang]
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* Upgrade PHP due to the security considerations #16. [Lang]
|
||||||
|
|
||||||
|
|
||||||
## v2026.3.1-2 (2026-09-21)
|
## v2026.3.1-2 (2026-09-21)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available commands:"
|
@echo "Available commands:"
|
||||||
@echo " make start - Start services (storage migration runs automatically once)"
|
@echo " make start - Start services"
|
||||||
@echo " make start-build - Build and start services (storage migration runs automatically once)"
|
@echo " make start-build - Build and start services"
|
||||||
@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,41 +19,21 @@ help:
|
|||||||
@echo " make test - Run PHPUnit tests"
|
@echo " make test - Run PHPUnit tests"
|
||||||
|
|
||||||
start:
|
start:
|
||||||
docker compose up -d db mail
|
docker compose up -d --remove-orphans app scheduler
|
||||||
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
|
|
||||||
echo "Legacy MinIO found; migrating its data to RustFS..."; \
|
|
||||||
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 db mail
|
docker compose up -d --build --remove-orphans app scheduler
|
||||||
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
|
|
||||||
echo "Legacy MinIO found; migrating its data to RustFS..."; \
|
|
||||||
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 --profile storage-migration stop
|
docker compose stop
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker compose --profile storage-migration build
|
docker compose build
|
||||||
|
|
||||||
down:
|
down:
|
||||||
docker compose --profile storage-migration down
|
docker compose 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!"
|
||||||
@@ -62,7 +42,7 @@ prune-everything:
|
|||||||
echo "Aborted."; \
|
echo "Aborted."; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
docker compose --profile storage-migration down -v --rmi all --remove-orphans
|
docker compose down -v --rmi all --remove-orphans
|
||||||
|
|
||||||
mercure-jwt:
|
mercure-jwt:
|
||||||
@php bin/generate-mercure-jwt.php
|
@php bin/generate-mercure-jwt.php
|
||||||
|
|||||||
@@ -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 | RustFS (S3-compatible; one-time migration from legacy MinIO) |
|
| File storage | RustFS (S3-compatible) |
|
||||||
| 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)
|
||||||
- RustFS (API on port 9010, console on port 9011); legacy MinIO source on 9012/9013 during migration
|
- RustFS (API on port 9010, console on port 9011)
|
||||||
|
|
||||||
### 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` | S3 storage credentials (used by RustFS and legacy MinIO during migration) |
|
| `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` | RustFS S3 credentials |
|
||||||
| `MINIO_ENDPOINT` | `http://localhost:9010` (bare-metal) |
|
| `STORAGE_ENDPOINT` | RustFS S3 API URL (`http://localhost:9010` for bare-metal) |
|
||||||
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach RustFS (including media and LiipImagine thumbnails) |
|
| `STORAGE_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 images, and starts all services. RustFS migration runs automatically once before the app and scheduler start.
|
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker images, initializes the RustFS bucket and public media/cache policy, and starts the services.
|
||||||
|
|
||||||
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`).
|
||||||
|
|
||||||
@@ -123,17 +123,11 @@ Start RustFS and MailHog, then open the URL configured in your Caddy vhost.
|
|||||||
|
|
||||||
### 5. Object storage setup
|
### 5. Object storage setup
|
||||||
|
|
||||||
**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).
|
The Docker stack initializes the `mineseeker` bucket and configures public reads for media and thumbnail objects automatically. For bare-metal installs, create that bucket in RustFS and allow public `GetObject` access for `media/*` and `cache/*`.
|
||||||
|
|
||||||
**Bare-metal** — run once after MinIO is up:
|
### Object storage backup and restore
|
||||||
|
|
||||||
```bash
|
`bin/backup-rustfs.sh [backup-directory]` creates a compressed archive of the RustFS data volume (default: `backups/`). It briefly stops the app, scheduler, and RustFS for a consistent backup, then restarts the services that were running. To restore, run `bin/restore-rustfs.sh <backup-file>`; it verifies the archive and requires typing `yes` before replacing the current RustFS data.
|
||||||
mc alias set local http://localhost:9010 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
|
|
||||||
mc mb local/mineseeker
|
|
||||||
echo '' | mc pipe local/mineseeker/media/.keep
|
|
||||||
echo '' | mc pipe local/mineseeker/cache/.keep
|
|
||||||
# Apply public-read policy for media/ and cache/ — see docker/minio-init.sh for the JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,6 +193,8 @@ The `app` image is rebuilt with the new code; the database and storage container
|
|||||||
|
|
||||||
The workflow writes `PROD_ENV_FILE` to `.env` on every deploy, so you never need to manage the file on the server manually. To update a credential, overwrite the secret in Gitea and push a new tag.
|
The workflow writes `PROD_ENV_FILE` to `.env` on every deploy, so you never need to manage the file on the server manually. To update a credential, overwrite the secret in Gitea and push a new tag.
|
||||||
|
|
||||||
|
Before deploying this RustFS-only configuration, update `PROD_ENV_FILE` to use `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_ENDPOINT`, and `STORAGE_PUBLIC_URL` (the old `MINIO_*` names are no longer read).
|
||||||
|
|
||||||
#### `PROD_ENV_FILE` contents
|
#### `PROD_ENV_FILE` contents
|
||||||
|
|
||||||
Paste the filled-in `.env` file as the secret value:
|
Paste the filled-in `.env` file as the secret value:
|
||||||
@@ -215,10 +211,10 @@ POSTGRES_PASSWORD="<strong password>"
|
|||||||
POSTGRES_DB=mineseeker
|
POSTGRES_DB=mineseeker
|
||||||
POSTGRES_VERSION=18
|
POSTGRES_VERSION=18
|
||||||
|
|
||||||
MINIO_ROOT_USER=mineseeker
|
STORAGE_ACCESS_KEY=mineseeker
|
||||||
MINIO_ROOT_PASSWORD="<strong password>"
|
STORAGE_SECRET_KEY="<strong password>"
|
||||||
MINIO_ENDPOINT=http://rustfs:9000
|
STORAGE_ENDPOINT=http://rustfs:9000
|
||||||
MINIO_PUBLIC_URL=https://aws.mineseeker.hu
|
STORAGE_PUBLIC_URL=https://aws.mineseeker.hu
|
||||||
|
|
||||||
MAILER_DSN=smtp://mail:25?verify_peer=0
|
MAILER_DSN=smtp://mail:25?verify_peer=0
|
||||||
MAIL_DOMAIN=mineseeker.hu
|
MAIL_DOMAIN=mineseeker.hu
|
||||||
@@ -270,7 +266,7 @@ git tag v2026.01
|
|||||||
git push origin v2026.01
|
git push origin v2026.01
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
This writes `.env`, builds the Docker images, runs database migrations, initializes RustFS, and starts the application services.
|
||||||
|
|
||||||
#### 4. Verify
|
#### 4. Verify
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
CONTAINER_ID=$(docker compose ps -q minio)
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
echo "Error: The MineSeeker MinIO service is not running."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
|
|
||||||
if [ -z "$VOLUME_NAME" ]; then
|
|
||||||
echo "Error: Could not determine the MinIO data volume."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
|
|
||||||
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
|
|
||||||
BACKUP_FILE="mineseeker-minio.${TIMESTAMP}.tar.gz"
|
|
||||||
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
|
|
||||||
|
|
||||||
umask 077
|
|
||||||
|
|
||||||
restart_minio() {
|
|
||||||
docker compose start minio || echo "Warning: Failed to restart MinIO."
|
|
||||||
}
|
|
||||||
trap restart_minio EXIT
|
|
||||||
|
|
||||||
echo "Stopping MinIO for a consistent backup..."
|
|
||||||
docker compose stop minio
|
|
||||||
|
|
||||||
echo "Creating MinIO backup: $BACKUP_PATH"
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$VOLUME_NAME:/data:ro" \
|
|
||||||
--volume "$BACKUP_DIR:/backup" \
|
|
||||||
busybox tar czf "/backup/$BACKUP_FILE" -C /data .
|
|
||||||
|
|
||||||
echo "Backup complete: $BACKUP_PATH"
|
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
CONTAINER_ID=$(docker compose ps -q rustfs)
|
||||||
|
if [ -z "$CONTAINER_ID" ]; then
|
||||||
|
echo "Error: The MineSeeker RustFS service is not running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
|
||||||
|
if [ -z "$VOLUME_NAME" ]; then
|
||||||
|
echo "Error: Could not determine the RustFS data volume."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
|
||||||
|
BACKUP_FILE="mineseeker-rustfs.${TIMESTAMP}.tar.gz"
|
||||||
|
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
|
||||||
|
TEMP_PATH="$BACKUP_PATH.tmp"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
APP_WAS_RUNNING=0
|
||||||
|
SCHEDULER_WAS_RUNNING=0
|
||||||
|
RUSTFS_WAS_RUNNING=1
|
||||||
|
[ -z "$(docker compose ps -q app)" ] || APP_WAS_RUNNING=1
|
||||||
|
[ -z "$(docker compose ps -q scheduler)" ] || SCHEDULER_WAS_RUNNING=1
|
||||||
|
|
||||||
|
restart_services() {
|
||||||
|
result=$?
|
||||||
|
rm -f "$TEMP_PATH"
|
||||||
|
[ "$RUSTFS_WAS_RUNNING" -eq 0 ] || docker compose start rustfs || true
|
||||||
|
[ "$APP_WAS_RUNNING" -eq 0 ] || docker compose start app || true
|
||||||
|
[ "$SCHEDULER_WAS_RUNNING" -eq 0 ] || docker compose start scheduler || true
|
||||||
|
return "$result"
|
||||||
|
}
|
||||||
|
trap restart_services EXIT
|
||||||
|
|
||||||
|
echo "Stopping MineSeeker writers and RustFS for a consistent backup..."
|
||||||
|
docker compose stop app scheduler rustfs
|
||||||
|
|
||||||
|
echo "Creating RustFS backup: $BACKUP_PATH"
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$VOLUME_NAME:/data:ro" \
|
||||||
|
--volume "$BACKUP_DIR:/backup" \
|
||||||
|
busybox tar czf "/backup/$BACKUP_FILE.tmp" -C /data .
|
||||||
|
mv "$TEMP_PATH" "$BACKUP_PATH"
|
||||||
|
|
||||||
|
echo "Backup complete: $BACKUP_PATH"
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
if [ "$#" -ne 1 ]; then
|
|
||||||
echo "Usage: $0 <backup-file>"
|
|
||||||
echo "Example: $0 backups/mineseeker-minio.2026-08-18.120000.tar.gz"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_FILE=$1
|
|
||||||
if [ ! -f "$BACKUP_FILE" ]; then
|
|
||||||
echo "Error: Backup file not found: $BACKUP_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BACKUP_DIR=$(CDPATH= cd -- "$(dirname -- "$BACKUP_FILE")" && pwd)
|
|
||||||
BACKUP_NAME=$(basename "$BACKUP_FILE")
|
|
||||||
|
|
||||||
CONTAINER_ID=$(docker compose ps -q minio)
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
echo "Error: The MineSeeker MinIO service is not running."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
|
|
||||||
if [ -z "$VOLUME_NAME" ]; then
|
|
||||||
echo "Error: Could not determine the MinIO data volume."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Verifying MinIO archive..."
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$BACKUP_DIR:/backup:ro" \
|
|
||||||
busybox tar -tzf "/backup/$BACKUP_NAME" > /dev/null
|
|
||||||
echo "Archive OK."
|
|
||||||
|
|
||||||
printf 'WARNING: This erases all current MineSeeker MinIO data. Type yes to continue: '
|
|
||||||
read -r CONFIRM
|
|
||||||
if [ "$CONFIRM" != 'yes' ]; then
|
|
||||||
echo "Aborted."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
restart_minio() {
|
|
||||||
docker compose start minio || echo "Warning: Failed to restart MinIO."
|
|
||||||
}
|
|
||||||
trap restart_minio EXIT
|
|
||||||
|
|
||||||
echo "Stopping MinIO before restore..."
|
|
||||||
docker compose stop minio
|
|
||||||
|
|
||||||
echo "Restoring MinIO backup: $BACKUP_FILE"
|
|
||||||
docker run --rm \
|
|
||||||
--volume "$VOLUME_NAME:/data" \
|
|
||||||
--volume "$BACKUP_DIR:/backup:ro" \
|
|
||||||
busybox sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf "/backup/$1" -C /data' sh "$BACKUP_NAME"
|
|
||||||
|
|
||||||
echo "Restore complete."
|
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <backup-file>"
|
||||||
|
echo "Example: $0 backups/mineseeker-rustfs.2026-10-24.120000.tar.gz"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_FILE=$1
|
||||||
|
if [ ! -f "$BACKUP_FILE" ]; then
|
||||||
|
echo "Error: Backup file not found: $BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_DIR=$(CDPATH= cd -- "$(dirname -- "$BACKUP_FILE")" && pwd)
|
||||||
|
BACKUP_NAME=$(basename "$BACKUP_FILE")
|
||||||
|
|
||||||
|
CONTAINER_ID=$(docker compose ps -q rustfs)
|
||||||
|
if [ -z "$CONTAINER_ID" ]; then
|
||||||
|
echo "Error: The MineSeeker RustFS service must be running before restore."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
|
||||||
|
if [ -z "$VOLUME_NAME" ]; then
|
||||||
|
echo "Error: Could not determine the RustFS data volume."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Verifying RustFS archive..."
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$BACKUP_DIR:/backup:ro" \
|
||||||
|
busybox tar -tzf "/backup/$BACKUP_NAME" > /dev/null
|
||||||
|
echo "Archive OK."
|
||||||
|
|
||||||
|
printf 'WARNING: This erases all current MineSeeker RustFS data. Type yes to continue: '
|
||||||
|
read -r CONFIRM
|
||||||
|
if [ "$CONFIRM" != 'yes' ]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_WAS_RUNNING=0
|
||||||
|
SCHEDULER_WAS_RUNNING=0
|
||||||
|
RUSTFS_WAS_RUNNING=1
|
||||||
|
[ -z "$(docker compose ps -q app)" ] || APP_WAS_RUNNING=1
|
||||||
|
[ -z "$(docker compose ps -q scheduler)" ] || SCHEDULER_WAS_RUNNING=1
|
||||||
|
RESTORE_COMPLETE=0
|
||||||
|
|
||||||
|
restart_services() {
|
||||||
|
result=$?
|
||||||
|
if [ "$RESTORE_COMPLETE" -eq 1 ]; then
|
||||||
|
[ "$RUSTFS_WAS_RUNNING" -eq 0 ] || docker compose start rustfs || true
|
||||||
|
[ "$APP_WAS_RUNNING" -eq 0 ] || docker compose start app || true
|
||||||
|
[ "$SCHEDULER_WAS_RUNNING" -eq 0 ] || docker compose start scheduler || true
|
||||||
|
elif [ "$result" -ne 0 ]; then
|
||||||
|
echo "Restore did not complete; RustFS and application services remain stopped for safety." >&2
|
||||||
|
fi
|
||||||
|
return "$result"
|
||||||
|
}
|
||||||
|
trap restart_services EXIT
|
||||||
|
|
||||||
|
echo "Stopping MineSeeker writers and RustFS before restore..."
|
||||||
|
docker compose stop app scheduler rustfs
|
||||||
|
|
||||||
|
echo "Restoring RustFS backup: $BACKUP_FILE"
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$VOLUME_NAME:/data" \
|
||||||
|
--volume "$BACKUP_DIR:/backup:ro" \
|
||||||
|
busybox sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf "/backup/$1" -C /data' sh "$BACKUP_NAME"
|
||||||
|
|
||||||
|
RESTORE_COMPLETE=1
|
||||||
|
echo "Restore complete."
|
||||||
+17
-59
@@ -30,10 +30,10 @@ services:
|
|||||||
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
||||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
MINIO_ENDPOINT: http://rustfs:9000
|
STORAGE_ENDPOINT: http://rustfs:9000
|
||||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
|
STORAGE_PUBLIC_URL: ${STORAGE_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)
|
||||||
# For Kubernetes or external proxy, use the proxy's IP address.
|
# For Kubernetes or external proxy, use the proxy's IP address.
|
||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
rustfs:
|
rustfs:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
storage_init:
|
rustfs_init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
scheduler:
|
scheduler:
|
||||||
build:
|
build:
|
||||||
@@ -85,10 +85,10 @@ services:
|
|||||||
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
|
||||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
|
||||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
MINIO_ENDPOINT: http://rustfs:9000
|
STORAGE_ENDPOINT: http://rustfs:9000
|
||||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
|
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL:-http://localhost:9010}
|
||||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
|
||||||
volumes:
|
volumes:
|
||||||
- app_var:/app/var
|
- app_var:/app/var
|
||||||
@@ -99,7 +99,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
rustfs:
|
rustfs:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
storage_init:
|
rustfs_init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
@@ -109,8 +109,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: '${APP_NAME}-rustfs'
|
container_name: '${APP_NAME}-rustfs'
|
||||||
environment:
|
environment:
|
||||||
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
|
RUSTFS_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
||||||
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
RUSTFS_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
RUSTFS_ADDRESS: ':9000'
|
RUSTFS_ADDRESS: ':9000'
|
||||||
RUSTFS_CONSOLE_ADDRESS: ':9001'
|
RUSTFS_CONSOLE_ADDRESS: ':9001'
|
||||||
RUSTFS_CONSOLE_ENABLE: 'true'
|
RUSTFS_CONSOLE_ENABLE: 'true'
|
||||||
@@ -125,61 +125,21 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
storage_init:
|
rustfs_init:
|
||||||
build:
|
build:
|
||||||
context: ./docker/storage-migrate
|
context: ./docker/storage-init
|
||||||
entrypoint: ["python"]
|
entrypoint: ["python"]
|
||||||
command: ["/usr/local/bin/storage-init.py"]
|
command: ["/usr/local/bin/storage-init.py"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
container_name: '${APP_NAME}-storage-init'
|
container_name: '${APP_NAME}-rustfs-init'
|
||||||
depends_on:
|
depends_on:
|
||||||
rustfs:
|
rustfs:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
TARGET_ENDPOINT: http://rustfs:9000
|
TARGET_ENDPOINT: http://rustfs:9000
|
||||||
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
|
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
||||||
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
STORAGE_BUCKET: mineseeker
|
STORAGE_BUCKET: mineseeker
|
||||||
minio:
|
|
||||||
profiles: ["storage-migration"]
|
|
||||||
image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1}
|
|
||||||
pull_policy: never
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: '${APP_NAME}-minio'
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
ports:
|
|
||||||
- "${MINIO_PUBLISHED_API_PORT:-9012}:9000"
|
|
||||||
- "${MINIO_PUBLISHED_CONSOLE_PORT:-9013}:9001"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 10s
|
|
||||||
storage_migrate:
|
|
||||||
profiles: ["storage-migration"]
|
|
||||||
build:
|
|
||||||
context: ./docker/storage-migrate
|
|
||||||
restart: "no"
|
|
||||||
container_name: '${APP_NAME}-storage-migrate'
|
|
||||||
depends_on:
|
|
||||||
minio:
|
|
||||||
condition: service_healthy
|
|
||||||
rustfs:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
SOURCE_ENDPOINT: http://minio:9000
|
|
||||||
TARGET_ENDPOINT: http://rustfs:9000
|
|
||||||
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
|
|
||||||
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
|
||||||
STORAGE_BUCKET: mineseeker
|
|
||||||
volumes:
|
|
||||||
- storage_migration_state:/state
|
|
||||||
mail:
|
mail:
|
||||||
image: boky/postfix:latest
|
image: boky/postfix:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -221,6 +181,4 @@ volumes:
|
|||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
postfix_spool:
|
postfix_spool:
|
||||||
minio_data:
|
|
||||||
rustfs_data:
|
rustfs_data:
|
||||||
storage_migration_state:
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "splendid-bear/mineseeker",
|
"name": "splendid-bear/mineseeker",
|
||||||
"version": "2026.3.0",
|
"version": "2026.4.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "https://www.splendidbear.org",
|
"author": "https://www.splendidbear.org",
|
||||||
"bugs": "https://source.splendidbear.org",
|
"bugs": "https://source.splendidbear.org",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ liip_imagine:
|
|||||||
default:
|
default:
|
||||||
flysystem:
|
flysystem:
|
||||||
filesystem_service: mineseeker.media.storage
|
filesystem_service: mineseeker.media.storage
|
||||||
cache: minio_cache
|
cache: object_storage_cache
|
||||||
resolvers:
|
resolvers:
|
||||||
minio_cache:
|
object_storage_cache:
|
||||||
flysystem:
|
flysystem:
|
||||||
filesystem_service: mineseeker.cache.storage
|
filesystem_service: mineseeker.cache.storage
|
||||||
root_url: '%env(MINIO_PUBLIC_URL)%/mineseeker/cache'
|
root_url: '%env(STORAGE_PUBLIC_URL)%/mineseeker/cache'
|
||||||
cache_prefix: 'liip'
|
cache_prefix: 'liip'
|
||||||
filter_sets:
|
filter_sets:
|
||||||
avatar_thumb:
|
avatar_thumb:
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||||
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
|
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
|
||||||
$minioMediaStorage: '@mineseeker.media.storage'
|
$mediaStorage: '@mineseeker.media.storage'
|
||||||
|
|
||||||
Aws\S3\S3Client:
|
Aws\S3\S3Client:
|
||||||
arguments:
|
arguments:
|
||||||
- version: 'latest'
|
- version: 'latest'
|
||||||
region: 'us-east-1'
|
region: 'us-east-1'
|
||||||
endpoint: '%env(MINIO_ENDPOINT)%'
|
endpoint: '%env(STORAGE_ENDPOINT)%'
|
||||||
use_path_style_endpoint: true
|
use_path_style_endpoint: true
|
||||||
credentials:
|
credentials:
|
||||||
key: '%env(MINIO_ROOT_USER)%'
|
key: '%env(STORAGE_ACCESS_KEY)%'
|
||||||
secret: '%env(MINIO_ROOT_PASSWORD)%'
|
secret: '%env(STORAGE_SECRET_KEY)%'
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "[minio-init] Waiting for MinIO..."
|
|
||||||
until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null 2>&1; do
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "[minio-init] Configuring bucket..."
|
|
||||||
|
|
||||||
if mc ls local/mineseeker > /dev/null 2>&1; then
|
|
||||||
echo "[minio-init] Already initialized, skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[minio-init] Creating bucket 'mineseeker'..."
|
|
||||||
mc mb local/mineseeker
|
|
||||||
|
|
||||||
echo "[minio-init] Creating directories..."
|
|
||||||
echo '' | mc pipe local/mineseeker/media/.keep
|
|
||||||
echo '' | mc pipe local/mineseeker/cache/.keep
|
|
||||||
|
|
||||||
echo "[minio-init] Applying anonymous read policy for media/ and cache/..."
|
|
||||||
printf '%s' '{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": { "AWS": ["*"] },
|
|
||||||
"Action": ["s3:GetObject"],
|
|
||||||
"Resource": [
|
|
||||||
"arn:aws:s3:::mineseeker/media/*",
|
|
||||||
"arn:aws:s3:::mineseeker/cache/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}' > /tmp/policy.json
|
|
||||||
mc anonymous set-json /tmp/policy.json local/mineseeker
|
|
||||||
|
|
||||||
echo "[minio-init] Done."
|
|
||||||
@@ -2,7 +2,6 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
RUN pip install --no-cache-dir boto3==1.43.118
|
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
|
COPY init.py /usr/local/bin/storage-init.py
|
||||||
|
|
||||||
ENTRYPOINT ["python", "/usr/local/bin/storage-migrate.py"]
|
ENTRYPOINT ["python", "/usr/local/bin/storage-init.py"]
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Object storage migration
|
|
||||||
|
|
||||||
RustFS is the normal S3-compatible store. MinIO and `storage_migrate` are gated
|
|
||||||
behind the `storage-migration` Compose profile and are not started by a normal
|
|
||||||
`docker compose up -d`.
|
|
||||||
|
|
||||||
On the first production release with a legacy MinIO container, the deployment
|
|
||||||
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.
|
|
||||||
|
|
||||||
RustFS retains the public host ports `9010` (S3 API) and `9011` (console); the
|
|
||||||
legacy MinIO temporarily uses `9012` and `9013`. Confirm the public storage
|
|
||||||
hostname configured by `MINIO_PUBLIC_URL` (for example, `d.aws...`) proxies to
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
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
@@ -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.
|
||||||
|
|
||||||
### [Object storage migration](./MINIO_INITIALIZATION.md)
|
### Object storage
|
||||||
Automatic, one-shot migration from legacy MinIO to RustFS.
|
RustFS is initialized automatically by the Docker stack. Configure its S3 credentials and public media URL in `.env`.
|
||||||
|
|
||||||
### [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:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mine-seeker",
|
"name": "mine-seeker",
|
||||||
"version": "2026.3.0",
|
"version": "2026.4.0",
|
||||||
"author": "https://www.splendidbear.org",
|
"author": "https://www.splendidbear.org",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"bugs": "https://source.splendidbear.org",
|
"bugs": "https://source.splendidbear.org",
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class ProfileController extends AbstractController
|
|||||||
$cacheManager->remove($oldPath, 'avatar_thumb');
|
$cacheManager->remove($oldPath, 'avatar_thumb');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload original to MinIO media/avatar/ */
|
/** Upload original avatar to object storage. */
|
||||||
$stream = fopen($file->getPathname(), 'rb');
|
$stream = fopen($file->getPathname(), 'rb');
|
||||||
try {
|
try {
|
||||||
$mediaStorage->writeStream($newPath, $stream);
|
$mediaStorage->writeStream($newPath, $stream);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ final class BattleCardGenerator
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $cacheDir,
|
private readonly string $cacheDir,
|
||||||
private readonly string $fontPath,
|
private readonly string $fontPath,
|
||||||
private readonly FilesystemOperator $minioMediaStorage,
|
private readonly FilesystemOperator $mediaStorage,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -193,12 +193,12 @@ final class BattleCardGenerator
|
|||||||
{
|
{
|
||||||
$avatarImg = null;
|
$avatarImg = null;
|
||||||
|
|
||||||
/** Try to load avatar from MinIO if path exists*/
|
/** Try to load avatar from object storage if a path exists. */
|
||||||
if ($avatarPath) {
|
if ($avatarPath) {
|
||||||
try {
|
try {
|
||||||
/** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/
|
/** Remove 'avatar/' prefix if it exists since storage already has media/ prefix*/
|
||||||
$path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath;
|
$path = str_starts_with($avatarPath, 'avatar/') ? $avatarPath : 'avatar/' . $avatarPath;
|
||||||
$avatarData = $this->minioMediaStorage->read($path);
|
$avatarData = $this->mediaStorage->read($path);
|
||||||
$avatarImg = imagecreatefromstring($avatarData);
|
$avatarImg = imagecreatefromstring($avatarData);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
/** Failed to load avatar, will use initials*/
|
/** Failed to load avatar, will use initials*/
|
||||||
|
|||||||
Reference in New Issue
Block a user