CI/CD et GitOps#

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
import matplotlib.gridspec as gridspec
import numpy as np
import pandas as pd
import seaborn as sns
import json
import random
import time
from collections import deque

sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams['figure.dpi'] = 110
plt.rcParams['font.family'] = 'DejaVu Sans'

Du code au cluster : le pipeline CI/CD#

Imaginez une équipe de développeurs qui pousse du code plusieurs fois par jour. Sans automatisation, chaque déploiement demande : exécuter les tests à la main, construire l’image Docker, la pousser sur un registry, modifier les manifestes YAML, les appliquer sur le cluster. Ça prend du temps, introduit des erreurs humaines, et ralentit l’équipe.

CI/CD (Intégration Continue / Déploiement Continu) automatise entièrement ce pipeline.

Hide code cell source

fig, ax = plt.subplots(figsize=(16, 5))
ax.set_xlim(-0.5, 16)
ax.set_ylim(-0.5, 5.5)
ax.axis('off')

etapes = [
    (0.5, "Code\n(git push)", "#1565C0"),
    (2.8, "Tests\nunitaires", "#2196F3"),
    (5.1, "Build\nimage Docker", "#FF9800"),
    (7.4, "Tests\nintégration", "#4CAF50"),
    (9.7, "Push\nRegistry", "#9C27B0"),
    (12.0, "Deploy\nStaging", "#00BCD4"),
    (14.3, "Deploy\nProduction", "#F44336"),
]

for i, (x, label, color) in enumerate(etapes):
    # Hexagone simulé avec FancyBboxPatch
    rect = FancyBboxPatch((x, 1.5), 1.8, 1.5,
                          boxstyle="round,pad=0.2",
                          facecolor=color, edgecolor='white', alpha=0.9, linewidth=2)
    ax.add_patch(rect)
    ax.text(x+0.9, 2.25, label, ha='center', va='center',
            fontsize=8.5, fontweight='bold', color='white', multialignment='center')

    # Numéro d'étape
    circle = plt.Circle((x+0.9, 3.5), 0.35, color=color, alpha=0.9, zorder=3)
    ax.add_patch(circle)
    ax.text(x+0.9, 3.5, str(i+1), ha='center', va='center',
            fontsize=9, fontweight='bold', color='white', zorder=4)

    # Durée simulée
    durees = ["< 1s", "2 min", "3 min", "5 min", "1 min", "2 min", "30s"]
    ax.text(x+0.9, 0.9, durees[i], ha='center', va='center',
            fontsize=8, color=color)

    # Flèche vers la suivante
    if i < len(etapes) - 1:
        next_x = etapes[i+1][0]
        ax.annotate("", xy=(next_x, 2.25), xytext=(x+1.8, 2.25),
                    arrowprops=dict(arrowstyle='->', color='#555', lw=2))

# Délimitation CI vs CD
ax.axvline(x=9.0, color='#555', linestyle='--', alpha=0.5, linewidth=1.5)
ax.text(4.5, 0.2, "◄ CI — Intégration Continue ►", ha='center', fontsize=9,
        color='#1565C0', style='italic',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='#E3F2FD', edgecolor='#1565C0', alpha=0.7))
ax.text(12.5, 0.2, "◄ CD — Déploiement Continu ►", ha='center', fontsize=9,
        color='#C62828', style='italic',
        bbox=dict(boxstyle='round,pad=0.2', facecolor='#FFEBEE', edgecolor='#C62828', alpha=0.7))

