From 08c30aa47f442dd7a7e42b0b5c82425611b1e21c Mon Sep 17 00:00:00 2001 From: javad Date: Thu, 10 Sep 2026 16:39:02 +0330 Subject: [PATCH] seed script --- .dockerignore | 1 + .env.example | 2 + .gitignore | 1 + README.md | 3 +- apps/core/admin.py | 17 ++- .../commands/export_production_seed.py | 98 ++++++++++++++++ .../commands/import_production_seed.py | 105 ++++++++++++++++++ .../migrations/0006_productionseedstate.py | 22 ++++ apps/core/models.py | 16 +++ apps/core/production_seed.py | 48 ++++++++ docker-compose.yml | 6 + docker/entrypoint.sh | 19 +++- docs/DEPLOYMENT_COOLIFY.md | 24 ++-- docs/PRODUCTION_SEED.md | 79 +++++++++++++ tests/test_production_seed.py | 72 ++++++++++++ 15 files changed, 496 insertions(+), 17 deletions(-) create mode 100644 apps/core/management/commands/export_production_seed.py create mode 100644 apps/core/management/commands/import_production_seed.py create mode 100644 apps/core/migrations/0006_productionseedstate.py create mode 100644 apps/core/production_seed.py create mode 100644 docs/PRODUCTION_SEED.md create mode 100644 tests/test_production_seed.py diff --git a/.dockerignore b/.dockerignore index 43cc7e0..b5a0ada 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ venv *.sqlite3-* media staticfiles +production-seed node_modules graphify-out test-results diff --git a/.env.example b/.env.example index 7ff98f5..e1bb8c4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 5e65b9b..4489b87 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ blob-report/ *.sqlite3-* media/ staticfiles/ +production-seed/ *.log # Local environment and secrets; keep documented templates diff --git a/README.md b/README.md index 732fa36..5d0b7ba 100644 --- a/README.md +++ b/README.md @@ -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) است. ## تست diff --git a/apps/core/admin.py b/apps/core/admin.py index a1bd8f9..4be4e23 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -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 diff --git a/apps/core/management/commands/export_production_seed.py b/apps/core/management/commands/export_production_seed.py new file mode 100644 index 0000000..6a342fc --- /dev/null +++ b/apps/core/management/commands/export_production_seed.py @@ -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." + )) diff --git a/apps/core/management/commands/import_production_seed.py b/apps/core/management/commands/import_production_seed.py new file mode 100644 index 0000000..f5fb685 --- /dev/null +++ b/apps/core/management/commands/import_production_seed.py @@ -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." + )) diff --git a/apps/core/migrations/0006_productionseedstate.py b/apps/core/migrations/0006_productionseedstate.py new file mode 100644 index 0000000..d018f42 --- /dev/null +++ b/apps/core/migrations/0006_productionseedstate.py @@ -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",), + }, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index 410223d..ca88386 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -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}" diff --git a/apps/core/production_seed.py b/apps/core/production_seed.py new file mode 100644 index 0000000..fc759e2 --- /dev/null +++ b/apps/core/production_seed.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index b277622..190af5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e88240b..91b61b1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md index 3d43153..c3594a9 100644 --- a/docs/DEPLOYMENT_COOLIFY.md +++ b/docs/DEPLOYMENT_COOLIFY.md @@ -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= +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. کنترل سلامت و عیب‌یابی diff --git a/docs/PRODUCTION_SEED.md b/docs/PRODUCTION_SEED.md new file mode 100644 index 0000000..95eb2aa --- /dev/null +++ b/docs/PRODUCTION_SEED.md @@ -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 منتقل نخواهند شد. diff --git a/tests/test_production_seed.py b/tests/test_production_seed.py new file mode 100644 index 0000000..79cbc9d --- /dev/null +++ b/tests/test_production_seed.py @@ -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"