diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2916e6f..8fc5c88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,13 +7,69 @@ on: pull_request: types: [opened, synchronize, reopened] jobs: + test: + name: Tests & Coverage + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests with coverage + run: | + pytest --cov=gitpull --cov-report=xml --cov-report=term-missing + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + + pylint: + name: Pylint + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') + sonarqube: name: SonarQube runs-on: ubuntu-latest + needs: test steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install dependencies and run coverage + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pytest --cov=gitpull --cov-report=xml - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@f00de44f574073760c9deaf47f694e10431f3988 env: diff --git a/.gitignore b/.gitignore index 1c06c63..6ba5422 100644 --- a/.gitignore +++ b/.gitignore @@ -209,3 +209,4 @@ __marimo__/ demo/demo.json config.json /.idea/ +nohup.out diff --git a/.sonarlint/connectedMode.json b/.sonarlint/connectedMode.json new file mode 100644 index 0000000..d08faa7 --- /dev/null +++ b/.sonarlint/connectedMode.json @@ -0,0 +1,5 @@ +{ + "sonarCloudOrganization": "lenoirpatrick", + "projectKey": "lenoirpatrick_githubwebhook", + "region": "EU" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d42a8d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A FastAPI-based GitHub webhook server that automatically pulls the latest code from GitHub repositories when a push event is received. Intended for CI/CD deployment on a Raspberry Pi or similar server. + +## Commands + +### Install dependencies +```shell +pip install -r requirements.txt +``` + +### Run the server +```shell +python gitpull.py +``` + +### Lint +```shell +pylint gitpull.py +``` + +### Run tests +```shell +pytest +``` + +### Run tests with coverage +```shell +pytest --cov=gitpull --cov-report=term-missing +``` + +## Architecture + +The entire application is in a single file: `gitpull.py`. + +- **`/`** — HTML home page with a link to the demo endpoint. +- **`/beats`** — Health check endpoint; returns `{"result": true}`. +- **`/webhook`** (POST) — Receives GitHub push events. Checks the `ref` field; only processes pushes to `refs/heads/main`. Calls `update_webhook()`. +- **`/webhookdemo`** — Triggers a simulated webhook using `demo/demo.json` as the payload. Useful for manual testing. +- **`update_webhook(webhook_github)`** — Core logic: resolves the repo path from `config/config.json`, runs `git reset --hard HEAD~1` then `git pull` in that path. + +## Configuration + +`config/config.json` maps repository full names (e.g. `"lenoirpatrick/githubwebhook"`) to local paths, and contains the server bind IP: + +```json +{ + "ip": "127.0.0.1", + "lenoirpatrick/githubwebhook": { + "path": "/home/pi/app/githubwebhook" + } +} +``` + +The config is loaded at module startup (global scope), so the server must be started from the project root directory. + +## CI + +`.github/workflows/build.yml` runs two jobs on push to `main`/`develop` and on PRs: +- **SonarQube** — static analysis via SonarCloud (requires `SONAR_TOKEN` secret). +- **pylint** — runs across Python 3.11, 3.12, 3.13 matrix. \ No newline at end of file diff --git a/README.md b/README.md index 45557b9..be741a3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,24 @@ Gestion du webhook Github pour déploiement CI/CD sur vos environnements. [![GitHub stars](https://img.shields.io/github/stars/lenoirpatrick/githubwebhook)](https://github.com/lenoirpatrick/githubwebhook) [![GitHub license](https://img.shields.io/github/license/lenoirpatrick/githubwebhook)](https://github.com/lenoirpatrick/githubwebhook) +# Prérequis — Configurer le webhook GitHub + +Sur chaque dépôt à déployer, un webhook doit être configuré dans GitHub pour notifier cette application à chaque push. + +1. Aller dans **Settings → Webhooks → Add webhook** du dépôt concerné +2. Renseigner les champs suivants : + +| Champ | Valeur | +|-------|--------| +| **Payload URL** | `http://:5000/webhook` | +| **Content type** | `application/json` | +| **Secret** | La valeur de `webhook_secret` définie dans `config.json` (si configurée) | +| **Which events?** | *Just the push event* | + +3. Cocher **Active** et valider. + +GitHub enverra alors un événement `POST /webhook` à chaque push. Seuls les pushs sur la branche `main` déclenchent un `git pull`. + # Installation ```shell git clone githubwebhook.git @@ -23,14 +41,85 @@ chmod +x run.sh ``` # Configuration -Dans le répertoire config, créer un fichier config.json + +Dans le répertoire `config`, créer un fichier `config.json` : + ```json { - "lenoirpatrick/githubwebhook": { - "path": "/home/pi/app/githubwebhook" - } + "ip": "0.0.0.0", + "webhook_secret": "votre_secret_github", + "lenoirpatrick/githubwebhook": { + "path": "/home/pi/app/githubwebhook" + }, + "lenoirpatrick/autreprojet": { + "path": "/home/pi/app/autreprojet" + } } ``` +| Clé | Description | +|-----|-------------| +| `ip` | Adresse d'écoute : `0.0.0.0` pour toutes les interfaces, `127.0.0.1` pour local uniquement | +| `webhook_secret` | Secret partagé avec GitHub pour valider la signature HMAC-SHA256 (optionnel mais recommandé) | +| `"owner/repo"` | Chemin absolu local du dépôt à mettre à jour lors d'un push sur `main` | + +Plusieurs dépôts peuvent être configurés simultanément. + # Lancement -Executer le script ```run.sh``` \ No newline at end of file + +## Manuel + +```shell +./run.sh +``` + +Le script installe les dépendances, puis lance l'application en arrière-plan via `nohup`. Les logs sont disponibles dans `nohup.out`. + +## Démarrage automatique avec systemd (recommandé) + +Pour que le serveur se lance automatiquement au démarrage de Linux, créer un service systemd. + +**1. Créer le fichier de service** (adapter les chemins et l'utilisateur) : + +```shell +sudo nano /etc/systemd/system/githubwebhook.service +``` + +```ini +[Unit] +Description=GitHub Webhook Server +After=network.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/home/pi/app/githubwebhook +ExecStart=/usr/bin/python3 /home/pi/app/githubwebhook/gitpull.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +**2. Activer et démarrer le service :** + +```shell +sudo systemctl daemon-reload +sudo systemctl enable githubwebhook +sudo systemctl start githubwebhook +``` + +**3. Vérifier que le service tourne :** + +```shell +sudo systemctl status githubwebhook +``` + +**Commandes utiles :** + +```shell +sudo systemctl stop githubwebhook # arrêter +sudo systemctl restart githubwebhook # redémarrer +journalctl -u githubwebhook -f # suivre les logs en temps réel +``` \ No newline at end of file diff --git a/gitpull.py b/gitpull.py index 221781c..9b15d9b 100644 --- a/gitpull.py +++ b/gitpull.py @@ -1,83 +1,342 @@ -import subprocess -import os -import json - -import uvicorn -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse - -app = FastAPI() - -with(open('config/config.json', 'r')) as githubjson: - config_github = json.load(githubjson) - - -@app.get("/", response_class=HTMLResponse) -async def home(): - # Page HTML simple avec un lien vers /webhookdemo - html_content = """ - - - - Accueil - Webhook Demo - - -