ax.set_title("Pipeline CI/CD : du git push au déploiement production", fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig("cicd_pipeline.png", dpi=110, bbox_inches='tight')
plt.show()
_images/0ff94e48cea50839288a67f09ae0dbbef33554d9cca074a72ee551b81c0783c0.png

GitHub Actions : anatomie d’un workflow#

GitHub Actions est aujourd’hui l’outil CI/CD le plus utilisé. Un workflow est un fichier YAML dans .github/workflows/ :

# .github/workflows/cicd.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ─── Job 1 : Tests ───────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout du code
        uses: actions/checkout@v4

      - name: Configurer Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Installer les dépendances
        run: pip install -r requirements.txt

      - name: Lancer les tests
        run: pytest tests/ --cov=src --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v4

  # ─── Job 2 : Build et push de l'image ───────────────────────────
  build-push:
    needs: test          # Lance seulement si test réussit
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write    # Pour OIDC (sans secret long-lived)
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Login GHCR via OIDC
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Métadonnées de l'image
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build et push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Signer l'image (cosign)
        uses: sigstore/cosign-installer@v3
        run: |
          cosign sign --yes ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

  # ─── Job 3 : Déploiement K8s ────────────────────────────────────
  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configurer kubectl
        uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBECONFIG }}

      - name: Mettre à jour l'image dans le manifeste
        run: |
          IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-push.outputs.image-digest }}"
          sed -i "s|image: .*|image: ${IMAGE}|g" k8s/deployment.yaml

      - name: Déployer sur Kubernetes
        run: kubectl apply -f k8s/

      - name: Vérifier le déploiement
        run: kubectl rollout status deployment/mon-app --timeout=5m

OIDC : plus de secrets long-lived

GitHub Actions supporte l’authentification OIDC (OpenID Connect). Au lieu de stocker un token Docker Hub dans les secrets, le workflow obtient un token éphémère signé par GitHub, valide uniquement pour cette exécution. C’est bien plus sécurisé.

La permission id-token: write active ce mécanisme.

GitOps : Git comme source de vérité#

L’approche CI/CD traditionnelle est en mode push : le pipeline CI « pousse » le déploiement sur le cluster. GitOps inverse cette logique.

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(16, 7))

# --- Gauche : mode Push (traditionnel) ---
ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 10)
ax1.axis('off')

def box_s(ax, x, y, w, h, text, fc, fontsize=9):
    r = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.15",
                       facecolor=fc, edgecolor='white', alpha=0.85, linewidth=2)
    ax.add_patch(r)
    ax.text(x+w/2, y+h/2, text, ha='center', va='center',
            fontsize=fontsize, fontweight='bold', color='white', multialignment='center')

def arr_s(ax, x1, y1, x2, y2, label='', color='#555'):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle='->', color=color, lw=2))
    if label:
        ax.text((x1+x2)/2+0.1, (y1+y2)/2+0.15, label, ha='center',
                fontsize=7.5, color=color, style='italic')

box_s(ax1, 1, 8.5, 3, 1, "Developer\ngit push", "#2196F3")
box_s(ax1, 1, 6.5, 3, 1, "GitHub Actions\n(CI)", "#FF9800")
box_s(ax1, 1, 4.5, 3, 1, "Build image\n+ Tests", "#FF9800")
box_s(ax1, 5, 5.0, 4, 1, "kubectl apply\n(push actif)", "#F44336")
box_s(ax1, 5, 3.0, 4, 1, "Cluster K8s", "#9C27B0")

arr_s(ax1, 2.5, 8.5, 2.5, 7.5, "1. push code")
arr_s(ax1, 2.5, 6.5, 2.5, 5.5, "2. déclenche")
arr_s(ax1, 4.0, 5.0, 5.0, 5.5, "3. déploie →", "#F44336")
arr_s(ax1, 7.0, 5.0, 7.0, 4.0, "4. apply")

# Problèmes
ax1.text(5, 1.5, "⚠ Problèmes :\n• Pipeline a accès direct au cluster\n• Dérive possible (state ≠ git)\n• Secrets dans CI",
         ha='center', va='center', fontsize=8.5, color='#C62828',
         bbox=dict(boxstyle='round,pad=0.3', facecolor='#FFEBEE', edgecolor='#F44336'))

ax1.set_title("Mode Push (traditionnel)", fontweight='bold', fontsize=12, color='#F44336')

# --- Droite : mode Pull (GitOps) ---
ax2 = axes[1]
ax2.set_xlim(0, 10)
ax2.set_ylim(0, 10)
ax2.axis('off')

box_s(ax2, 1, 8.5, 3, 1, "Developer\ngit push", "#2196F3")
box_s(ax2, 5, 8.5, 4, 1, "Git Repo\n(source de vérité)", "#4CAF50")
box_s(ax2, 1, 6.0, 3, 1, "CI : build + push\nimage uniquement", "#FF9800")
box_s(ax2, 5, 6.0, 4, 1, "ArgoCD / Flux\n(dans le cluster)", "#9C27B0")
box_s(ax2, 3, 3.5, 4, 1, "Cluster K8s", "#1565C0")

