seed script
This commit is contained in:
1 parent
fa9d154db9
commit
08c30aa47f
15 files changed
+496
-17
No files matched your search
@@ -6,6 +6,7 @@ venv
|
||||
*.sqlite3-*
|
||||
media
|
||||
staticfiles
|
||||
production-seed
|
||||
node_modules
|
||||
graphify-out
|
||||
test-results
|
||||
|
||||
@@ -21,7 +21,9 @@ DATABASE_WAIT_TIMEOUT=60
|
||||
|
||||
STATIC_VOLUME_NAME=customer_ticket_static
|
||||
MEDIA_VOLUME_NAME=customer_ticket_media
|
||||
SEED_VOLUME_NAME=customer_ticket_seed
|
||||
MEDIA_SIGNING_SECRET=replace-with-a-separate-random-secret-of-at-least-32-characters
|
||||
REQUIRE_PRODUCTION_SEED=1
|
||||
|
||||
SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
|
||||
SMS0098_USERNAME=
|
||||
|
||||
@@ -36,6 +36,7 @@ blob-report/
|
||||
*.sqlite3-*
|
||||
media/
|
||||
staticfiles/
|
||||
production-seed/
|
||||
*.log
|
||||
|
||||
# Local environment and secrets; keep documented templates
|
||||
|
||||
@@ -21,13 +21,14 @@ python -m venv .venv
|
||||
Copy-Item .env.example .env
|
||||
docker volume create customer_ticket_static
|
||||
docker volume create customer_ticket_media
|
||||
docker volume create customer_ticket_seed
|
||||
docker compose up --build
|
||||
docker compose exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
قبل از استقرار مقادیر secret و host در `.env` را تغییر دهید. TLS و proxy اپلیکیشن توسط Traefik خود Coolify مدیریت میشود. migration، bootstrap و collectstatic هنگام شروع web به شکل خودکار اجرا میشوند. Nginx فقط static را سرو میکند و فایلهای آپلودی خصوصی پس از کنترل مجوز مستقیماً توسط Django تحویل داده میشوند.
|
||||
|
||||
راهنمای کامل تنظیم Coolify، volumeها، PostgreSQL خارجی و worker در [docs/DEPLOYMENT_COOLIFY.md](docs/DEPLOYMENT_COOLIFY.md) است.
|
||||
راهنمای انتقال خودکار تمام دادههای پایدار SQLite به PostgreSQL در [docs/PRODUCTION_SEED.md](docs/PRODUCTION_SEED.md) است.
|
||||
|
||||
## تست
|
||||
|
||||
|
||||
+16
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PageTitle, SiteBranding
|
||||
from .models import PageTitle, ProductionSeedState, SiteBranding
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
@@ -35,3 +35,18 @@ class PageTitleAdmin(admin.ModelAdmin):
|
||||
list_editable = ("title", "is_active")
|
||||
search_fields = ("route_name", "title")
|
||||
list_filter = ("is_active",)
|
||||
|
||||
|
||||
@admin.register(ProductionSeedState)
|
||||
class ProductionSeedStateAdmin(admin.ModelAdmin):
|
||||
list_display = ("bundle_sha256", "applied_at")
|
||||
readonly_fields = ("bundle_sha256", "manifest", "applied_at")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,98 @@
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, transaction
|
||||
|
||||
from apps.core.production_seed import EXCLUDED_VOLATILE_MODELS, SEED_MODEL_LABELS, sha256_file
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export durable SQLite data and media as a PostgreSQL-safe production seed bundle."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--output", default="production-seed")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if connection.vendor != "sqlite":
|
||||
raise CommandError("Export must run against the source SQLite database.")
|
||||
|
||||
output_dir = Path(options["output"]).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
targets = [output_dir / name for name in ("data.json.gz", "media.tar.gz", "manifest.json")]
|
||||
if any(path.exists() for path in targets) and not options["force"]:
|
||||
raise CommandError(f"Seed files already exist in {output_dir}; pass --force to replace them.")
|
||||
|
||||
temporary_dir = output_dir / ".building"
|
||||
if temporary_dir.exists():
|
||||
shutil.rmtree(temporary_dir)
|
||||
temporary_dir.mkdir()
|
||||
try:
|
||||
data_path = temporary_dir / "data.json.gz"
|
||||
buffer = io.StringIO()
|
||||
with transaction.atomic():
|
||||
call_command(
|
||||
"dumpdata",
|
||||
*SEED_MODEL_LABELS,
|
||||
format="json",
|
||||
indent=2,
|
||||
use_natural_foreign_keys=True,
|
||||
use_natural_primary_keys=True,
|
||||
stdout=buffer,
|
||||
verbosity=0,
|
||||
)
|
||||
with gzip.open(data_path, "wt", encoding="utf-8") as stream:
|
||||
stream.write(buffer.getvalue())
|
||||
|
||||
media_path = temporary_dir / "media.tar.gz"
|
||||
media_root = Path(settings.MEDIA_ROOT)
|
||||
media_files = []
|
||||
with tarfile.open(media_path, "w:gz") as archive:
|
||||
if media_root.exists():
|
||||
for source in sorted(media_root.rglob("*")):
|
||||
if source.is_file() and not source.is_symlink():
|
||||
relative = source.relative_to(media_root).as_posix()
|
||||
archive.add(source, arcname=relative, recursive=False)
|
||||
media_files.append(relative)
|
||||
|
||||
counts = {
|
||||
label: apps.get_model(label).objects.count()
|
||||
for label in SEED_MODEL_LABELS
|
||||
}
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_database": "sqlite",
|
||||
"models": counts,
|
||||
"media_file_count": len(media_files),
|
||||
"excluded_volatile_models": list(EXCLUDED_VOLATILE_MODELS),
|
||||
"files": {
|
||||
"data.json.gz": sha256_file(data_path),
|
||||
"media.tar.gz": sha256_file(media_path),
|
||||
},
|
||||
}
|
||||
manifest_path = temporary_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
for target in targets:
|
||||
source = temporary_dir / target.name
|
||||
source.replace(target)
|
||||
finally:
|
||||
shutil.rmtree(temporary_dir, ignore_errors=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Production seed exported to {output_dir} "
|
||||
f"({sum(counts.values())} rows, {len(media_files)} media files)."
|
||||
))
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"This bundle contains personal data and password hashes. Keep it out of Git and transfer it securely."
|
||||
))
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, transaction
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.models import ProductionSeedState
|
||||
from apps.core.production_seed import sha256_file
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import a verified production seed bundle into a pristine PostgreSQL database."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("bundle_dir", nargs="?", default="/app/production-seed")
|
||||
|
||||
def _load_manifest(self, bundle_dir):
|
||||
manifest_path = bundle_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise CommandError(f"Missing seed manifest: {manifest_path}")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if manifest.get("schema_version") != 1:
|
||||
raise CommandError("Unsupported production seed schema version.")
|
||||
for filename, expected in manifest.get("files", {}).items():
|
||||
path = bundle_dir / filename
|
||||
if not path.is_file() or sha256_file(path) != expected:
|
||||
raise CommandError(f"Seed integrity check failed for {filename}.")
|
||||
return manifest, sha256_file(manifest_path)
|
||||
|
||||
def _extract_media(self, archive_path, destination):
|
||||
destination = destination.resolve()
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
member_path = Path(member.name)
|
||||
if member.issym() or member.islnk() or member_path.is_absolute() or ".." in member_path.parts:
|
||||
raise CommandError(f"Unsafe media archive member: {member.name}")
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile():
|
||||
raise CommandError(f"Unsupported media archive member: {member.name}")
|
||||
target = (destination / member_path).resolve()
|
||||
if destination not in target.parents:
|
||||
raise CommandError(f"Unsafe media archive path: {member.name}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise CommandError(f"Could not read media archive member: {member.name}")
|
||||
with source, target.open("wb") as stream:
|
||||
shutil.copyfileobj(source, stream)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if connection.vendor != "postgresql":
|
||||
raise CommandError("Production seed import is only allowed on PostgreSQL.")
|
||||
|
||||
bundle_dir = Path(options["bundle_dir"]).resolve()
|
||||
manifest, bundle_sha256 = self._load_manifest(bundle_dir)
|
||||
if ProductionSeedState.objects.filter(bundle_sha256=bundle_sha256).exists():
|
||||
self.stdout.write(self.style.SUCCESS("Production seed is already applied; skipping."))
|
||||
return
|
||||
if ProductionSeedState.objects.exists() or User.objects.exists():
|
||||
raise CommandError("Target database is not pristine; refusing to replace production data.")
|
||||
|
||||
media_root = Path(settings.MEDIA_ROOT).resolve()
|
||||
if media_root.exists() and any(path.is_file() for path in media_root.rglob("*")):
|
||||
raise CommandError("MEDIA_ROOT is not empty; refusing to overwrite production files.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="customer-ticket-seed-") as temporary:
|
||||
staged_media = Path(temporary) / "media"
|
||||
staged_media.mkdir()
|
||||
self._extract_media(bundle_dir / "media.tar.gz", staged_media)
|
||||
|
||||
copied_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Migrations contain defaults. Flush those rows before loading
|
||||
# the authoritative SQLite snapshot; schema/migration history remains.
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
|
||||
media_root.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted(staged_media.rglob("*")):
|
||||
if not source.is_file():
|
||||
continue
|
||||
target = media_root / source.relative_to(staged_media)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
copied_files.append(target)
|
||||
ProductionSeedState.objects.create(
|
||||
bundle_sha256=bundle_sha256,
|
||||
manifest=manifest,
|
||||
)
|
||||
except Exception:
|
||||
for path in reversed(copied_files):
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Production seed applied: {sum(manifest['models'].values())} rows and "
|
||||
f"{manifest['media_file_count']} media files."
|
||||
))
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0005_sitebranding_company_logo_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProductionSeedState",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("bundle_sha256", models.CharField(max_length=64, unique=True)),
|
||||
("manifest", models.JSONField(default=dict)),
|
||||
("applied_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "وضعیت بذر Production",
|
||||
"verbose_name_plural": "وضعیت بذرهای Production",
|
||||
"ordering": ("-applied_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -125,3 +125,19 @@ class PageTitle(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.route_name}: {self.title}"
|
||||
|
||||
|
||||
class ProductionSeedState(models.Model):
|
||||
"""Audit marker that makes production seed imports idempotent."""
|
||||
|
||||
bundle_sha256 = models.CharField(max_length=64, unique=True)
|
||||
manifest = models.JSONField(default=dict)
|
||||
applied_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-applied_at",)
|
||||
verbose_name = "وضعیت بذر Production"
|
||||
verbose_name_plural = "وضعیت بذرهای Production"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bundle_sha256[:12]} @ {self.applied_at}"
|
||||
@@ -0,0 +1,48 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SEED_MODEL_LABELS = (
|
||||
"core.SiteBranding",
|
||||
"core.PageTitle",
|
||||
"auth.Group",
|
||||
"accounts.User",
|
||||
"staff.OrganizationalUnit",
|
||||
"customers.ContactGroup",
|
||||
"customers.CustomerProfile",
|
||||
"staff.UnitMembership",
|
||||
"staff.StaffProfile",
|
||||
"tickets.TicketCategory",
|
||||
"tickets.SLAConfig",
|
||||
"tickets.TicketTag",
|
||||
"tickets.GroupDispatch",
|
||||
"tickets.Ticket",
|
||||
"tickets.TicketAccessGrant",
|
||||
"tickets.TicketEvent",
|
||||
"messaging.Message",
|
||||
"messaging.MessageRevision",
|
||||
"messaging.MessageMention",
|
||||
"messaging.MessageReadReceipt",
|
||||
"tickets.TicketReadState",
|
||||
"attachments.AttachmentPolicy",
|
||||
"attachments.Attachment",
|
||||
"notifications.SMSTemplate",
|
||||
"notifications.NotificationPolicy",
|
||||
)
|
||||
|
||||
EXCLUDED_VOLATILE_MODELS = (
|
||||
"admin.LogEntry",
|
||||
"sessions.Session",
|
||||
"accounts.OTPChallenge",
|
||||
"accounts.TicketMagicLink",
|
||||
"accounts.MagicLinkAccessLog",
|
||||
"notifications.NotificationJob",
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -22,6 +22,8 @@ x-django-service: &django-service
|
||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-10}
|
||||
DB_SSLMODE: ${DB_SSLMODE:-prefer}
|
||||
DATABASE_WAIT_TIMEOUT: ${DATABASE_WAIT_TIMEOUT:-60}
|
||||
PRODUCTION_SEED_DIR: /app/production-seed
|
||||
REQUIRE_PRODUCTION_SEED: ${REQUIRE_PRODUCTION_SEED:-1}
|
||||
MEDIA_ROOT: /app/media
|
||||
MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify}
|
||||
USE_NGINX_MEDIA: "true"
|
||||
@@ -58,6 +60,7 @@ services:
|
||||
volumes:
|
||||
- static_data:/app/staticfiles
|
||||
- media_data:/app/media
|
||||
- seed_data:/app/production-seed:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import http.client,os; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=5); c.request('GET','/healthz/',headers={'Host':os.getenv('HEALTHCHECK_HOST','localhost'),'X-Forwarded-Proto':'https'}); r=c.getresponse(); raise SystemExit(0 if r.status==200 else 1)"]
|
||||
interval: 15s
|
||||
@@ -103,3 +106,6 @@ volumes:
|
||||
media_data:
|
||||
external: true
|
||||
name: ${MEDIA_VOLUME_NAME:-customer_ticket_media}
|
||||
seed_data:
|
||||
external: true
|
||||
name: ${SEED_VOLUME_NAME:-customer_ticket_seed}
|
||||
+17
-2
@@ -38,8 +38,23 @@ if [ "${RUN_STARTUP_TASKS:-0}" = "1" ]; then
|
||||
echo "==> Applying database migrations..."
|
||||
python manage.py migrate --noinput
|
||||
|
||||
echo "==> Bootstrapping required application data..."
|
||||
python manage.py bootstrap
|
||||
seed_dir="${PRODUCTION_SEED_DIR:-/app/production-seed}"
|
||||
seed_available=0
|
||||
if [ -f "$seed_dir/manifest.json" ]; then
|
||||
echo "==> Importing production seed (idempotent)..."
|
||||
python manage.py import_production_seed "$seed_dir"
|
||||
seed_available=1
|
||||
elif [ "${REQUIRE_PRODUCTION_SEED:-1}" = "1" ]; then
|
||||
echo "ERROR: Required production seed is missing from $seed_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$seed_available" = "0" ]; then
|
||||
echo "==> Bootstrapping required application data..."
|
||||
python manage.py bootstrap
|
||||
else
|
||||
echo "==> Production seed is authoritative; skipping bootstrap mutations."
|
||||
fi
|
||||
|
||||
echo "==> Collecting static files..."
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
+11
-13
@@ -10,14 +10,15 @@ PostgreSQL جزو این Compose نیست و از دیتابیس مدیریت
|
||||
|
||||
## 1. Volumeهای Coolify
|
||||
|
||||
پیش از اولین deploy دو Docker volume با همین نامها ایجاد کنید:
|
||||
پیش از اولین deploy سه Docker volume با همین نامها ایجاد کنید:
|
||||
|
||||
```text
|
||||
customer_ticket_static
|
||||
customer_ticket_media
|
||||
customer_ticket_seed
|
||||
```
|
||||
|
||||
در صورت انتخاب نام دیگر، متغیرهای `STATIC_VOLUME_NAME` و `MEDIA_VOLUME_NAME` را مطابق آن تنظیم کنید. media حاوی آپلودهای ماندگار است. static در هر startup دوباره با `collectstatic` تکمیل میشود اما برای اشتراک بین web و Nginx volume لازم دارد.
|
||||
در صورت انتخاب نام دیگر، متغیرهای `STATIC_VOLUME_NAME`، `MEDIA_VOLUME_NAME` و `SEED_VOLUME_NAME` را مطابق آن تنظیم کنید. media حاوی آپلودهای ماندگار است. static در هر startup دوباره با `collectstatic` تکمیل میشود اما برای اشتراک بین web و Nginx volume لازم دارد. سه فایل تولیدشده در `production-seed/` باید پیش از اولین startup داخل ریشه volume سوم قرار گیرند.
|
||||
|
||||
## 2. دامنهها و Traefik خود Coolify
|
||||
|
||||
@@ -47,7 +48,9 @@ DATABASE_WAIT_TIMEOUT=60
|
||||
|
||||
STATIC_VOLUME_NAME=customer_ticket_static
|
||||
MEDIA_VOLUME_NAME=customer_ticket_media
|
||||
SEED_VOLUME_NAME=customer_ticket_seed
|
||||
MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters>
|
||||
REQUIRE_PRODUCTION_SEED=1
|
||||
```
|
||||
|
||||
نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساختهشده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم میشود به دیتابیس پیشفرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید.
|
||||
@@ -115,17 +118,12 @@ SECURE_HSTS_PRELOAD=false
|
||||
## 7. ترتیب اولین deploy
|
||||
|
||||
1. volumeها و database خالی را ایجاد کنید.
|
||||
2. Environment Variables را در Coolify وارد کنید.
|
||||
3. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفتهشده را به `nginx:80` متصل کنید.
|
||||
4. log سرویس `web` را برای موفقیت migrate، bootstrap، collectstatic و Gunicorn بررسی کنید.
|
||||
5. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
||||
6. یکبار superuser بسازید:
|
||||
|
||||
```bash
|
||||
docker compose exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
7. در پنل مدیریت، SMS templateها و `NotificationPolicy`ها را بررسی کنید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
||||
2. طبق `docs/PRODUCTION_SEED.md` سه فایل seed را داخل volume `customer_ticket_seed` قرار دهید.
|
||||
3. Environment Variables را در Coolify وارد کنید.
|
||||
4. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفتهشده را به `nginx:80` متصل کنید.
|
||||
5. log سرویس `web` را برای موفقیت migrate، import production seed، collectstatic و Gunicorn بررسی کنید. وقتی seed موجود است، bootstrap عمداً اجرا نمیشود تا snapshot تغییر نکند.
|
||||
6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
||||
7. با superuser منتقلشده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
||||
|
||||
## 8. کنترل سلامت و عیبیابی
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# انتقال خودکار SQLite به PostgreSQL Production
|
||||
|
||||
این فرایند تمام دادههای پایدار فعلی را منتقل میکند:
|
||||
|
||||
- کاربران، hash رمزهای قابل استفاده، superuser، گروهها و permissionها
|
||||
- واحدهای سازمانی، عضویتها، نقشها و پروفایل پرسنل
|
||||
- مشتریان، گروه مخاطبان، دستهبندیها و SLA
|
||||
- branding، عنوان صفحات، SMS templateها، NotificationPolicy و سیاست فایلها
|
||||
- تیکتها، ارسالهای گروهی، رویدادها، پیامها، رسیدهای خواندن و attachmentها
|
||||
- تمام فایلهای موجود در `MEDIA_ROOT`، شامل branding، avatar و فایل تیکت
|
||||
|
||||
دادههای موقتی و خطرناک زیر عمداً منتقل نمیشوند: sessionها، OTPهای قبلی، Magic Linkهای قبلی، admin log و `NotificationJob`. انتقال outbox قبلی میتوانست بعد از بالا آمدن worker باعث ارسال مجدد SMS یا Bale شود.
|
||||
|
||||
## خروجی ساختهشده
|
||||
|
||||
دستور زیر از SQLite فعلی snapshot میگیرد:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe manage.py export_production_seed --output production-seed --force
|
||||
```
|
||||
|
||||
برای وضعیت فعلی پروژه خروجی قبلاً تولید شده و شامل ۳۹۵ رکورد پایدار و ۵۷ فایل media است:
|
||||
|
||||
```text
|
||||
production-seed/
|
||||
data.json.gz
|
||||
media.tar.gz
|
||||
manifest.json
|
||||
```
|
||||
|
||||
این پوشه شامل اطلاعات شخصی و password hash است و عمداً توسط Git و Docker ignore میشود. آن را commit، ایمیل یا در فضای عمومی بارگذاری نکنید.
|
||||
|
||||
## انتقال امن به volume سرور
|
||||
|
||||
در Coolify volume خارجی `customer_ticket_seed` را ایجاد کنید. سه فایل بالا باید مستقیماً در ریشه آن قرار بگیرند، به شکلی که container مسیر زیر را ببیند:
|
||||
|
||||
```text
|
||||
/app/production-seed/manifest.json
|
||||
/app/production-seed/data.json.gz
|
||||
/app/production-seed/media.tar.gz
|
||||
```
|
||||
|
||||
فایلها را با قابلیت Persistent Storage/File Mount خود Coolify یا یک انتقال امن مانند SCP به سرور ببرید. اگر روی سرور در `/tmp/customer-ticket-seed` قرار گرفتهاند، مدیر سرور میتواند volume را اینگونه پر کند:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v customer_ticket_seed:/target \
|
||||
-v /tmp/customer-ticket-seed:/source:ro \
|
||||
alpine:3.20 sh -c 'cp /source/data.json.gz /source/media.tar.gz /source/manifest.json /target/'
|
||||
```
|
||||
|
||||
پس از کپی، نسخه موجود در `/tmp` را امن حذف کنید. volume seed در Compose فقط read-only داخل web mount میشود.
|
||||
|
||||
## اجرای خودکار
|
||||
|
||||
در اولین startup سرویس web ترتیب زیر اجرا میشود:
|
||||
|
||||
1. انتظار برای PostgreSQL
|
||||
2. اجرای migrationها
|
||||
3. اعتبارسنجی SHA-256 فایلهای seed
|
||||
4. اطمینان از خالی بودن PostgreSQL و media volume
|
||||
5. حذف defaultهای data migration و import کامل snapshot
|
||||
6. انتقال media به volume اصلی
|
||||
7. ثبت `ProductionSeedState`
|
||||
8. رد کردن `bootstrap` برای حفظ دقیق snapshot بهعنوان منبع authoritative
|
||||
9. اجرای `collectstatic` و Gunicorn
|
||||
|
||||
در deployهای بعدی checksum قبلاً ثبتشده تشخیص داده میشود و import دوباره انجام نمیشود. اگر bundle نباشد، با `REQUIRE_PRODUCTION_SEED=1` سرویس عمداً متوقف میشود تا production ناقص بالا نیاید.
|
||||
|
||||
## محدودیتهای ایمنی
|
||||
|
||||
- import فقط روی PostgreSQL اجرا میشود.
|
||||
- دیتابیس دارای User یا seed قبلی با checksum متفاوت پذیرفته نمیشود.
|
||||
- media volume غیرخالی overwrite نمیشود.
|
||||
- مسیرهای مشکوک، symlink و path traversal در archive رد میشوند.
|
||||
- checksum هر دو فایل پیش از هر تغییر بررسی میشود.
|
||||
- شکست import باعث rollback دیتابیس و حذف فایلهای کپیشده همان اجرا میشود.
|
||||
|
||||
اگر واقعاً میخواهید production بدون snapshot بالا بیاید، `REQUIRE_PRODUCTION_SEED=0` بگذارید؛ در این حالت فقط bootstrap استاندارد اجرا میشود و کاربران دارای password فعلی SQLite منتقل نخواهند شد.
|
||||
@@ -0,0 +1,72 @@
|
||||
import gzip
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.core.models import ProductionSeedState
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_path, settings):
|
||||
media_root = tmp_path / "source-media"
|
||||
settings.MEDIA_ROOT = media_root
|
||||
(media_root / "branding" / "logo").mkdir(parents=True)
|
||||
(media_root / "branding" / "logo" / "current.png").write_bytes(b"seed-media")
|
||||
|
||||
unit = OrganizationalUnit.objects.create(name="واحد Seed", slug="seed-unit")
|
||||
user = User.objects.create_user(
|
||||
username="seed-staff",
|
||||
password="not-a-production-password",
|
||||
account_type=User.AccountType.STAFF,
|
||||
)
|
||||
StaffProfile.objects.create(user=user, primary_unit=unit, display_name="کارشناس Seed")
|
||||
permission = Permission.objects.order_by("pk").first()
|
||||
permission_key = (permission.content_type.app_label, permission.codename)
|
||||
unit.permissions.add(permission)
|
||||
membership = user.unit_memberships.get(unit=unit)
|
||||
membership.permissions.add(permission)
|
||||
user.user_permissions.add(permission)
|
||||
user.groups.get().permissions.add(permission)
|
||||
NotificationJob.objects.create(
|
||||
channel="BALE",
|
||||
event_type="SHOULD_NOT_IMPORT",
|
||||
dedupe_key="excluded-outbox-job",
|
||||
next_attempt_at="2026-09-10T00:00:00Z",
|
||||
)
|
||||
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
call_command("export_production_seed", output=str(bundle_dir))
|
||||
with gzip.open(bundle_dir / "data.json.gz", "rt", encoding="utf-8") as stream:
|
||||
serialized = json.load(stream)
|
||||
assert any(row["model"] == "accounts.user" for row in serialized)
|
||||
assert not any(row["model"] == "notifications.notificationjob" for row in serialized)
|
||||
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
target_media = tmp_path / "target-media"
|
||||
settings.MEDIA_ROOT = target_media
|
||||
original_vendor = connection.vendor
|
||||
connection.vendor = "postgresql"
|
||||
try:
|
||||
call_command("import_production_seed", str(bundle_dir))
|
||||
call_command("import_production_seed", str(bundle_dir))
|
||||
finally:
|
||||
connection.vendor = original_vendor
|
||||
|
||||
restored = User.objects.get(username="seed-staff")
|
||||
assert restored.check_password("not-a-production-password")
|
||||
assert restored.staff_profile.primary_unit.slug == "seed-unit"
|
||||
restored_membership = restored.unit_memberships.get(unit__slug="seed-unit")
|
||||
permission_filter = {"content_type__app_label": permission_key[0], "codename": permission_key[1]}
|
||||
assert restored.user_permissions.filter(**permission_filter).exists()
|
||||
assert restored.groups.get().permissions.filter(**permission_filter).exists()
|
||||
assert restored_membership.permissions.filter(**permission_filter).exists()
|
||||
assert restored_membership.unit.permissions.filter(**permission_filter).exists()
|
||||
assert ProductionSeedState.objects.count() == 1
|
||||
assert NotificationJob.objects.count() == 0
|
||||
assert (target_media / "branding" / "logo" / "current.png").read_bytes() == b"seed-media"
|
||||
Reference in new issue
Block a user