Add deploy webhook receiver for snotes

This commit is contained in:
2026-09-23 10:15:36 -07:00
parent abfbf3de1f
commit 3486edc141
5 changed files with 283 additions and 0 deletions

61
deploy/README.md Normal file
View File

@@ -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 "<your-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 `<your-secret>` 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.

59
deploy/deploy.sh Executable file
View File

@@ -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

View File

@@ -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

143
deploy/webhook-server.py Executable file
View File

@@ -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()