arr_s(ax2, 2.5, 8.5, 2.5, 7.0, "1. push code")
arr_s(ax2, 4.0, 9.0, 5.0, 9.0, "2. push manifestes →", "#4CAF50")
arr_s(ax2, 7.0, 8.5, 7.0, 7.0, "3. commit yaml")
arr_s(ax2, 7.0, 6.0, 7.0, 4.5, "4. pull continu ↓", "#9C27B0")
arr_s(ax2, 5.0, 4.0, 7.0, 4.0, "", "#9C27B0")
arr_s(ax2, 5.0, 4.0, 5.0, 4.5, "5. reconcile ↓", "#9C27B0")

# Avantages
ax2.text(5, 1.5, "✓ Avantages :\n• Cluster n'est jamais accessible depuis CI\n• Git = audit trail complet\n• Auto-correction de dérive",
         ha='center', va='center', fontsize=8.5, color='#1B5E20',
         bbox=dict(boxstyle='round,pad=0.3', facecolor='#E8F5E9', edgecolor='#4CAF50'))

ax2.set_title("Mode Pull (GitOps)", fontweight='bold', fontsize=12, color='#4CAF50')

plt.suptitle("Push vs Pull : CI/CD traditionnel vs GitOps", fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig("cicd_push_vs_pull.png", dpi=110, bbox_inches='tight')
plt.show()
_images/34aaccff9af993f498ae4a4af138552e151da5a2a864c2edc54afa1179a1e45c.png

Le principe GitOps repose sur quatre règles :

  1. Tout est déclaratif : l’état désiré est décrit dans des fichiers (YAML, Helm charts)

  2. Git est la source de vérité : aucune modification manuelle sur le cluster

  3. Les changements passent par Git : pull request → review → merge → déploiement automatique

  4. La réconciliation est continue : un agent surveille le cluster et corrige toute dérive

ArgoCD#

ArgoCD est l’outil GitOps le plus populaire. Il tourne dans le cluster et surveille en permanence un dépôt Git.

Hide code cell source

fig, ax = plt.subplots(figsize=(15, 8))
ax.set_xlim(0, 15)
ax.set_ylim(0, 9)
ax.axis('off')

def box_a(ax, x, y, w, h, text, fc, fontsize=9):
    r = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.2",
                       facecolor=fc, edgecolor='white', alpha=0.88, linewidth=2)
    ax.add_patch(r)
    ax.text(x+w/2, y+h/2, text, ha='center', va='center',
            fontsize=fontsize, fontweight='bold', color='white', multialignment='center')

def arr_a(ax, x1, y1, x2, y2, label='', color='#555', style='->'):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle=style, color=color, lw=2))
    if label:
        ax.text((x1+x2)/2, (y1+y2)/2+0.2, label, ha='center',
                fontsize=7.5, color=color, style='italic')

# Git Repo
box_a(ax, 0.3, 6.5, 3.0, 2.0, "Git Repo\n\nmanifestes YAML\nHelm charts\nKustomize", "#4CAF50")

# Cluster K8s boundary
cluster_rect = FancyBboxPatch((4.5, 0.5), 10.0, 8.0, boxstyle="round,pad=0.3",
                               facecolor='#E3F2FD', edgecolor='#2196F3',
                               alpha=0.3, linewidth=2, linestyle='--')
ax.add_patch(cluster_rect)
ax.text(9.5, 8.3, "Cluster Kubernetes", ha='center', fontsize=10,
        fontweight='bold', color='#1565C0', style='italic')

# ArgoCD composants
box_a(ax, 5.0, 6.0, 3.0, 1.5, "Repo Server\n(clone git, render)", "#9C27B0", fontsize=8)
box_a(ax, 5.0, 4.0, 3.0, 1.5, "Application\nController", "#7B1FA2", fontsize=8)
box_a(ax, 5.0, 2.0, 3.0, 1.5, "API Server\n(UI + CLI)", "#6A1B9A", fontsize=8)

