Insset GCP M1 2026M1 Informatique
Tous les projets

Groupe 2 / Réseau privé et reprise

MediRdv

Un service de rendez-vous à fiabiliser

Votre mission

MediRdv prépare un pilote avec quelques cabinets médicaux. L’équipe a une petite API de rendez-vous, mais elle ne sait pas encore dire qui peut atteindre la base, comment ses secrets sont gérés ni comment retrouver une donnée supprimée par erreur. Vous prenez en main l’environnement cloud avant une démonstration à la direction. Le laboratoire n’utilise que des données fictives.

« Un rendez-vous doit pouvoir être enregistré puis retrouvé. Je veux comprendre qui accède aux données, et voir comment on les récupère après une erreur. »

Ce dont le produit a besoin

  • Créer et lire quelques rendez-vous fictifs.
  • Garder la base de données hors d’atteinte directe depuis Internet.
  • Séparer l’identité de l’application, les permissions cloud et l’authentification à la base.
  • Restaurer un jeu de données de test et mesurer le temps que cela prend.

Le périmètre du laboratoire

Vous réalisez un VPC dédié, une instance Cloud SQL PostgreSQL en IP privée, l’API sur Cloud Run, Secret Manager et les sauvegardes. Une petite base sans haute disponibilité suffit pour le laboratoire. La cible de production, elle, sera argumentée.

Le formateur vous donnera les volumes métier quand vous les lui demanderez. Ils servent à raisonner, pas à être reproduits : les essais de charge du laboratoire ont leurs propres limites, fixées à l’étape 5.

Le cœur du module, c’est l’infrastructure

L’application sert seulement à faire passer des requêtes ou des messages. Votre travail porte sur Terraform, le réseau, IAM, le pipeline, le monitoring et la reprise. Personne ne vous demande une application métier complète, et elle ne sera pas évaluée. Le kit de démarrage est là pour vous éviter de l’écrire.

Ce qui compte en premier

Le parcours suit six étapes, et le temps passe vite. Le socle, c’est ce que vous devez pouvoir montrer quoi qu’il arrive. La version complète vient ensuite, si le temps le permet. Un socle solide et bien expliqué vaut mieux qu’une version complète vérifiée à moitié. À chaque étape, notez en quelques lignes ce que vous avez décidé et ce que vous avez mesuré.

ÉtapeSocleVersion complète
01 CadrerUne fiche d’hypothèses et trois critères de réussite validés par le formateur.Les volumes obtenus, une limite de coût et ce que vous écartez, écrits noir sur blanc.
02 ArchitectureLe schéma cible expliqué flux par flux, avec votre découpage Terraform.Trois choix d’infrastructure argumentés, avec les alternatives écartées.
03 ConstruireLe chemin nominal déployé par Terraform, avec un état distant et des identités dédiées : une création puis une lecture de rendez-vous, avec une base en IP privée.Des modules documentés, un plan sans dérive et un test négatif, c’est-à-dire un refus ou une erreur attendus.
04 AutomatiserUne CI qui s’authentifie par fédération d’identité et lance fmt, validate et plan à chaque changement.L’apply du plan relu après approbation, un test du parcours nominal et une destruction approuvée.
05 ÉprouverUn dashboard, une alerte, un essai de charge borné et une analyse argumentée de la nouvelle contrainte.Deux alertes dont la notification a bien été reçue, une expérience menée jusqu’au retour au service, et un essai borné sur la nouvelle contrainte.
06 Présenter20 minutes sur ce qui fonctionne vraiment, un ordre de grandeur des coûts et le nettoyage des ressources.Un calcul de coûts détaillé et les écarts avec une cible de production.

Avant de commencer

Avant de créer la moindre ressource, faites le point avec le formateur :

  • Le projet GCP du laboratoire. La facturation doit être active, les API et les quotas disponibles, et vous devez pouvoir créer les ressources et déléguer les permissions nécessaires.
  • La région, le préfixe de votre groupe, les labels à poser et le plafond de dépense. Une alerte de budget prévient, elle ne coupe rien.
  • Vos outils : Terraform, Git et gcloud. Si votre sujet utilise des conteneurs, il vous faut aussi de quoi construire une image et un registre pour la stocker.
  • Un dépôt Git pour le groupe et une plateforme de CI. En local, vous travaillez avec les identifiants prévus par le cours. En CI, vous préparerez une identité fédérée aux droits limités.
  • Un endroit où ranger vos preuves : schémas, captures, résultats horodatés et décisions, sans aucun secret. L’un exécute, l’autre relit, le troisième mesure, et vous changez de rôle régulièrement.

Votre jeu de démonstration

Une API minimale avec une route de lecture et une route d’écriture, et cinq rendez-vous fictifs. Pas de dossier médical, pas de vrai nom de patient, pas d’authentification métier à développer. Le kit ci-dessous fournit cette API.

Avant de continuer

Vous savez dans quel projet vous travaillez, ce que vous avez le droit de créer et comment tout arrêter puis supprimer. S’il vous manque un accès, demandez-le plutôt que de le contourner avec des permissions trop larges.

Kit de démarrage

Ce kit vous évite d’écrire l’application. Il contient le code à déployer, avec une interface qui montre ce que fait votre infrastructure, et un docker compose qui imite les services GCP pour tout faire tourner sur votre machine avant le premier déploiement. Il ne contient pas de Terraform : l’écrire reste le cœur du TP.

Vous pouvez le modifier, mais il ne sera pas évalué. Lisez-le avant de l’utiliser, car vous devrez pouvoir l’expliquer comme n’importe quel code que vous n’avez pas écrit vous-même.

Une API de rendez-vous fictifs et son agenda, prêts à tourner sur Cloud Run et à rejoindre Cloud SQL en IP privée par le connecteur officiel. L’interface montre aussi ce que voit l’infrastructure : le mode de connexion, la latence, le remplissage du pool et, quand la base ne répond pas, la couche à vérifier en premier. Des repères horodatés aident à mesurer la perte de données après une restauration.

Télécharger le kit, archive zip

RôleEn localSur GCP
Base de donnéesPostgreSQL sans port publiéCloud SQL PostgreSQL sans IP publique
Chemin vers la baseréseau interne de Dockersortie VPC de Cloud Run et Private Service Access
Mot de passefichier monté par Composesecret Secret Manager, monté comme fichier ou en variable
API et interfaceconteneur apiCloud Run, avec son identité dédiée

