forked from retoor/devplacepy
234 lines
6.9 KiB
Python
234 lines
6.9 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import sys
|
|
from devplacepy.cli._shared import _audit_cli
|
|
|
|
|
|
def _crc32(path):
|
|
import zlib
|
|
|
|
crc = 0
|
|
with open(path, "rb") as handle:
|
|
while True:
|
|
chunk = handle.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
crc = zlib.crc32(chunk, crc)
|
|
return crc & 0xFFFFFFFF
|
|
|
|
|
|
def _migrate_file(source, dest, dry_run, report):
|
|
import os
|
|
import shutil
|
|
|
|
if not source.exists():
|
|
return
|
|
if source.resolve() == dest.resolve():
|
|
return
|
|
size = source.stat().st_size
|
|
if dest.exists():
|
|
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
|
|
report.append(("done", source, dest, size))
|
|
if not dry_run:
|
|
source.unlink()
|
|
return
|
|
report.append(("conflict", source, dest, size))
|
|
return
|
|
report.append(("move", source, dest, size))
|
|
if dry_run:
|
|
return
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = dest.with_name(dest.name + ".migrating")
|
|
shutil.copyfile(source, tmp)
|
|
with open(tmp, "rb") as handle:
|
|
os.fsync(handle.fileno())
|
|
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
|
|
tmp.unlink(missing_ok=True)
|
|
raise RuntimeError(f"verification failed copying {source} -> {dest}")
|
|
os.replace(tmp, dest)
|
|
source.unlink()
|
|
|
|
|
|
def _prune_empty_dirs(root):
|
|
if not root.exists():
|
|
return
|
|
for path in sorted(root.rglob("*"), reverse=True):
|
|
if path.is_dir():
|
|
try:
|
|
path.rmdir()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
root.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _migrate_tree(source, dest, dry_run, report):
|
|
if not source.exists():
|
|
return
|
|
if source.resolve() == dest.resolve():
|
|
return
|
|
for child in sorted(source.rglob("*")):
|
|
if child.is_file():
|
|
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
|
|
if not dry_run:
|
|
_prune_empty_dirs(source)
|
|
|
|
|
|
def _db_is_locked(path):
|
|
import sqlite3
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(path), timeout=0.5)
|
|
try:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
conn.rollback()
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
except sqlite3.OperationalError:
|
|
return True
|
|
|
|
|
|
def _checkpoint(path):
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(path), timeout=5)
|
|
try:
|
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _migrate_db(source, dest, dry_run, report):
|
|
if not source.exists():
|
|
return
|
|
if source.resolve() == dest.resolve():
|
|
return
|
|
if _db_is_locked(source):
|
|
raise RuntimeError(
|
|
f"{source} is locked - stop the app before running migrate-data"
|
|
)
|
|
if not dry_run:
|
|
_checkpoint(source)
|
|
_migrate_file(source, dest, dry_run, report)
|
|
for suffix in ("-wal", "-shm"):
|
|
_migrate_file(
|
|
source.with_name(source.name + suffix),
|
|
dest.with_name(dest.name + suffix),
|
|
dry_run,
|
|
report,
|
|
)
|
|
|
|
|
|
def cmd_emoji_sync(args):
|
|
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
|
|
|
|
count = write_emoji_module()
|
|
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
|
|
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
|
|
|
|
|
|
def cmd_migrate_data(args):
|
|
import os
|
|
from pathlib import Path
|
|
from collections import Counter
|
|
from devplacepy import config
|
|
|
|
base = config.BASE_DIR
|
|
home = Path.home()
|
|
dry = args.dry_run
|
|
report = []
|
|
|
|
config.ensure_data_dirs()
|
|
|
|
db_items = []
|
|
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
|
|
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
|
|
else:
|
|
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
|
|
if not os.environ.get("DEVII_TASKS_DB"):
|
|
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
|
|
if not os.environ.get("DEVII_LESSONS_DB"):
|
|
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
|
|
|
|
file_items = [
|
|
(base / name, config.KEYS_DIR / name)
|
|
for name in (
|
|
"notification-private.pem",
|
|
"notification-private.pkcs8.pem",
|
|
"notification-public.pem",
|
|
)
|
|
]
|
|
registry_dest = config.BOT_DIR / "article_registry.json"
|
|
registry_sources = [
|
|
path
|
|
for path in (
|
|
home / ".dpbot_article_registry.json",
|
|
base / ".dpbot_article_registry.json",
|
|
)
|
|
if path.exists()
|
|
]
|
|
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
|
if registry_sources:
|
|
file_items.append((registry_sources[0], registry_dest))
|
|
for stale in registry_sources[1:]:
|
|
print(f"Leaving older duplicate registry untouched: {stale}")
|
|
|
|
legacy_var = base / "var"
|
|
tree_items = [
|
|
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
|
|
(home / ".devplace_bots", config.BOT_DIR),
|
|
]
|
|
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
|
|
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
|
|
|
|
try:
|
|
for source, dest in db_items:
|
|
_migrate_db(source, dest, dry, report)
|
|
for source, dest in file_items:
|
|
_migrate_file(source, dest, dry, report)
|
|
for source, dest in tree_items:
|
|
_migrate_tree(source, dest, dry, report)
|
|
except RuntimeError as exc:
|
|
print(f"ERROR: {exc}")
|
|
sys.exit(1)
|
|
|
|
if not report:
|
|
print("Nothing to migrate; the data directory is already consolidated.")
|
|
return
|
|
for status, source, dest, size in report:
|
|
print(f" [{status}] {source} -> {dest} ({size} bytes)")
|
|
counts = Counter(status for status, *_ in report)
|
|
print()
|
|
print(
|
|
("Planned: " if dry else "Migrated: ")
|
|
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
|
|
)
|
|
if any(status == "conflict" for status, *_ in report):
|
|
print(
|
|
"Conflicts left both source and destination untouched; resolve them by hand."
|
|
)
|
|
if dry:
|
|
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
|
|
|
|
|
|
def register_migrate(subparsers):
|
|
subparsers.add_parser(
|
|
"emoji-sync",
|
|
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
|
|
).set_defaults(func=cmd_emoji_sync)
|
|
|
|
migrate = subparsers.add_parser(
|
|
"migrate-data",
|
|
help="Relocate legacy runtime files into the consolidated data/ directory",
|
|
)
|
|
migrate.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print the source-to-destination plan without changing anything",
|
|
)
|
|
migrate.set_defaults(func=cmd_migrate_data)
|