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-*
|
*.sqlite3-*
|
||||||
media
|
media
|
||||||
staticfiles
|
staticfiles
|
||||||
|
production-seed
|
||||||
node_modules
|
node_modules
|
||||||
graphify-out
|
graphify-out
|
||||||
test-results
|
test-results
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ DATABASE_WAIT_TIMEOUT=60
|
|||||||
|
|
||||||
STATIC_VOLUME_NAME=customer_ticket_static
|
STATIC_VOLUME_NAME=customer_ticket_static
|
||||||
MEDIA_VOLUME_NAME=customer_ticket_media
|
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
|
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
|
SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
|
||||||
SMS0098_USERNAME=
|
SMS0098_USERNAME=
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ blob-report/
|
|||||||
*.sqlite3-*
|
*.sqlite3-*
|
||||||
media/
|
media/
|
||||||
staticfiles/
|
staticfiles/
|
||||||
|
production-seed/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Local environment and secrets; keep documented templates
|
# Local environment and secrets; keep documented templates
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ python -m venv .venv
|
|||||||
Copy-Item .env.example .env
|
Copy-Item .env.example .env
|
||||||
docker volume create customer_ticket_static
|
docker volume create customer_ticket_static
|
||||||
docker volume create customer_ticket_media
|
docker volume create customer_ticket_media
|
||||||
|
docker volume create customer_ticket_seed
|
||||||
docker compose up --build
|
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 تحویل داده میشوند.
|
قبل از استقرار مقادیر 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) است.
|
راهنمای کامل تنظیم 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 django.contrib import admin
|
||||||
|
|
||||||
from .models import PageTitle, SiteBranding
|
from .models import PageTitle, ProductionSeedState, SiteBranding
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SiteBranding)
|
@admin.register(SiteBranding)
|
||||||
@@ -35,3 +35,18 @@ class PageTitleAdmin(admin.ModelAdmin):
|
|||||||
list_editable = ("title", "is_active")
|
list_editable = ("title", "is_active")
|
||||||
search_fields = ("route_name", "title")
|
search_fields = ("route_name", "title")
|
||||||
list_filter = ("is_active",)
|
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):
|
def __str__(self):
|
||||||
return f"{self.route_name}: {self.title}"
|
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_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-10}
|
||||||
DB_SSLMODE: ${DB_SSLMODE:-prefer}
|
DB_SSLMODE: ${DB_SSLMODE:-prefer}
|
||||||
DATABASE_WAIT_TIMEOUT: ${DATABASE_WAIT_TIMEOUT:-60}
|
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_ROOT: /app/media
|
||||||
MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify}
|
MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?Set MEDIA_SIGNING_SECRET in Coolify}
|
||||||
USE_NGINX_MEDIA: "true"
|
USE_NGINX_MEDIA: "true"
|
||||||
@@ -58,6 +60,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- static_data:/app/staticfiles
|
- static_data:/app/staticfiles
|
||||||
- media_data:/app/media
|
- media_data:/app/media
|
||||||
|
- seed_data:/app/production-seed:ro
|
||||||
healthcheck:
|
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)"]
|
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
|
interval: 15s
|
||||||
@@ -103,3 +106,6 @@ volumes:
|
|||||||
media_data:
|
media_data:
|
||||||
external: true
|
external: true
|
||||||
name: ${MEDIA_VOLUME_NAME:-customer_ticket_media}
|
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..."
|
echo "==> Applying database migrations..."
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
echo "==> Bootstrapping required application data..."
|
seed_dir="${PRODUCTION_SEED_DIR:-/app/production-seed}"
|
||||||
python manage.py bootstrap
|
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..."
|
echo "==> Collecting static files..."
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|||||||
+11
-13
@@ -10,14 +10,15 @@ PostgreSQL جزو این Compose نیست و از دیتابیس مدیریت
|
|||||||
|
|
||||||
## 1. Volumeهای Coolify
|
## 1. Volumeهای Coolify
|
||||||
|
|
||||||
پیش از اولین deploy دو Docker volume با همین نامها ایجاد کنید:
|
پیش از اولین deploy سه Docker volume با همین نامها ایجاد کنید:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
customer_ticket_static
|
customer_ticket_static
|
||||||
customer_ticket_media
|
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
|
## 2. دامنهها و Traefik خود Coolify
|
||||||
|
|
||||||
@@ -47,7 +48,9 @@ DATABASE_WAIT_TIMEOUT=60
|
|||||||
|
|
||||||
STATIC_VOLUME_NAME=customer_ticket_static
|
STATIC_VOLUME_NAME=customer_ticket_static
|
||||||
MEDIA_VOLUME_NAME=customer_ticket_media
|
MEDIA_VOLUME_NAME=customer_ticket_media
|
||||||
|
SEED_VOLUME_NAME=customer_ticket_seed
|
||||||
MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters>
|
MEDIA_SIGNING_SECRET=<separate-random-secret-at-least-32-characters>
|
||||||
|
REQUIRE_PRODUCTION_SEED=1
|
||||||
```
|
```
|
||||||
|
|
||||||
نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساختهشده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم میشود به دیتابیس پیشفرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید.
|
نام دیتابیس انتهای URL باید نام واقعی دیتابیس باشد. اگر دیتابیس ساختهشده `customerTicket` است، URL باید به `/customerTicket` ختم شود؛ URLی که به `/postgres` ختم میشود به دیتابیس پیشفرض `postgres` متصل خواهد شد. اگر password شامل `@`، `:`، `/`، `#` یا `%` است آن را URL-encode کنید.
|
||||||
@@ -115,17 +118,12 @@ SECURE_HSTS_PRELOAD=false
|
|||||||
## 7. ترتیب اولین deploy
|
## 7. ترتیب اولین deploy
|
||||||
|
|
||||||
1. volumeها و database خالی را ایجاد کنید.
|
1. volumeها و database خالی را ایجاد کنید.
|
||||||
2. Environment Variables را در Coolify وارد کنید.
|
2. طبق `docs/PRODUCTION_SEED.md` سه فایل seed را داخل volume `customer_ticket_seed` قرار دهید.
|
||||||
3. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفتهشده را به `nginx:80` متصل کنید.
|
3. Environment Variables را در Coolify وارد کنید.
|
||||||
4. log سرویس `web` را برای موفقیت migrate، bootstrap، collectstatic و Gunicorn بررسی کنید.
|
4. Compose را deploy، دامنه اصلی را به `web:8000` و pathهای static/media گفتهشده را به `nginx:80` متصل کنید.
|
||||||
5. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
5. log سرویس `web` را برای موفقیت migrate، import production seed، collectstatic و Gunicorn بررسی کنید. وقتی seed موجود است، bootstrap عمداً اجرا نمیشود تا snapshot تغییر نکند.
|
||||||
6. یکبار superuser بسازید:
|
6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
|
||||||
|
7. با superuser منتقلشده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
||||||
```bash
|
|
||||||
docker compose exec web python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
7. در پنل مدیریت، SMS templateها و `NotificationPolicy`ها را بررسی کنید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
|
|
||||||
|
|
||||||
## 8. کنترل سلامت و عیبیابی
|
## 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