# ArgoCD Application CRD
box_a(ax, 9.5, 5.5, 4.5, 2.0, "Application (CRD)\n\nsource: git repo\ntargetRevision: main\ndestination: cluster\npath: k8s/production", "#FF9800", fontsize=8)

# Resources K8s cibles
box_a(ax, 9.5, 3.0, 2.0, 1.5, "Deployment\n(desired)", "#2196F3", fontsize=8)
box_a(ax, 12.0, 3.0, 2.0, 1.5, "Service\n(desired)", "#2196F3", fontsize=8)
box_a(ax, 9.5, 1.0, 2.0, 1.5, "ConfigMap\n(desired)", "#2196F3", fontsize=8)
box_a(ax, 12.0, 1.0, 2.0, 1.5, "Ingress\n(desired)", "#2196F3", fontsize=8)

# Flèches
arr_a(ax, 3.3, 7.5, 5.0, 6.75, "1. poll/webhook", "#4CAF50")
arr_a(ax, 6.5, 6.0, 6.5, 5.5, "2. render", "#9C27B0")
arr_a(ax, 6.5, 4.0, 6.5, 3.5, "", "#7B1FA2")
arr_a(ax, 8.0, 6.75, 9.5, 6.5, "3. compare", "#FF9800")
arr_a(ax, 8.0, 4.75, 9.5, 4.0, "4. sync si diff →", "#7B1FA2")
arr_a(ax, 11.5, 5.5, 11.0, 4.5, "", "#FF9800")
arr_a(ax, 11.5, 5.5, 13.0, 4.5, "", "#FF9800")

# Statut de sync
statuses = [
    ("Synced ✓", "#4CAF50", 10.5, 4.8),
    ("OutOfSync !", "#F44336", 10.5, 4.5),
    ("Progressing…", "#FF9800", 10.5, 4.2),
]

ax.text(0.5, 5.0, "Sync Policies :\n• Manual\n• Auto-sync\n• Auto-prune\n• Self-heal", ha='left', va='center',
        fontsize=8.5, color='#1B5E20',
        bbox=dict(boxstyle='round,pad=0.3', facecolor='#E8F5E9', edgecolor='#4CAF50'))

ax.set_title("Architecture ArgoCD — Réconciliation continue", fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig("cicd_argocd.png", dpi=110, bbox_inches='tight')
plt.show()
_images/cb9b3637e089dc4b8c42526be63d618a8dcf3fe58948a2037cc80a3e052faa9e.png

Application ArgoCD (CRD)#

# argocd-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: mon-app-production
  namespace: argocd
spec:
  project: default

  source:
    repoURL: https://github.com/mon-org/mon-app-gitops
    targetRevision: main
    path: kubernetes/production

  destination:
    server: https://kubernetes.default.svc
    namespace: production

  syncPolicy:
    automated:
      prune: true        # Supprime les ressources plus dans git
      selfHeal: true     # Corrige les dérives manuelles
    syncOptions:
      - CreateNamespace=true
      - PruneLast=true
# Commandes ArgoCD CLI
argocd app list                              # Lister les applications
argocd app get mon-app-production            # État détaillé
argocd app sync mon-app-production           # Synchronisation manuelle
argocd app rollback mon-app-production 5     # Rollback à la révision git 5
argocd app diff mon-app-production           # Diff live vs git

Kustomize : overlays et personnalisation#

Kustomize est un outil natif Kubernetes pour personnaliser des manifestes YAML sans les modifier. Il utilise le concept d”overlays : une base partagée, déclinée par environnement.

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 8))
ax.set_xlim(0, 14)
ax.set_ylim(0, 9)
ax.axis('off')

# Base
base_rect = FancyBboxPatch((0.3, 5.5), 3.5, 3.0, boxstyle="round,pad=0.2",
                            facecolor='#1565C0', edgecolor='white', alpha=0.85, linewidth=2)
ax.add_patch(base_rect)
ax.text(2.05, 7.8, "base/", fontsize=12, fontweight='bold', color='white', ha='center')
ax.text(2.05, 7.2, "kustomization.yaml", fontsize=8.5, color='white', ha='center', fontfamily='monospace')
ax.text(2.05, 6.7, "deployment.yaml", fontsize=8.5, color='white', ha='center', fontfamily='monospace')
ax.text(2.05, 6.2, "service.yaml", fontsize=8.5, color='white', ha='center', fontfamily='monospace')

