diff --git a/.gitignore b/.gitignore index 36b13f1..07940a5 100644 --- a/.gitignore +++ b/.gitignore @@ -168,6 +168,9 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Deploy secrets +deploy/deploy.conf + # Ruff stuff: .ruff_cache/ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..1308d41 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,61 @@ +# Deploy + +Auto-deploy snotes when code is pushed to Gitea. + +## How it works + +``` +User pushes → Gitea webhook POST → webhook-server.py → git pull + → docker compose build + → docker compose up -d + → health check +``` + +The webhook server runs on the Docker host (ppv.loc) and listens for push events +from Gitea. + +## Setup + +### 1. Start the webhook server on the Docker host + +```bash +cd /root/git/snotes/deploy +python3 webhook-server.py --port 9997 --secret "" +``` + +Or run as a systemd service (see below). + +### 2. Configure Gitea webhook + +In the Gitea repo **Settings > Webhooks > Add Webhook > Gitea**: + +- **Target URL**: `http://ppv.loc:9997/deploy` +- **Secret**: same `` from step 1 +- **Trigger On**: Push events +- **Active**: ✅ + +### 3. Test + +Push to `main`. The webhook server logs each step. + +## systemd service (recommended) + +Install `deploy/snotes-deploy.service`: + +```bash +cp deploy/snotes-deploy.service /etc/systemd/system/snotes-deploy.service +# edit the --secret value in the unit, then: +systemctl daemon-reload +systemctl enable --now snotes-deploy +``` + +## Manual deploy + +```bash +bash deploy/deploy.sh +``` + +## Secrets / config + +`deploy/deploy.conf` (gitignored) can carry a `GIT_TOKEN` for HTTP remotes. The +default checkout uses SSH, so no token is needed. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..9e0b6a2 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy script for snotes — git pull, build, restart, health check. +# Designed to run on the Docker host (ppv.loc, checkout at /root/git/snotes). +# Can be called directly or triggered by webhook-server.py. +# +# Authentication: uses SSH to clone/pull from gitea. For HTTP remotes, create +# deploy/deploy.conf with GIT_TOKEN=your_gitea_token_here + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="$REPO_DIR/docker-compose.yml" +CONFIG_FILE="$(dirname "$0")/deploy.conf" +HEALTH_URL="http://localhost:8019/api/me" +MAX_RETRIES=5 +RETRY_DELAY=3 + +# Load token from config file if present +if [ -f "$CONFIG_FILE" ]; then + # shellcheck source=/dev/null + . "$CONFIG_FILE" +fi + +echo "[deploy] Pulling latest code..." +cd "$REPO_DIR" +if [ -n "${GIT_TOKEN:-}" ]; then + # Inject token into remote URL for this pull only + REMOTE_URL=$(git remote get-url origin) + if [[ "$REMOTE_URL" != *"${GIT_TOKEN}@"* ]]; then + # Replace https:// or http:// with scheme://token@ + AUTH_URL="${REMOTE_URL/https:\/\//https:\/\/${GIT_TOKEN}@}" + AUTH_URL="${AUTH_URL/http:\/\//http:\/\/${GIT_TOKEN}@}" + git pull "$AUTH_URL" "$(git rev-parse --abbrev-ref HEAD)" + else + git pull + fi +else + git pull +fi + +echo "[deploy] Building Docker image..." +docker compose -f "$COMPOSE_FILE" build + +echo "[deploy] Restarting container..." +docker compose -f "$COMPOSE_FILE" down --remove-orphans +docker compose -f "$COMPOSE_FILE" up -d + +echo "[deploy] Health check: $HEALTH_URL" +for i in $(seq 1 $MAX_RETRIES); do + sleep "$RETRY_DELAY" + if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then + echo "[deploy] OK — healthy" + exit 0 + fi + echo "[deploy] attempt $i/$MAX_RETRIES — not ready yet" +done + +echo "[deploy] FAIL — health check did not pass" +exit 1 diff --git a/deploy/snotes-deploy.service b/deploy/snotes-deploy.service new file mode 100644 index 0000000..31d975b --- /dev/null +++ b/deploy/snotes-deploy.service @@ -0,0 +1,17 @@ +[Unit] +Description=snotes deploy webhook receiver +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +User=root +WorkingDirectory=/root/git/snotes/deploy +ExecStart=/usr/bin/python3 /root/git/snotes/deploy/webhook-server.py --port 9997 --secret __WEBHOOK_SECRET__ +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/webhook-server.py b/deploy/webhook-server.py new file mode 100755 index 0000000..6af8b6b --- /dev/null +++ b/deploy/webhook-server.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Gitea push-webhook receiver for snotes auto-deploy. + +Runs deploy/deploy.sh on every push event to the main branch. +Designed to run on the Docker host. + +Usage: + python webhook-server.py --port 9997 --secret "hunter2" + python webhook-server.py --port 9997 --secret "hunter2" --deploy /path/to/deploy.sh +""" + +from __future__ import annotations + +import argparse +import hmac +import json +import logging +import os +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s deploy-webhook %(levelname)s %(message)s", +) +log = logging.getLogger("deploy-webhook") + + +class DeployHandler(BaseHTTPRequestHandler): + secret: str = "" + deploy_script: str = "" + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + log.info(format, *args) + + def _verify_signature(self, body: bytes) -> bool: + if not self.secret: + return True + sig_header = self.headers.get("X-Gitea-Signature", "") + expected = hmac.new( + self.secret.encode("utf-8"), body, "sha256" + ).hexdigest() + return hmac.compare_digest(sig_header, expected) + + def do_POST(self) -> None: + if self.path != "/deploy": + self.send_response(404) + self.end_headers() + return + + content_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_len) + + if not self._verify_signature(body): + log.warning("Signature verification failed") + self.send_response(403) + self.end_headers() + self.wfile.write(b"Forbidden") + return + + # Only deploy on pushes to main + try: + payload = json.loads(body) + except json.JSONDecodeError: + payload = {} + + event = self.headers.get("X-Gitea-Event", "push") + ref = payload.get("ref", "") + + if event != "push" or not ref.endswith("/main"): + log.info("Skipping non-push or non-main event: %s %s", event, ref) + self.send_response(204) + self.end_headers() + return + + log.info("Push to main detected — deploying...") + self.send_response(202) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"Deploy triggered\n") + + # Run deploy in background — don't block the webhook response + try: + result = subprocess.run( + ["bash", self.deploy_script], + capture_output=True, + text=True, + timeout=300, + ) + for line in result.stdout.splitlines(): + log.info("[deploy] %s", line) + for line in result.stderr.splitlines(): + log.warning("[deploy] %s", line) + if result.returncode == 0: + log.info("Deploy succeeded") + else: + log.error("Deploy failed (exit %d)", result.returncode) + except subprocess.TimeoutExpired: + log.error("Deploy timed out after 300s") + except Exception as exc: + log.error("Deploy error: %s", exc) + + +def main() -> None: + parser = argparse.ArgumentParser(description="snotes deploy webhook receiver") + parser.add_argument("--port", type=int, default=9997, help="Listen port") + parser.add_argument("--secret", default="", help="Gitea webhook secret") + parser.add_argument( + "--deploy", + default=os.path.join(os.path.dirname(__file__), "deploy.sh"), + help="Path to deploy script", + ) + args = parser.parse_args() + + DeployHandler.secret = args.secret + DeployHandler.deploy_script = os.path.abspath(args.deploy) + + if not os.path.exists(DeployHandler.deploy_script): + log.error("Deploy script not found: %s", DeployHandler.deploy_script) + sys.exit(1) + + if not os.access(DeployHandler.deploy_script, os.X_OK): + log.error("Deploy script is not executable: %s", DeployHandler.deploy_script) + sys.exit(1) + + server = HTTPServer(("0.0.0.0", args.port), DeployHandler) + log.info( + "Listening on :%d, secret=%s, deploy=%s", + args.port, + "yes" if args.secret else "no", + DeployHandler.deploy_script, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("Shutting down") + server.server_close() + + +if __name__ == "__main__": + main()