Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

16
scripts/backup_db.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-./backups}"
COMPOSE="${COMPOSE:-docker compose}"
DB_SERVICE="${DB_SERVICE:-db}"
POSTGRES_DB="${POSTGRES_DB:-drivers}"
POSTGRES_USER="${POSTGRES_USER:-drivers}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
OUT="${BACKUP_DIR}/carpass-${POSTGRES_DB}-${STAMP}.dump"
mkdir -p "$BACKUP_DIR"
echo "Creating database backup: $OUT"
$COMPOSE exec -T "$DB_SERVICE" pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "$OUT"
echo "Backup complete: $OUT"

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import asyncio
from sqlalchemy import select
from app.core.config import settings
from app.db.session import async_session_factory
from app.models.user import User
async def main() -> None:
admin_ids = settings.admin_telegram_id_list
if not admin_ids:
raise SystemExit("ADMIN_TELEGRAM_IDS is empty")
async with async_session_factory() as session:
for telegram_id in admin_ids:
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
if user is None:
user = User(telegram_id=telegram_id, username=str(telegram_id), platform_role="admin")
session.add(user)
else:
user.platform_role = "admin"
await session.commit()
print(f"Bootstrapped admins: {', '.join(str(item) for item in admin_ids)}")
if __name__ == "__main__":
asyncio.run(main())

9
scripts/check_migrations.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Checking Alembic migration chain..."
python -m alembic heads
python -m alembic current || true
python -m alembic upgrade head
python -m alembic current
echo "Alembic migrations applied successfully."

67
scripts/cleanup_jobs.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from app.db.session import async_session_factory
from app.models.car import ServiceEmployee, ServiceNotification, ServiceVisit
async def main() -> None:
now = datetime.now(UTC)
async with async_session_factory() as session:
expired = (
await session.execute(
select(ServiceEmployee).where(
ServiceEmployee.status == "invited",
ServiceEmployee.invite_expires_at.is_not(None),
ServiceEmployee.invite_expires_at <= now,
)
)
).scalars()
expired_count = 0
for employee in expired:
employee.status = "expired"
employee.invite_token = None
expired_count += 1
abandoned_count = 0
abandoned = (
await session.execute(
select(ServiceNotification).where(
ServiceNotification.status.in_(["failed", "retrying"]),
ServiceNotification.retry_count >= 5,
ServiceNotification.created_at < now - timedelta(days=1),
)
)
).scalars()
for notification in abandoned:
notification.status = "abandoned"
abandoned_count += 1
old_notifications = await session.execute(
delete(ServiceNotification).where(
ServiceNotification.status == "abandoned",
ServiceNotification.created_at < now - timedelta(days=30),
)
)
orphan_drafts = await session.execute(
delete(ServiceVisit).where(
ServiceVisit.status == "draft",
ServiceVisit.created_at < now - timedelta(days=90),
)
)
await session.commit()
print(
"Cleanup done: "
f"expired_invites={expired_count}, "
f"abandoned_notifications={abandoned_count}, "
f"deleted_old_notifications={old_notifications.rowcount or 0}, "
f"orphan_drafts={orphan_drafts.rowcount or 0}"
)
if __name__ == "__main__":
asyncio.run(main())

55
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/carpass/app}"
BRANCH="${DEPLOY_BRANCH:-main}"
COMPOSE="${COMPOSE:-docker compose}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8000/ready}"
BACKUP_BEFORE_DEPLOY="${BACKUP_BEFORE_DEPLOY:-false}"
if [[ ! -d "$APP_DIR/.git" ]]; then
echo "Deploy directory is not a git repository: $APP_DIR" >&2
exit 1
fi
cd "$APP_DIR"
if [[ ! -f ".env" ]]; then
echo ".env is missing in $APP_DIR" >&2
exit 1
fi
echo "Fetching $BRANCH..."
git fetch origin "$BRANCH"
git checkout "$BRANCH"
git pull --ff-only origin "$BRANCH"
if [[ "$BACKUP_BEFORE_DEPLOY" == "true" ]]; then
echo "Creating pre-deploy database backup..."
./scripts/backup_db.sh
fi
echo "Building and starting containers..."
$COMPOSE build
$COMPOSE up -d
echo "Applying migrations..."
$COMPOSE exec -T api alembic upgrade head
echo "Running smoke checks..."
$COMPOSE exec -T api python -m compileall -q app bot
echo "Running health check: $HEALTH_URL"
for attempt in {1..30}; do
if curl -fsS "$HEALTH_URL" >/tmp/carpass-ready.json; then
cat /tmp/carpass-ready.json
echo
$COMPOSE ps
exit 0
fi
sleep 2
done
echo "API readiness check failed" >&2
$COMPOSE logs --tail=120 api >&2
exit 1

22
scripts/restore_db.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 path/to/backup.dump" >&2
exit 2
fi
BACKUP_FILE="$1"
COMPOSE="${COMPOSE:-docker compose}"
DB_SERVICE="${DB_SERVICE:-db}"
POSTGRES_DB="${POSTGRES_DB:-drivers}"
POSTGRES_USER="${POSTGRES_USER:-drivers}"
if [[ ! -f "$BACKUP_FILE" ]]; then
echo "Backup file not found: $BACKUP_FILE" >&2
exit 1
fi
echo "Restoring $BACKUP_FILE into $POSTGRES_DB. This replaces database contents."
cat "$BACKUP_FILE" | $COMPOSE exec -T "$DB_SERVICE" pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists
echo "Restore complete"