seed script
This commit is contained in:
1 parent
fa9d154db9
commit
08c30aa47f
15 files changed
+496
-17
No files matched your search
@@ -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."
|
||||
))
|
||||
Reference in new issue
Block a user