Bienvenue sur le serveur de webhook GitHub

-

Cliquez ci-dessous pour tester le webhook :

- Tester le webhook - - - """ - return HTMLResponse(content=html_content, status_code=200) - - -@app.post('/webhook') -def webhook(request: Request): - # Vérifier la signature (optionnel) - if request.headers.get('X-Hub-Signature-256'): - # Ici, vous pouvez vérifier la signature avec votre clé secrète - pass - - # Récupérer les données du webhook - webhook_github = request.json - - # Vérifier que c'est un push sur la branche principale - if webhook_github['ref'] == 'refs/heads/main': - print("Nouveau push détecté ! Mise à jour en cours...") - - return update_webhook(webhook_github), 200 - else: - return "Ignoré : ce n'est pas un push sur la branche principale.", 200 - - -@app.get('/webhookdemo') -def webhookdemo(): - # Vérifier la signature (optionnel) - import json - with(open('demo/demo.json', 'r')) as openjson: - webhook_github = json.load(openjson) - - return update_webhook(webhook_github) - - -def update_webhook(webhook_github): - try: - repo = webhook_github['repository']['full_name'] - path_repo = config_github[repo]['path'] - command = ['git', '-C', path_repo, 'pull'] - if os.path.isdir(path_repo): - print("Retour en arrière") - command_reset = ['git', 'reset', '--hard', 'HEAD~1'] - subprocess.run(command_reset) - - print("Mise à jour du dépot") - subprocess.run(command) - return f"repo mis à jour dans {path_repo} avec la commande : {command}" - except Exception as ex: - return ex - - -if __name__ == '__main__': - ip_address = config_github.get("ip") - - uvicorn.run(app, host=ip_address, port=5000) \ No newline at end of file +""" Mies à jour automatique d'un dépot git """ + +import hashlib +import hmac +import json +import pathlib +import subprocess + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse + +app = FastAPI( + title="GitHub Webhook Server", + description="Serveur de webhook GitHub pour déploiement CI/CD automatique via `git pull`.", + version="1.2.0", +) + +BASE_DIR = pathlib.Path(__file__).parent + +with (BASE_DIR / 'config' / 'config.json').open(encoding="utf-8") as githubjson: + config_github = json.load(githubjson) + + +@app.get("/", response_class=HTMLResponse) +async def home(): + """ Page index """ + html_content = """ + + + + + GitHub Webhook Server + + + +
+
🔗
+