# Overlays
overlays = [
    (4.8, 6.8, "dev/", "#4CAF50",
     ["kustomization.yaml", "patch: replicas=1", "patch: image=:dev", "namespace: dev"]),
    (8.3, 6.8, "staging/", "#FF9800",
     ["kustomization.yaml", "patch: replicas=2", "patch: image=:staging", "namespace: staging"]),
    (11.8, 6.8, "prod/", "#F44336",
     ["kustomization.yaml", "patch: replicas=10", "patch: image=:1.3.0", "resources+: HPA"]),
]

for x, y, nom, couleur, lignes in overlays:
    rect = FancyBboxPatch((x-1.5, y-1.8), 3.0, 3.0, boxstyle="round,pad=0.2",
                          facecolor=couleur, edgecolor='white', alpha=0.85, linewidth=2)
    ax.add_patch(rect)
    ax.text(x, y+0.9, nom, fontsize=11, fontweight='bold', color='white', ha='center')
    for i, ligne in enumerate(lignes):
        ax.text(x, y+0.2 - i*0.5, ligne, fontsize=7.5, color='white',
                ha='center', fontfamily='monospace')

    # Flèche depuis la base
    ax.annotate("", xy=(x-1.5, y-0.3), xytext=(3.8, 7.0),
                arrowprops=dict(arrowstyle='->', color=couleur, lw=2,
                                connectionstyle='arc3,rad=0.0'))
    ax.text((3.8+x-1.5)/2, 7.1, "extends", ha='center', fontsize=7,
            color=couleur, style='italic')

# Résultats
resultats = [
    (3.3, 3.0, "#4CAF50", "Résultat dev\n(après kustomize build)\nreplicas: 1\nimage: :dev\nns: dev"),
    (6.8, 3.0, "#FF9800", "Résultat staging\nreplicas: 2\nimage: :staging\nns: staging"),
    (10.3, 3.0, "#F44336", "Résultat prod\nreplicas: 10\nimage: :1.3.0\nns: production\n+ HPA"),
]

for x, y, couleur, texte in resultats:
    rect = FancyBboxPatch((x-1.5, y-1.0), 3.0, 2.2, boxstyle="round,pad=0.15",
                          facecolor=couleur, edgecolor='white', alpha=0.3, linewidth=1.5)
    ax.add_patch(rect)
    ax.text(x, y+0.5, texte, ha='center', va='center',
            fontsize=7.5, color='#333', multialignment='center')

for (ox, _, oc, _), (rx, ry, rc, _) in zip(overlays, resultats):
    ax.annotate("", xy=(rx, ry+1.2), xytext=(ox, 5.0),
                arrowprops=dict(arrowstyle='->', color=oc, lw=1.5))
    ax.text(rx, ry+1.6, "kustomize build", ha='center',
            fontsize=7, color=oc, style='italic')

ax.set_title("Kustomize — Base partagée + Overlays par environnement",
             fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig("cicd_kustomize.png", dpi=110, bbox_inches='tight')
plt.show()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[5], line 55
     51     ax.add_patch(rect)
     52     ax.text(x, y+0.5, texte, ha='center', va='center',
     53             fontsize=7.5, color='#333', multialignment='center')
---> 55 for (ox, _, oc, _), (rx, ry, rc, _) in zip(overlays, resultats):
     56     ax.annotate("", xy=(rx, ry+1.2), xytext=(ox, 5.0),
     57                 arrowprops=dict(arrowstyle='->', color=oc, lw=1.5))
     58     ax.text(rx, ry+1.6, "kustomize build", ha='center',
     59             fontsize=7, color=oc, style='italic')

ValueError: too many values to unpack (expected 4)
_images/7a7543d9e9f276c88ce69e1c347327759b6d8c19b5c15ae4d01a7b6195bdb594.png
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml

commonLabels:
  app: mon-app

images:
  - name: mon-app
    newTag: latest
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

bases:
  - ../../base

namespace: production

patches:
  - patch: |
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: mon-app
      spec:
        replicas: 10
  - path: patch-resources.yaml

images:
  - name: mon-app
    newTag: "1.3.0"

resources:
  - hpa.yaml
# Construire et voir le résultat (sans appliquer)
kustomize build overlays/production

# Appliquer directement via kubectl
kubectl apply -k overlays/production

# ArgoCD supporte Kustomize nativement
# (détection automatique à la présence du kustomization.yaml)

Flux vs ArgoCD#

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 7))
ax.axis('off')

