Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-13 19:48:41 +09:00
parent 733298bf06
commit cb35bb12e3
86 changed files with 2993 additions and 2455 deletions

View File

@@ -0,0 +1,26 @@
"""Create a pg_dump backup without putting passwords in argv or output."""
import os
from pathlib import Path
import subprocess
from sqlalchemy.engine import make_url
def main():
url = make_url(os.environ["DATABASE_URL"])
if url.get_backend_name() != "postgresql":
raise SystemExit("Production deployment requires PostgreSQL")
path = Path(os.environ["BACKUP_FILE"])
environment = dict(os.environ, PGPASSWORD=url.password or "")
if "sslmode" in url.query:
environment["PGSSLMODE"] = url.query["sslmode"]
with path.open("xb") as backup:
path.chmod(0o600)
subprocess.run(["pg_dump", "--format=custom", "--no-password", "--host", url.host or "localhost",
"--port", str(url.port or 5432), "--username", url.username or "postgres",
"--dbname", url.database], env=environment, stdout=backup, check=True, timeout=600)
print("Database backup created")
if __name__ == "__main__":
main()

27
scripts/build_release.py Normal file
View File

@@ -0,0 +1,27 @@
"""Build a release from an explicit allowlist, excluding local secrets and caches."""
import hashlib
from pathlib import Path
import tarfile
ROOT = Path(__file__).resolve().parents[1]
def build_release(destination=None):
destination = Path(destination or ROOT / "dist" / "lottery.tar.gz")
destination.parent.mkdir(parents=True, exist_ok=True)
allowed = ["src", "migrations", "scripts", "main.py", "requirements.txt", "alembic.ini",
"Dockerfile", "docker-compose.yml", ".dockerignore"]
with tarfile.open(destination, "w:gz") as archive:
for name in allowed:
entry = ROOT / name
paths = entry.rglob("*") if entry.is_dir() else [entry]
for path in sorted(paths):
if not path.is_file() or path.is_symlink() or "__pycache__" in path.parts or path.suffix == ".pyc":
continue
archive.add(path, arcname=path.relative_to(ROOT).as_posix(), recursive=False)
print(f"Release SHA256: {hashlib.sha256(destination.read_bytes()).hexdigest()}")
return destination
if __name__ == "__main__":
build_release()

27
scripts/check_schema.py Normal file
View File

@@ -0,0 +1,27 @@
"""Fail deployment if migrations or runtime table definitions are missing."""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import select, text
from src.core.database import engine, Base
from src.core import models
async def main():
expected = set(ScriptDirectory.from_config(Config("alembic.ini")).get_heads())
async with engine.connect() as connection:
actual = set((await connection.scalars(text("SELECT version_num FROM alembic_version"))).all())
if actual != expected:
raise RuntimeError("Database migrations are not at the release head")
for table in Base.metadata.sorted_tables:
await connection.execute(select(table).limit(0))
await engine.dispose()
print("Database schema verified")
if __name__ == "__main__":
asyncio.run(main())

36
scripts/check_secrets.py Normal file
View File