Pour l’essayer : décompressez l’archive, puis lancez docker compose up --build dans le dossier medirdv et ouvrez http://localhost:8080. Le README de l’archive explique comment passer sur GCP.

docker-compose.yml, le lancement local
# MediRdv en local : docker compose up --build, puis http://localhost:8080
name: medirdv-local

services:
  # Joue le rôle de Cloud SQL. Aucun port n'est publié : comme une instance sans IP publique,
  # la base n'est joignable que depuis le réseau interne, pas depuis votre machine.
  base:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: medirdv
      POSTGRES_USER: medirdv_app
      POSTGRES_PASSWORD_FILE: /run/medirdv/db-password
    configs:
      - source: db-password
        target: /run/medirdv/db-password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U medirdv_app -d medirdv"]
      interval: 3s
      retries: 20

  # Le code à déployer sur Cloud Run.
  api:
    build: ./api
    ports:
      - "8080:8080"
    environment:
      APP_VERSION: local
      DB_HOST: base
      DB_NAME: medirdv
      DB_USER: medirdv_app
      # Sur Cloud Run, le secret Secret Manager peut être monté comme fichier de la même façon.
      DB_PASSWORD_FILE: /run/medirdv/db-password
      POOL_MAX: "5"
    configs:
      - source: db-password
        target: /run/medirdv/db-password
    depends_on:
      base:
        condition: service_healthy

# Remplace Secret Manager en local. Ce mot de passe ne sert qu'ici.
configs:
  db-password:
    content: medirdv-local-uniquement
api/stockage.mjs, l’accès à la base
// Accès aux données de MediRdv. Trois modes, choisis par les variables d'environnement :
//   connecteur  INSTANCE_CONNECTION_NAME défini : Cloud SQL en IP privée via le connecteur Node.js
//               (l'identité du service a besoin de roles/cloudsql.client, l'API Cloud SQL Admin doit être active)
//   direct      DB_HOST défini : connexion PostgreSQL classique, utilisée par le docker compose local
//   memoire     DB_MODE=memoire : aucune base, pour essayer l'interface sans rien installer
import fs from 'node:fs';
import { appelGoogle } from './gcp.mjs';

const fictifs = [
  ['Patient fictif 1', 'Dr Exemple', 1, 9],
  ['Patient fictif 2', 'Dr Exemple', 1, 10],
  ['Patient fictif 3', 'Dr Démo', 2, 14],
  ['Patient fictif 4', 'Dr Démo', 3, 11],
  ['Patient fictif 5', 'Dr Exemple', 4, 16],
];
const fictif = ([patient, praticien, jours, heure]) => {
  const debut = new Date();
  debut.setDate(debut.getDate() + jours);
  debut.setHours(heure, 0, 0, 0);
  return { patient, praticien, debut: debut.toISOString() };
};

// Le mot de passe a trois sources possibles :
//   DB_PASSWORD_SECRET  le service lit lui-même la version du secret dans Secret Manager, avec son identité
//   DB_PASSWORD_FILE    un fichier : Cloud Run sait y monter un secret, le compose local fait de même
//   DB_PASSWORD         une variable, injectée par Cloud Run depuis Secret Manager ou écrite en clair
export function sourceMotDePasse(env = process.env) {
  if (env.DB_PASSWORD_SECRET) return 'secret';
  if (env.DB_PASSWORD_FILE) return 'fichier';
  if (env.DB_PASSWORD) return 'variable';
  return 'aucune';
}

export async function lireSecret(nom = process.env.DB_PASSWORD_SECRET) {
  const { status, body } = await appelGoogle(`https://secretmanager.googleapis.com/v1/${nom}:access`);
  if (status !== 200) throw Object.assign(new Error(`lecture du secret refusée (HTTP ${status || 'hors GCP'})`), { code: status === 403 ? 'PERMISSION_DENIED' : `SECRET_${status}` });
  return Buffer.from(body.payload.data, 'base64').toString('utf8').trim();
}

async function motDePasse() {
  const source = sourceMotDePasse();
  if (source === 'secret') return lireSecret();
  if (source === 'fichier') return fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim();
  return process.env.DB_PASSWORD;
}

export function choisirMode(env = process.env) {
  if (env.DB_MODE === 'memoire') return 'memoire';
  if (env.INSTANCE_CONNECTION_NAME) return 'connecteur';
  if (env.DB_HOST) return 'direct';
  return 'non configuré';
}

function stockageMemoire() {
  let id = 0;
  const rendezVous = fictifs.map(fictif).map((rdv) => ({ id: ++id, ...rdv, cree_le: new Date().toISOString() }));
  const reperes = [];
  let derniereEcriture = null;
  const ecrit = () => { derniereEcriture = new Date().toISOString(); };
  return {
    async lister() { return [...rendezVous].sort((a, b) => a.debut.localeCompare(b.debut)); },
    async creer(rdv) { const ligne = { id: ++id, ...rdv, cree_le: new Date().toISOString() }; rendezVous.push(ligne); ecrit(); return ligne; },
    async supprimer(cible) { const i = rendezVous.findIndex((rdv) => rdv.id === cible); if (i >= 0) rendezVous.splice(i, 1); ecrit(); return i >= 0; },
    async poserRepere(note) { const ligne = { id: reperes.length + 1, note, cree_le: new Date().toISOString() }; reperes.unshift(ligne); ecrit(); return ligne; },
    async reperes() { return reperes.slice(0, 20); },
    async limiter(max) { rendezVous.sort((a, b) => b.id - a.id).splice(max); reperes.splice(max); },
    async etat() {
      return { latence_ms: 0, pool: null, compteurs: { rendez_vous: rendezVous.length, reperes: reperes.length }, derniere_ecriture: derniereEcriture };
    },
  };
}

