fc5: fix GS export script — use async engine + correct imports, designed for in-container exec

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 20:51:32 -04:00
parent d47b4c8a64
commit 908c95f690
+83 -58
View File
@@ -11,71 +11,105 @@ Credentials are decrypted in this script's process using GS's secret_key
ingest with its own Fernet key. The output JSON contains plaintext ingest with its own Fernet key. The output JSON contains plaintext
credentials — delete it after the FC ingest completes. credentials — delete it after the FC ingest completes.
Usage: Designed to run INSIDE the live GS container:
python scripts/export_for_fabledcurator.py --secret-key "$SECRET_KEY" > gs-export.json docker cp scripts/export_for_fabledcurator.py <gs_container>:/app/scripts/
docker exec -w /app <gs_container> python scripts/export_for_fabledcurator.py \\
--secret-key "$SECRET_KEY" > ~/gs-export.json
The container has GS's Python deps (asyncpg, sqlalchemy, cryptography)
and the runtime env vars (DATABASE_URL, SECRET_KEY) already set.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio
import json import json
import os import os
import sys import sys
from datetime import UTC, datetime from datetime import UTC, datetime
# Bootstrap GS's app context. # Ensure the container's WORKDIR (/app) is on sys.path so the GS package
sys.path.insert(0, "backend") # `app.*` imports resolve. Container layout: /app/app/ is the package.
from app import create_app # noqa: E402 sys.path.insert(0, os.getcwd())
from app.database import get_session # noqa: E402
from sqlalchemy import select # noqa: E402
from sqlalchemy.ext.asyncio import ( # noqa: E402
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.models.credential import Credential # noqa: E402 from app.models.credential import Credential # noqa: E402
from app.models.source import Source # noqa: E402 from app.models.source import Source # noqa: E402
from app.models.subscription import Subscription # noqa: E402 from app.models.subscription import Subscription # noqa: E402
from app.utils.encryption import decrypt_data # noqa: E402 from app.utils.encryption import decrypt_data # noqa: E402
def _export_subscriptions(session) -> list[dict]: def _async_database_url() -> str:
"""Subscriptions with nested sources.""" """Resolve GS's async DB URL the same way app.config.Settings does."""
out: list[dict] = [] url = os.environ.get("DATABASE_URL")
for sub in session.query(Subscription).all(): if not url:
sources_out: list[dict] = [] # GS's default fallback chain — should rarely fire in prod since
for src in sub.sources or []: # the container's DATABASE_URL is set by compose.
sources_out.append({ url = "postgresql://gdl:password@localhost:5432/gallery_subscriber"
"platform": src.platform, return url.replace("postgresql://", "postgresql+asyncpg://")
"url": src.url,
"enabled": bool(src.enabled),
"check_interval": int(src.check_interval or 28800),
"metadata": src.metadata_ or {},
})
out.append({
"name": sub.name,
"enabled": bool(sub.enabled),
"metadata": sub.metadata_ or {},
"sources": sources_out,
})
return out
def _export_credentials(session, secret_key: str) -> list[dict]: async def _export_async(secret_key: str) -> dict:
"""Credentials decrypted to plaintext (FC re-encrypts on ingest).""" engine = create_async_engine(_async_database_url())
out: list[dict] = [] Session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
for cred in session.query(Credential).all(): try:
try: async with Session() as session:
plaintext = decrypt_data(cred.data, secret_key) subs_out: list[dict] = []
except Exception as exc: subs = (await session.execute(select(Subscription))).scalars().all()
print( for sub in subs:
f"WARNING: failed to decrypt credential for " srcs = (await session.execute(
f"platform={cred.platform!r}: {exc}", select(Source).where(Source.subscription_id == sub.id)
file=sys.stderr, )).scalars().all()
) sources_out = [{
continue "platform": s.platform,
out.append({ "url": s.url,
"platform": cred.platform, "enabled": bool(s.enabled),
"credential_type": cred.credential_type, "check_interval": int(s.check_interval or 28800),
"plaintext": plaintext, "metadata": s.metadata_ or {},
"expires_at": ( } for s in srcs]
cred.expires_at.isoformat() if cred.expires_at else None subs_out.append({
), "name": sub.name,
}) "enabled": bool(sub.enabled),
return out "metadata": sub.metadata_ or {},
"sources": sources_out,
})
creds_out: list[dict] = []
creds = (await session.execute(select(Credential))).scalars().all()
for cred in creds:
try:
plaintext = decrypt_data(cred.data, secret_key)
except Exception as exc:
print(
f"WARNING: failed to decrypt credential for "
f"platform={cred.platform!r}: {exc}",
file=sys.stderr,
)
continue
creds_out.append({
"platform": cred.platform,
"credential_type": cred.credential_type,
"plaintext": plaintext,
"expires_at": (
cred.expires_at.isoformat() if cred.expires_at else None
),
})
return {
"source_app": "gallerysubscriber",
"schema_version": 1,
"exported_at": datetime.now(UTC).isoformat(),
"subscriptions": subs_out,
"credentials": creds_out,
}
finally:
await engine.dispose()
def main() -> None: def main() -> None:
@@ -93,16 +127,7 @@ def main() -> None:
) )
sys.exit(2) sys.exit(2)
app = create_app() export = asyncio.run(_export_async(args.secret_key))
with app.app_context():
with get_session() as session:
export = {
"source_app": "gallerysubscriber",
"schema_version": 1,
"exported_at": datetime.now(UTC).isoformat(),
"subscriptions": _export_subscriptions(session),
"credentials": _export_credentials(session, args.secret_key),
}
json.dump(export, sys.stdout, indent=2) json.dump(export, sys.stdout, indent=2)
sys.stdout.write("\n") sys.stdout.write("\n")