@@ -0,0 +1,36 @@
"""Scan tracked content without printing matched credentials."""
from pathlib import Path
import re
import subprocess
RULES = {
"telegram-token": re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{32,}\b"),
"private-key": re.compile(r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
}
def main():
files = subprocess.check_output(["git", "ls-files", "-z"]).decode("utf-8").split("\0")
violations = []
for name in filter(None, files):
path = Path(name)
if not path.is_file():
continue
if path.name.startswith(".env") and not path.name.endswith(".example"):
violations.append(f"{name}: tracked runtime environment")
if ".history" in path.parts:
violations.append(f"{name}: tracked editor history")
text = path.read_text(encoding="utf-8", errors="replace")
for rule, pattern in RULES.items():
for match in pattern.finditer(text):
line = text.count("\n", 0, match.start()) + 1
violations.append(f"{name}:{line}: {rule}")
for violation in violations:
print(violation)
if violations:
raise SystemExit(1)
print("Tracked secret scan passed")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
"""Create/update repository secrets without putting their values in command arguments."""
import argparse
import json
import os
from pathlib import Path
import re
import urllib.error
import urllib.request
from urllib.parse import urlsplit
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--server", required=True)
parser.add_argument("--repo", default="trevor/new_lottery_bot")
parser.add_argument("--token-file", type=Path)
parser.add_argument("--ssh-key", type=Path, required=True)
parser.add_argument("--known-hosts", type=Path, required=True)
parser.add_argument("--env-file", type=Path, default=Path(".env.prod"))
parser.add_argument("--deploy-user", required=True)
parser.add_argument("--deploy-path", required=True)
args = parser.parse_args()
if urlsplit(args.server).scheme != "https":
parser.error("Drone must use HTTPS")
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repo):
parser.error("Invalid repository name")
token = args.token_file.read_text(encoding="utf-8").strip() if args.token_file else os.getenv("DRONE_TOKEN", "")
if not token:
parser.error("Provide --token-file or DRONE_TOKEN")
values = {
"lottery_deploy_user": args.deploy_user,
"lottery_deploy_path": args.deploy_path,
"lottery_deploy_ssh_key": args.ssh_key.read_text(encoding="utf-8"),
"lottery_deploy_known_hosts": args.known_hosts.read_text(encoding="utf-8"),
"lottery_env": args.env_file.read_text(encoding="utf-8-sig"),
}
if any(not value.strip() for value in values.values()):
parser.error("All secret values must be nonempty")
endpoint = args.server.rstrip("/") + f"/api/repos/{args.repo}/secrets"
def request(url, method="GET", body=None):
payload = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=payload, method=method,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as response:
data = response.read()
return json.loads(data) if data else None
except urllib.error.HTTPError as error:
raise SystemExit(f"Drone API rejected {method}: HTTP {error.code}. Secret values were not logged.") from None
existing = {item["name"] for item in request(endpoint)}
for name, value in values.items():
body = {"name": name, "data": value, "pull_request": False}
request(endpoint + "/" + name if name in existing else endpoint,
"PATCH" if name in existing else "POST", body)
print(f"Configured {name} (pull requests disabled)")
configured = {item["name"] for item in request(endpoint)}
if not values.keys() <= configured:
raise SystemExit("Drone secret verification failed")
print("All required repository secrets are present")
if __name__ == "__main__":
main()

61
scripts/deploy_release.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
umask 077
root=$1
upload=$2
commit=$3
build=$4
case "$root" in /opt/*|/srv/*|/home/*/*) ;; *) echo "Use a dedicated absolute deployment directory" >&2; exit 1;; esac
case "$root" in *..*|*[!a-zA-Z0-9_./-]*) echo "Invalid deployment path" >&2; exit 1;; esac
mkdir -p "$root/releases" "$root/backups"
root=$(cd "$root" && pwd -P)
exec 9>"$root/deploy.lock"
flock -w 600 9
if [ -f "$root/last-build" ] && [ "$build" -le "$(cat "$root/last-build")" ]; then
echo "A newer or identical build is already deployed"
exit 0
fi
release="$root/releases/$commit-$build"
mkdir -p "$release"
tar -xzf "$upload/lottery.tar.gz" -C "$release"
install -m 600 "$upload/runtime.env" "$release/.env.prod"
cd "$release"
export BOT_IMAGE="new_lottery_bot:$commit"
compose() { docker compose --project-name new_lottery_bot --env-file .env.prod "$@"; }
compose config --quiet
compose build --pull bot
old_image=$(docker inspect --format '{{.Image}}' lottery_bot 2>/dev/null || true)
old_project=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' lottery_bot 2>/dev/null || true)
if [ -n "$old_image" ] && [ "$old_project" != "new_lottery_bot" ]; then
echo "Existing lottery_bot belongs to another stack; align the Compose project before the first deployment" >&2
exit 1
fi
services=$(compose config --services | grep -v '^bot$')
# Service names come from this repository's trusted Compose file.
compose up -d --wait $services
compose run --rm --no-deps --user "$(id -u):$(id -g)" \
-v "$root/backups:/backups" -e BACKUP_FILE="/backups/$commit-$build-$(date -u +%Y%m%dT%H%M%SZ).dump" \
bot python scripts/backup_database.py
rollback() {
code=$?
trap - EXIT
if [ "$code" -ne 0 ] && [ -n "$old_image" ]; then
echo "Deployment failed; restoring previous application image" >&2
BOT_IMAGE="$old_image" compose up -d --no-build --no-deps bot || true
fi
exit "$code"
}
trap rollback EXIT
compose stop bot
compose run --rm --no-deps bot python -m alembic upgrade head
compose run --rm --no-deps bot python scripts/check_schema.py
compose up -d --no-build --wait --wait-timeout 180 bot
docker exec lottery_bot python -m src.core.health
ln -s "$release" "$root/current.$build"
mv -Tf "$root/current.$build" "$root/current"
printf '%s\n' "$build" > "$root/last-build"
trap - EXIT
echo "Deployment verified: $commit (build $build)"