async function stockagePostgres(mode) {
  const { default: pg } = await import('pg');
  let options = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT || 5432) };
  let connector = null;
  if (mode === 'connecteur') {
    const { Connector } = await import('@google-cloud/cloud-sql-connector');
    connector = new Connector();
    options = await connector.getOptions({ instanceConnectionName: process.env.INSTANCE_CONNECTION_NAME, ipType: 'PRIVATE' });
  }
  const max = Number(process.env.POOL_MAX || 5);
  const pool = new pg.Pool({
    ...options,
    user: process.env.DB_USER,
    // Une fonction plutôt qu'une valeur : le mot de passe est relu à chaque nouvelle connexion.
    password: motDePasse,
    database: process.env.DB_NAME,
    max,
    connectionTimeoutMillis: 5000,
  });
  // Une connexion inactive coupée (bascule, redémarrage) ne doit pas arrêter le processus.
  pool.on('error', (error) => console.log(JSON.stringify({ severity: 'ERROR', message: error.message, code: error.code })));

  try {
    await pool.query(`CREATE TABLE IF NOT EXISTS rendez_vous (
      id SERIAL PRIMARY KEY, patient TEXT NOT NULL, praticien TEXT NOT NULL,
      debut TIMESTAMPTZ NOT NULL, cree_le TIMESTAMPTZ NOT NULL DEFAULT now())`);
    await pool.query(`CREATE TABLE IF NOT EXISTS reperes (
      id SERIAL PRIMARY KEY, note TEXT NOT NULL, cree_le TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp())`);
    const { rows } = await pool.query('SELECT count(*)::int AS n FROM rendez_vous');
    if (rows[0].n === 0 && process.env.SEED_DEMO !== 'false') {
      for (const rdv of fictifs.map(fictif)) {
        await pool.query('INSERT INTO rendez_vous (patient, praticien, debut) VALUES ($1, $2, $3)', [rdv.patient, rdv.praticien, rdv.debut]);
      }
    }
  } catch (error) {
    await pool.end().catch(() => {});
    connector?.close();
    throw error;
  }

  return {
    async lister() {
      return (await pool.query('SELECT id, patient, praticien, debut, cree_le FROM rendez_vous ORDER BY debut LIMIT 100')).rows;
    },
    async creer({ patient, praticien, debut }) {
      const sql = 'INSERT INTO rendez_vous (patient, praticien, debut) VALUES ($1, $2, $3) RETURNING id, patient, praticien, debut, cree_le';
      return (await pool.query(sql, [patient, praticien, debut])).rows[0];
    },
    async supprimer(id) {
      return (await pool.query('DELETE FROM rendez_vous WHERE id = $1', [id])).rowCount > 0;
    },
    async poserRepere(note) {
      return (await pool.query('INSERT INTO reperes (note) VALUES ($1) RETURNING id, note, cree_le', [note])).rows[0];
    },
    async reperes() {
      return (await pool.query('SELECT id, note, cree_le FROM reperes ORDER BY id DESC LIMIT 20')).rows;
    },
    // Démo publique : seules les lignes les plus récentes sont gardées.
    async limiter(max) {
      await pool.query('DELETE FROM rendez_vous WHERE id NOT IN (SELECT id FROM rendez_vous ORDER BY id DESC LIMIT $1)', [max]);
      await pool.query('DELETE FROM reperes WHERE id NOT IN (SELECT id FROM reperes ORDER BY id DESC LIMIT $1)', [max]);
    },
    async etat() {
      const debut = performance.now();
      const { rows: [ligne] } = await pool.query(`SELECT
        (SELECT count(*)::int FROM rendez_vous) AS rendez_vous,
        (SELECT count(*)::int FROM reperes) AS reperes,
        greatest((SELECT max(cree_le) FROM rendez_vous), (SELECT max(cree_le) FROM reperes)) AS derniere_ecriture`);
      return {
        latence_ms: Math.round(performance.now() - debut),
        pool: { total: pool.totalCount, inactives: pool.idleCount, en_attente: pool.waitingCount, max },
        compteurs: { rendez_vous: ligne.rendez_vous, reperes: ligne.reperes },
        derniere_ecriture: ligne.derniere_ecriture,
      };
    },
    async fermer() {
      await pool.end();
      connector?.close();
    },
  };
}

// La connexion s'ouvre à la première demande et se retente ensuite : le service démarre même si la
// base est injoignable, et l'erreur exacte apparaît dans les journaux et dans l'interface.
let enCours = null;
export function stockage() {
  const mode = choisirMode();
  enCours ??= (async () => {
    if (mode === 'memoire') return stockageMemoire();
    if (mode === 'non configuré') throw Object.assign(new Error('définissez DB_HOST, INSTANCE_CONNECTION_NAME ou DB_MODE=memoire'), { code: 'CONFIG' });
    for (const nom of ['DB_NAME', 'DB_USER']) {
      if (!process.env[nom]) throw Object.assign(new Error(`variable ${nom} manquante`), { code: 'CONFIG' });
    }
    return stockagePostgres(mode);
  })().catch((error) => {
    enCours = null;
    throw error;
  });
  return enCours;
}
api/server.mjs, les routes de l’API
// API et agenda de démonstration pour MediRdv, à déployer sur Cloud Run.
// Données fictives uniquement. La configuration de la base est décrite dans stockage.mjs.
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import { stockage, choisirMode } from './stockage.mjs';
import { carteDeploiement } from './deploiement.mjs';

const here = new URL('.', import.meta.url);
const version = process.env.APP_VERSION || 'dev';
const plateforme = process.env.K_SERVICE ? 'Cloud Run' : 'local';
const instance = process.env.K_REVISION || os.hostname();
// Démo publique : les noms sont générés par le serveur et le nombre de lignes est plafonné.
const demoPublique = process.env.DEMO_PUBLIQUE === 'true';
const praticiens = ['Dr Exemple', 'Dr Démo'];
const assets = {
  '/': ['index.html', 'text/html; charset=utf-8'],
  '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
  '/kit.css': ['kit.css', 'text/css; charset=utf-8'],
  '/carte.js': ['carte.js', 'text/javascript; charset=utf-8'],
};

// Chaque code d'erreur est rattaché à la couche qu'il faut vérifier en premier.
function couche(code = '') {
  if (['ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ECONNRESET'].includes(code)) return 'réseau';
  if (['28P01', '28000'].includes(code)) return 'authentification PostgreSQL';
  if (code === '3D000') return 'base de données absente';
  if (code === 'CONFIG') return 'configuration du service';
  if (/PERMISSION|403|401/.test(code)) return 'IAM';
  return 'à diagnostiquer dans les journaux';
}