comparaison = [
    ("Critère", "ArgoCD", "Flux v2"),
    ("Interface utilisateur", "UI web complète et intuitive", "CLI uniquement (ou UI tiers)"),
    ("Approche", "Pull + réconciliation continue", "Pull + réconciliation continue"),
    ("Modèle de config", "Application CRD", "GitRepository + Kustomization CRDs"),
    ("Support Helm", "Oui, natif", "Oui, via HelmRelease CRD"),
    ("Support Kustomize", "Oui, natif", "Oui, natif (Kustomize controller)"),
    ("Multi-cluster", "Oui, natif dans ArgoCD", "Oui (avec Flux sur chaque cluster)"),
    ("RBAC", "Riche (projets, rôles)", "Via les RBAC Kubernetes"),
    ("Image automation", "Limité (Image Updater externe)", "Natif (image-reflector-controller)"),
    ("Notifications", "Via notifications-controller", "Via notification-controller"),
    ("Installation", "helm install argocd", "flux bootstrap github"),
    ("Courbe d'apprentissage", "Modérée (UI aide)", "Plus raide (tout GitOps)"),
]

# Dessin du tableau
cell_height = 0.55
col_widths = [3.5, 5.0, 5.0]
x_starts = [0.2, 3.9, 9.1]
y_start = 6.8

colors_header = ['#1565C0', '#9C27B0', '#2E7D32']
colors_row_odd = ['#F5F5F5', '#F3E5F5', '#E8F5E9']
colors_row_even = ['white', 'white', 'white']

for i, row in enumerate(comparaison):
    y = y_start - i * cell_height
    is_header = (i == 0)
    for j, (cell, x, w) in enumerate(zip(row, x_starts, col_widths)):
        if is_header:
            fc = colors_header[j]
            tc = 'white'
            fw = 'bold'
        else:
            fc = colors_row_odd[j] if i % 2 == 1 else colors_row_even[j]
            tc = '#1A237E' if j == 1 else '#1B5E20' if j == 2 else '#333'
            fw = 'normal'

        rect = FancyBboxPatch((x, y - cell_height + 0.05), w - 0.1, cell_height - 0.05,
                               boxstyle="round,pad=0.05",
                               facecolor=fc, edgecolor='white', alpha=0.9, linewidth=1)
        ax.add_patch(rect)
        ax.text(x + (w-0.1)/2, y - cell_height/2 + 0.02, cell, ha='center', va='center',
                fontsize=8.5, color=tc, fontweight=fw, multialignment='center')

