diff --git a/CLAUDE.md b/CLAUDE.md index d42a8d4..8493fd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,33 +33,60 @@ pytest pytest --cov=gitpull --cov-report=term-missing ``` +### Run a single test +```shell +pytest test_gitpull.py::TestWebhook::test_invalid_signature_returns_401 -v +``` + ## 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. +**Endpoints:** +- **`/`** — Dark-themed HTML home page. Lists configured repos (loaded via `GET /config/repos`) with Add / Edit / Delete buttons. All CRUD actions use JS `fetch` — no page reload. +- **`/beats`** — Health check; returns `{"result": true}`. +- **`/webhook`** (POST) — Receives GitHub push events. Verifies the HMAC-SHA256 signature if `webhook_secret` is set in config. Only processes pushes to `refs/heads/main`. Calls `update_webhook()`. +- **`/webhookdemo`** — Returns an HTML terminal-style page simulating `git reset` and `git pull` output. No real git commands are run. +- **`/docs`** — Swagger UI (built into FastAPI). +- **`GET /config/repos`** — Lists configured repos (excludes `ip` and `webhook_secret` keys). +- **`POST /config/repos`** — Adds a repo `{"repo": "owner/repo", "path": "/abs/path"}`. Returns 409 if already exists. +- **`PUT /config/repos/{owner}/{repo}`** — Updates the path of an existing repo. Returns 404 if not found. +- **`DELETE /config/repos/{owner}/{repo}`** — Removes a repo. Returns 404 if not found. + +**Key functions:** +- **`_load_config()`** — Loads `config/config.json`. If the file is absent, creates it with `{"ip": "127.0.0.1"}` and returns the default. Never raises on missing file. +- **`_save_config()`** — Writes `config_github` back to `CONFIG_PATH`. Called after every CRUD mutation. +- **`update_webhook(webhook_github)`** — Resolves the repo path, checks the directory exists, runs `git reset --hard HEAD` then `git pull` via `subprocess`. Returns `{"result": bool, "message": str}`. + +**Key implementation details:** +- `BASE_DIR = pathlib.Path(__file__).parent` and `CONFIG_PATH = BASE_DIR / 'config' / 'config.json'` are used for all file paths — server can be started from any directory. +- `config_github` is a module-level dict loaded at startup; tests patch it directly along with `BASE_DIR` and `CONFIG_PATH`. +- HMAC verification uses `hmac.compare_digest` to prevent timing attacks. Skipped if `webhook_secret` is absent from config. +- `subprocess.run(check=True)` is wrapped in `try/except CalledProcessError` — errors surface in the JSON response, not as 500s. ## Configuration -`config/config.json` maps repository full names (e.g. `"lenoirpatrick/githubwebhook"`) to local paths, and contains the server bind IP: +`config/config.json` maps repository full names to local paths, and holds the server bind IP and optional webhook secret: ```json { - "ip": "127.0.0.1", + "ip": "0.0.0.0", + "webhook_secret": "votre_secret_github", "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. +`webhook_secret` must match the secret configured in the GitHub webhook settings for HMAC validation to work. The file is created automatically on first startup if missing. + +## Tests + +`test_gitpull.py` — 23 tests, 98% coverage. Uses `fastapi.testclient.TestClient` (synchronous). The `patch_config` autouse fixture swaps `gitpull.config_github`, `gitpull.BASE_DIR`, and `gitpull.CONFIG_PATH` in-process for each test, then restores them — no real config files are read or written during tests (except in `TestLoadConfig` which explicitly tests file creation). ## 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 +`.github/workflows/build.yml` runs three jobs on push to `main` and on PRs: +- **test** — `pytest --cov` across Python 3.11, 3.12, 3.13; uploads `coverage.xml` as artifact. +- **pylint** — linting across the same Python matrix. +- **sonarqube** — runs after `test`, regenerates `coverage.xml` and sends it to SonarCloud (requires `SONAR_TOKEN` secret). diff --git a/README.md b/README.md index be741a3..2980efd 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ GitHub enverra alors un événement `POST /webhook` à chaque push. Seuls les pu # Installation ```shell -git clone githubwebhook.git +git clone https://github.com/lenoirpatrick/githubwebhook.git cd githubwebhook pip install -r requirements.txt --break-system-packages chmod +x run.sh @@ -42,7 +42,11 @@ chmod +x run.sh # Configuration -Dans le répertoire `config`, créer un fichier `config.json` : +Si le fichier `config/config.json` est absent au démarrage, il est créé automatiquement avec les valeurs par défaut (`ip: 127.0.0.1`). L'application ne plante pas. + +Le fichier peut être édité manuellement ou via l'interface web de la page d'accueil (boutons Ajouter / Modifier / Supprimer). + +Structure du fichier : ```json { @@ -63,7 +67,7 @@ Dans le répertoire `config`, créer un fichier `config.json` : | `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. +Plusieurs dépôts peuvent être configurés simultanément. Toute modification via l'interface web est immédiatement persistée dans `config.json`. # Lancement diff --git a/gitpull.py b/gitpull.py index 9b15d9b..2aadef0 100644 --- a/gitpull.py +++ b/gitpull.py @@ -9,17 +9,40 @@ import uvicorn from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse +from pydantic import BaseModel app = FastAPI( title="GitHub Webhook Server", description="Serveur de webhook GitHub pour déploiement CI/CD automatique via `git pull`.", - version="1.2.0", + version="1.3.0", ) BASE_DIR = pathlib.Path(__file__).parent +CONFIG_PATH = BASE_DIR / 'config' / 'config.json' -with (BASE_DIR / 'config' / 'config.json').open(encoding="utf-8") as githubjson: - config_github = json.load(githubjson) + +def _load_config() -> dict: + if not CONFIG_PATH.exists(): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + default = {"ip": "127.0.0.1"} + CONFIG_PATH.write_text(json.dumps(default, indent=4), encoding="utf-8") + return default + with CONFIG_PATH.open(encoding="utf-8") as f: + return json.load(f) + + +def _save_config() -> None: + CONFIG_PATH.write_text( + json.dumps(config_github, indent=4, ensure_ascii=False), encoding="utf-8" + ) + + +config_github = _load_config() + + +class RepoConfig(BaseModel): + repo: str + path: str @app.get("/", response_class=HTMLResponse) @@ -42,111 +65,247 @@ async def home(): display: flex; flex-direction: column; align-items: center; - justify-content: center; - gap: 2rem; + justify-content: flex-start; + padding: 3rem 1rem 4rem; + gap: 1.5rem; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; - padding: 2.5rem 3rem; - max-width: 480px; - width: 90%; + padding: 2rem 2.5rem; + width: 100%; + max-width: 680px; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } - .icon { - font-size: 2.8rem; - margin-bottom: 1rem; - } + .icon { font-size: 2.4rem; margin-bottom: 0.75rem; } - h1 { - font-size: 1.4rem; - font-weight: 600; - color: #f0f6fc; - margin-bottom: 0.5rem; - } + h1 { font-size: 1.4rem; font-weight: 600; color: #f0f6fc; margin-bottom: 0.4rem; } p.subtitle { - font-size: 0.9rem; - color: #8b949e; - margin-bottom: 2rem; - line-height: 1.5; + font-size: 0.88rem; color: #8b949e; + margin-bottom: 1.5rem; line-height: 1.5; } .badge { - display: inline-flex; - align-items: center; - gap: 0.4rem; - background: #1f6feb22; - border: 1px solid #1f6feb55; - color: #58a6ff; - border-radius: 20px; - padding: 0.25rem 0.75rem; - font-size: 0.75rem; - font-weight: 500; - margin-bottom: 2rem; + display: inline-flex; align-items: center; gap: 0.4rem; + background: #1f6feb22; border: 1px solid #1f6feb55; + color: #58a6ff; border-radius: 20px; + padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 500; + margin-bottom: 1.5rem; } - .badge::before { - content: ""; - width: 7px; height: 7px; - background: #3fb950; - border-radius: 50%; - display: inline-block; + content: ""; width: 7px; height: 7px; + background: #3fb950; border-radius: 50%; display: inline-block; } .btn { - display: inline-block; - padding: 0.65rem 1.5rem; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 500; - text-decoration: none; + display: inline-block; padding: 0.6rem 1.4rem; + border-radius: 8px; font-size: 0.88rem; font-weight: 500; + text-decoration: none; cursor: pointer; border: none; transition: opacity 0.15s, transform 0.1s; } - .btn:active { transform: scale(0.97); } - - .btn-primary { - background: #238636; - color: #fff; - border: 1px solid #2ea043; - } - + .btn-primary { background: #238636; color: #fff; border: 1px solid #2ea043; } .btn-primary:hover { opacity: 0.85; } - - footer { - display: flex; - gap: 1.5rem; - font-size: 0.78rem; + .btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; } + .btn-secondary:hover { background: #30363d; } + + /* Repos panel */ + .panel { + background: #161b22; border: 1px solid #30363d; + border-radius: 12px; width: 100%; max-width: 680px; + overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.4); } - - footer a { - color: #484f58; - text-decoration: none; - transition: color 0.15s; + .panel-header { + padding: 1rem 1.25rem; + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid #30363d; + } + .panel-header h2 { font-size: 0.95rem; font-weight: 600; color: #f0f6fc; } + + table { width: 100%; border-collapse: collapse; } + th { + text-align: left; padding: 0.6rem 1.25rem; + font-size: 0.75rem; font-weight: 600; color: #8b949e; + text-transform: uppercase; letter-spacing: 0.05em; + border-bottom: 1px solid #21262d; + } + td { padding: 0.75rem 1.25rem; font-size: 0.85rem; border-bottom: 1px solid #21262d; } + tr:last-child td { border-bottom: none; } + tr:hover td { background: #1c2128; } + + td code { color: #58a6ff; font-size: 0.82rem; } + td.path { color: #8b949e; font-size: 0.8rem; word-break: break-all; } + td.actions { white-space: nowrap; text-align: right; } + td.empty { color: #484f58; text-align: center; padding: 2rem; font-size: 0.85rem; } + + .btn-sm { + display: inline-block; padding: 0.3rem 0.75rem; + border-radius: 6px; font-size: 0.78rem; font-weight: 500; + cursor: pointer; border: 1px solid #30363d; + transition: opacity 0.15s; + } + .btn-edit { background: #21262d; color: #e6edf3; } + .btn-edit:hover { background: #30363d; } + .btn-delete { background: transparent; color: #f85149; border-color: #f8514944; margin-left: 0.4rem; } + .btn-delete:hover { background: #f8514911; } + + /* Modal */ + .modal-overlay { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,0.7); z-index: 100; + align-items: center; justify-content: center; } + .modal-overlay.open { display: flex; } + .modal { + background: #161b22; border: 1px solid #30363d; + border-radius: 12px; padding: 1.75rem 2rem; + width: 90%; max-width: 440px; + box-shadow: 0 16px 48px rgba(0,0,0,0.6); + } + .modal h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1.25rem; color: #f0f6fc; } + label { display: block; font-size: 0.8rem; color: #8b949e; margin-bottom: 0.3rem; } + input[type=text] { + width: 100%; padding: 0.5rem 0.75rem; + background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; font-size: 0.88rem; + margin-bottom: 1rem; outline: none; + } + input[type=text]:focus { border-color: #58a6ff; } + input[type=text]:disabled { opacity: 0.5; cursor: not-allowed; } + .modal-hint { font-size: 0.75rem; color: #8b949e; margin-top: -0.6rem; margin-bottom: 1rem; } + .modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; } + footer { + display: flex; gap: 1.5rem; font-size: 0.78rem; margin-top: 0.5rem; + } + footer a { color: #484f58; text-decoration: none; transition: color 0.15s; } footer a:hover { color: #8b949e; }
+ +Déploiement CI/CD automatique via git pull au push sur main.
| Dépôt | +Chemin local | ++ |
|---|---|---|
| Chargement… | ||