Skip to content

Compose ​

Objectifs ​

  • Estimer son travail
  • Créer un Makefile
  • Utiliser Traefik
  • Connecter les services d'un Docker Compose
  • Appliquer les twelve-factor app

Rendu ​

  • Rapport individuel en Markdown sur Cyberlearn avant le prochain cours
    • Nom du fichier: lab03-compose-{nom}.md
    • Délai: 1 semaine

Tâches ​

Estimer son travail ​

  • Estimez le temps total nécessaire pour réaliser ce laboratoire
    • Découpez le travail en tâches pour faciliter l'estimation
  • A la fin du rapport, comparez le temps estimé avec le temps réellement passé:
    TâcheTemps estiméTemps réelCommentaire
    ...30m45m...
    ............
    Total2h1h30...

Git ​

  • Mettez tout votre travail sur une branche feature/03-compose et faites une merge request (MR) sur main en m'ajoutant comme reviewer
  • Séparez votre travail en commits cohérents
  • Utilisez des messages de commit clairs et concis

Créer un Makefile ​

  • Créez un Makefile à la racine pour automatiser les tâches suivantes:
    • make install pour installer les dépendances (backend et frontend)
    • make dev-backend pour démarrer le backend en mode développement
    • make dev-frontend pour démarrer le frontend en mode développement
    • make dev-database pour démarrer uniquement la database
  • Indiquez votre démarche dans le rapport

Utiliser Traefik ​

Pour éviter des probèmes de CORS avec le frontend, nous allons utiliser Traefik comme reverse proxy afin de servir le frontend et le backend sur le même domaine.

  • Ajoutez Traefik au docker-compose.yml afin de rediriger les requêtes comme suit:

    • Commençant par / vers le frontend
    • Commençant par /api vers le backend
  • Exemple de Docker Compose

  • Utilisez la rule PathPrefix pour rediriger les requêtes vers le bon service

  • Pour le backend, utilisez le StripPrefix afin de supprimer le préfixe /api avant de rediriger la requête

    • Une fois le middleware créé, il faut l'ajouter au service. Voir l'exemple
  • De plus, il faut configurer le root_path de FastAPI pour qu'il corresponde au préfixe /api

    • Afin de pouvoir changer la valeur du root_path grâce à une variable d'environnement, modifiez le fichier main.py comme suit:

      python
      from typing import Union
      from os import getenv // [!code focus]
      
      from fastapi import FastAPI
      
      app = FastAPI(root_path=getenv("ROOT_PATH")) // [!code focus]
      
      @app.get("/")
      def read_root():
          return {"Hello": "World"}
      
      @app.get("/items/{item_id}")
      def read_item(item_id: int, q: Union[str, None] = None):
          return {"item_id": item_id, "q": q}
  • Indiquez votre démarche dans le rapport

Solution compose.yml
yml
services:
  reverse-proxy:
    image: traefik:v2.10
    command:
      - "--providers.docker=true"
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
  frontend:
    labels:
      - "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
  backend:
    labels:
      - "traefik.http.routers.backend.rule=PathPrefix(`/api`)"
      - "traefik.http.routers.backend.middlewares=backend-stripprefix"
      - "traefik.http.middlewares.backend-stripprefix.stripprefix.prefixes=/api"

Connecter le backend à la database ​

  • On va utiliser SQLAlchemy pour connecter le backend à la database en suivant la documentation de FastAPI

    • Installez le package sqlalchemy et psycopg2 dans le backend poetry add sqlalchemy psycopg2
    • Créez/modifiez les fichiers suivants dans /backend/backend/ afin d'avoir un service CRUD :
python
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker

# Remplacez les valeurs par les valeurs de votre database
DATABASE_URL = "postgresql://user:password@postgresserver/db"

engine = create_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()
python
from sqlalchemy import Column, Double, Integer, String

from .database import Base


class Product(Base):
    __tablename__ = "products"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String, index=True, nullable=False)
    description = Column(String, index=True)
    price = Column(Double, index=True, nullable=False)
python
from pydantic import BaseModel


class ProductBase(BaseModel):
    name: str
    description: str | None = None
    price: float


class ProductCreate(ProductBase):
    pass


class Product(ProductBase):
    id: int

    class ConfigDict:
        from_attributes = True
python
from os import getenv

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session

from . import models, schemas
from .database import SessionLocal, engine

models.Base.metadata.create_all(bind=engine)

app = FastAPI(root_path=getenv("ROOT_PATH"))


@app.get("/")
def read_root():
    return {"Hello": "World"}


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
    db_product = models.Product(**product.model_dump())
    db.add(db_product)
    db.commit()
    db.refresh(db_product)
    return db_product


@app.get("/products/", response_model=list[schemas.Product])
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    return db.query(models.Product).offset(skip).limit(limit).all()


@app.get("/products/{product_id}", response_model=schemas.Product)
def read_product(product_id: int, db: Session = Depends(get_db)):
    db_product = (
        db.query(models.Product).filter(models.Product.id == product_id).first()
    )
    if db_product is None:
        raise HTTPException(status_code=404, detail="Product not found")
    return db_product


