diff --git a/.dockerignore b/.dockerignore index 41e643e..7e5ef5c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,3 +24,8 @@ var/ site/ docs/ *.md + +# Secrets de production : montés par FrankenDeploy depuis le serveur +.env.local +.env.*.local +*.sql.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb3052..9c663dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ **Présentation des versions du framework Edu Framework** +## v2.9.0 - 21/10/2026 + +**new features** + +- Profiler : **flamegraph intégré** à l'onglet **Code**, sans fichier à télécharger. Trois vues s'ajoutent au tableau des fonctions : **Flamegraph** (appels regroupés, le plus coûteux à gauche, `#code/{token}/flame`), **Chronologique** (ordre des appels avec axe du temps, `#code/{token}/time`) et **Comparer** (flamegraph différentiel de deux requêtes profilées, en rouge ce qui est plus lent et en bleu ce qui est plus rapide, `#code/{token}/diff/{autre}`). Les couleurs indiquent l'origine du code (`app` / `framework` / `vendor`). On peut zoomer d'un clic, remonter par le chemin d'appels ou avec Échap, rechercher une fonction (avec le temps total trouvé), afficher une infobulle et le détail de la fonction, avec un lien vers ses appelants et ses appelés. Les appels de moins de 0,1 % sont regroupés dans « … (autres) » +- Barre de debug : **mini-flamegraph** dans le panneau du bloc **Code** ; un clic l'ouvre en grand dans le profiler +- Endpoint `GET /edu-code-profile` : nouveau `format=flame` avec les paramètres `order` (`heavy` / `time`), `compare` (token de référence), `min` (élagage) et `depth` (profondeur) ; nouvelles méthodes `CodeProfileAggregator::flameTree()` et `CodeProfileAggregator::diffTree()` +- Déploiement sur un serveur Debian / Ubuntu avec [FrankenDeploy](https://yoanbernabeu.github.io/frankendeploy/) : nouvelle commande `php bin/edu make:deploy` qui génère le fichier `frankendeploy.yaml` (options `--domain`, `--name`, `--database=mariadb|mysql|sqlite|none`, `--force`) avec les dossiers partagés entre les déploiements (`.env.local`, `var/logs`, `var/sessions`, `var/sqlite`, `public/upload`) +- `Dockerfile` multi-stage : `frankenphp_base` (socle commun), `frankenphp_prod` (image de production : HTTP sur le port 8080, utilisateur non-root `app`, `php.ini-production`, OPcache sans revalidation, sessions dans `var/sessions`, sans Excimer ni dépendances de dev, healthcheck) et `frankenphp_dev` (image de développement, ciblée explicitement par `compose.yaml`). Nouveau script `docker-entrypoint.sh` qui attend la base de données avant de démarrer FrankenPHP +- Variables d'environnement : nouvelle méthode `ConfigCore::loadEnv()` utilisée par l'application et par `bin/edu`. Les fichiers `.env` et `.env.local` sont optionnels (le `.env.local` est prioritaire sur le `.env`), les variables de l'environnement du processus (Docker) sont prioritaires et lues par `ConfigCore::getEnv()` / `existEnv()` même lorsque `variables_order` ne contient pas `E` (cas de `php.ini-production`). Si `DATABASE_URL` est définie (`mysql://user:pass@host:3306/db`), les variables `DB_*` manquantes en sont déduites ; une `DATABASE_URL` MySQL / MariaDB / PostgreSQL mal formée (Exemple: mot de passe contenant `#` non encodé) lève une `ErrorConfigException` explicite + +**bug Fixes** + +- `ConfigCore::getEnv()` ne lève plus d'avertissement pour une variable absente (retourne une chaîne vide) +- `bin/edu` démarre sans fichier `.env` +- Tests : `APP_ENV` hérité du shell ou du conteneur de développement n'influence plus les tests (`tests/bootstrap.php`) + +**documentation** + +- update [La barre de debug et le profiler](docs/boost/debug-bar.md) : nouvelles sections « Le flamegraph intégré » et « Comparer deux requêtes » (avec captures d'écran), mini-flamegraph de la barre de debug, paramètres `format=flame`, `order`, `compare`, `min` et `depth` de l'API +- new [Déployer son application](docs/deploy/index.md) : préparation du serveur, `make:deploy`, variables d'environnement, déploiement, import de la base, dossiers partagés, commandes du quotidien, image de production +- update [La commande bin/edu](docs/installation/command-edu.md) : commande `make:deploy` +- update [L'application dans Docker](docs/installation/docker.md) : lien vers l'image de production + ## v2.8.0 - 20/10/2026 **new features** diff --git a/Dockerfile b/Dockerfile index bef466d..1e4948d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,22 @@ # https://frankenphp.dev/docs/docker/ # # Edu-Framework exige PHP >= 8.4 (voir composer.json et public/index.php) -FROM dunglas/frankenphp:php8.4 +# +# Image multi-stage : +# frankenphp_base -> socle commun (extensions PHP, Composer) +# frankenphp_prod -> image de production (déploiement avec FrankenDeploy, +# voir frankendeploy.yaml et la doc "Déployer son application") +# frankenphp_dev -> image de développement (compose.yaml), stage par défaut +# +# docker build --target frankenphp_prod -t mon-app . => image de production +# docker compose build => image de développement -# Outils CLI : curl (tests d'API, comme pour le poste de travail local) +################### +# Socle commun +################### +FROM dunglas/frankenphp:php8.4 AS frankenphp_base + +# Outils CLI : curl (tests d'API et healthcheck de l'image de production) # et unzip (décompression propre des archives Composer) RUN apt-get update \ && apt-get install -y --no-install-recommends curl unzip \ @@ -32,6 +45,83 @@ RUN install-php-extensions \ intl \ opcache +# Composer (depuis l'image officielle) +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +ENV COMPOSER_ALLOW_SUPERUSER=1 + +################### +# Production +################### +# Contrat FrankenDeploy : stage nommé frankenphp_prod, HTTP sur le port 8080 +# (le HTTPS est assuré par le Caddy frontal du serveur), utilisateur non-root +# "app" (uid 1000) et script docker-entrypoint +FROM frankenphp_base AS frankenphp_prod + +ENV APP_ENV=prod +# HTTP simple sur le port 8080 (voir Caddyfile) +ENV SERVER_NAME=:8080 + +# Configuration PHP de production +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +# - OPcache : le code ne change pas sans redéploiement (pas de revalidation) +# - Sessions : stockées dans var/sessions, dossier partagé entre les releases +# (les utilisateurs restent connectés après un déploiement) +COPY < image sans profiler @@ -39,14 +129,8 @@ RUN install-php-extensions \ ARG EDU_PROFILER=1 RUN if [ "$EDU_PROFILER" = "1" ]; then install-php-extensions excimer; fi -# Composer (depuis l'image officielle) -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -WORKDIR /app - # Installation des dépendances PHP (couche mise en cache tant que # composer.json / composer.lock ne changent pas) -ENV COMPOSER_ALLOW_SUPERUSER=1 COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress diff --git a/compose.yaml b/compose.yaml index 455e549..07aa6a6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,8 @@ services: container_name: ${APP_NAME}-http-server build: context: . + # Stage de développement du Dockerfile (frankenphp_prod sert au déploiement) + target: frankenphp_dev args: # Profiler de code de la barre de debug (extension Excimer) : # 1 => installé (défaut, développement) | 0 => image sans profiler diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..6fc01d5 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Edu-Framework - point d'entrée de l'image de production (stage frankenphp_prod) +# +# Attend que la base de données accepte les connexions, puis démarre FrankenPHP. +# La base est décrite par DATABASE_URL (base gérée par FrankenDeploy) ou par les +# variables DB_* (DB_HOST_STATUS=true), passées par Docker ou le fichier .env.local. +set -e + +log() { + echo "[Edu-Framework] $1" +} + +# Hôte et port de la base de données, calculés par le framework lui-même +# (même lecture des variables que l'application : environnement, .env.local, .env) +db_target() { + # Code PHP entre guillemets simples : les $ ne doivent pas être interprétés par le shell + # shellcheck disable=SC2016 + php -r ' + require "/app/vendor/autoload.php"; + use Studoo\EduFramework\Core\ConfigCore; + new ConfigCore([]); + try { + ConfigCore::loadEnv("/app/"); + } catch (Throwable $exception) { + // Configuration invalide (Exemple: DATABASE_URL mal formée) : arrêt explicite + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); + } + if (ConfigCore::getEnv("DB_HOST_STATUS") !== "true" + || in_array(ConfigCore::getEnv("DB_TYPE"), ["mysql", "mariadb", "pgsql"], true) === false + || ConfigCore::getEnv("DB_HOST") === "") { + exit; + } + $port = ConfigCore::getEnv("DB_SOCKET"); + if ($port === "") { + $port = ConfigCore::getEnv("DB_TYPE") === "pgsql" ? "5432" : "3306"; + } + echo ConfigCore::getEnv("DB_HOST") . " " . $port; + ' +} + +wait_for_database() { + if ! target=$(db_target); then + log "Configuration invalide : vérifiez les variables d'environnement (frankendeploy env list prod)" + exit 1 + fi + if [ -z "$target" ]; then + return 0 + fi + + host=${target% *} + port=${target#* } + log "Attente de la base de données $host:$port..." + attempts=0 + # shellcheck disable=SC2016 + until php -r 'exit(@fsockopen($argv[1], (int) $argv[2], $e, $s, 2) ? 0 : 1);' "$host" "$port"; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 60 ]; then + log "Base de données injoignable" + exit 1 + fi + sleep 1 + done + log "Base de données prête" +} + +wait_for_database + +log "Démarrage de FrankenPHP" +exec docker-php-entrypoint "$@" diff --git a/docs/_summary.md b/docs/_summary.md index d4652f4..2832971 100644 --- a/docs/_summary.md +++ b/docs/_summary.md @@ -24,6 +24,8 @@ * [Route - Générer une URL par le nom](boost/route.md) * [Les erreurs HTTP](boost/http-error.md) * [La barre de debug et le profiler](boost/debug-bar.md) +* [Déployer](deploy/index.md) + * [Déployer son application](deploy/index.md) * [Migrer](migrate/major.md) * [Migrer de la version 2.3 à la version 2.8](migrate/migration-2_3-2_8.md) * [Migrer de la version 2.2 à la version 2.3](migrate/migration-2_2-2_3.md) diff --git a/docs/assets/screen-profiler-code-bar-flame.png b/docs/assets/screen-profiler-code-bar-flame.png new file mode 100644 index 0000000..5bf5a56 Binary files /dev/null and b/docs/assets/screen-profiler-code-bar-flame.png differ diff --git a/docs/assets/screen-profiler-flamegraph-diff.png b/docs/assets/screen-profiler-flamegraph-diff.png new file mode 100644 index 0000000..7f9c306 Binary files /dev/null and b/docs/assets/screen-profiler-flamegraph-diff.png differ diff --git a/docs/assets/screen-profiler-flamegraph.png b/docs/assets/screen-profiler-flamegraph.png new file mode 100644 index 0000000..5fe0efc Binary files /dev/null and b/docs/assets/screen-profiler-flamegraph.png differ diff --git a/docs/boost/debug-bar.md b/docs/boost/debug-bar.md index 4a83860..c93fa8d 100644 --- a/docs/boost/debug-bar.md +++ b/docs/boost/debug-bar.md @@ -275,11 +275,75 @@ En mode `trigger`, un clic sur le bloc **Code** pose le cookie `edu_profile=1` e
Après un clic sur une fonction : ses appelants (Appelée par) et ses appelés (Appelle)
-- Le bouton **Télécharger le flamegraph (Speedscope)** exporte les piles d'appels, à ouvrir sur [speedscope.app](https://www.speedscope.app){:target="_blank"} pour obtenir un *flamegraph* (voir ci-dessous). +- Les onglets **Flamegraph**, **Chronologique** et **Comparer** de l'onglet **Code** affichent le *flamegraph* de la requête directement dans le profiler (voir ci-dessous). +- Le panneau du bloc **Code** de la barre de debug affiche un **mini-flamegraph** de la requête : un clic l'ouvre en grand dans le profiler. + +
+![Mini-flamegraph du bloc Code](../assets/screen-profiler-code-bar-flame.png){width="360"} +
Le mini-flamegraph du panneau du bloc Code : les appels de plus de 1 % du temps, colorés selon leur origine
+
+ +- Le bouton **Télécharger le flamegraph (Speedscope)** exporte les piles d'appels, à ouvrir sur [speedscope.app](https://www.speedscope.app){:target="_blank"} pour une analyse plus poussée (voir plus bas). + +### Le flamegraph intégré + +Le tableau de l'onglet **Code** additionne les temps par fonction, mais il ne montre pas **l'enchaînement des appels**. Le *flamegraph* le montre : on voit d'un coup d'œil quel chemin dans le code consomme le temps. + +Le profiler dessine le flamegraph **sans quitter la page et sans télécharger de fichier**. Choisissez une vue avec les onglets placés au-dessus du tableau : + +| Onglet | URL | Ce qu'il montre | +|-------------------|---------------------------------------|-----------------| +| **Tableau** | `/_debug/profiler#code/{token}` | Le tableau des fonctions (temps propre et temps inclusif) | +| **Flamegraph** | `/_debug/profiler#code/{token}/flame` | Les appels identiques **regroupés** et triés, le plus coûteux à gauche (la vue *Left Heavy* de Speedscope) | +| **Chronologique** | `/_debug/profiler#code/{token}/time` | Les appels dans **l'ordre où ils ont eu lieu** : l'axe horizontal est le temps écoulé depuis le début de la requête (la vue *Time Order* de Speedscope) | +| **Comparer** | `/_debug/profiler#code/{token}/diff/{autre}` | Le flamegraph **différentiel** de deux requêtes profilées (voir ci-dessous) | + +
+![Flamegraph intégré au profiler](../assets/screen-profiler-flamegraph.png) +
Le flamegraph d'une requête vers /medecin/new : sous Twig\Environment::compileSource, la compilation du template occupe la plus grande partie de la largeur
+
+ +**Lire le graphe.** Chaque rectangle est une fonction : + +- **la largeur** est le temps passé dans la fonction **et dans tout ce qu'elle appelle** (le temps inclusif) ; +- **de haut en bas**, on descend dans la pile d'appels : la première ligne est la requête entière, puis le point d'entrée (`public/index.php`), puis les fonctions qu'il appelle, et ainsi de suite ; +- **la couleur** indique l'origine du code : **jaune** pour votre code (`app/`), **bleu** pour le framework, **violet** pour les dépendances (`vendor/`), **gris** pour le reste. La nuance varie d'une fonction à l'autre pour distinguer deux rectangles voisins. + +Cherchez **les rectangles jaunes larges** : c'est le temps consommé par votre propre code. + +**Naviguer.** + +- Le **survol** d'un rectangle affiche le nom de la fonction, son fichier, son temps inclusif et son temps propre. +- Un **clic** zoome sur la fonction : elle prend toute la largeur, ses parents restent affichés en atténué au-dessus. Le **chemin d'appels** au-dessus du graphe permet de remonter d'un clic, la touche ++esc++ remonte d'un niveau et le bouton **Réinitialiser le zoom** revient à la requête entière. +- Sous le graphe, le **détail** de la fonction zoomée donne son fichier, son origine, ses temps et le lien **Voir les appelants et les appelés**, qui affiche les mêmes tableaux que l'onglet **Tableau**. +- Le champ **Rechercher une fonction** surligne les fonctions dont le nom contient le texte saisi et indique le temps total qu'elles représentent (Exemple : `Twig` ou `Repository`). + +!!! info "Appels regroupés dans « … (autres) »" + + Pour que le graphe reste lisible et léger, les appels qui représentent moins de **0,1 %** du temps de la requête sont regroupés dans un rectangle **… (autres)** (vue Flamegraph) ou ne sont pas dessinés (vue Chronologique). Le fichier Speedscope, lui, contient toutes les piles d'appels. + +### Comparer deux requêtes + +L'onglet **Comparer** répond à la question : « *Pourquoi cette page est-elle plus lente que tout à l'heure ?* ». + +1. Profilez **deux fois** la page : par exemple avant et après une modification, ou au premier chargement puis au suivant. +2. Ouvrez l'onglet **Code** de l'une des deux requêtes, puis l'onglet **Comparer**. +3. Choisissez la requête de **référence** dans la liste **Comparer avec**. Les requêtes profilées sur **la même URL** sont proposées en premier, et la précédente est choisie par défaut. + +La **forme** du graphe est celle de la requête affichée. La **couleur** indique l'écart avec la référence pour le même chemin d'appels : **rouge** si la requête est plus lente, **bleu** si elle est plus rapide, **gris** si le temps est identique. Plus la couleur est foncée, plus l'écart est grand. Le survol et le détail affichent le temps de la référence et l'écart en ms. + +
+![Flamegraph différentiel](../assets/screen-profiler-flamegraph-diff.png) +
Premier chargement de /medecin/new comparé au suivant : la compilation du template Twig (en rouge) n'existe pas dans la référence
+
+ +!!! tip "Un chemin qui a disparu" + + Un appel présent seulement dans la référence n'est pas dessiné, puisque la forme est celle de la requête affichée : son temps apparaît dans l'écart de la fonction qui l'appelait. Pour le voir, inversez la comparaison en ouvrant l'onglet **Comparer** de l'autre requête. ### Lire le flamegraph avec Speedscope -Le tableau de l'onglet **Code** additionne les temps par fonction, mais il ne montre pas **l'enchaînement des appels**. Le *flamegraph* le montre : chaque relevé de la pile d'appels est dessiné, et l'on voit d'un coup d'œil quel chemin dans le code consomme le temps. +Le flamegraph intégré suffit dans la plupart des cas. Pour aller plus loin (toutes les piles d'appels sans regroupement, zoom à la molette, vue *Sandwich*), exportez le profil vers Speedscope. [Speedscope](https://github.com/jlfwong/speedscope){:target="_blank"} est un visualiseur de flamegraph open source qui fonctionne dans le navigateur. @@ -336,9 +400,13 @@ GET /edu-code-profile?token=42 |-----------|---------------------------------------------------------------------|--------| | `token` | Identifiant du log de la requête HTTP (`#42` accepté) | requis | | `full` | `1` : ajoute toutes les fonctions (`functions`) et le graphe d'appels (`edges`) | — | -| `format` | `speedscope` : retourne le fichier Speedscope de la requête | — | +| `format` | `speedscope` : retourne le fichier Speedscope de la requête ; `flame` : retourne l'arbre du flamegraph intégré (clé `tree`) | — | +| `order` | Avec `format=flame` : `heavy` (appels regroupés) ou `time` (ordre chronologique) | `heavy` | +| `compare` | Avec `format=flame` : token d'une autre requête profilée, pour le flamegraph différentiel (clés `compare`, `compare_summary`, et `base_ms` / `delta_ms` dans chaque nœud) | — | +| `min` | Avec `format=flame` : part minimale du temps total pour garder un appel (de `0` à `0.1`) | `0.001` | +| `depth` | Avec `format=flame` : profondeur maximale de l'arbre (`0` : illimitée) | `0` | -La réponse contient `status` (`ready`, ou `pending` tant que le profil n'est pas enregistré), `summary` (`driver`, `period_ms`, `samples`, `wall_ms`, `peak_mb`, `functions_count`) et `top` (fonctions les plus coûteuses). Les profils sont stockés, compressés, dans la table `serv_code_profiles` de la base des logs. +La réponse contient `status` (`ready`, ou `pending` tant que le profil n'est pas enregistré), `summary` (`driver`, `period_ms`, `samples`, `wall_ms`, `peak_mb`, `functions_count`) et `top` (fonctions les plus coûteuses). Avec `format=flame`, chaque nœud de `tree` contient `name`, `origin`, `file`, `line`, `start` et `value` (position et largeur en échantillons), `self`, `ms`, `self_ms`, `pct` et `children`. Les profils sont stockés, compressés, dans la table `serv_code_profiles` de la base des logs. ## Le stockage des logs diff --git a/docs/changelog.md b/docs/changelog.md index 7440652..9f842f5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,30 @@ **Présentation des versions du framework Edu Framework** +## v2.9.0 - 21/10/2026 + +**new features** + +- Profiler : **flamegraph intégré** à l'onglet **Code**, sans fichier à télécharger. Trois vues s'ajoutent au tableau des fonctions : **Flamegraph** (appels regroupés, le plus coûteux à gauche, `#code/{token}/flame`), **Chronologique** (ordre des appels avec axe du temps, `#code/{token}/time`) et **Comparer** (flamegraph différentiel de deux requêtes profilées, en rouge ce qui est plus lent et en bleu ce qui est plus rapide, `#code/{token}/diff/{autre}`). Les couleurs indiquent l'origine du code (`app` / `framework` / `vendor`). On peut zoomer d'un clic, remonter par le chemin d'appels ou avec Échap, rechercher une fonction (avec le temps total trouvé), afficher une infobulle et le détail de la fonction, avec un lien vers ses appelants et ses appelés. Les appels de moins de 0,1 % sont regroupés dans « … (autres) » +- Barre de debug : **mini-flamegraph** dans le panneau du bloc **Code** ; un clic l'ouvre en grand dans le profiler +- Endpoint `GET /edu-code-profile` : nouveau `format=flame` avec les paramètres `order` (`heavy` / `time`), `compare` (token de référence), `min` (élagage) et `depth` (profondeur) ; nouvelles méthodes `CodeProfileAggregator::flameTree()` et `CodeProfileAggregator::diffTree()` +- Déploiement sur un serveur Debian / Ubuntu avec [FrankenDeploy](https://yoanbernabeu.github.io/frankendeploy/) : nouvelle commande `php bin/edu make:deploy` qui génère le fichier `frankendeploy.yaml` (options `--domain`, `--name`, `--database=mariadb|mysql|sqlite|none`, `--force`) avec les dossiers partagés entre les déploiements (`.env.local`, `var/logs`, `var/sessions`, `var/sqlite`, `public/upload`) +- `Dockerfile` multi-stage : `frankenphp_base` (socle commun), `frankenphp_prod` (image de production : HTTP sur le port 8080, utilisateur non-root `app`, `php.ini-production`, OPcache sans revalidation, sessions dans `var/sessions`, sans Excimer ni dépendances de dev, healthcheck) et `frankenphp_dev` (image de développement, ciblée explicitement par `compose.yaml`). Nouveau script `docker-entrypoint.sh` qui attend la base de données avant de démarrer FrankenPHP +- Variables d'environnement : nouvelle méthode `ConfigCore::loadEnv()` utilisée par l'application et par `bin/edu`. Les fichiers `.env` et `.env.local` sont optionnels (le `.env.local` est prioritaire sur le `.env`), les variables de l'environnement du processus (Docker) sont prioritaires et lues par `ConfigCore::getEnv()` / `existEnv()` même lorsque `variables_order` ne contient pas `E` (cas de `php.ini-production`). Si `DATABASE_URL` est définie (`mysql://user:pass@host:3306/db`), les variables `DB_*` manquantes en sont déduites ; une `DATABASE_URL` MySQL / MariaDB / PostgreSQL mal formée (Exemple: mot de passe contenant `#` non encodé) lève une `ErrorConfigException` explicite + +**bug Fixes** + +- `ConfigCore::getEnv()` ne lève plus d'avertissement pour une variable absente (retourne une chaîne vide) +- `bin/edu` démarre sans fichier `.env` +- Tests : `APP_ENV` hérité du shell ou du conteneur de développement n'influence plus les tests (`tests/bootstrap.php`) + +**documentation** + +- update [La barre de debug et le profiler](boost/debug-bar.md) : nouvelles sections « Le flamegraph intégré » et « Comparer deux requêtes » (avec captures d'écran), mini-flamegraph de la barre de debug, paramètres `format=flame`, `order`, `compare`, `min` et `depth` de l'API +- new [Déployer son application](deploy/index.md) : préparation du serveur, `make:deploy`, variables d'environnement, déploiement, import de la base, dossiers partagés, commandes du quotidien, image de production +- update [La commande bin/edu](installation/command-edu.md) : commande `make:deploy` +- update [L'application dans Docker](installation/docker.md) : lien vers l'image de production + ## v2.8.0 - 20/10/2026 **new features** diff --git a/docs/deploy/index.md b/docs/deploy/index.md new file mode 100644 index 0000000..c53c455 --- /dev/null +++ b/docs/deploy/index.md @@ -0,0 +1,355 @@ +# Déployer son application + +Votre application fonctionne sur votre poste : il est temps de la mettre en ligne, accessible à tous en HTTPS avec votre nom de domaine. + +Edu-Framework s'appuie pour cela sur [FrankenDeploy](https://yoanbernabeu.github.io/frankendeploy/){:target="_blank"}, un outil en ligne de commande qui déploie une application [FrankenPHP](https://frankenphp.dev){:target="_blank"} sur un serveur (VPS) **Debian** ou **Ubuntu** : il installe Docker sur le serveur, obtient le certificat HTTPS (Let's Encrypt), crée la base de données et met à jour l'application sans coupure de service. + +!!! warning "Outil expérimental" + + FrankenDeploy est jeune et conçu à l'origine pour Symfony : Edu-Framework fournit le `Dockerfile` et la configuration qui le rendent compatible. Consultez la [documentation de FrankenDeploy](https://yoanbernabeu.github.io/frankendeploy/){:target="_blank"} pour le détail de chaque commande. + +## Comment ça marche ? + +```mermaid +flowchart LR + P["Votre poste
frankendeploy deploy"] -->|"SSH"| S + subgraph S["Serveur Debian (VPS)"] + C["Caddy
HTTPS :443"] -->|":8080"| A["Conteneur de l'application
FrankenPHP + PHP 8.4"] + A -->|"DATABASE_URL"| DB[("MariaDB")] + A -.-> SH["Dossiers partagés
.env.local, var/logs, var/sessions,
var/sqlite, public/upload"] + end + N["Navigateur"] -->|"https://mon-app.fr"| C +``` + +1. Sur votre poste, FrankenDeploy construit l'**image Docker de production** à partir du `Dockerfile` du projet (stage `frankenphp_prod`) +2. L'image est envoyée sur le serveur et démarrée **à côté** de l'ancienne version +3. Quand la nouvelle version répond correctement (*healthcheck*), le trafic bascule dessus et l'ancienne est arrêtée +4. En cas d'échec, l'ancienne version continue de servir : vos utilisateurs ne voient rien + +## Avant de démarrer + +- [ ] Un **serveur** (VPS) sous Debian 12+ ou Ubuntu 22.04+, avec 2 Go de RAM +- [ ] Un accès **SSH par clé** à ce serveur, avec l'utilisateur `root` ou un utilisateur `sudo` sans mot de passe +- [ ] Un **nom de domaine** dont l'enregistrement DNS `A` pointe vers l'adresse IP du serveur +- [ ] **Docker Desktop** installé sur votre poste +- [ ] Le **CLI FrankenDeploy** installé sur votre poste : + +=== "macOS" + + ```bash + brew install yoanbernabeu/tap/frankendeploy + ``` + +=== "Linux" + + ```bash + curl -fsSL https://raw.githubusercontent.com/yoanbernabeu/frankendeploy/main/scripts/install.sh | sh + ``` + +Vérifiez l'installation : + +```bash +frankendeploy --version +``` + +!!! warning "Projet créé avant la version 2.9.0" + + L'image de production repose sur trois fichiers à la racine du projet : le `Dockerfile` (avec le stage `frankenphp_prod`), `docker-entrypoint.sh` et `.dockerignore`. Si votre projet a été créé avant la version 2.9.0, récupérez-les depuis le framework installé par Composer : + + ```bash + cp vendor/studoo/edu-framework/Dockerfile vendor/studoo/edu-framework/docker-entrypoint.sh vendor/studoo/edu-framework/.dockerignore . + ``` + + Vérifiez aussi que le fichier `composer.lock` est versionné (il ne doit pas figurer dans le `.gitignore`) : l'image de production installe exactement les versions qu'il contient. + +## Étape 1 : décrire le déploiement + +La commande `make:deploy` génère le fichier `frankendeploy.yaml` à la racine du projet : + +```bash +php bin/edu make:deploy --domain=mon-app.fr +``` + +| Option | Description | Défaut | +|----------------|----------------------------------------------------------------|---------------------| +| `--domain` | nom de domaine de l'application | aucun | +| `--name` | nom de l'application sur le serveur (minuscules, tirets), unique sur le serveur | nom du dossier (obligatoire dans le conteneur Docker) | +| `--database` | base de données : `mariadb`, `mysql`, `sqlite` ou `none` | `mariadb` | +| `--force` | écrase un fichier `frankendeploy.yaml` existant | | + +!!! info "Dans le conteneur Docker de développement" + + Dans le [flux 100 % Docker](../installation/docker.md), le projet est monté dans le dossier `/app` du conteneur : précisez le nom de l'application, sinon tous les projets s'appelleraient `app` sur le serveur. + + ```bash + docker compose exec frankenphp php bin/edu make:deploy --name=mon-app --domain=mon-app.fr + ``` + +Le fichier généré ressemble à ceci : + +```yaml +name: mon-app + +php: + version: "8.4" + extensions: [pdo_sqlite, pdo_mysql, mbstring, zip, intl, opcache] + +# Base de données gérée par FrankenDeploy (conteneur sur le serveur) +database: + driver: mariadb + version: "11" + managed: true + +deploy: + domain: mon-app.fr + healthcheck_path: / + keep_releases: 5 + shared_files: + - .env.local + shared_dirs: + - var/logs + - var/sessions + - var/sqlite + - public/upload +``` + +!!! info "Le healthcheck" + + Avant de basculer le trafic, FrankenDeploy vérifie que la page `healthcheck_path` (par défaut `/`) répond avec un code HTTP 200. Docker surveille aussi l'application avec la ligne `HEALTHCHECK` du `Dockerfile`, utilisée notamment par `frankendeploy env set ... --reload`. + + Si votre page d'accueil nécessite d'être connecté, choisissez une autre route publique et modifiez **les deux** : `healthcheck_path` dans `frankendeploy.yaml` et l'URL de la ligne `HEALTHCHECK` du `Dockerfile` (`http://localhost:8080/...`). + +Versionnez ce fichier avec votre projet : + +```bash +git add frankendeploy.yaml +git commit -m "Configuration du déploiement" +``` + +## Étape 2 : préparer le serveur + +Ces commandes ne sont à lancer **qu'une seule fois** par serveur. Enregistrez d'abord le serveur sous le nom `prod` : + +```bash +frankendeploy server add prod deploy@mon-serveur.fr +``` + +Puis préparez-le (l'adresse e-mail sert au certificat Let's Encrypt) : + +```bash +frankendeploy server setup prod --email moi@exemple.fr +``` + +Cette commande installe Docker, configure le pare-feu (SSH, HTTP, HTTPS), protège le serveur contre les attaques SSH (Fail2ban) et démarre le proxy Caddy qui gère le HTTPS. + +## Étape 3 : configurer les variables d'environnement + +En production, le fichier `.env` de votre poste **n'est pas envoyé** sur le serveur (il est exclu de l'image Docker). La configuration est stockée sur le serveur, dans un fichier `.env.local` propre à l'application : + +```bash +frankendeploy env set prod APP_ENV=prod +``` + +FrankenDeploy exige aussi la variable `APP_SECRET` (héritée de Symfony) : lors du premier déploiement, il propose de la générer pour vous. + +Pour un mot de passe ou une clé d'API, utilisez la saisie masquée : + +```bash +frankendeploy env set prod MAILER_DSN --from-stdin +``` + +`env set` n'accepte **qu'une variable par commande**. Pour en définir plusieurs, répétez la commande ou envoyez un fichier : + +```bash +frankendeploy env push prod .env.prod +``` + +Consultez les variables définies : + +```bash +frankendeploy env list prod +``` + +!!! danger "Le caractère # dans une valeur" + + FrankenDeploy écrit les valeurs sans guillemets dans le `.env.local` : à la lecture, tout ce qui suit un `#` est ignoré (`S3cr#t` devient `S3cr`). Évitez le `#` dans les mots de passe. Dans une URL (`DATABASE_URL`, `MAILER_DSN`), encodez les caractères spéciaux de l'utilisateur et du mot de passe : `#` => `%23`, `@` => `%40`, `/` => `%2F`, `?` => `%3F`. + + Une `DATABASE_URL` mal formée arrête l'application avec le message « La variable DATABASE_URL est invalide ». + +!!! info "La base de données est configurée automatiquement" + + Avec une base gérée (`managed: true`), FrankenDeploy crée le conteneur MariaDB et transmet à l'application la variable `DATABASE_URL` : + + ``` + mysql://mon-app:motdepasse@mon-app-db:3306/mon_app + ``` + + Edu-Framework en déduit automatiquement les variables `DB_HOST_STATUS`, `DB_TYPE`, `DB_HOST`, `DB_SOCKET`, `DB_USER`, `DB_PASSWORD` et `DB_NAME`. Une variable `DB_*` définie dans le `.env.local` reste prioritaire. + + Cette `DATABASE_URL` n'est pas stockée dans le `.env.local` : `frankendeploy env list prod` ne l'affiche pas. Pour la consulter : + + ```bash + frankendeploy exec prod printenv DATABASE_URL + ``` + +!!! info "Avec une base SQLite (`--database=sqlite`)" + + Aucune `DATABASE_URL` n'est transmise : définissez les variables une par une. + + ```bash + frankendeploy env set prod DB_HOST_STATUS=true + ``` + + ```bash + frankendeploy env set prod DB_TYPE=sqlite + ``` + + ```bash + frankendeploy env set prod DB_NAME=app_db + ``` + + Le fichier `var/sqlite/app_db.sqlite` est conservé entre les déploiements (dossier partagé). Au premier déploiement, il est vide. + +### Ordre de lecture des variables + +| Priorité | Source | Usage | +|----------|---------------------------------------------------|--------------------------------------| +| 1 | variables d'environnement (Docker, serveur web) | `APP_ENV`, `DATABASE_URL` en production | +| 2 | fichier `.env.local` | secrets de production, jamais versionné | +| 3 | fichier `.env` | configuration de développement | + +## Étape 4 : vérifier puis déployer + +Vérifiez que tout est prêt (connexion SSH, DNS, Docker) : + +```bash +frankendeploy doctor prod +``` + +Lancez le déploiement : + +```bash +frankendeploy deploy prod +``` + +!!! tip "Mac avec puce Apple (M1, M2...)" + + Votre Mac (ARM) et le serveur (souvent x86_64) n'ont pas la même architecture. Si la construction de l'image est trop lente, construisez-la directement sur le serveur : + + ```bash + frankendeploy deploy prod --remote-build + ``` + +Votre application est en ligne sur `https://mon-app.fr` :tada: + +Pour chaque nouvelle version, il suffit de relancer `frankendeploy deploy prod`. + +## Importer sa base de données + +Au premier déploiement, la base MariaDB est vide. Pour importer le contenu de votre base de développement, exportez-la depuis votre poste (phpMyAdmin ou dbgate) dans un fichier `app_db.sql`, puis envoyez-le sur le serveur : + +```bash +scp app_db.sql deploy@mon-serveur.fr: +``` + +Connectez-vous en SSH au serveur et importez le fichier dans le conteneur `mon-app-db`. Les identifiants sont lus dans les variables du conteneur : aucun mot de passe à saisir. + +=== "MariaDB" + + ```bash + docker exec -i mon-app-db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' < app_db.sql + ``` + +=== "MySQL" + + ```bash + docker exec -i mon-app-db sh -c 'exec mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' < app_db.sql + ``` + +!!! info "Les identifiants de la base" + + FrankenDeploy les enregistre sur le serveur dans le fichier `/opt/frankendeploy/apps/mon-app/shared/.db_credentials` (lisible avec `sudo cat`). + +!!! warning "Sauvegardes" + + FrankenDeploy ne sauvegarde automatiquement la base qu'avant les migrations Doctrine, qu'Edu-Framework n'utilise pas. Pensez à planifier vous-même des sauvegardes (`mariadb-dump`) sur le serveur. + +## Les fichiers conservés entre deux déploiements + +Le code de l'application est **dans l'image Docker** : à chaque déploiement, le conteneur est remplacé. Seuls les fichiers et dossiers partagés (`shared_files` et `shared_dirs`) sont conservés : + +| Chemin | Contenu | +|-------------------|-------------------------------------------------------------| +| `.env.local` | variables d'environnement de production (lecture seule) | +| `var/logs` | base SQLite des logs du serveur | +| `var/sessions` | sessions PHP : les utilisateurs restent connectés | +| `var/sqlite` | bases SQLite de l'application | +| `public/upload` | fichiers envoyés par les utilisateurs | + +!!! danger "Tout ce qui est écrit ailleurs est perdu" + + Si votre application enregistre des fichiers dans un autre dossier, ajoutez-le à `shared_dirs` **avant** le déploiement. Attention : définir `shared_dirs` remplace toute la liste, conservez les dossiers ci-dessus. + +## Au quotidien + +| Besoin | Commande | +|-------------------------------------------|---------------------------------------------| +| Déployer une nouvelle version | `frankendeploy deploy prod` | +| Consulter les logs de l'application | `frankendeploy logs prod` | +| Revenir à la version précédente | `frankendeploy rollback prod` | +| Ouvrir un terminal dans le conteneur | `frankendeploy shell prod` | +| Lancer une commande `bin/edu` sur le serveur | `frankendeploy exec prod php bin/edu cache:clear` | +| Modifier une variable et redémarrer | `frankendeploy env set prod CLE=valeur --reload` | + +## L'image de production + +Le `Dockerfile` du projet contient trois étapes (*stages*) : + +| Stage | Usage | +|-------------------|--------------------------------------------------------------------| +| `frankenphp_base` | socle commun : PHP 8.4, extensions, Composer | +| `frankenphp_prod` | production : utilisé par FrankenDeploy | +| `frankenphp_dev` | développement : utilisé par `compose.yaml` (voir [L'application dans Docker](../installation/docker.md)) | + +L'image de production diffère de l'image de développement : + +- l'application est servie en **HTTP sur le port 8080** : le HTTPS est assuré par le Caddy du serveur +- elle s'exécute avec un utilisateur **non-root** (`app`) +- PHP utilise la configuration de production (`php.ini-production`, OPcache sans revalidation des fichiers) +- les dépendances de développement et le profiler Excimer ne sont pas installés +- la barre de debug est désactivée (`APP_ENV=prod`) +- le script `docker-entrypoint.sh` attend que la base de données soit disponible avant de démarrer + +Vous pouvez construire et tester l'image de production sur votre poste : + +```bash +docker build --target frankenphp_prod -t mon-app . +``` + +```bash +docker run --rm -p 8080:8080 mon-app +``` + +!!! warning "Ne supprimez pas et ne régénérez pas les fichiers Docker du projet" + + Si le `Dockerfile`, `docker-entrypoint.sh` ou `.dockerignore` sont absents, FrankenDeploy en génère de nouveaux, prévus pour Symfony : la construction de l'image échouera (commande `bin/console` introuvable). + + Pour la même raison, ne lancez pas `frankendeploy build`, `frankendeploy init --force` ni `frankendeploy dev` dans un projet Edu-Framework : ces commandes écrasent `Dockerfile`, `docker-entrypoint.sh`, `.dockerignore` et `compose.yaml`. Pour restaurer les fichiers : + + ```bash + git checkout -- Dockerfile docker-entrypoint.sh .dockerignore compose.yaml + ``` + +## Si vous rencontrez une erreur + +### Le healthcheck échoue + +FrankenDeploy affiche les 50 dernières lignes de logs du conteneur. Vérifiez que la route `healthcheck_path` répond sans authentification, et que les variables d'environnement sont définies (`frankendeploy env list prod`). + +### Base de données injoignable + +Le conteneur attend la base de données 60 secondes au démarrage. Vérifiez les variables `DB_*` avec `frankendeploy env list prod` et la `DATABASE_URL` d'une base gérée avec `frankendeploy exec prod printenv DATABASE_URL`. + +### Le certificat HTTPS n'est pas délivré + +L'enregistrement DNS du domaine doit pointer vers le serveur et les ports 80 et 443 doivent être ouverts. `frankendeploy doctor prod` vérifie ces deux points. diff --git a/docs/features.md b/docs/features.md index 3045080..d381ffa 100644 --- a/docs/features.md +++ b/docs/features.md @@ -177,7 +177,7 @@ En mode développement (`APP_ENV=dev`), Edu Framework fournit des outils inspir | **Barre de debug** | Lire d'un coup d'œil le statut, la route, le temps et la mémoire d'une requête | Web Debug Toolbar | | **Profiler et historique des requêtes** | Retrouver et filtrer les requêtes passées, avec leurs erreurs | Profiler (`/_profiler`) | | **Requêtes SQL par requête HTTP** | Repérer une requête SQL lente ou répétée (problème « N+1 ») | Panneau Doctrine du profiler | -| **Profiler de code** (Excimer, flamegraph) | Mesurer le temps passé dans chaque fonction, lire un flamegraph | Blackfire, panneau *Performance* | +| **Profiler de code** (Excimer, flamegraph intégré) | Mesurer le temps passé dans chaque fonction, lire et comparer des flamegraphs | Blackfire, panneau *Performance* | Pour aller plus loin : [La barre de debug et le profiler](boost/debug-bar.md). diff --git a/docs/installation/command-edu.md b/docs/installation/command-edu.md index 9ad7ebc..5ae2f2b 100644 --- a/docs/installation/command-edu.md +++ b/docs/installation/command-edu.md @@ -39,6 +39,7 @@ Voici les commandes disponibles par version : | make:command | génération d'un commande line | v2.0 | | make:entity | génération d'une entité et son repository | v2.5.0 | | cache:clear | suppression du cache de l'application | v2.4.0 | +| make:deploy | génération de la configuration de déploiement | v2.9.0 | !!! info "Pour aller plus loin" @@ -83,6 +84,18 @@ Si le dossier `var/cache` n'existe pas, un message vous indique qu'il n'y a aucu Les templates modifiés sont automatiquement recompilés (option `auto_reload` de Twig). La commande `cache:clear` reste utile pour repartir d'un cache vierge, par exemple après un déploiement. +### La commande make:deploy + +La commande `make:deploy` génère le fichier `frankendeploy.yaml` qui décrit le déploiement de l'application sur un serveur Debian ou Ubuntu avec [FrankenDeploy](https://yoanbernabeu.github.io/frankendeploy/){:target="_blank"} (à partir de la version v2.9.0) : + +```bash +php bin/edu make:deploy --domain=mon-app.fr +``` + +!!! info "Pour aller plus loin" + + Vous pouvez consulter la page [Déployer son application](../deploy/index.md) pour le détail des options et des étapes du déploiement. + ### La commande make:entity La commande `make:entity` génère les **deux fichiers du modèle** pour une table de votre base de données (à partir de la version v2.5.0) : diff --git a/docs/installation/docker.md b/docs/installation/docker.md index 9a30abd..79308aa 100644 --- a/docs/installation/docker.md +++ b/docs/installation/docker.md @@ -9,6 +9,10 @@ Vous pouvez aller plus loin : exécuter **l'application elle-même** dans Docker - **Flux classique** (inchangé) : les services dans Docker, l'application sur votre poste via `php bin/edu start` — voir [Start/Stop l'application](start-application.md) - **Flux 100 % Docker** (cette page) : l'application **et** les services dans Docker +!!! tip "Mettre son application en ligne" + + Le `Dockerfile` contient aussi une image de production (stage `frankenphp_prod`), utilisée pour déployer l'application sur un serveur : voir [Déployer son application](../deploy/index.md). + ## Architecture du stack Le fichier `compose.yaml` à la racine du projet démarre cinq services : diff --git a/eduframe.yml b/eduframe.yml index 5440a32..12bb624 100644 --- a/eduframe.yml +++ b/eduframe.yml @@ -9,10 +9,10 @@ name: 'EduFramework' # La version du framework -version: 'v2.8.0' +version: 'v2.9.0' # La date de livraison de la version -date_version: '2026-10-20' +date_version: '2026-10-21' # La version minimale de PHP requise php_version: '8.4' diff --git a/src/EduFramework/Commands/CreateDeployCommand.php b/src/EduFramework/Commands/CreateDeployCommand.php new file mode 100644 index 0000000..9f9a46c --- /dev/null +++ b/src/EduFramework/Commands/CreateDeployCommand.php @@ -0,0 +1,279 @@ + version de l'image + */ + private const DATABASES = [ + 'mariadb' => '11', + 'mysql' => '8.4', + 'sqlite' => null, + 'none' => null, + ]; + + /** + * Fichiers nécessaires à la construction de l'image de production + */ + private const REQUIRED_FILES = ['Dockerfile', 'docker-entrypoint.sh', 'Caddyfile', '.dockerignore']; + + /** + * Déclare les options de la commande : --name, --domain, --database et --force + * @return void + */ + protected function configure(): void + { + $this + ->addOption('name', null, InputOption::VALUE_REQUIRED, "Nom de l'application sur le serveur") + ->addOption('domain', null, InputOption::VALUE_REQUIRED, "Nom de domaine de l'application") + ->addOption( + 'database', + null, + InputOption::VALUE_REQUIRED, + 'Base de données : ' . implode(', ', array_keys(self::DATABASES)), + 'mariadb' + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Écrase le fichier frankendeploy.yaml existant'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + $console = new SymfonyStyle($input, $output); + $basePath = (string) ConfigCore::getConfig('base_path'); + + $name = $this->resolveName($input->getOption('name'), $basePath); + if ($name === '') { + $console->error("Nom d'application invalide : utilisez des minuscules, des chiffres et des tirets. " + . "Dans le conteneur Docker (dossier /app), précisez le nom : --name=mon-app"); + return Command::FAILURE; + } + + $database = (string) $input->getOption('database'); + if (array_key_exists($database, self::DATABASES) === false) { + $console->error('Base de données inconnue : ' . $database + . ' (valeurs possibles : ' . implode(', ', array_keys(self::DATABASES)) . ')'); + return Command::FAILURE; + } + + $domain = $input->getOption('domain'); + if ($domain !== null && preg_match('/^(?!-)[a-z0-9.-]+\.[a-z]{2,}$/', strtolower($domain)) !== 1) { + $console->error('Nom de domaine invalide : ' . $domain); + return Command::FAILURE; + } + + $configPath = $basePath . self::CONFIG_FILE; + if (file_exists($configPath) === true && $input->getOption('force') === false) { + $console->error('Le fichier ' . self::CONFIG_FILE . ' existe déjà (utilisez --force pour l\'écraser)'); + return Command::FAILURE; + } + + file_put_contents($configPath, $this->generateConfig($name, $domain, $database)); + $console->success('Fichier ' . self::CONFIG_FILE . ' généré (application : ' . $name . ')'); + + $this->checkDockerFiles($console, $basePath); + + $console->section('Étapes suivantes'); + $console->listing([ + 'Préparer le serveur : frankendeploy server add prod deploy@mon-serveur.fr', + 'Puis : frankendeploy server setup prod --email moi@exemple.fr', + 'Vérifier : frankendeploy doctor prod', + 'Déployer : frankendeploy deploy prod', + ]); + + return Command::SUCCESS; + } + + /** + * Nom de l'application : option --name, sinon nom du dossier du projet + * Dans le conteneur Docker de développement, le dossier du projet est /app : + * le nom n'est pas déduit, sinon tous les projets s'appelleraient "app" sur le serveur + * @param string|null $option Valeur de l'option --name + * @param string $basePath Dossier du projet + * @return string Nom formaté, vide si invalide ou impossible à déduire + */ + private function resolveName(?string $option, string $basePath): string + { + if ($option !== null) { + return $this->formatName($option); + } + + $folder = basename((string) realpath($basePath)); + return $folder === 'app' ? '' : $this->formatName($folder); + } + + /** + * Vérifie la présence des fichiers Docker du framework nécessaires à l'image de production + * Sans eux, FrankenDeploy génère des fichiers prévus pour Symfony (bin/console), + * incompatibles avec Edu-Framework + * @param SymfonyStyle $console Sortie de la commande + * @param string $basePath Dossier du projet + * @return void + */ + private function checkDockerFiles(SymfonyStyle $console, string $basePath): void + { + $missing = array_filter( + self::REQUIRED_FILES, + static fn (string $file): bool => file_exists($basePath . $file) === false + ); + $hasProdStage = $missing === [] + && str_contains((string) file_get_contents($basePath . 'Dockerfile'), 'AS frankenphp_prod'); + if ($hasProdStage === true) { + return; + } + + $console->warning( + ($missing !== [] + ? 'Fichiers manquants pour construire l\'image : ' . implode(', ', $missing) . '. ' + : 'Le Dockerfile ne contient pas de stage "frankenphp_prod", requis par FrankenDeploy. ') + . 'Copiez les fichiers ' . implode(', ', self::REQUIRED_FILES) + . ' depuis vendor/studoo/edu-framework/ (voir la doc "Déployer son application").' + ); + } + + /** + * Formate le nom de l'application au format FrankenDeploy (minuscules, chiffres, tirets) + * @param string $name Nom brut + * @return string + */ + private function formatName(string $name): string + { + $name = strtolower($name); + $name = (string) preg_replace('/[^a-z0-9]+/', '-', $name); + return trim($name, '-'); + } + + /** + * Génère le contenu du fichier frankendeploy.yaml + * @param string $name Nom de l'application + * @param string|null $domain Nom de domaine + * @param string $database Base de données + * @return string + */ + private function generateConfig(string $name, ?string $domain, string $database): string + { + $domainLine = $domain === null + ? " # domain: mon-app.fr # à renseigner : sans domaine, l'app n'est pas exposée" + : ' domain: ' . strtolower($domain); + + $databaseBlock = match ($database) { + 'mariadb', 'mysql' => <<databaseVersion($database)}" + managed: true + + YAML, + 'sqlite' => << "\n", + }; + + return <<load(); + // Gestion des variables d'environnement (environnement, .env.local, .env) + ConfigCore::loadEnv(ConfigCore::getConfig('base_path')); - // Gestion de la couche Model et de la connexion à la base de données - if (ConfigCore::getEnv('DB_HOST_STATUS') === 'true') { - (new DatabaseService()); - } + // Gestion de la couche Model et de la connexion à la base de données + if (ConfigCore::getEnv('DB_HOST_STATUS') === 'true') { + (new DatabaseService()); } $this->add(new \Studoo\EduFramework\Commands\DefaultCommand()); @@ -49,6 +45,7 @@ public function __construct() $this->add(new \Studoo\EduFramework\Commands\CreateApiCommand()); $this->add(new \Studoo\EduFramework\Commands\CreateEntityCommand()); $this->add(new \Studoo\EduFramework\Commands\ClearCacheCommand()); + $this->add(new \Studoo\EduFramework\Commands\CreateDeployCommand()); if (file_exists(ConfigCore::getConfig('command_config_path') . 'commands.yaml') === true) { $commandList = Yaml::parseFile(ConfigCore::getConfig('command_config_path') . 'commands.yaml'); diff --git a/src/EduFramework/Core/ConfigCore.php b/src/EduFramework/Core/ConfigCore.php index b78571e..88106da 100644 --- a/src/EduFramework/Core/ConfigCore.php +++ b/src/EduFramework/Core/ConfigCore.php @@ -11,6 +11,11 @@ namespace Studoo\EduFramework\Core; +use Dotenv\Dotenv; +use Dotenv\Repository\Adapter\EnvConstAdapter; +use Dotenv\Repository\Adapter\PutenvAdapter; +use Dotenv\Repository\RepositoryBuilder; +use Dotenv\Repository\RepositoryInterface; use Studoo\EduFramework\Core\Controller\Request; use Studoo\EduFramework\Core\Exception\ErrorConfigException; use Symfony\Component\Yaml\Exception\ParseException; @@ -31,6 +36,18 @@ class ConfigCore private static Request $resquest; + /** + * Schémas de DATABASE_URL reconnus => valeur de DB_TYPE + */ + private const DATABASE_URL_TYPES = [ + 'mysql' => 'mysql', + 'mysql2' => 'mysql', + 'mariadb' => 'mariadb', + 'pgsql' => 'pgsql', + 'postgres' => 'pgsql', + 'postgresql' => 'pgsql', + ]; + /** * ConfigCore constructor. * Les paramètres d'identité du framework (name, version, date_version, php_version) @@ -131,6 +148,103 @@ public static function setConfig(string $key, mixed $value): void } } + /** + * Charge les variables d'environnement du projet + * Ordre de priorité (la première valeur trouvée est conservée) : + * 1. les variables d'environnement du processus (Docker, serveur web) + * 2. le fichier .env.local (secrets de production, jamais versionné) + * 3. le fichier .env + * Les fichiers sont optionnels : en production (image Docker), le .env est absent + * et la configuration provient de l'environnement et du .env.local + * Si DATABASE_URL est définie, les variables DB_* manquantes en sont déduites + * @param string $basePath Dossier contenant les fichiers .env + * @return void + */ + public static function loadEnv(string $basePath): void + { + // Fichiers lus dans l'ordre : une valeur du .env.local remplace celle du .env, + // mais jamais une variable déjà présente dans l'environnement (mode immutable), + // y compris une variable visible uniquement par getenv() (php -S, mod_php) + $repository = RepositoryBuilder::createWithDefaultAdapters() + ->addReader(PutenvAdapter::class) + ->immutable() + ->make(); + Dotenv::create($repository, $basePath, ['.env', '.env.local'], false)->safeLoad(); + self::loadDatabaseUrl(); + } + + /** + * Déduit les variables DB_* de la variable DATABASE_URL + * Format : mysql://user:password@host:3306/db_name?serverVersion=11 + * (format utilisé par FrankenDeploy et Symfony pour une base gérée) + * Une variable DB_* déjà définie n'est jamais écrasée + * Une DATABASE_URL d'un autre type (Exemple: sqlite:///...) est ignorée + * @return void + * @throws ErrorConfigException Si la DATABASE_URL MySQL / MariaDB / PostgreSQL est mal formée + */ + private static function loadDatabaseUrl(): void + { + $databaseUrl = self::readEnv('DATABASE_URL') ?? ''; + $type = self::DATABASE_URL_TYPES[strstr($databaseUrl, '://', true) ?: ''] ?? null; + if ($type === null) { + return; + } + + // Exemple : un mot de passe contenant # est tronqué à la lecture du fichier .env.local + $url = parse_url($databaseUrl); + if ($url === false || isset($url['host']) === false) { + throw new ErrorConfigException( + "La variable DATABASE_URL est invalide. Format attendu : " + . "mysql://utilisateur:motdepasse@hote:3306/base. Les caractères spéciaux " + . "de l'utilisateur et du mot de passe doivent être encodés (# => %23, @ => %40, / => %2F)" + ); + } + + $values = [ + 'DB_HOST_STATUS' => 'true', + 'DB_TYPE' => $type, + 'DB_HOST' => $url['host'] ?? '', + 'DB_SOCKET' => (string) ($url['port'] ?? ''), + 'DB_USER' => $url['user'] ?? '', + 'DB_PASSWORD' => $url['pass'] ?? '', + 'DB_NAME' => ltrim($url['path'] ?? '', '/'), + ]; + + // Dépôt "immutable" : une variable déjà définie n'est pas écrasée + $repository = self::envRepository(); + foreach (array_filter($values, 'strlen') as $key => $value) { + $repository->set($key, rawurldecode($value)); + } + } + + /** + * Dépôt des variables d'environnement (librairie phpdotenv) : + * - lecture : $_ENV (fichiers .env) puis l'environnement du processus + * (variables passées par Docker, absentes de $_ENV lorsque variables_order + * ne contient pas "E", comme avec php.ini-production) + * - écriture : $_ENV, sans écraser une variable déjà définie + * @return RepositoryInterface + */ + private static function envRepository(): RepositoryInterface + { + return RepositoryBuilder::createWithNoAdapters() + ->addReader(EnvConstAdapter::class) + ->addReader(PutenvAdapter::class) + ->addWriter(EnvConstAdapter::class) + ->immutable() + ->make(); + } + + /** + * Lit une variable d'environnement brute (sans filtrage) + * @param string $key Clé de la variable + * @return string|null + */ + private static function readEnv(string $key): ?string + { + return self::envRepository()->get($key); + } + /** * Permet de récupérer la configuration du fichier .env à la racine du projet * @param string $key Clé de la configuration @@ -138,7 +252,7 @@ public static function setConfig(string $key, mixed $value): void */ public static function getEnv(string $key): mixed { - return strip_tags($_ENV[$key]); + return strip_tags(self::readEnv($key) ?? ''); } /** @@ -148,7 +262,7 @@ public static function getEnv(string $key): mixed */ public static function existEnv(string $key): bool { - return isset($_ENV[$key]); + return self::readEnv($key) !== null; } /** diff --git a/src/EduFramework/Core/Controller/Debug/DebugCodeProfileController.php b/src/EduFramework/Core/Controller/Debug/DebugCodeProfileController.php index d9d16ad..c473c97 100644 --- a/src/EduFramework/Core/Controller/Debug/DebugCodeProfileController.php +++ b/src/EduFramework/Core/Controller/Debug/DebugCodeProfileController.php @@ -35,6 +35,11 @@ class DebugCodeProfileController implements ControllerInterface * - full=1 : ajoute le détail de toutes les fonctions et le graphe d'appels * - format=speedscope : retourne les piles d'appels au format Speedscope * (fichier à ouvrir sur https://www.speedscope.app pour le flamegraph) + * - format=flame : retourne l'arbre du flamegraph intégré au profiler (clé tree), avec : + * - order=heavy (défaut, appels regroupés) ou time (ordre chronologique) ; + * - compare=token : flamegraph différentiel par rapport à une autre requête ; + * - min : part minimale du temps pour garder un appel (défaut 0.001, soit 0,1 %) ; + * - depth : profondeur maximale de l'arbre (défaut : illimitée) * * Réponse : * - token : identifiant du log de la requête HTTP (null si invalide) @@ -50,6 +55,7 @@ class DebugCodeProfileController implements ControllerInterface public function execute(Request $request): string|null { $speedscope = ((string) $request->get('format') === 'speedscope'); + $flame = ((string) $request->get('format') === 'flame'); // L'en-tête n'est envoyé que si la sortie n'a pas déjà commencé // (Exemple : exécution dans un contexte de test) @@ -64,7 +70,7 @@ public function execute(Request $request): string|null $tokenId = (int) $token; $full = ((string) $request->get('full') === '1'); - $profile = LogsService::getCodeProfile($tokenId, ($full === true || $speedscope === true)); + $profile = LogsService::getCodeProfile($tokenId, ($full === true || $speedscope === true || $flame === true)); if ($profile === null) { return json_encode(['token' => $tokenId, 'status' => 'pending', 'summary' => null, 'top' => []]); @@ -73,18 +79,14 @@ public function execute(Request $request): string|null if ($speedscope === true) { return $this->speedscope($tokenId, $profile); } + if ($flame === true) { + return (string) json_encode($this->flame($tokenId, $profile, $request)); + } $response = [ 'token' => $tokenId, 'status' => 'ready', - 'summary' => [ - 'driver' => $profile['driver'], - 'period_ms' => $profile['period_ms'], - 'samples' => $profile['samples'], - 'wall_ms' => $profile['wall_ms'], - 'peak_mb' => $profile['peak_mb'], - 'functions_count' => $profile['functions_count'], - ], + 'summary' => $this->summary($profile), 'top' => $profile['top'], ]; @@ -95,6 +97,70 @@ public function execute(Request $request): string|null return json_encode($response); } + /** + * Résumé d'un profil : driver, période, échantillons, durée, mémoire et nombre de fonctions + * + * @param array $profile Profil (LogsService::getCodeProfile) + * @return array + */ + private function summary(array $profile): array + { + return [ + 'driver' => $profile['driver'], + 'period_ms' => $profile['period_ms'], + 'samples' => $profile['samples'], + 'wall_ms' => $profile['wall_ms'], + 'peak_mb' => $profile['peak_mb'], + 'functions_count' => $profile['functions_count'], + ]; + } + + /** + * Arbre du flamegraph intégré au profiler (format=flame) : + * Left Heavy, chronologique, ou différentiel avec compare=token + * + * @param int $tokenId Identifiant du log de la requête HTTP + * @param array $profile Profil avec ses données (LogsService::getCodeProfile) + * @param Request $request Requête HTTP (order, compare, min, depth) + * @return array token, status, order, summary, tree (et compare, compare_summary pour un diff) + */ + private function flame(int $tokenId, array $profile, Request $request): array + { + $order = ((string) $request->get('order') === 'time' ? 'time' : 'heavy'); + $min = (is_numeric($request->get('min')) === true + ? min(0.1, max(0.0, (float) $request->get('min'))) + : CodeProfileAggregator::FLAME_MIN_RATIO); + $depth = (is_numeric($request->get('depth')) === true ? min(500, max(0, (int) $request->get('depth'))) : 0); + $compare = ltrim((string) $request->get('compare'), '#'); + + if ($compare === '') { + $tree = CodeProfileAggregator::flameTree($profile['data'], $order, $min, $depth); + $tree['name'] = 'Requête #' . $tokenId; + + return ['token' => $tokenId, 'status' => 'ready', 'order' => $order, 'summary' => $this->summary($profile), 'tree' => $tree]; + } + + // Flamegraph différentiel : toujours en Left Heavy (les chemins d'appels sont comparés) + $compareId = (preg_match('/^\d{1,18}$/', $compare) === 1 ? (int) $compare : null); + $base = ($compareId === null ? null : LogsService::getCodeProfile($compareId, true)); + if ($base === null) { + return ['token' => $tokenId, 'compare' => $compareId, 'status' => 'pending', 'order' => 'heavy', 'summary' => null, 'tree' => null]; + } + + $tree = CodeProfileAggregator::diffTree($profile['data'], $base['data'], $min, $depth); + $tree['name'] = 'Requête #' . $tokenId . ' / #' . $compareId; + + return [ + 'token' => $tokenId, + 'compare' => $compareId, + 'status' => 'ready', + 'order' => 'heavy', + 'summary' => $this->summary($profile), + 'compare_summary' => $this->summary($base), + 'tree' => $tree, + ]; + } + /** * Retourne les piles d'appels du profil au format Speedscope (fichier téléchargé) * diff --git a/src/EduFramework/Core/Controller/Template/profiler.html.twig b/src/EduFramework/Core/Controller/Template/profiler.html.twig index 0315f43..5749767 100644 --- a/src/EduFramework/Core/Controller/Template/profiler.html.twig +++ b/src/EduFramework/Core/Controller/Template/profiler.html.twig @@ -274,6 +274,42 @@ .code-graph-cols a { cursor: pointer; } .profiled-badge { display: inline-block; margin-left: 6px; padding: 1px 5px; border-radius: 2px; font-size: 11px; background: #fff1c2; color: #7a5b00; text-decoration: none; } @media (max-width: 800px) { .code-graph-cols { grid-template-columns: 1fr; } } + + /* Onglet Code : flamegraph intégré */ + #codeModes { margin-bottom: 16px; } + .flame-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 12px; font-size: 14px; } + .flame-toolbar input[type=search] { padding: 6px 8px; border: 1px solid #ccc; border-radius: 3px; min-width: 240px; font-size: 13px; } + .flame-toolbar select { padding: 5px; margin-left: 4px; font-size: 13px; max-width: 420px; } + .flame-match { font-size: 13px; color: #555; } + .flame-crumbs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; font-size: 12px; color: #999; } + .flame-crumbs button { font-family: monospace; font-size: 12px; color: #0b62a3; text-decoration: underline; background: none; border: 0; padding: 0; cursor: pointer; } + .flame-crumbs b { font-family: monospace; color: #222; } + .flame-axis { position: relative; height: 18px; font-size: 11px; color: #888; border: 1px solid #ddd; border-bottom: 0; background: #fafafa; } + .flame-axis span { position: absolute; top: 2px; padding-left: 3px; border-left: 1px solid #ccc; white-space: nowrap; } + .flame-chart { position: relative; overflow: hidden; background: #fff; border: 1px solid #ddd; } + .flame-node { + position: absolute; height: 18px; margin: 0; padding: 0 4px; box-sizing: border-box; + border: 0; border-right: 1px solid #fff; border-bottom: 1px solid #fff; border-radius: 0; + font: 12px/17px monospace; color: #222; text-align: left; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; + } + .flame-node:hover { filter: brightness(.92); } + .flame-node:focus-visible { outline: 2px solid #222; outline-offset: -2px; z-index: 1; } + .flame-node.ancestor { opacity: .55; } + .flame-node.dim { opacity: .25; } + .flame-node.match { box-shadow: inset 0 0 0 2px #b0413e; } + .flame-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 10px 0 8px; font-size: 12px; color: #555; } + .flame-legend i { display: inline-block; width: 12px; height: 12px; margin-right: 5px; border-radius: 2px; vertical-align: -2px; } + #flameEmpty { padding: 30px 0; text-align: center; color: #999; background: #fff; border: 1px solid #ddd; margin: 0; } + #flameDetail { margin-top: 6px; } + #flameDetail .req-label { width: 180px; } + .flame-tooltip { + position: fixed; z-index: 10; max-width: 460px; padding: 8px 10px; pointer-events: none; + background: #222; color: #ddd; font-size: 12px; line-height: 1.5; border-radius: 3px; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); + } + .flame-tooltip b { display: block; color: #fff; font-family: monospace; word-break: break-all; } + .flame-tooltip .slower { color: #ff9b94; } + .flame-tooltip .faster { color: #9fc6ff; } @@ -427,6 +463,33 @@ Télécharger le flamegraph (Speedscope) Ouvrir speedscope.app +
+ + + + +
+ + +
@@ -443,6 +506,7 @@ +