diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42a28e8..fffb639 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,10 @@ on: branches: - main - dev + # Filet de sécurité anti-boucle : le commit du job "deploy" ne touche que ce fichier. + # (Un push fait avec GITHUB_TOKEN ne déclenche déjà aucun workflow.) + paths-ignore: + - '_k8s/overlays/*/commit-patch.yaml' pull_request: branches: - main @@ -81,3 +85,79 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + deploy: + name: Trigger ArgoCD redeploy + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + permissions: + contents: write + concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: Résoudre l'overlay cible + id: overlay + run: | + case "${{ github.ref_name }}" in + main) echo "path=_k8s/overlays/prod" >> "$GITHUB_OUTPUT" ;; + dev) echo "path=_k8s/overlays/dev" >> "$GITHUB_OUTPUT" ;; + *) echo "Branche non gérée : ${{ github.ref_name }}" >&2; exit 1 ;; + esac + + - name: Écrire l'ENV COMMIT dans le patch kustomize + env: + PATCH: ${{ steps.overlay.outputs.path }}/commit-patch.yaml + BRANCH: ${{ github.ref_name }} + run: | + SHORT_SHA="${GITHUB_SHA::7}" + cat > "$PATCH" < ArgoCD redéploie + # sans changer le tag de l'image. + apiVersion: apps/v1 + kind: Deployment + metadata: + name: demoapp + spec: + template: + spec: + containers: + - name: demoapp + env: + - name: COMMIT + value: "${SHORT_SHA}" + YAML + cat "$PATCH" + + - name: Commit & push (sans redéclencher de workflow) + env: + PATCH: ${{ steps.overlay.outputs.path }}/commit-patch.yaml + BRANCH: ${{ github.ref_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git diff --quiet -- "$PATCH"; then + echo "COMMIT déjà à jour, rien à pousser." + exit 0 + fi + + git add "$PATCH" + git commit -m "[skip ci] [Claude] chore: COMMIT=${GITHUB_SHA::7} (${BRANCH})" + + for attempt in 1 2 3; do + if git push origin "HEAD:${BRANCH}"; then + echo "Poussé (tentative ${attempt})." + exit 0 + fi + echo "Push refusé, rebase puis nouvelle tentative..." + git pull --rebase origin "${BRANCH}" + done + echo "Échec du push après 3 tentatives." >&2 + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index a651dd7..14450c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,16 @@ cannot serve it. returns 503 so Kubernetes pulls the pod out of rotation. - Container runs as uid 10001 with `readOnlyRootFilesystem: true`. Any code writing to disk breaks the deployment — keep `PYTHONDONTWRITEBYTECODE=1`. -- All config comes from env vars: `REDIS_URL`, `COUNTER_KEY`, `TZ`, `PORT`. No hardcoded values. +- All config comes from env vars: `REDIS_URL`, `COUNTER_KEY`, `TZ`, `PORT`, `COMMIT`. No + hardcoded values. +- `_k8s/overlays/*/commit-patch.yaml` is **CI-generated** — do not hand-edit it, the `deploy` job + overwrites the file on every push. ## Branches and deployment | Branch | Image tag | Overlay | Namespace | Hostname | |---|---|---|---|---| -| `dev` | `:dev` | `_k8s/overlays/dev` | `demoapp-dev` | `dev.demoapp.k8s.lab.ops.nc` | +| `dev` | `:dev` | `_k8s/overlays/dev` | `demoapp-dev` | `demoapp-dev.k8s.lab.ops.nc` | | `main` | `:main` | `_k8s/overlays/prod` | `demoapp` | `demoapp.k8s.lab.ops.nc` | Work on `dev`. Reach `main` through a PR. Hostnames are written literally in each overlay's @@ -68,5 +71,15 @@ Never mark k8s or Docker work done without rendering the manifests or running th ## Known limitations - Redis uses an `emptyDir`: the counter resets when the pod restarts. -- Image tags are mutable per branch, so a new build needs `kubectl rollout restart` to land. +- Image tags are mutable per branch. Redeployment is triggered by the `deploy` job bumping the + `COMMIT` env var, which changes the pod template and makes ArgoCD roll the pods. That commit + must never retrigger CI: it relies on `GITHUB_TOKEN` pushes not firing workflows, plus + `[skip ci]` and `paths-ignore`. Keep all three if you touch the workflow. + +## Gotcha: commit messages + +GitHub scans the whole commit message — subject *and* body — for the skip marker (`skip ci` +in square brackets). Never write that literal sequence in a commit message when describing +this mechanism, or your push silently produces no CI run. Referring to it inside files +(README, workflow YAML) is safe; only the commit message matters. - `/api/info` returns the server date in the server locale; the browser reformats it in French. diff --git a/README.md b/README.md index f0cae65..d463694 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ compteur de vues persisté dans **Redis** (sans authentification). | `COUNTER_KEY` | `demoapp:views` | Clé du compteur | | `TZ` | `Pacific/Noumea` | Timezone affichée | | `PORT` | `8000` | Port d'écoute | +| `COMMIT` | `unknown` | SHA court affiché sous l'IP ; réécrit par la CI à chaque déploiement | ## Lancer en local @@ -64,8 +65,9 @@ Le workflow `.github/workflows/build.yml` se déclenche sur `dev` et `main` : la branche comme tag** (plus un tag court de SHA) : - branche `dev` → `ghcr.io/ops-nc/demoapp:dev` - branche `main` → `ghcr.io/ops-nc/demoapp:main` +3. **Job `deploy`** — déclenche le redéploiement ArgoCD (voir ci-dessous). -Sur pull request, seul le job `test` est exécuté (pas de push d'image). +Sur pull request, seuls les jobs `test` s'exécutent (ni push d'image, ni commit). --- @@ -78,7 +80,7 @@ deux overlays. Chaque overlay surcharge le hostname de l'HTTPRoute via un patch ``` _k8s/ ├── base/ deployment · service · httproute · redis -├── overlays/dev/ → dev.demoapp.k8s.lab.ops.nc, ns demoapp-dev, image :dev, 1 replica +├── overlays/dev/ → demoapp-dev.k8s.lab.ops.nc, ns demoapp-dev, image :dev, 1 replica ├── overlays/prod/ → demoapp.k8s.lab.ops.nc, ns demoapp, image :main, 2 replicas └── argocd/ Applications ArgoCD prêtes à appliquer ``` @@ -89,7 +91,7 @@ L'HTTPRoute est rattachée à la Gateway existante `main-gateway` ## Vérifier le rendu avant de déployer ```bash -kubectl kustomize _k8s/overlays/dev | grep -A1 hostnames # dev.demoapp.k8s.lab.ops.nc +kubectl kustomize _k8s/overlays/dev | grep -A1 hostnames # demoapp-dev.k8s.lab.ops.nc kubectl kustomize _k8s/overlays/prod | grep -A1 hostnames # demoapp.k8s.lab.ops.nc ``` @@ -99,7 +101,7 @@ Les manifests fournis pointent chacun vers une branche et un overlay différents | Application | Branche suivie | Path | Namespace | URL | |---|---|---|---|---| -| `demoapp-dev` | `dev` | `_k8s/overlays/dev` | `demoapp-dev` | https://dev.demoapp.k8s.lab.ops.nc | +| `demoapp-dev` | `dev` | `_k8s/overlays/dev` | `demoapp-dev` | https://demoapp-dev.k8s.lab.ops.nc | | `demoapp` | `main` | `_k8s/overlays/prod` | `demoapp` | https://demoapp.k8s.lab.ops.nc | ```bash @@ -109,23 +111,35 @@ kubectl apply -f _k8s/argocd/application-prod.yaml Les deux Applications sont en `automated` (prune + selfHeal) avec `CreateNamespace=true`. -## Cycle de déploiement +## Redéploiement automatique (job `deploy`) -1. Push sur `dev` → CI publie `ghcr.io/ops-nc/demoapp:dev` → ArgoCD resynchronise `demoapp-dev`. -2. Merge `dev` → `main` → CI publie `:main` → ArgoCD resynchronise `demoapp`. +Le tag d'image est fixe par branche : un nouveau push ne modifie donc aucun manifest, et ArgoCD +n'a rien à resynchroniser. Le job `deploy` résout ce problème **sans changer de tag** — il réécrit +l'ENV var `COMMIT` du Deployment avec le SHA court dans +`_k8s/overlays//commit-patch.yaml`, puis commite sur la branche courante. -Le tag d'image étant fixe par branche (`imagePullPolicy: Always`), forcer le redéploiement après -un nouveau build se fait via : +Modifier une ENV var change le pod template : ArgoCD voit la dérive, sync, et Kubernetes fait un +rolling update. Comme `imagePullPolicy: Always`, les nouveaux pods tirent l'image fraîche du +même tag. Le SHA déployé est affiché en petit sous l'IP sur la page. + +``` +push sur dev → test → build & push :dev → commit "COMMIT=" → ArgoCD sync → rollout +``` + +**Le commit de la CI ne redéclenche aucun workflow**, via trois protections : + +1. Un push authentifié avec le `GITHUB_TOKEN` ne déclenche pas de workflow (garantie GitHub). +2. Le message de commit porte `[skip ci]`. +3. `on.push.paths-ignore` exclut `_k8s/overlays/*/commit-patch.yaml`. + +Mapping branche → overlay patché : `dev` → `overlays/dev`, `main` → `overlays/prod`. + +Redéploiement manuel si besoin : ```bash kubectl rollout restart deploy/demoapp -n demoapp-dev -# ou, côté ArgoCD -argocd app actions run demoapp-dev restart --kind Deployment ``` -> Pour un suivi automatique des nouvelles images, brancher **ArgoCD Image Updater** ou passer la -> CI en tag immuable (SHA) avec commit du tag dans l'overlay. - ## Notes - Le Redis déployé utilise un `emptyDir` : le compteur repart à zéro si le pod redémarre. diff --git a/_k8s/overlays/dev/commit-patch.yaml b/_k8s/overlays/dev/commit-patch.yaml new file mode 100644 index 0000000..6e981eb --- /dev/null +++ b/_k8s/overlays/dev/commit-patch.yaml @@ -0,0 +1,15 @@ +# Généré par la CI (job "deploy") à chaque push sur dev. +# La modification de cette ENV var change le pod template -> ArgoCD redéploie +# sans changer le tag de l'image. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demoapp +spec: + template: + spec: + containers: + - name: demoapp + env: + - name: COMMIT + value: "2c008d5" diff --git a/_k8s/overlays/dev/httproute-patch.yaml b/_k8s/overlays/dev/httproute-patch.yaml index 4d53fc3..82ba758 100644 --- a/_k8s/overlays/dev/httproute-patch.yaml +++ b/_k8s/overlays/dev/httproute-patch.yaml @@ -4,4 +4,4 @@ metadata: name: demoapp spec: hostnames: - - dev.demoapp.k8s.lab.ops.nc + - demoapp-dev.k8s.lab.ops.nc diff --git a/_k8s/overlays/dev/kustomization.yaml b/_k8s/overlays/dev/kustomization.yaml index 5206668..284d140 100644 --- a/_k8s/overlays/dev/kustomization.yaml +++ b/_k8s/overlays/dev/kustomization.yaml @@ -15,6 +15,8 @@ replicas: - name: demoapp count: 1 -# hostname de l'HTTPRoute : dev.demoapp.k8s.lab.ops.nc +# httproute-patch : hostname demoapp-dev.k8s.lab.ops.nc +# commit-patch : ENV COMMIT, réécrit par la CI pour forcer le redéploiement ArgoCD patches: - path: httproute-patch.yaml + - path: commit-patch.yaml diff --git a/_k8s/overlays/prod/commit-patch.yaml b/_k8s/overlays/prod/commit-patch.yaml new file mode 100644 index 0000000..d06789e --- /dev/null +++ b/_k8s/overlays/prod/commit-patch.yaml @@ -0,0 +1,15 @@ +# Généré par la CI (job "deploy") à chaque push sur main. +# La modification de cette ENV var change le pod template -> ArgoCD redéploie +# sans changer le tag de l'image. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demoapp +spec: + template: + spec: + containers: + - name: demoapp + env: + - name: COMMIT + value: "initial" diff --git a/_k8s/overlays/prod/kustomization.yaml b/_k8s/overlays/prod/kustomization.yaml index c9638cd..85178f5 100644 --- a/_k8s/overlays/prod/kustomization.yaml +++ b/_k8s/overlays/prod/kustomization.yaml @@ -15,6 +15,8 @@ replicas: - name: demoapp count: 2 -# hostname de l'HTTPRoute : demoapp.k8s.lab.ops.nc +# httproute-patch : hostname demoapp.k8s.lab.ops.nc +# commit-patch : ENV COMMIT, réécrit par la CI pour forcer le redéploiement ArgoCD patches: - path: httproute-patch.yaml + - path: commit-patch.yaml diff --git a/app.py b/app.py index 5934bb0..0b72de5 100644 --- a/app.py +++ b/app.py @@ -5,11 +5,12 @@ import redis from asgiref.wsgi import WsgiToAsgi -from flask import Flask, jsonify, render_template +from flask import Flask, jsonify, make_response, render_template REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") COUNTER_KEY = os.environ.get("COUNTER_KEY", "demoapp:views") TZ_NAME = os.environ.get("TZ", "Pacific/Noumea") +COMMIT = os.environ.get("COMMIT", "unknown") app = Flask(__name__) redis_client = redis.Redis.from_url(REDIS_URL, decode_responses=True) @@ -52,7 +53,7 @@ def index(): hostname, ip = get_host_info() time_str, date_str, tz_label, offset = get_time() views = incr_views() - return render_template( + page = render_template( "index.html", time=time_str, date=date_str, @@ -61,7 +62,12 @@ def index(): hostname=hostname, ip=ip, views=views, + commit=COMMIT, ) + # Le compteur doit s'incrémenter à chaque visite : pas de cache navigateur. + response = make_response(page) + response.headers["Cache-Control"] = "no-store" + return response @app.route("/api/info") @@ -77,6 +83,7 @@ def api_info(): hostname=hostname, ip=ip, views=get_views(), + commit=COMMIT, ) diff --git a/templates/index.html b/templates/index.html index 01aeecb..89fa61f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -135,6 +135,16 @@ word-break: break-all; } + .commit { + margin-top: 0.45rem; + font-size: 0.68rem; + letter-spacing: 0.04em; + color: #6b748c; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + } + + .commit span { color: #97a1b8; } + .badge { display: inline-block; margin-top: 0.9rem; @@ -168,6 +178,7 @@

DemoApp

Machine
{{ hostname }}
{{ ip }}
+
commit {{ commit }}
Adresse IP interne