ax.set_xlim(0, 14.5)
ax.set_ylim(-0.5, 7.5)
ax.set_title("Comparaison ArgoCD vs Flux v2", fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig("cicd_argocd_vs_flux.png", dpi=110, bbox_inches='tight')
plt.show()

Simulation d’un contrôleur GitOps#

Hide code cell source

import random
import json
from collections import defaultdict

# Simulation simplifiée d'un contrôleur GitOps (comme ArgoCD/Flux)
class GitState:
    """Simule l'état souhaité dans Git."""
    def __init__(self):
        self.revision = "abc123"
        self.manifests = {
            "deployment/mon-app": {"replicas": 3, "image": "mon-app:1.3.0"},
            "service/mon-app": {"port": 80, "type": "ClusterIP"},
            "configmap/mon-app-config": {"DEBUG": "false", "LOG_LEVEL": "info"},
        }

class ClusterState:
    """Simule l'état réel du cluster Kubernetes."""
    def __init__(self):
        self.resources = {
            "deployment/mon-app": {"replicas": 3, "image": "mon-app:1.3.0"},
            "service/mon-app": {"port": 80, "type": "ClusterIP"},
            "configmap/mon-app-config": {"DEBUG": "false", "LOG_LEVEL": "info"},
        }
        self.applied_revision = "abc123"

class GitOpsController:
    """Contrôleur de réconciliation simplifié."""
    def __init__(self, git, cluster, poll_interval=5):
        self.git = git
        self.cluster = cluster
        self.poll_interval = poll_interval
        self.history = []

    def compute_diff(self):
        """Retourne les ressources qui diffèrent entre git et cluster."""
        diffs = []
        for resource, desired in self.git.manifests.items():
            current = self.cluster.resources.get(resource)
            if current != desired:
                diffs.append({
                    "resource": resource,
                    "desired": desired,
                    "current": current,
                    "action": "update" if current else "create"
                })
        # Ressources dans le cluster mais plus dans git (prune)
        for resource in self.cluster.resources:
            if resource not in self.git.manifests:
                diffs.append({
                    "resource": resource,
                    "desired": None,
                    "current": self.cluster.resources[resource],
                    "action": "delete"
                })
        return diffs

    def reconcile(self):
        """Applique les diffs pour amener le cluster à l'état git."""
        diffs = self.compute_diff()
        if not diffs:
            self.history.append({"status": "synced", "diffs": 0, "revision": self.git.revision})
            return False  # Rien à faire

        for diff in diffs:
            if diff["action"] == "delete":
                del self.cluster.resources[diff["resource"]]
            else:
                self.cluster.resources[diff["resource"]] = diff["desired"]

        self.cluster.applied_revision = self.git.revision
        self.history.append({
            "status": "reconciled",
            "diffs": len(diffs),
            "revision": self.git.revision,
            "changes": [d["resource"] for d in diffs]
        })
        return True

# Scénario de simulation
git = GitState()
cluster = ClusterState()
ctrl = GitOpsController(git, cluster)

events = []
steps = []

# Cycle 1 : tout est synced
result = ctrl.reconcile()
events.append(("t=0s", "État initial : cluster synced avec git", "synced", 0))

# Cycle 2 : dérive manuelle (quelqu'un a fait kubectl edit)
cluster.resources["deployment/mon-app"]["replicas"] = 7  # dérive !
diffs = ctrl.compute_diff()
events.append(("t=15s", "Dérive détectée ! (kubectl edit manuel)", "out-of-sync", len(diffs)))

# Réconciliation
ctrl.reconcile()
events.append(("t=15s+", "Réconciliation : replicas corrigé 7→3", "reconciled", 1))

# Cycle 3 : commit git (nouvelle version)
git.manifests["deployment/mon-app"]["image"] = "mon-app:1.4.0"
git.revision = "def456"
diffs = ctrl.compute_diff()
events.append(("t=30s", f"Nouveau commit git ({git.revision[:6]})", "out-of-sync", len(diffs)))
ctrl.reconcile()
events.append(("t=30s+", "Image mise à jour : 1.3.0 → 1.4.0", "reconciled", 1))

# Cycle 4 : ressource orpheline (prune)
cluster.resources["deployment/old-service"] = {"replicas": 1, "image": "old:1.0"}
diffs = ctrl.compute_diff()
events.append(("t=45s", "Ressource orpheline détectée (old-service)", "out-of-sync", len(diffs)))
ctrl.reconcile()
events.append(("t=45s+", "Prune : old-service supprimé du cluster", "reconciled", 1))

# Cycle 5 : état stable
ctrl.reconcile()
events.append(("t=60s", "Cluster stable et synced", "synced", 0))

# Visualisation
fig, axes = plt.subplots(2, 1, figsize=(14, 9))

# Timeline des événements
ax1 = axes[0]
ax1.axis('off')
ax1.set_xlim(0, 14)
ax1.set_ylim(-0.5, len(events) + 0.5)

status_colors_map = {
    "synced": "#4CAF50",
    "out-of-sync": "#F44336",
    "reconciled": "#FF9800"
}

for i, (temps, desc, status, ndiffs) in enumerate(events):
    y = len(events) - 1 - i
    color = status_colors_map[status]

    # Cercle de statut
    circle = plt.Circle((0.7, y + 0.3), 0.25, color=color, zorder=3)
    ax1.add_patch(circle)

    # Timestamp
    ax1.text(1.2, y + 0.3, temps, ha='left', va='center',
             fontsize=8.5, fontweight='bold', color='#555', fontfamily='monospace')

    # Description
    ax1.text(3.5, y + 0.3, desc, ha='left', va='center', fontsize=9, color='#333')

    # Badge statut
    ax1.text(11.5, y + 0.3, status, ha='center', va='center',
             fontsize=8, fontweight='bold', color=color,
             bbox=dict(boxstyle='round,pad=0.2', facecolor=color, alpha=0.15, edgecolor=color))

    # Nombre de diffs
    if ndiffs > 0:
        ax1.text(13.2, y + 0.3, f"{ndiffs} diff(s)", ha='center', va='center',
                 fontsize=8, color='#555')

ax1.set_title("Simulation d'un contrôleur GitOps — Timeline de réconciliation",
              fontweight='bold', fontsize=11)

# Graphe de l'état du cluster dans le temps
ax2 = axes[1]
t_points = list(range(70))
sync_status = [1]*15 + [0]*2 + [1]*13 + [0]*2 + [1]*13 + [0]*2 + [1]*23
sync_arr = np.array(sync_status[:70])

ax2.fill_between(t_points, sync_arr, alpha=0.4, color='#4CAF50', label='Synced')
ax2.fill_between(t_points, 1 - sync_arr, alpha=0.4, color='#F44336', label='Out-of-sync')
ax2.plot(t_points, sync_arr, color='#2E7D32', linewidth=2)

for te, label in [(15, "Dérive\nmanuelle"), (30, "Commit\ngit"), (45, "Ressource\northeline")]:
    ax2.axvline(x=te, color='#F44336', linestyle='--', alpha=0.6, linewidth=1.5)
    ax2.text(te+0.5, 0.5, label, fontsize=7.5, color='#C62828', style='italic',
             multialignment='center')

ax2.set_ylim(-0.1, 1.4)
ax2.set_yticks([0, 1])
ax2.set_yticklabels(['Out-of-sync', 'Synced'])
ax2.set_xlabel("Temps (secondes)")
ax2.set_title("État de synchronisation cluster ↔ git", fontweight='bold', fontsize=11)
ax2.legend(fontsize=9)

plt.tight_layout()
plt.savefig("cicd_gitops_simulation.png", dpi=110, bbox_inches='tight')
plt.show()

print("État final du cluster :")
print(json.dumps(cluster.resources, indent=2, ensure_ascii=False))

Sécurité CI/CD#

OIDC : fini les secrets long-lived#

Bonne pratique : OIDC partout

Ne jamais stocker de tokens Docker Hub, de kubeconfig ou de clés API dans les secrets GitHub. Utilisez OIDC :

  • GitHub Actions → AWS : aws-actions/configure-aws-credentials avec role-to-assume

  • GitHub Actions → GCP : google-github-actions/auth avec workload_identity_provider

  • GitHub Actions → Docker Hub : depuis 2024, Docker Hub supporte OIDC

  • GitHub Actions → GHCR : GITHUB_TOKEN (automatique, OIDC natif)

L’avantage : si le workflow est compromis, le token est déjà expiré. Il n’y a rien à révoquer.

cosign : signer les images dans le pipeline#

# Dans le pipeline GitHub Actions
- name: Installer cosign
  uses: sigstore/cosign-installer@v3

- name: Signer l'image
  run: |
    cosign sign --yes \
      --oidc-issuer=https://token.actions.githubusercontent.com \
      ghcr.io/mon-org/mon-app@${DIGEST}

# Dans le cluster : politique d'admission (Policy Controller)
# Refuse tout pod avec une image non signée par cosign

Récapitulatif#

Ce qu’il faut retenir

  1. CI/CD automatise le chemin du code au cluster : tests → build → push image → déploiement.

  2. GitHub Actions est l’outil CI/CD le plus répandu. Un workflow = fichier YAML dans .github/workflows/.

  3. GitOps = Git comme source de vérité, réconciliation continue par un agent dans le cluster. Pas d’accès direct au cluster depuis la CI.

  4. ArgoCD : UI riche, Application CRD, sync automatique. Flux : plus minimaliste, tout GitOps, automation des images nativement.

  5. Kustomize : personnaliser des manifestes YAML avec des overlays sans les modifier. Base + dev/staging/prod.

  6. Sécurité : OIDC plutôt que secrets long-lived. Signer les images avec cosign.