46
scripts/drone_deploy.py Normal file
View File

@@ -0,0 +1,46 @@
"""Upload a tested artifact and environment over authenticated SSH; never log secrets."""
import os
from pathlib import Path
import re
import shlex
import subprocess
import tempfile
import uuid
def main():
required = ["DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS", "DEPLOY_PATH", "LOTTERY_ENV"]
for key in required:
if not os.getenv(key, "").strip():
raise SystemExit(f"Missing Drone secret/environment: {key}")
host, user = os.environ["DEPLOY_HOST"], os.environ["DEPLOY_USER"]
commit, build = os.environ["DRONE_COMMIT_SHA"], os.environ["DRONE_BUILD_NUMBER"]
if not re.fullmatch(r"[a-zA-Z0-9.-]+", host) or not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", user):
raise SystemExit("Invalid deployment host/user")
if not re.fullmatch(r"[a-f0-9]{40}", commit) or not re.fullmatch(r"[0-9]+", build):
raise SystemExit("Invalid build identity")
destination = f"{user}@{host}"
remote = f"/tmp/lottery-deploy-{uuid.uuid4().hex}"
with tempfile.TemporaryDirectory(prefix="lottery-ssh-") as directory:
directory = Path(directory)
key, known, environment = directory / "key", directory / "known_hosts", directory / "runtime.env"
for path, value in ((key, "DEPLOY_SSH_KEY"), (known, "DEPLOY_KNOWN_HOSTS"), (environment, "LOTTERY_ENV")):
path.write_text(os.environ[value].strip() + "\n", encoding="utf-8")
path.chmod(0o600)
options = ["-i", str(key), "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={known}",
"-o", "ConnectTimeout=15"]
def ssh(command):
subprocess.run(["ssh", *options, destination, command], check=True, timeout=1200)
ssh(f"umask 077; mkdir {shlex.quote(remote)}")
try:
subprocess.run(["scp", *options, "dist/lottery.tar.gz", "scripts/deploy_release.sh", str(environment),
f"{destination}:{remote}/"], check=True, timeout=300)
ssh("sh " + shlex.join([remote + "/deploy_release.sh", os.environ["DEPLOY_PATH"], remote, commit, build]))
finally:
# Remove only the exact uploaded secret, even when a deployment fails.
ssh("rm -f -- " + shlex.quote(remote + "/runtime.env"))
if __name__ == "__main__":
main()

24
scripts/wait_database.py Normal file
View File

@@ -0,0 +1,24 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import text
from src.core.database import engine
async def main():
for attempt in range(30):
try:
async with engine.connect() as connection:
await connection.execute(text("SELECT 1"))
await engine.dispose()
return
except Exception:
if attempt == 29:
raise
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())