function log(severity, message, extra = {}) {
  console.log(JSON.stringify({ severity, message, ...extra }));
}

function send(res, status, body, type = 'application/json; charset=utf-8') {
  res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
  res.end(type.startsWith('application/json') ? JSON.stringify(body) : body);
}

// Le détail de l'erreur reste dans les journaux ; le client ne reçoit que son code et la couche à vérifier.
function indisponible(res, error) {
  const code = String(error.code || error.status || 'INCONNU');
  log('ERROR', error.message, { code });
  send(res, 503, { statut: 'indisponible', code, couche: couche(code) });
}

async function lireJson(req) {
  let body = '';
  for await (const chunk of req) {
    body += chunk;
    if (body.length > 10_000) throw Object.assign(new Error('requête trop longue'), { http: 413 });
  }
  try {
    return JSON.parse(body || '{}');
  } catch {
    throw Object.assign(new Error('JSON invalide'), { http: 400 });
  }
}

const texte = (value) => typeof value === 'string' && value.trim().length > 0 && value.length < 200;

const server = http.createServer(async (req, res) => {
  const { pathname } = new URL(req.url, 'http://localhost');
  try {
    if (req.method === 'GET' && assets[pathname]) {
      const [file, type] = assets[pathname];
      return send(res, 200, fs.readFileSync(new URL(`public/${file}`, here)), type);
    }
    if (pathname === '/healthz') return send(res, 200, { statut: 'ok' });

    if (pathname === '/readyz' || pathname === '/api/etat') {
      const base = { plateforme, instance, version, mode: choisirMode(), demo_publique: demoPublique, heure_serveur: new Date().toISOString() };
      try {
        const etat = await (await stockage()).etat();
        return send(res, 200, { ...base, base: { statut: 'ok', ...etat } });
      } catch (error) {
        const code = String(error.code || 'INCONNU');
        log('ERROR', error.message, { code });
        return send(res, pathname === '/readyz' ? 503 : 200, { ...base, base: { statut: 'indisponible', code, couche: couche(code) } });
      }
    }

    if (pathname === '/api/deploiement') return send(res, 200, await carteDeploiement());

    if (pathname === '/api/rendez-vous' && req.method === 'GET') {
      return send(res, 200, await (await stockage()).lister());
    }
    if (pathname === '/api/rendez-vous' && req.method === 'POST') {
      let { patient, praticien, debut } = await lireJson(req);
      if (demoPublique) {
        // Aucune saisie libre en démo publique : personne ne peut y laisser une vraie donnée.
        patient = `Patient fictif ${100 + Math.floor(Math.random() * 900)}`;
        if (!praticiens.includes(praticien)) praticien = praticiens[0];
      }
      if (![patient, praticien, debut].every(texte) || Number.isNaN(Date.parse(debut))) {
        return send(res, 400, { erreur: 'patient, praticien et debut (date ISO) sont requis' });
      }
      const donnees = await stockage();
      const cree = await donnees.creer({ patient: patient.trim(), praticien: praticien.trim(), debut });
      if (demoPublique) await donnees.limiter(40);
      return send(res, 201, cree);
    }
    const suppression = pathname.match(/^\/api\/rendez-vous\/(\d+)$/);
    if (suppression && req.method === 'DELETE') {
      const supprime = await (await stockage()).supprimer(Number(suppression[1]));
      log('NOTICE', `rendez-vous ${suppression[1]} supprimé`);
      return send(res, supprime ? 200 : 404, { supprime });
    }

    // Les repères horodatés servent à mesurer la perte de données lors d'une restauration.
    if (pathname === '/api/reperes' && req.method === 'GET') {
      return send(res, 200, await (await stockage()).reperes());
    }
    if (pathname === '/api/reperes' && req.method === 'POST') {
      const { note } = await lireJson(req);
      const libelle = texte(note) && !demoPublique ? note.trim() : 'repère';
      const donnees = await stockage();
      const repere = await donnees.poserRepere(libelle);
      if (demoPublique) await donnees.limiter(40);
      return send(res, 201, repere);
    }

    send(res, 404, { erreur: 'route inconnue' });
  } catch (error) {
    if (error.http) return send(res, error.http, { erreur: error.message });
    indisponible(res, error);
  }
});

server.listen(Number(process.env.PORT || 8080), () => log('INFO', `API prête sur le port ${server.address().port}, mode ${choisirMode()}`));
process.on('SIGTERM', () => server.close(async () => {
  try { await (await stockage()).fermer?.(); } catch {}
  process.exit(0);
}));
README.md, le passage sur GCP
# MediRdv, kit de démarrage

Une API de rendez-vous fictifs avec son agenda, prête à tourner sur Cloud Run et à rejoindre Cloud SQL en IP privée. L’interface montre aussi ce que voit l’infrastructure : le mode de connexion, la latence de la base, le remplissage du pool et la couche à vérifier quand quelque chose casse. Le kit est un démonstrateur : il sert à vérifier votre infrastructure, il n’est pas évalué.

N’utilisez que des données fictives, ici comme dans le laboratoire.

## Ce qu’il contient

```text
api/                  Le code à déployer sur Cloud Run
  server.mjs          Routes de l’API et de l’interface
  stockage.mjs        Accès à PostgreSQL : connecteur Cloud SQL, connexion directe ou mémoire
  public/             Interface : agenda, état de la connexion, repères horodatés
  package.json, package-lock.json, Dockerfile
docker-compose.yml    PostgreSQL et l’API, pour travailler en local
```

## Lancer en local

Avec Docker :

```sh
docker compose up --build
```

Puis ouvrez http://localhost:8080. La base démarre avec cinq rendez-vous fictifs.

Sans Docker ni base, pour voir l’interface seulement :

```sh
cd api
npm install
npm run demo
```

| Rôle | En local | Sur GCP |
| --- | --- | --- |
| Base de données | conteneur `base`, PostgreSQL sans port publié | Cloud SQL PostgreSQL sans IP publique |
| Chemin vers la base | réseau interne de Docker | Sortie VPC de Cloud Run et Private Service Access |
| Mot de passe | fichier monté depuis une config Compose | secret Secret Manager, monté comme fichier ou injecté en variable |
| API et interface | conteneur `api` | Cloud Run, avec son identité dédiée |

