Stabilize staff concurrency and premium emoji; enable verified Drone deployment
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
26
scripts/backup_database.py
Normal file
26
scripts/backup_database.py
Normal 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
27
scripts/build_release.py
Normal 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
27
scripts/check_schema.py
Normal 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
36
scripts/check_secrets.py
Normal 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()
|
||||
63
scripts/configure_drone_secrets.py
Normal file
63
scripts/configure_drone_secrets.py
Normal 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
61
scripts/deploy_release.sh
Normal 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
46
scripts/drone_deploy.py
Normal 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
24
scripts/wait_database.py
Normal 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())
|
||||
Reference in New Issue
Block a user