@app.delete("/products/{product_id}", response_model=schemas.Product)
def delete_product(product_id: int, db: Session = Depends(get_db)):
    db_product = read_product(product_id, db)
    db.delete(db_product)
    db.commit()
    return db_product
  • Démarrez le backend poetry run uvicorn backend.main:app --reload et testez les endpoints sur http://localhost:8000/docs
  • Vérifiez que le Docker Compose fonctionne toujours docker compose up --build et corrigez au besoin
    • Indiquez vos corrections dans le rapport
  • Modifiez le backend pour qu'il suive les twelve-factors app et configurez-le correctement dans le Docker Compose
    • Utilisez les mêmes variables d'environnement que la database ainsi que leur valeur par défaut
    • Extraire le mot de passe de la database dans un .env pour s'assurer qu'il soit le même dans les deux services
    • Ajoutez une vraie dépendance à la database dans le backend en utilisant un healthcheck, voici un exemple
    • Changez le mot de passe de la database et vérifiez que le backend fonctionne toujours (il faudra peut-être supprimer le volume de la database pour la recréer)
    • Pour lancer le backend en utilisant le .env, on va utiliser python-dotenv
      • poetry add --group dev python-dotenv
      • poetry run dotenv -f ../.env run uvicorn backend.main:app --reload
    • Indiquez vos modifications (autres que celles indiquées) dans le rapport
Solution /backend/backend/database.py
python
from os import getenv

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker

postgres_user = getenv("POSTGRES_USER", "postgres")
postgres_password = getenv("POSTGRES_PASSWORD", "postgres")
postgres_host = getenv("POSTGRES_HOST", "localhost")
postgres_db = getenv("POSTGRES_DB", postgres_user)
DATABASE_URL = (
    f"postgresql://{postgres_user}:{postgres_password}@{postgres_host}/{postgres_db}"
)

engine = create_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()
Solution /backend/Dockerfile

Il faut ajouter des librairies supplémentaires pour que le backend fonctionne avec PostgreSQL:

dockerfile
RUN apk add --no-cache \
  musl-dev \
  postgresql-dev

Ajoutez un frontend ​

  • Dans le dossier /frontend/, ajoutez/modifiez les fichiers suivants afin de configurer les environnements et le proxy :
txt
VITE_BACKEND_URL=/api
ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_BACKEND_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
ts
import { fileURLToPath, URL } from "node:url";

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  server: {
    proxy: {
      "/api": {
        target: "http://127.0.0.1:8000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});
  • On va utiliser Milligram comme framework CSS :

    • npm install milligram

    • Modifier le fichier frontend/src/main.ts comme suit :

      ts
      import "milligram/dist/milligram.min.css"; 
      
      import { createApp } from "vue";
      import App from "./App.vue";
      
      createApp(App).mount("#app");
  • Supprimez tous les fichiers sous /frontend/src/assets/ et /frontend/src/components/

  • Sous /frontend/src/, créez/modifiez les fichiers suivants :

vue
<script setup lang="ts">
import ProductManager from "./components/ProductManager.vue";
</script>

<template>
  <div class="container">
    <ProductManager />
  </div>
</template>
vue
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue";

interface CreateProduct {
  name: string;
  description?: string;
  price: number;
}

interface Product {
  id: number;
  name: string;
  description?: string;
  price: number;
}

const products = ref<Product[]>([]);
const product = reactive<CreateProduct>({
  name: "",
  price: 0,
});

onMounted(() => {
  updateTable();
});

function updateTable() {
  fetch(`${import.meta.env.VITE_BACKEND_URL}/products/`)
    .then((response) => response.json())
    .then((data) => {
      console.log(data);
      products.value = data;
    });
}

function createProduct(product: CreateProduct) {
  fetch(`${import.meta.env.VITE_BACKEND_URL}/products/`, {
    method: "POST",
    body: JSON.stringify(product),
    headers: {
      "Content-Type": "application/json",
    },
  }).then(() => {
    product.name = "";
    product.description = undefined;
    product.price = 0;
    updateTable();
  });
}

function deleteProduct(id: number) {
  fetch(`${import.meta.env.VITE_BACKEND_URL}/products/${id}`, {
    method: "DELETE",
  }).then(() => {
    updateTable();
  });
}
</script>

<template>
  <div class="row">
    <div class="column">
      <table>
        <thead>
          <tr>
            <th>#</th>
            <th>Name</th>
            <th>Description</th>
            <th>Price</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="product in products" :key="product.id">
            <td>{{ product.id }}</td>
            <td>{{ product.name }}</td>
            <td>{{ product.description }}</td>
            <td>{{ product.price }}</td>
            <td>
              <button
                class="button button-outline"
                @click="deleteProduct(product.id)"
              >
                Delete
              </button>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    <div class="column">
      <form>
        <fieldset>
          <label for="name">Name</label>
          <input v-model="product.name" type="text" id="name" />
          <label for="description">Description</label>
          <textarea v-model="product.description" id="description" />
          <label for="price">Price</label>
          <input v-model="product.price" type="number" id="price" />
          <input
            class="button-primary"
            type="submit"
            value="Create"
            @click.prevent="createProduct(product)"
          />
        </fieldset>
      </form>
    </div>
  </div>
</template>
  • Démarrez le frontend npm run dev et testez l'application (avec le backend et la database démarrés)
  • Vérifiez que le Docker Compose fonctionne

Docker Registry ​

  • Poussez les images Docker sur le GitLab Registry
    • Documentation
    • Les noms des images sont préfixés par l'adresse du registry (défaut au Docker Hub)
      • Exemple: registry.gitlab.com/username/project/image:tag
    • Doit fonctionner avec docker compose push