Pour voir l’interface réagir à une panne, arrêtez la base avec `docker compose stop base` : l’agenda affiche le code d’erreur et la couche à vérifier. `docker compose start base` rétablit tout.

## Passer sur GCP

Le code d’`api/` se déploie tel quel. Le réseau, la base, le secret, les identités et le service Cloud Run, c’est vous qui les écrivez en Terraform.

```sh
REGION=europe-west9
PROJECT_ID=votre-projet
IMAGE="$REGION-docker.pkg.dev/$PROJECT_ID/medirdv/api:v1"
gcloud builds submit --tag "$IMAGE" api
gcloud artifacts docker images describe "$IMAGE" --format='value(image_summary.digest)'
```

Le service attend ces variables sur Cloud Run :

| Variable | Rôle |
| --- | --- |
| `INSTANCE_CONNECTION_NAME` | `projet:region:instance`. Active le connecteur Cloud SQL en IP privée. |
| `DB_NAME`, `DB_USER` | Base et utilisateur applicatif, qui n’est pas un superutilisateur. |
| `DB_PASSWORD_FILE` ou `DB_PASSWORD` | Le mot de passe, fourni par Secret Manager. Jamais dans le code ni dans Terraform en clair. |
| `POOL_MAX` | Connexions maximales par instance, 5 par défaut. À multiplier par le nombre d’instances pour comparer à la limite de la base. |
| `APP_VERSION` | Version affichée dans l’interface. |
| `SEED_DEMO` | Mettre `false` pour ne pas créer les cinq rendez-vous de départ. |

Le connecteur appelle l’API Cloud SQL Admin : elle doit être activée, et l’identité du service a besoin de `roles/cloudsql.client`. Ce rôle ne crée pas de chemin réseau ; sans sortie VPC vers le réseau de la base, la connexion échoue avec une erreur réseau, et l’interface vous le dit.

Si le mot de passe est injecté en variable, il est lu au démarrage de l’instance : retirer l’accès au secret ne se voit qu’avec une nouvelle révision. Monté comme fichier, il est relu à chaque nouvelle connexion.

## Routes

| Route | Rôle |
| --- | --- |
| `GET /` | Interface |
| `GET /healthz` | Le processus tourne |
| `GET /readyz` | La base répond (503 sinon, avec le code d’erreur) |
| `GET /api/etat` | Mode, latence, pool, compteurs, dernière écriture |
| `GET, POST /api/rendez-vous` | Lire et créer des rendez-vous fictifs |
| `DELETE /api/rendez-vous/:id` | Supprimer, pour simuler l’erreur à rattraper |
| `GET, POST /api/reperes` | Repères horodatés pour mesurer le RPO |

## La carte du déploiement

En haut de l’interface, l’architecture cible est dessinée bloc par bloc. Chaque bloc s’allume selon ce que l’application constate elle-même, avec la preuve affichée dessous :

| Couleur | Sens |
| --- | --- |
| vert, « prouvé » | l’application l’a vérifié elle-même |
| orange, « à revoir » | ça fonctionne, mais c’est un anti-pattern connu |
| rouge, « en échec » | l’application a essayé et ça ne marche pas |
| pointillés, « pas encore détecté » | rien de visible pour l’instant |
| gris, « à prouver vous-même » | invisible depuis l’application : montrez-le dans la console |
| violet, « simulé en local » | l’équivalent local, en attendant le déploiement |

La carte constate, elle ne note pas : un bloc vert ne dit pas que votre choix est le bon, seulement qu’il est en place.

Pour MediRdv, le service lit lui-même :

| Bloc | Comment | Droit nécessaire |
| --- | --- | --- |
| Entrée du service | configuration Cloud Run : ingress et invocation publique ou non | `roles/run.viewer` sur le service, facultatif |
| Identité | serveur de métadonnées | aucun |
| Secret Manager | lecture du secret, si `DB_PASSWORD_SECRET` est utilisé | `roles/secretmanager.secretAccessor`, déjà nécessaire |
| Réseau et base | résultat de la connexion, avec la couche en cause | aucun |
| Exposition, sauvegardes, disponibilité | configuration de l’instance par l’API Cloud SQL Admin | couvert par `roles/cloudsql.client` |

Avec `DB_PASSWORD_SECRET=projects/PROJET/secrets/NOM/versions/latest`, le service lit le mot de passe directement dans Secret Manager à chaque nouvelle connexion : retirez-lui le droit d’accès et le bloc passe au rouge dans les secondes qui suivent.

## Démo publique

Avec `DEMO_PUBLIQUE=true`, le nom du patient est généré par le serveur et le nombre de lignes est plafonné à quarante. C’est le réglage des démos hébergées sur la VM du cours.

01 Cadrer

Comprendre la demande

Laissez Terraform de côté pour l’instant. Partez du message du responsable et traduisez-le en un parcours utilisateur et en critères de réussite que l’on peut observer.

À faire

  • Choisissez dans la liste ci-dessous les trois questions qui vous semblent prioritaires et posez-les au formateur. Les autres pourront venir ensuite.
  • Séparez ce qui est confirmé, ce que vous supposez pour l’instant et ce qui reste à décider.
  • Fixez un minimum démontrable, une limite de coût et ce que vous laissez de côté dans la première version.

Les questions à poser

  • Les données doivent-elles rester dans une région précise ?
  • Qui doit pouvoir appeler l’API pendant le pilote ?
  • Quel RPO et quel RTO sont acceptables ?
  • Combien de professionnels utilisent la plateforme ?
  • Faut-il résister à la perte d’une zone ?
  • Qui peut lire les secrets et administrer la base ?
Modèle de fiche de cadrage
Besoin ou questionRéponse ou hypothèsePreuve prévue
À compléterQui l’a confirmée ?Comment la vérifier ?
Avant de continuer

Montrez au formateur une fiche courte avec vos hypothèses et trois critères de réussite. Il valide le périmètre et le plafond de charge avant la suite.

02 Architecture

Comprendre l’architecture cible

Cette architecture est votre point de départ. Votre travail consiste à la déployer avec Terraform, à la sécuriser, à l’automatiser et à vérifier qu’elle se comporte comme prévu.

