Add deploy webhook receiver for snotes
This commit is contained in:
61
deploy/README.md
Normal file
61
deploy/README.md
Normal 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
59
deploy/deploy.sh
Executable 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
|
||||
17
deploy/snotes-deploy.service
Normal file
17
deploy/snotes-deploy.service
Normal 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
143
deploy/webhook-server.py
Executable 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()
|
||||
Reference in New Issue
Block a user