GitHub Webhook Server

+

Déploiement CI/CD automatique via git pull au push sur main.

+
En ligne
+
+ ▶ Lancer la démo +
+ + + +""" + return HTMLResponse(content=html_content, status_code=200) + + +@app.get('/beats') +def beats(): + """ heart beats """ + return {"result": True} + + +@app.post('/webhook', responses={401: {"description": "Signature HMAC-SHA256 invalide"}}) +async def webhook(request: Request): + """ webhook pour lancer le pull de github """ + body = await request.body() + + secret = config_github.get("webhook_secret", "") + if secret: + expected_sig = "sha256=" + hmac.new( + secret.encode(), body, hashlib.sha256 + ).hexdigest() + received_sig = request.headers.get("X-Hub-Signature-256", "") + if not hmac.compare_digest(expected_sig, received_sig): + raise HTTPException(status_code=401, detail="Signature invalide") + + webhook_github = json.loads(body) + + if webhook_github.get('ref') == 'refs/heads/main': + print("Nouveau push détecté ! Mise à jour en cours...") + return update_webhook(webhook_github) + + return {"result": False, "message": "Ignoré : ce n'est pas un push sur la branche principale."} + + +@app.get('/webhookdemo', response_class=HTMLResponse) +def webhookdemo(): + """ gitpull de demo """ + with (BASE_DIR / 'demo' / 'demo.json').open(encoding="utf-8") as openjson: + webhook_github = json.load(openjson) + + repo = webhook_github['repository']['full_name'] + path_repo = config_github.get(repo, {}).get('path', '/home/pi/app/' + repo.split('/')[-1]) + + cmd_reset = f"git -C {path_repo} reset --hard HEAD" + cmd_pull = f"git -C {path_repo} pull" + out_reset = "HEAD is now at a8d27fd pylint" + out_pull = ( + "remote: Enumerating objects: 5, done.\n" + "remote: Counting objects: 100% (5/5), done.\n" + "remote: Compressing objects: 100% (3/3), done.\n" + "Unpacking objects: 100% (3/3), done.\n" + f"From https://github.com/{repo}\n" + " a8d27fd..4c9f049 main -> origin/main\n" + "Updating a8d27fd..4c9f049\n" + "Fast-forward\n" + " gitpull.py | 12 ++++++------\n" + " 1 file changed, 6 insertions(+), 6 deletions(-)" + ) + + html = f""" + + + + + Webhook Demo — {repo} + + + +