L’entrée vers l’API et la sortie vers la base sont deux décisions distinctes. Vous devrez expliquer séparément le chemin réseau privé, les permissions IAM et l’authentification PostgreSQL. La variante de production peut différer du laboratoire.

flowchart TB
 U["Client de démonstration"] --> IN["Entrée contrôlée
politique à justifier"]
 IN --> RUN["Cloud Run
identité dédiée"]
 RUN --> VPC["Sortie VPC
Direct VPC ou connecteur"]
 VPC --> PSA["Private Service Access"]
 PSA --> SQL["Cloud SQL PostgreSQL
IP privée"]
 RUN -->|"lecture autorisée"| SEC["Secret Manager"]
 SQL --> BK["Sauvegardes et PITR"]
 RUN -.-> MON["Logging et Monitoring"]
 SQL -.-> MON

Sur un petit écran, faites défiler le schéma horizontalement.

Voir et copier le code Mermaid
flowchart TB
 U["Client de démonstration"] --> IN["Entrée contrôlée
politique à justifier"]
 IN --> RUN["Cloud Run
identité dédiée"]
 RUN --> VPC["Sortie VPC
Direct VPC ou connecteur"]
 VPC --> PSA["Private Service Access"]
 PSA --> SQL["Cloud SQL PostgreSQL
IP privée"]
 RUN -->|"lecture autorisée"| SEC["Secret Manager"]
 SQL --> BK["Sauvegardes et PITR"]
 RUN -.-> MON["Logging et Monitoring"]
 SQL -.-> MON

Comment ça circule

Une requête atteint l’API

Votre politique d’ingress décide par où Cloud Run accepte les requêtes. Il faudra justifier le niveau d’exposition retenu.

L’API utilise sa propre identité

Son compte de service lit le secret dont elle a besoin, avec un rôle limité. Les droits d’administration ne sont pas ceux de l’application.

La connexion rejoint la base privée

Cloud Run a besoin d’un chemin réseau vers l’adresse privée de Cloud SQL. Une IP privée ne dispense pas de s’authentifier auprès de la base.

Une erreur impose une reprise

Expliquez le point de restauration, les étapes et le temps nécessaire pour remettre le service en route. Le RPO mesure la perte de données tolérée, le RTO la durée d’interruption tolérée.

À retenir

Une sauvegarde, une base privée et la haute disponibilité répondent à trois besoins différents. Pour la démonstration, uniquement des données fictives.

Approfondir dans la documentation Google Cloud

À faire à partir du schéma

  • Suivez une requête ou un événement de bout en bout et expliquez le rôle de chaque service GCP.
  • Listez les ressources Terraform nécessaires, leurs dépendances et ce que chaque module expose aux autres.
  • Repérez ce qui reste à décider : région et zones, exposition réseau, identités et permissions, dimensionnement, seuils et politique de reprise.
  • Annotez trois choix d’infrastructure avec leurs compromis. Gardez un schéma fidèle à ce que vous déployez réellement.
Avant de continuer

Présentez au formateur votre découpage Terraform, les flux autorisés et les paramètres retenus. On parle ici d’infrastructure : concevoir une application métier n’est pas l’objet du TP.

03 Construire

Construire le premier parcours

Avancez par petits pas : vous déployez, vous vérifiez, puis vous ajoutez la dépendance suivante. Les ressources finales sont décrites dans Terraform. La console reste utile pour observer et diagnostiquer.

À faire

  1. Créez le VPC, le subnet et ce qu’il faut pour la connectivité privée de Cloud SQL.
  2. Créez une petite instance PostgreSQL sans IPv4 publique. Configurez les sauvegardes et notez les possibilités de restauration.
  3. Déployez l’API avec une identité dédiée, la sortie VPC choisie et un accès minimal au seul secret dont elle a besoin.
  4. Faites une lecture et une écriture depuis l’application, puis vérifiez qu’un accès non autorisé au secret est bien refusé.

À montrer

La création puis la lecture d’un rendez-vous, la configuration privée de la base, un refus IAM attendu et des journaux qui ne contiennent aucun secret.

À expliquer

L’API a le bon rôle IAM mais ne joint pas la base. Que vérifiez-vous côté réseau et côté application, et dans quel ordre ?

Besoin d’un indice ?

Indice 1, une piste

Une permission IAM ne crée pas de chemin réseau. Vérifiez séparément le réseau, la lecture du secret et l’authentification PostgreSQL.

Indice 2, où chercher

Sortie VPC directe de Cloud Run

Les ressources du provider sont ensuite listées dans la rubrique Documentation Terraform.

Indice 3, un coup de pouce

Dessinez deux liaisons distinctes : de Cloud Run vers le VPC, puis du VPC vers le service Cloud SQL privé. Cherchez ensuite les ressources Terraform qu’il faut pour chacune.

Organiser Terraform

Découpez vos modules par responsabilité. Écrivez d’abord leurs entrées, leurs sorties et leurs dépendances : le module racine se contente de les assembler. Chaque module a une courte documentation. Inutile en revanche de créer un module par ressource.

Un découpage à discuter, une fois votre propre proposition faite

Chaque module contient main.tf, variables.tf, outputs.tf et un court README. Le module racine relie les sorties des uns aux entrées des autres. Ce découpage est une proposition : à vous de le défendre ou de l’améliorer.

ModuleResponsabilitéEntrées principalesSorties utiles
networkLe VPC, le subnet et le Private Service Access pour Cloud SQL.region, cidr, plage des services privésnetwork_id, subnet_id, private_service_connection
databaseCloud SQL PostgreSQL en IP privée, les sauvegardes, la rétention et l’option HA.network_id, tier, backup_config, availability_typeinstance_connection_name, private_ip
applicationCloud Run, le chemin vers le VPC, l’identité dédiée et l’accès à Secret Manager.subnet_id, db_connection, secret_version, image_digestservice_name, service_account_email
observabilityLe dashboard de l’API et de la base, l’alerte de disponibilité et le runbook de reprise.service_name, db_id, seuils, canauxdashboard_id, alert_policy_ids

Une organisation de dépôt possible :

infra/
  bootstrap/          # backend GCS et accès CI, cycle séparé
  envs/
    lab/              # module racine déployé, backend et variables
    prod-design/      # variante documentée, sans déploiement imposé
  modules/
    network/
    database/
    application/
    observability/
app/                  # le kit de démarrage, si vous l’utilisez
tests/
  smoke/              # vérification du chemin nominal
  load/               # scripts et jeux synthétiques
docs/
  architecture.mmd
  decisions/          # décisions et alternatives (ADR)
  runbooks/           # diagnostic et reprise
  evidence/           # résultats, sans secret
README.md

État, environnements et bootstrap

L’état Terraform vit dans un bucket GCS créé par un bootstrap séparé, lancé avant le laboratoire et qui n’en dépend pas. Activez le versioning de ce bucket et limitez qui peut y accéder. Le backend GCS gère le verrouillage, et un préfixe par environnement évite de mélanger les états.

Ne commitez ni l’état, ni les plans, ni les clés, ni les valeurs de secrets. Attention, sensitive = true masque une valeur à l’affichage mais ne la retire pas du state. Gardez plutôt un fichier d’exemple avec les variables non sensibles. Voir la documentation des modules Terraform.

Avant de continuer

Le chemin nominal fonctionne. terraform fmt -check et terraform validate passent, et un nouveau plan ne propose rien d’inattendu. Un autre membre du groupe sait expliquer les dépendances.

04 Automatiser

Rendre le déploiement reproductible

Un collègue doit pouvoir relire puis déployer un changement sans refaire vos manipulations dans la console.

À faire

  • À chaque changement, la CI vérifie le format, initialise sans backend et lance validate. Les versions sont figées et le fichier de verrouillage est commité.
  • La CI s’authentifie par fédération d’identité, limitée à votre dépôt et aux branches ou environnements autorisés. Aucune clé JSON durable dans Git.
  • Le plan est produit sur une branche de confiance, relu, puis appliqué tel quel après une approbation explicite. Un seul déploiement à la fois par environnement.
  • Un test du parcours nominal suit l’apply. Si quelque chose échoue, le pipeline s’arrête là.
  • La destruction est un job manuel et approuvé, suivi d’un inventaire des ressources restantes. Le bootstrap garde son propre cycle.

À montrer

Un petit changement suivi de son commit jusqu’au test final, et une erreur de validation volontaire arrêtée avant tout apply. Les plans sont des artefacts privés, conservés peu de temps.

Besoin d’un indice ?

Indice 1, une piste

Dessinez les jobs et demandez-vous quelle identité exécute chacun d’eux. Qu’est-ce qui prouve que le plan appliqué est bien celui qui a été relu ?

Indice 2, où chercher

La page du backend GCS et celle sur la fédération d’identité pour les pipelines.

Indice 3, un coup de pouce

Séparez quatre temps : une validation sans accès au cloud, un plan authentifié, une approbation, puis l’apply. Rattachez le plan au commit et à l’environnement. Si le code, les variables ou l’état changent, le plan doit être recalculé et relu à nouveau.

Avant de continuer

Montrez une exécution de la CI avec son test final. Une procédure manuelle documentée aide au diagnostic, mais elle ne remplace pas cette preuve d’automatisation.

05 Éprouver

Observer, tester, expliquer

Annoncez votre hypothèse avant chaque test. Un graphique doit répondre à une question précise : avoir un dashboard ne prouve pas, à lui seul, que le système est fiable.

Préparer les mesures

La latence et les erreurs de l’API, les connexions à la base, l’état des sauvegardes et la durée réellement observée d’une restauration.

Déployez avec Terraform un dashboard et deux alertes utiles. Pour chacune, précisez la métrique, le filtre, la fenêtre, le seuil, le destinataire et la première action de diagnostic. Vérifiez que la notification arrive bien, puis qu’elle se referme quand tout revient à la normale.

Des signaux à adapter
SignalOù le trouverDécision ou exemple de seuil
Disponibilité, latence p95, erreursCloud Run, ou une requête synthétique autoriséeExemple pour le labo : 3 échecs consécutifs d’une sonde. Adaptez la sonde à un ingress restreint.
Connexions, CPU, stockageCloud SQLExemple : des connexions au-dessus de 80 % de la limite choisie pendant 5 minutes. Regardez alors du côté du pool.
Dernière sauvegarde et restaurationÉtat des sauvegardes et compte rendu de testVérifiez que la sauvegarde a réussi et mesurez le RPO et le RTO réellement obtenus.

L’essai de charge du laboratoire

Faites des lectures synthétiques : 1 utilisateur pendant 2 minutes, puis 1, 3 et 5 utilisateurs pendant 1 minute chacun. Plafonnez avant le test le nombre d’instances de l’API et la taille du pool de connexions, que le kit lit dans POOL_MAX.

Avant de lancer quoi que ce soit, fixez la cible autorisée, le plafond de ressources, la durée, le volume et la règle d’arrêt. Arrêtez si le coût dérape ou si la dégradation persiste. Un exemple de seuil à faire valider : plus de 5 % d’erreurs pendant 30 secondes.

Une expérience pour vous entraîner

Créez un jeu de données fictif reconnaissable, sauvegardez-le, puis restaurez-le vers une instance séparée. Comparez le contenu et mesurez le temps de reprise. Ne touchez pas à la base de référence.

Écrivez la procédure de retour avant de commencer et ne changez qu’un paramètre à la fois. Si vous modifiez quelque chose à la main pour diagnostiquer, remettez ensuite Terraform en accord avec l’état voulu.

À montrer

Un relevé de concurrence, les connexions observées et un compte rendu de restauration : les horodatages, les données récupérées, la perte constatée et les limites de l’exercice.

Trame du compte rendu
  • L’hypothèse et le résultat attendu.
  • Le périmètre, la charge, l’heure de début et l’heure de fin.
  • Ce que vous avez observé avant, pendant et après, et l’impact sur le parcours utilisateur.
  • Les pistes de diagnostic, les vérifications faites et la cause retenue.
  • La correction, la preuve du retour au service et les limites de votre conclusion.
Une nouvelle contrainte

En cours de route, le formateur vous annoncera un événement supplémentaire. Vous devrez en expliquer l’impact et proposer une réponse. Selon le temps et le budget, cette réponse pourra mêler une expérience bornée et une évolution d’architecture argumentée.

Avant de continuer

Votre compte rendu sépare ce qui a été mesuré, ce qui reste une hypothèse et ce qui demanderait un test plus large. Chaque membre sait lire les courbes.

06 Présenter

Présenter ce que vous avez réalisé

Chaque groupe dispose de 20 minutes de présentation technique, démonstration comprise. Les trois membres prennent la parole. Montrez la solution telle qu’elle est, avec ses résultats et ses limites, et gardez des traces de secours au cas où la démonstration en direct échouerait.

SéquenceDuréeÀ montrer
Besoin et périmètre2 minLes hypothèses, les objectifs et le minimum réellement livré.
Architecture réalisée4 minLe schéma, les flux, la sécurité, vos choix et les alternatives écartées.
Terraform et pipeline4 minLes modules et leurs interfaces, l’état distant et la trace d’un déploiement par la CI.
Démonstration et résultats6 minLe chemin nominal, l’essai de charge, l’incident, le monitoring et la reprise. Prévoyez des captures au cas où la démonstration échouerait.
Coûts et limites3 minLes coûts estimés, les écarts avec la production, ce qui n’est pas fait et la suite.
Conclusion1 minLe bilan technique et ce que le groupe retient.

Ce que votre support doit référencer

  • Le dépôt, son README de déploiement et de destruction, le contrat de chaque module et une trace du pipeline.
  • Le schéma réellement déployé et trois décisions argumentées.
  • Les résultats de charge, le compte rendu d’incident, les alertes et le runbook.
  • Une estimation des principaux coûts, ce qui n’a pas été réalisé et l’écart avec une cible de production.

Après la présentation

À la date convenue avec le formateur, détruisez les ressources du laboratoire, vérifiez ce qui reste et signalez ce que vous gardez volontairement. Tenir le budget et nettoyer font partie du travail.

Avant la présentation

Répétez avec un chronomètre. Chaque membre doit pouvoir expliquer un flux, une permission, une panne et une limite sans se contenter de lire les diapositives.

Où en êtes-vous ?

Socle

Version complète

Ces cases sont enregistrées dans votre navigateur uniquement. Le formateur ne les voit pas.

Si vous êtes en avance

Choisissez une seule extension, une fois le parcours principal terminé et son coût validé. Ajouter des services ne remplace pas des preuves de qualité.

  • Tester une restauration à un instant précis, si la configuration choisie le permet.
  • Comparer disponibilité zonale et HA régionale, sans faire porter ce coût au laboratoire.
  • Restreindre davantage l’entrée de l’API et prouver que les accès prévus fonctionnent toujours.

Documentation Terraform

Ouvrez ces aides quand vous en avez besoin. Le rôle de chaque service est indiqué, mais leur assemblage, leurs paramètres et leurs permissions restent à concevoir.

Ressources Terraform utiles

Cette liste est un point de départ et votre architecture demandera d’autres ressources. Chaque lien ouvre la page du provider Google, avec ses arguments et ses exemples.

RessourceRôleConseil
google_compute_networkCréer le VPC de l’application.Mettez auto_create_subnetworks à false pour garder la main sur vos subnets.
google_compute_subnetworkDéfinir le subnet et sa plage d’adresses.La région et les plages doivent être cohérentes avec la sortie VPC de Cloud Run.
google_compute_global_addressRéserver une plage pour les services privés.Pour Private Service Access, utilisez purpose VPC_PEERING et address_type INTERNAL. Ce n’est pas une IP publique.
google_service_networking_connectionÉtablir le Private Service Access.Reliez le VPC à servicenetworking.googleapis.com avec la plage réservée, avant de créer la base.
google_sql_database_instanceCréer PostgreSQL, ses sauvegardes et sa configuration réseau.Vérifiez explicitement l’absence d’IPv4 publique. La protection contre la suppression n’est pas une sauvegarde.
google_cloud_run_v2_serviceDéployer l’API et sa sortie VPC.Connaître le nom de connexion d’une base privée ne suffit pas à la joindre.
google_secret_manager_secretCréer le conteneur d’un secret.Cette ressource ne contient pas la valeur. Évitez que le mot de passe apparaisse en clair dans Terraform, dans le state ou dans les logs.
Ressources communes et configuration du provider

google_project_service active les API nécessaires, et google_service_account crée une identité dédiée. Donnez à chaque identité les permissions dont elle a besoin, au bon niveau, plutôt qu’un rôle Owner ou Editor sur tout le projet.

Pour l’observabilité, il vous faudra un dashboard, une politique d’alerte et un canal de notification. Vérifiez que la notification arrive réellement.

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "8.6.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

Cette version est un exemple, vérifié lors de la préparation du support. Contrôlez la dernière version publiée avant le TP. Déclarez vos variables et commitez .terraform.lock.hcl. Si votre projet utilise déjà une autre version, n’en changez pas sans relire le plan et les notes de migration. En local, utilisez ADC ; en CI, une identité fédérée. Jamais de clé JSON dans le code.

Documentation du provider Google

Critères d’évaluation

L’évaluation porte sur ce que le groupe a réalisé, sur ses résultats et sur la façon dont il les explique. Il n’y a pas de barème chiffré.

CritèreCe qui doit être observable
Cadrage et architectureDes hypothèses explicites, des flux lisibles, des alternatives et des compromis documentés.
Modules et reproductibilitéDes responsabilités cohérentes, des interfaces claires, un état distant, des versions figées et un redéploiement documenté.
Pipeline TerraformUne validation automatique, un plan relu puis appliqué tel quel, et des identités CI limitées.
Réseau, IAM et secretsDes expositions justifiées, le moindre privilège, aucun secret dans Git et un state protégé.
Charge et résilienceUn protocole reproductible, des mesures avant et après, un incident analysé et un retour au service démontré.
ObservabilitéUn dashboard utile, des seuils justifiés, une notification vérifiée et une procédure de diagnostic.
Coût et périmètreDes ordres de grandeur, les limites du laboratoire, la destruction et le contrôle des ressources restantes.
Présentation et défense collectiveLe groupe tient ses 20 minutes et montre ce qui fonctionne vraiment, avec ses limites. Chaque membre explique les choix, y compris le code proposé par un LLM.