Simulation webhook — {repo}

+

Répertoire cible : {path_repo}

+ +
+
+ + + + bash — webhook deploy +
+
$ {cmd_reset} +{out_reset} + +$ {cmd_pull} +{out_pull} +✓ Mise à jour terminée avec succès.
+
+ + + +""" + return HTMLResponse(content=html, status_code=200) + + +def update_webhook(webhook_github): + """ Fonction commune de mise à jour du dépot """ + repo = webhook_github['repository']['full_name'] + + if repo not in config_github: + return {"result": False, "message": f"Repo {repo} non configuré"} + + path_repo = config_github[repo]['path'] + + if not pathlib.Path(path_repo).is_dir(): + return {"result": False, "message": f"Chemin introuvable : {path_repo}"} + + try: + print("Retour à l'état propre") + subprocess.run( + ['git', '-C', path_repo, 'reset', '--hard', 'HEAD'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + print("Mise à jour du dépot") + retour_git = subprocess.run( + ['git', '-C', path_repo, 'pull'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + print(f"Sortie standard : {retour_git.stdout}") + return {"result": True, "message": retour_git.stdout} + + except subprocess.CalledProcessError as exc: + print(f"La commande a échoué : {exc.stderr}") + return {"result": False, "message": exc.stderr} + + +if __name__ == '__main__': + ip_address = config_github.get("ip", "127.0.0.1") + uvicorn.run(app, host=ip_address, port=5000) diff --git a/requirements.txt b/requirements.txt index 3a29f1e..63e615d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi==0.135.3 uvicorn==0.44.0 +httpx==0.28.1 +pytest==8.3.5 +pytest-cov==6.1.0 \ No newline at end of file diff --git a/test_gitpull.py b/test_gitpull.py new file mode 100644 index 0000000..484f9ae --- /dev/null +++ b/test_gitpull.py @@ -0,0 +1,218 @@ +""" Tests de couverture pour gitpull.py """ + +import hashlib +import hmac +import json +import pathlib +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +CONFIG_FIXTURE = { + "ip": "127.0.0.1", + "webhook_secret": "supersecret", + "lenoirpatrick/testrepo": {"path": "/fake/path/testrepo"}, +} + +DEMO_PAYLOAD = { + "ref": "refs/heads/main", + "repository": {"full_name": "lenoirpatrick/testrepo"}, +} + + +@pytest.fixture(autouse=True) +def patch_config(tmp_path): + """Remplace la config globale et les fichiers lus au module-level.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "config.json").write_text(json.dumps(CONFIG_FIXTURE), encoding="utf-8") + + demo_dir = tmp_path / "demo" + demo_dir.mkdir() + (demo_dir / "demo.json").write_text(json.dumps(DEMO_PAYLOAD), encoding="utf-8") + + import gitpull + original = dict(gitpull.config_github) + original_base = gitpull.BASE_DIR + + gitpull.config_github.clear() + gitpull.config_github.update(CONFIG_FIXTURE) + gitpull.BASE_DIR = tmp_path + (tmp_path / "demo" / "demo.json").write_text(json.dumps(DEMO_PAYLOAD), encoding="utf-8") + + yield + + gitpull.config_github.clear() + gitpull.config_github.update(original) + gitpull.BASE_DIR = original_base + + +@pytest.fixture() +def client(): + from gitpull import app + return TestClient(app, raise_server_exceptions=False) + + +def _make_signature(body: bytes, secret: str = "supersecret") -> str: + return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +# --------------------------------------------------------------------------- +# GET / +# --------------------------------------------------------------------------- + +class TestHome: + def test_returns_html(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + assert "webhook" in resp.text.lower() + + +# --------------------------------------------------------------------------- +# GET /beats +# --------------------------------------------------------------------------- + +class TestBeats: + def test_returns_true(self, client): + resp = client.get("/beats") + assert resp.status_code == 200 + assert resp.json() == {"result": True} + + +# --------------------------------------------------------------------------- +# POST /webhook +# --------------------------------------------------------------------------- + +class TestWebhook: + def _post(self, client, payload: dict, secret: str = "supersecret", headers=None): + body = json.dumps(payload).encode() + sig = _make_signature(body, secret) + h = {"X-Hub-Signature-256": sig} + if headers: + h.update(headers) + return client.post("/webhook", content=body, headers=h) + + def test_push_main_triggers_update(self, client): + with patch("gitpull.update_webhook", return_value={"result": True, "message": "ok"}) as mock_upd: + resp = self._post(client, DEMO_PAYLOAD) + assert resp.status_code == 200 + mock_upd.assert_called_once() + + def test_push_other_branch_ignored(self, client): + payload = {"ref": "refs/heads/develop", "repository": {"full_name": "lenoirpatrick/testrepo"}} + resp = self._post(client, payload) + assert resp.status_code == 200 + data = resp.json() + assert data["result"] is False + assert "principale" in data["message"] + + def test_invalid_signature_returns_401(self, client): + body = json.dumps(DEMO_PAYLOAD).encode() + resp = client.post( + "/webhook", + content=body, + headers={"X-Hub-Signature-256": "sha256=invalide"}, + ) + assert resp.status_code == 401 + + def test_no_secret_in_config_skips_check(self, client): + import gitpull + gitpull.config_github.pop("webhook_secret", None) + body = json.dumps(DEMO_PAYLOAD).encode() + with patch("gitpull.update_webhook", return_value={"result": True, "message": "ok"}): + resp = client.post("/webhook", content=body) + assert resp.status_code == 200 + gitpull.config_github["webhook_secret"] = "supersecret" + + +# --------------------------------------------------------------------------- +# GET /webhookdemo +# --------------------------------------------------------------------------- + +class TestWebhookDemo: + def test_returns_html(self, client): + resp = client.get("/webhookdemo") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + + def test_shows_repo_and_commands(self, client): + resp = client.get("/webhookdemo") + assert "lenoirpatrick/testrepo" in resp.text + assert "git" in resp.text + assert "pull" in resp.text + + def test_no_real_path_required(self, client): + """La page s'affiche même si le répertoire du repo n'existe pas.""" + resp = client.get("/webhookdemo") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# update_webhook() +# --------------------------------------------------------------------------- + +class TestUpdateWebhook: + def test_repo_not_in_config(self): + from gitpull import update_webhook + payload = {"repository": {"full_name": "unknown/repo"}} + result = update_webhook(payload) + assert result["result"] is False + assert "non configuré" in result["message"] + + def test_path_not_a_directory(self): + from gitpull import update_webhook + result = update_webhook(DEMO_PAYLOAD) + assert result["result"] is False + assert "introuvable" in result["message"] + + def test_successful_pull(self, tmp_path): + from gitpull import update_webhook + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + + mock_result = MagicMock() + mock_result.stdout = "Already up to date." + mock_result.returncode = 0 + + with patch("subprocess.run", return_value=mock_result) as mock_run: + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is True + assert "Already up to date." in result["message"] + assert mock_run.call_count == 2 # reset + pull + + reset_call = mock_run.call_args_list[0] + pull_call = mock_run.call_args_list[1] + assert "reset" in reset_call.args[0] + assert "-C" in reset_call.args[0] + assert "pull" in pull_call.args[0] + + def test_git_pull_failure(self, tmp_path): + from gitpull import update_webhook + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + + error = subprocess.CalledProcessError(1, "git pull", stderr="fatal: error") + + with patch("subprocess.run", side_effect=[MagicMock(), error]): + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is False + assert "fatal: error" in result["message"] + + def test_git_reset_failure(self, tmp_path): + from gitpull import update_webhook + import gitpull + gitpull.config_github["lenoirpatrick/testrepo"]["path"] = str(tmp_path) + + error = subprocess.CalledProcessError(1, "git reset", stderr="fatal: reset error") + + with patch("subprocess.run", side_effect=error): + result = update_webhook(DEMO_PAYLOAD) + + assert result["result"] is False + assert "fatal: reset error" in result["message"] \ No newline at end of file