From a0f4fb19a3ebfbdd9303485ef3fe2755df64645c Mon Sep 17 00:00:00 2001 From: bfoujols Date: Tue, 20 Oct 2026 23:57:19 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(deploy):=20d=C3=A9ploiement=20sur=20De?= =?UTF-8?q?bian/Ubuntu=20avec=20FrankenDeploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile multi-stage : frankenphp_base, frankenphp_prod (port 8080, utilisateur app, php.ini-production, healthcheck) et frankenphp_dev (ciblé par compose.yaml) - docker-entrypoint.sh : attend la base de données avant FrankenPHP - ConfigCore::loadEnv() : .env et .env.local optionnels, lecture de l'environnement du processus, déduction des DB_* depuis DATABASE_URL - commande make:deploy : génère frankendeploy.yaml - doc « Déployer son application », changelog v2.9.0 Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 5 + CHANGELOG.md | 11 + Dockerfile | 99 +++++- compose.yaml | 2 + docker-entrypoint.sh | 58 ++++ docs/_summary.md | 2 + docs/changelog.md | 11 + docs/deploy/index.md | 281 ++++++++++++++++++ docs/installation/command-edu.md | 13 + docs/installation/docker.md | 4 + .../Commands/CreateDeployCommand.php | 230 ++++++++++++++ .../Commands/Extends/AppCommand.php | 15 +- src/EduFramework/Core/ConfigCore.php | 87 +++++- src/EduFramework/Core/LoadCouchCore.php | 6 +- tests/Command/CreateDeployCommandTest.php | 103 +++++++ tests/Core/LoadEnvTest.php | 147 +++++++++ 16 files changed, 1051 insertions(+), 23 deletions(-) create mode 100755 docker-entrypoint.sh create mode 100644 docs/deploy/index.md create mode 100644 src/EduFramework/Commands/CreateDeployCommand.php create mode 100644 tests/Command/CreateDeployCommandTest.php create mode 100644 tests/Core/LoadEnvTest.php 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 a18edef..ecb3933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,21 @@ - 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 + +**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` **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 diff --git a/Dockerfile b/Dockerfile index bef466d..ffff916 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,82 @@ 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 +128,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..370f70f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,58 @@ +#!/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() { + php -r ' + require "/app/vendor/autoload.php"; + use Studoo\EduFramework\Core\ConfigCore; + new ConfigCore([]); + ConfigCore::loadEnv("/app/"); + 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; + ' 2>/dev/null || true +} + +wait_for_database() { + target=$(db_target) + if [ -z "$target" ]; then + return 0 + fi + + host=${target% *} + port=${target#* } + log "Attente de la base de données $host:$port..." + attempts=0 + 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/changelog.md b/docs/changelog.md index 780aea9..9f40aac 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,10 +9,21 @@ - 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 + +**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` **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 diff --git a/docs/deploy/index.md b/docs/deploy/index.md new file mode 100644 index 0000000..dd9ac01 --- /dev/null +++ b/docs/deploy/index.md @@ -0,0 +1,281 @@ +# 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 +``` + +## É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) | nom du dossier | +| `--database` | base de données : `mariadb`, `mysql`, `sqlite` ou `none` | `mariadb` | +| `--force` | écrase un fichier `frankendeploy.yaml` existant | | + +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. Si votre page d'accueil nécessite d'être connecté, indiquez une autre route publique. + +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 +``` + +Consultez les variables définies : + +```bash +frankendeploy env list prod +``` + +!!! 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. + +### 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), puis récupérez les identifiants de la base de production : + +```bash +frankendeploy env get prod DATABASE_URL +``` + +Envoyez le fichier SQL sur le serveur, connectez-vous en SSH et importez-le dans le conteneur `mon-app-db` : + +```bash +docker exec -i mon-app-db mariadb -umon-app -p mon_app < app_db.sql +``` + +!!! 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 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). + +## 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 connexion à la base ni 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 la variable `DATABASE_URL` ou les variables `DB_*` avec `frankendeploy env list prod`. + +### 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/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/src/EduFramework/Commands/CreateDeployCommand.php b/src/EduFramework/Commands/CreateDeployCommand.php new file mode 100644 index 0000000..ab593a1 --- /dev/null +++ b/src/EduFramework/Commands/CreateDeployCommand.php @@ -0,0 +1,230 @@ + 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']; + + 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 + { + $io = new SymfonyStyle($input, $output); + $basePath = (string) ConfigCore::getConfig('base_path'); + + $name = $this->formatName((string) ($input->getOption('name') ?? basename((string) realpath($basePath)))); + if ($name === '') { + $io->error("Nom d'application invalide : utilisez des minuscules, des chiffres et des tirets"); + return Command::FAILURE; + } + + $database = (string) $input->getOption('database'); + if (array_key_exists($database, self::DATABASES) === false) { + $io->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) { + $io->error('Nom de domaine invalide : ' . $domain); + return Command::FAILURE; + } + + $configPath = $basePath . self::CONFIG_FILE; + if (file_exists($configPath) === true && $input->getOption('force') === false) { + $io->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)); + $io->success('Fichier ' . self::CONFIG_FILE . ' généré'); + + // L'image de production repose sur les fichiers Docker du framework + $missing = array_filter( + self::REQUIRED_FILES, + static fn (string $file): bool => file_exists($basePath . $file) === false + ); + if ($missing !== []) { + $io->warning('Fichiers manquants pour construire l\'image : ' . implode(', ', $missing) + . '. FrankenDeploy générerait des fichiers prévus pour Symfony, incompatibles avec Edu-Framework.'); + } elseif (str_contains((string) file_get_contents($basePath . 'Dockerfile'), 'AS frankenphp_prod') === false) { + $io->warning('Le Dockerfile ne contient pas de stage "frankenphp_prod", requis par FrankenDeploy.'); + } + + $io->section('Étapes suivantes'); + $io->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; + } + + /** + * 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..55cc153 100644 --- a/src/EduFramework/Core/ConfigCore.php +++ b/src/EduFramework/Core/ConfigCore.php @@ -11,6 +11,7 @@ namespace Studoo\EduFramework\Core; +use Dotenv\Dotenv; use Studoo\EduFramework\Core\Controller\Request; use Studoo\EduFramework\Core\Exception\ErrorConfigException; use Symfony\Component\Yaml\Exception\ParseException; @@ -131,6 +132,88 @@ 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) + Dotenv::createImmutable($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 + * @return void + */ + private static function loadDatabaseUrl(): void + { + if (self::existEnv('DATABASE_URL') === false) { + return; + } + + $url = parse_url(self::getEnv('DATABASE_URL')); + if ($url === false || isset($url['scheme']) === false) { + return; + } + + $types = [ + 'mysql' => 'mysql', + 'mysql2' => 'mysql', + 'mariadb' => 'mariadb', + 'pgsql' => 'pgsql', + 'postgres' => 'pgsql', + 'postgresql' => 'pgsql', + ]; + if (isset($types[$url['scheme']]) === false) { + return; + } + + $values = [ + 'DB_HOST_STATUS' => 'true', + 'DB_TYPE' => $types[$url['scheme']], + 'DB_HOST' => $url['host'] ?? null, + 'DB_SOCKET' => isset($url['port']) ? (string) $url['port'] : null, + 'DB_USER' => isset($url['user']) ? rawurldecode($url['user']) : null, + 'DB_PASSWORD' => isset($url['pass']) ? rawurldecode($url['pass']) : null, + 'DB_NAME' => isset($url['path']) ? rawurldecode(ltrim($url['path'], '/')) : null, + ]; + foreach ($values as $key => $value) { + if ($value !== null && self::existEnv($key) === false) { + $_ENV[$key] = $value; + } + } + } + + /** + * Lit une variable d'environnement : $_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) + * @param string $key Clé de la variable + * @return string|null + */ + private static function readEnv(string $key): ?string + { + if (isset($_ENV[$key]) === true) { + return (string) $_ENV[$key]; + } + $value = getenv($key); + return $value === false ? null : $value; + } + /** * Permet de récupérer la configuration du fichier .env à la racine du projet * @param string $key Clé de la configuration @@ -138,7 +221,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 +231,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/LoadCouchCore.php b/src/EduFramework/Core/LoadCouchCore.php index 5a94380..7525637 100644 --- a/src/EduFramework/Core/LoadCouchCore.php +++ b/src/EduFramework/Core/LoadCouchCore.php @@ -11,7 +11,6 @@ namespace Studoo\EduFramework\Core; -use Dotenv\Dotenv; use PDO; use Studoo\EduFramework\Core\ConfigCore; use Studoo\EduFramework\Core\Controller\Debug\DebugCodeProfileController; @@ -44,9 +43,8 @@ class LoadCouchCore */ public function run(): void { - // Gestion du fichier des variables d'environnement (.env) - $dotenv = Dotenv::createImmutable(ConfigCore::getConfig('base_path')); - $dotenv->load(); + // Gestion des variables d'environnement (environnement, .env.local, .env) + ConfigCore::loadEnv(ConfigCore::getConfig('base_path')); // Gestion de la couche View TwigCore::setEnvironment(); diff --git a/tests/Command/CreateDeployCommandTest.php b/tests/Command/CreateDeployCommandTest.php new file mode 100644 index 0000000..c146a4f --- /dev/null +++ b/tests/Command/CreateDeployCommandTest.php @@ -0,0 +1,103 @@ +projectPath = sys_get_temp_dir() . '/edu-framework-test-deploy-' . uniqid() . '/Mon_Projet/'; + mkdir($this->projectPath, 0777, true); + + (new ConfigCore(['base_path' => $this->projectPath])); + $application = new Application(ConfigCore::getConfig('name'), ConfigCore::getConfig('version')); + $application->add(new CreateDeployCommand()); + $this->commandeTester = new CommandTester($application->find('make:deploy')); + } + + protected function tearDown(): void + { + (new Filesystem())->remove(dirname($this->projectPath)); + $this->commandeTester = null; + } + + public function testGenerationParDefaut(): void + { + $this->assertEquals(Command::SUCCESS, $this->commandeTester->execute(['--domain' => 'mon-app.fr'])); + + $config = Yaml::parseFile($this->projectPath . 'frankendeploy.yaml'); + // Le nom par défaut est celui du dossier, au format FrankenDeploy + $this->assertEquals('mon-projet', $config['name']); + $this->assertEquals('8.4', $config['php']['version']); + $this->assertEquals('mariadb', $config['database']['driver']); + $this->assertTrue($config['database']['managed']); + $this->assertEquals('mon-app.fr', $config['deploy']['domain']); + $this->assertContains('.env.local', $config['deploy']['shared_files']); + $this->assertContains('var/logs', $config['deploy']['shared_dirs']); + $this->assertContains('public/upload', $config['deploy']['shared_dirs']); + } + + public function testFichiersDockerManquants(): void + { + $this->commandeTester->execute([]); + $this->assertStringContainsString('Fichiers manquants', $this->commandeTester->getDisplay()); + } + + public function testSansDomaineNiBase(): void + { + $this->commandeTester->execute(['--name' => 'demo', '--database' => 'none']); + + $config = Yaml::parseFile($this->projectPath . 'frankendeploy.yaml'); + $this->assertEquals('demo', $config['name']); + $this->assertArrayNotHasKey('database', $config); + $this->assertArrayNotHasKey('domain', $config['deploy']); + } + + public function testSqlite(): void + { + $this->commandeTester->execute(['--database' => 'sqlite']); + + $config = Yaml::parseFile($this->projectPath . 'frankendeploy.yaml'); + $this->assertEquals('sqlite', $config['database']['driver']); + $this->assertStringStartsWith('var/sqlite/', $config['database']['path']); + } + + public function testBaseInconnue(): void + { + $this->assertEquals(Command::FAILURE, $this->commandeTester->execute(['--database' => 'oracle'])); + $this->assertFileDoesNotExist($this->projectPath . 'frankendeploy.yaml'); + } + + public function testDomaineInvalide(): void + { + $this->assertEquals(Command::FAILURE, $this->commandeTester->execute(['--domain' => 'pas un domaine'])); + } + + public function testFichierExistant(): void + { + file_put_contents($this->projectPath . 'frankendeploy.yaml', "name: existant\n"); + + $this->assertEquals(Command::FAILURE, $this->commandeTester->execute([])); + $this->assertEquals("name: existant\n", file_get_contents($this->projectPath . 'frankendeploy.yaml')); + + $this->assertEquals(Command::SUCCESS, $this->commandeTester->execute(['--force' => true])); + $this->assertStringContainsString('mon-projet', file_get_contents($this->projectPath . 'frankendeploy.yaml')); + } +} diff --git a/tests/Core/LoadEnvTest.php b/tests/Core/LoadEnvTest.php new file mode 100644 index 0000000..8764948 --- /dev/null +++ b/tests/Core/LoadEnvTest.php @@ -0,0 +1,147 @@ + + */ + private array $backupEnv = []; + + private string $envPath; + + protected function setUp(): void + { + $this->envPath = sys_get_temp_dir() . '/edu-framework-test-env-' . uniqid() . '/'; + mkdir($this->envPath); + + foreach (self::KEYS as $key) { + if (array_key_exists($key, $_ENV) === true) { + $this->backupEnv[$key] = $_ENV[$key]; + } + unset($_ENV[$key], $_SERVER[$key]); + putenv($key); + } + (new ConfigCore([])); + } + + protected function tearDown(): void + { + foreach (self::KEYS as $key) { + unset($_ENV[$key], $_SERVER[$key]); + putenv($key); + } + foreach ($this->backupEnv as $key => $value) { + $_ENV[$key] = $value; + } + (new Filesystem())->remove($this->envPath); + } + + public function testSansFichierEnv(): void + { + // En production, l'image Docker ne contient pas de .env : pas d'exception + ConfigCore::loadEnv($this->envPath); + $this->assertFalse(ConfigCore::existEnv('EDU_TEST_VAR')); + } + + public function testFichierEnv(): void + { + file_put_contents($this->envPath . '.env', "EDU_TEST_VAR=depuis-env\n"); + ConfigCore::loadEnv($this->envPath); + $this->assertEquals('depuis-env', ConfigCore::getEnv('EDU_TEST_VAR')); + } + + public function testEnvLocalPrioritaireSurEnv(): void + { + file_put_contents($this->envPath . '.env', "EDU_TEST_VAR=depuis-env\nEDU_TEST_LOCAL=env\n"); + file_put_contents($this->envPath . '.env.local', "EDU_TEST_VAR=depuis-env-local\n"); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('depuis-env-local', ConfigCore::getEnv('EDU_TEST_VAR')); + // Les variables absentes du .env.local proviennent du .env + $this->assertEquals('env', ConfigCore::getEnv('EDU_TEST_LOCAL')); + } + + public function testEnvironnementProcessus(): void + { + // Variable passée par Docker (docker run -e) : absente de $_ENV + putenv('EDU_TEST_VAR=depuis-docker'); + $this->assertTrue(ConfigCore::existEnv('EDU_TEST_VAR')); + $this->assertEquals('depuis-docker', ConfigCore::getEnv('EDU_TEST_VAR')); + } + + public function testEnvironnementProcessusPrioritaireSurFichiers(): void + { + putenv('EDU_TEST_VAR=depuis-docker'); + $_SERVER['EDU_TEST_VAR'] = 'depuis-docker'; + file_put_contents($this->envPath . '.env.local', "EDU_TEST_VAR=depuis-env-local\n"); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('depuis-docker', ConfigCore::getEnv('EDU_TEST_VAR')); + } + + public function testVariableAbsente(): void + { + $this->assertFalse(ConfigCore::existEnv('EDU_TEST_VAR')); + $this->assertEquals('', ConfigCore::getEnv('EDU_TEST_VAR')); + } + + public function testDatabaseUrl(): void + { + // Format de l'URL générée par FrankenDeploy pour une base MariaDB gérée + putenv('DATABASE_URL=mysql://mon-app:5f2b%40c9@mon-app-db:3306/mon_app?serverVersion=11-MariaDB&charset=utf8mb4'); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('true', ConfigCore::getEnv('DB_HOST_STATUS')); + $this->assertEquals('mysql', ConfigCore::getEnv('DB_TYPE')); + $this->assertEquals('mon-app-db', ConfigCore::getEnv('DB_HOST')); + $this->assertEquals('3306', ConfigCore::getEnv('DB_SOCKET')); + $this->assertEquals('mon-app', ConfigCore::getEnv('DB_USER')); + $this->assertEquals('5f2b@c9', ConfigCore::getEnv('DB_PASSWORD')); + $this->assertEquals('mon_app', ConfigCore::getEnv('DB_NAME')); + } + + public function testDatabaseUrlNEcrasePasLesVariablesDb(): void + { + putenv('DATABASE_URL=mysql://user:pass@db-host:3306/db_name'); + file_put_contents($this->envPath . '.env.local', "DB_NAME=autre_base\nDB_HOST_STATUS=false\n"); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('autre_base', ConfigCore::getEnv('DB_NAME')); + $this->assertEquals('false', ConfigCore::getEnv('DB_HOST_STATUS')); + $this->assertEquals('db-host', ConfigCore::getEnv('DB_HOST')); + } + + public function testDatabaseUrlPostgresql(): void + { + putenv('DATABASE_URL=postgresql://user:pass@db-host:5432/db_name?serverVersion=16'); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('pgsql', ConfigCore::getEnv('DB_TYPE')); + $this->assertEquals('5432', ConfigCore::getEnv('DB_SOCKET')); + } + + public function testDatabaseUrlSchemaInconnu(): void + { + putenv('DATABASE_URL=sqlite:///var/data.db'); + ConfigCore::loadEnv($this->envPath); + + $this->assertFalse(ConfigCore::existEnv('DB_TYPE')); + $this->assertFalse(ConfigCore::existEnv('DB_HOST_STATUS')); + } +} From ffba427ea13d2fbba4df394be0609b3a95c354b3 Mon Sep 17 00:00:00 2001 From: bfoujols Date: Wed, 21 Oct 2026 09:44:13 +0200 Subject: [PATCH 2/5] docs(changelog): version v2.9.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb3933..2f869fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ **Présentation des versions du framework Edu Framework** -## Non publié +## v2.9.0 - 21/10/2026 **new features** diff --git a/docs/changelog.md b/docs/changelog.md index 9f40aac..f64d88d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,7 @@ **Présentation des versions du framework Edu Framework** -## Non publié +## v2.9.0 - 21/10/2026 **new features** From 2205770c2a3b41ec4fcd137592ea0ccdaa890c03 Mon Sep 17 00:00:00 2001 From: bfoujols Date: Wed, 21 Oct 2026 09:44:34 +0200 Subject: [PATCH 3/5] chore: bump version to v2.9.0 Co-Authored-By: Claude Opus 5.5 --- eduframe.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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' From 661ded35b88d5f28661cfd1c348b19149906cb60 Mon Sep 17 00:00:00 2001 From: bfoujols Date: Wed, 21 Oct 2026 10:33:20 +0200 Subject: [PATCH 4/5] fix(deploy): corrections suite aux retours de revue (Codacy et relecture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codacy : - docker-entrypoint.sh : SC2016 désactivé (code PHP volontairement entre guillemets simples) - ConfigCore : lecture/écriture des variables via le dépôt phpdotenv (plus d'accès direct à $_ENV), loadDatabaseUrl simplifiée - CreateDeployCommand : $io renommé $console Relecture : - loadEnv : une variable visible uniquement par getenv() (php -S, mod_php) reste prioritaire sur .env et .env.local - DATABASE_URL MySQL/MariaDB/PostgreSQL mal formée (ex. mot de passe avec # tronqué par phpdotenv) : ErrorConfigException explicite, et arrêt du conteneur au démarrage avec le message - tests : APP_ENV hérité du shell ou du conteneur neutralisé dans bootstrap - make:deploy : --name requis dans le conteneur (/app), commandes SQLite une variable par commande, avertissement qui indique où récupérer les fichiers Docker, healthcheck à aligner avec le Dockerfile - doc : projets créés avant 2.9.0, caractère #, env set une variable, DATABASE_URL d'une base gérée, procédure d'import corrigée, SQLite, ne pas lancer frankendeploy build / init --force / dev Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- Dockerfile | 1 + docker-entrypoint.sh | 18 ++- docs/changelog.md | 3 +- docs/deploy/index.md | 96 ++++++++++++++-- .../Commands/CreateDeployCommand.php | 87 +++++++++++---- src/EduFramework/Core/ConfigCore.php | 103 ++++++++++++------ tests/Command/CreateDeployCommandTest.php | 37 ++++++- tests/Core/LoadEnvTest.php | 22 +++- tests/bootstrap.php | 5 + 10 files changed, 300 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f869fe..9c663dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,13 @@ - 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 +- 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** diff --git a/Dockerfile b/Dockerfile index ffff916..1e4948d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,6 +110,7 @@ ENTRYPOINT ["docker-entrypoint"] CMD ["--config", "/etc/frankenphp/Caddyfile", "--adapter", "caddyfile"] # Sonde applicative : vérifie que l'application répond, pas seulement Caddy +# (même URL que healthcheck_path dans frankendeploy.yaml) HEALTHCHECK --start-period=60s CMD curl -f http://localhost:8080/ || exit 1 USER app diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 370f70f..6fc01d5 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -13,11 +13,19 @@ log() { # 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([]); - ConfigCore::loadEnv("/app/"); + 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") === "") { @@ -28,11 +36,14 @@ db_target() { $port = ConfigCore::getEnv("DB_TYPE") === "pgsql" ? "5432" : "3306"; } echo ConfigCore::getEnv("DB_HOST") . " " . $port; - ' 2>/dev/null || true + ' } wait_for_database() { - target=$(db_target) + 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 @@ -41,6 +52,7 @@ wait_for_database() { 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 diff --git a/docs/changelog.md b/docs/changelog.md index f64d88d..9f842f5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,12 +11,13 @@ - 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 +- 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** diff --git a/docs/deploy/index.md b/docs/deploy/index.md index dd9ac01..c53c455 100644 --- a/docs/deploy/index.md +++ b/docs/deploy/index.md @@ -52,6 +52,16 @@ Vérifiez l'installation : 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 : @@ -63,10 +73,18 @@ 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) | nom du dossier | +| `--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 @@ -97,7 +115,9 @@ deploy: !!! 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. Si votre page d'accueil nécessite d'être connecté, indiquez une autre route publique. + 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 : @@ -138,12 +158,24 @@ Pour un mot de passe ou une clé d'API, utilisez la saisie masquée : 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` : @@ -154,6 +186,30 @@ frankendeploy env list prod 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 | @@ -190,17 +246,29 @@ 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), puis récupérez les identifiants de la base de production : +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 -frankendeploy env get prod DATABASE_URL +scp app_db.sql deploy@mon-serveur.fr: ``` -Envoyez le fichier SQL sur le serveur, connectez-vous en SSH et importez-le dans le conteneur `mon-app-db` : +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. -```bash -docker exec -i mon-app-db mariadb -umon-app -p mon_app < app_db.sql -``` +=== "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" @@ -262,19 +330,25 @@ docker build --target frankenphp_prod -t mon-app . docker run --rm -p 8080:8080 mon-app ``` -!!! warning "Ne supprimez pas les fichiers Docker du projet" +!!! 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 connexion à la base ni authentification, et que les variables d'environnement sont définies (`frankendeploy env list prod`). +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 la variable `DATABASE_URL` ou les variables `DB_*` avec `frankendeploy env list prod`. +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é diff --git a/src/EduFramework/Commands/CreateDeployCommand.php b/src/EduFramework/Commands/CreateDeployCommand.php index ab593a1..47ba12c 100644 --- a/src/EduFramework/Commands/CreateDeployCommand.php +++ b/src/EduFramework/Commands/CreateDeployCommand.php @@ -77,51 +77,42 @@ protected function configure(): void */ public function execute(InputInterface $input, OutputInterface $output): int { - $io = new SymfonyStyle($input, $output); + $console = new SymfonyStyle($input, $output); $basePath = (string) ConfigCore::getConfig('base_path'); - $name = $this->formatName((string) ($input->getOption('name') ?? basename((string) realpath($basePath)))); + $name = $this->resolveName($input->getOption('name'), $basePath); if ($name === '') { - $io->error("Nom d'application invalide : utilisez des minuscules, des chiffres et des tirets"); + $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) { - $io->error('Base de données inconnue : ' . $database + $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) { - $io->error('Nom de domaine invalide : ' . $domain); + $console->error('Nom de domaine invalide : ' . $domain); return Command::FAILURE; } $configPath = $basePath . self::CONFIG_FILE; if (file_exists($configPath) === true && $input->getOption('force') === false) { - $io->error('Le fichier ' . self::CONFIG_FILE . ' existe déjà (utilisez --force pour l\'écraser)'); + $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)); - $io->success('Fichier ' . self::CONFIG_FILE . ' généré'); + $console->success('Fichier ' . self::CONFIG_FILE . ' généré (application : ' . $name . ')'); - // L'image de production repose sur les fichiers Docker du framework - $missing = array_filter( - self::REQUIRED_FILES, - static fn (string $file): bool => file_exists($basePath . $file) === false - ); - if ($missing !== []) { - $io->warning('Fichiers manquants pour construire l\'image : ' . implode(', ', $missing) - . '. FrankenDeploy générerait des fichiers prévus pour Symfony, incompatibles avec Edu-Framework.'); - } elseif (str_contains((string) file_get_contents($basePath . 'Dockerfile'), 'AS frankenphp_prod') === false) { - $io->warning('Le Dockerfile ne contient pas de stage "frankenphp_prod", requis par FrankenDeploy.'); - } + $this->checkDockerFiles($console, $basePath); - $io->section('Étapes suivantes'); - $io->listing([ + $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', @@ -131,6 +122,53 @@ public function execute(InputInterface $input, OutputInterface $output): int 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 @@ -170,7 +208,10 @@ private function generateConfig(string $name, ?string $domain, string $database) 'sqlite' => << 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) @@ -147,8 +163,13 @@ public static function setConfig(string $key, mixed $value): 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) - Dotenv::createImmutable($basePath, ['.env', '.env.local'], false)->safeLoad(); + // 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(); } @@ -157,61 +178,71 @@ public static function loadEnv(string $basePath): void * 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 { - if (self::existEnv('DATABASE_URL') === false) { + $databaseUrl = self::readEnv('DATABASE_URL') ?? ''; + $type = self::DATABASE_URL_TYPES[strstr($databaseUrl, '://', true) ?: ''] ?? null; + if ($type === null) { return; } - $url = parse_url(self::getEnv('DATABASE_URL')); - if ($url === false || isset($url['scheme']) === false) { - return; - } - - $types = [ - 'mysql' => 'mysql', - 'mysql2' => 'mysql', - 'mariadb' => 'mariadb', - 'pgsql' => 'pgsql', - 'postgres' => 'pgsql', - 'postgresql' => 'pgsql', - ]; - if (isset($types[$url['scheme']]) === false) { - 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' => $types[$url['scheme']], - 'DB_HOST' => $url['host'] ?? null, - 'DB_SOCKET' => isset($url['port']) ? (string) $url['port'] : null, - 'DB_USER' => isset($url['user']) ? rawurldecode($url['user']) : null, - 'DB_PASSWORD' => isset($url['pass']) ? rawurldecode($url['pass']) : null, - 'DB_NAME' => isset($url['path']) ? rawurldecode(ltrim($url['path'], '/')) : null, + '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'] ?? '', '/'), ]; - foreach ($values as $key => $value) { - if ($value !== null && self::existEnv($key) === false) { - $_ENV[$key] = $value; - } + + // 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)); } } /** - * Lit une variable d'environnement : $_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) + * 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 { - if (isset($_ENV[$key]) === true) { - return (string) $_ENV[$key]; - } - $value = getenv($key); - return $value === false ? null : $value; + return self::envRepository()->get($key); } /** diff --git a/tests/Command/CreateDeployCommandTest.php b/tests/Command/CreateDeployCommandTest.php index c146a4f..98bc474 100644 --- a/tests/Command/CreateDeployCommandTest.php +++ b/tests/Command/CreateDeployCommandTest.php @@ -57,7 +57,37 @@ public function testGenerationParDefaut(): void public function testFichiersDockerManquants(): void { $this->commandeTester->execute([]); - $this->assertStringContainsString('Fichiers manquants', $this->commandeTester->getDisplay()); + $output = $this->commandeTester->getDisplay(); + $this->assertStringContainsString('Fichiers manquants', $output); + // Le message indique où récupérer les fichiers Docker du framework + $this->assertStringContainsString('vendor/studoo/edu-framework/', $output); + } + + public function testDockerfileSansStageProd(): void + { + foreach (['Dockerfile', 'docker-entrypoint.sh', 'Caddyfile', '.dockerignore'] as $file) { + file_put_contents($this->projectPath . $file, ''); + } + file_put_contents($this->projectPath . 'Dockerfile', "FROM dunglas/frankenphp:php8.4\n"); + + $this->commandeTester->execute([]); + $this->assertStringContainsString('frankenphp_prod', $this->commandeTester->getDisplay()); + } + + public function testNomRequisDansLeConteneur(): void + { + // Dans le conteneur de développement, le projet est monté dans /app : + // le nom "app" ne doit pas être déduit du dossier + $appPath = dirname($this->projectPath) . '/app/'; + mkdir($appPath); + (new ConfigCore(['base_path' => $appPath])); + + $this->assertEquals(Command::FAILURE, $this->commandeTester->execute([])); + $this->assertStringContainsString('--name', $this->commandeTester->getDisplay()); + $this->assertFileDoesNotExist($appPath . 'frankendeploy.yaml'); + + $this->assertEquals(Command::SUCCESS, $this->commandeTester->execute(['--name' => 'mon-app'])); + $this->assertEquals('mon-app', Yaml::parseFile($appPath . 'frankendeploy.yaml')['name']); } public function testSansDomaineNiBase(): void @@ -77,6 +107,11 @@ public function testSqlite(): void $config = Yaml::parseFile($this->projectPath . 'frankendeploy.yaml'); $this->assertEquals('sqlite', $config['database']['driver']); $this->assertStringStartsWith('var/sqlite/', $config['database']['path']); + + // frankendeploy env set n'accepte qu'une variable par commande + $content = file_get_contents($this->projectPath . 'frankendeploy.yaml'); + $this->assertDoesNotMatchRegularExpression('/env set prod \S+=\S+ \S+=/', $content); + $this->assertStringContainsString('env set prod DB_TYPE=sqlite', $content); } public function testBaseInconnue(): void diff --git a/tests/Core/LoadEnvTest.php b/tests/Core/LoadEnvTest.php index 8764948..0717dfb 100644 --- a/tests/Core/LoadEnvTest.php +++ b/tests/Core/LoadEnvTest.php @@ -87,8 +87,8 @@ public function testEnvironnementProcessus(): void public function testEnvironnementProcessusPrioritaireSurFichiers(): void { + // Variable visible uniquement par getenv() (php -S ou mod_php avec variables_order=GPCS) putenv('EDU_TEST_VAR=depuis-docker'); - $_SERVER['EDU_TEST_VAR'] = 'depuis-docker'; file_put_contents($this->envPath . '.env.local', "EDU_TEST_VAR=depuis-env-local\n"); ConfigCore::loadEnv($this->envPath); @@ -144,4 +144,24 @@ public function testDatabaseUrlSchemaInconnu(): void $this->assertFalse(ConfigCore::existEnv('DB_TYPE')); $this->assertFalse(ConfigCore::existEnv('DB_HOST_STATUS')); } + + public function testDatabaseUrlTronqueeParUnDiese(): void + { + // FrankenDeploy n'entoure pas de guillemets une valeur contenant # : + // phpdotenv coupe la valeur au # (mysql://app:Xy7), l'erreur doit être explicite + file_put_contents($this->envPath . '.env.local', "DATABASE_URL=mysql://app:Xy7#kP@db-host:3306/app\n"); + + $this->expectException(\Studoo\EduFramework\Core\Exception\ErrorConfigException::class); + $this->expectExceptionMessage('DATABASE_URL est invalide'); + ConfigCore::loadEnv($this->envPath); + } + + public function testDatabaseUrlMotDePasseEncode(): void + { + file_put_contents($this->envPath . '.env.local', "DATABASE_URL=mysql://app:Xy7%23kP@db-host:3306/app\n"); + ConfigCore::loadEnv($this->envPath); + + $this->assertEquals('Xy7#kP', ConfigCore::getEnv('DB_PASSWORD')); + $this->assertEquals('db-host', ConfigCore::getEnv('DB_HOST')); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e59cdb0..82f4c5b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,5 +8,10 @@ // Driver de profilage factice partagé par les tests du profiler de code require_once __DIR__ . '/Core/Profiler/FakeProfilerDriver.php'; +// ConfigCore::getEnv() lit aussi l'environnement du processus (getenv) : on retire +// APP_ENV hérité du shell ou du conteneur de dev (compose.yaml : APP_ENV=dev), sinon +// la barre de debug s'injecte dans les rendus des tests qui neutralisent $_ENV['APP_ENV'] +putenv('APP_ENV'); + error_reporting(E_ALL); ini_set('display_errors', 'stderr'); \ No newline at end of file From 222a6f364b4ca6fb10edec6078a158aa56beec8a Mon Sep 17 00:00:00 2001 From: bfoujols Date: Wed, 21 Oct 2026 10:37:11 +0200 Subject: [PATCH 5/5] fix(deploy): corrections DeepSource PHP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dossiers temporaires des tests créés en 0700 au lieu de 0777 (PHP-A1006) - docblocks des méthodes et classes ajoutées (PHP-D1001, PHP-D1002) Co-Authored-By: Claude Opus 5.5 --- .../Commands/CreateDeployCommand.php | 4 ++ tests/Command/CreateDeployCommandTest.php | 51 +++++++++++++++- tests/Core/LoadEnvTest.php | 58 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/EduFramework/Commands/CreateDeployCommand.php b/src/EduFramework/Commands/CreateDeployCommand.php index 47ba12c..9f9a46c 100644 --- a/src/EduFramework/Commands/CreateDeployCommand.php +++ b/src/EduFramework/Commands/CreateDeployCommand.php @@ -55,6 +55,10 @@ class CreateDeployCommand extends Command */ 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 diff --git a/tests/Command/CreateDeployCommandTest.php b/tests/Command/CreateDeployCommandTest.php index 98bc474..c903d4d 100644 --- a/tests/Command/CreateDeployCommandTest.php +++ b/tests/Command/CreateDeployCommandTest.php @@ -11,6 +11,9 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; +/** + * Tests de la commande make:deploy (génération du fichier frankendeploy.yaml) + */ class CreateDeployCommandTest extends TestCase { /** @@ -21,10 +24,14 @@ class CreateDeployCommandTest extends TestCase private ?CommandTester $commandeTester; + /** + * Crée un projet temporaire isolé et la commande à tester + * @return void + */ protected function setUp(): void { $this->projectPath = sys_get_temp_dir() . '/edu-framework-test-deploy-' . uniqid() . '/Mon_Projet/'; - mkdir($this->projectPath, 0777, true); + mkdir($this->projectPath, 0700, true); (new ConfigCore(['base_path' => $this->projectPath])); $application = new Application(ConfigCore::getConfig('name'), ConfigCore::getConfig('version')); @@ -32,12 +39,20 @@ protected function setUp(): void $this->commandeTester = new CommandTester($application->find('make:deploy')); } + /** + * Supprime le projet temporaire + * @return void + */ protected function tearDown(): void { (new Filesystem())->remove(dirname($this->projectPath)); $this->commandeTester = null; } + /** + * Génération avec les valeurs par défaut : nom du dossier, MariaDB gérée, dossiers partagés + * @return void + */ public function testGenerationParDefaut(): void { $this->assertEquals(Command::SUCCESS, $this->commandeTester->execute(['--domain' => 'mon-app.fr'])); @@ -54,6 +69,10 @@ public function testGenerationParDefaut(): void $this->assertContains('public/upload', $config['deploy']['shared_dirs']); } + /** + * Sans les fichiers Docker du framework, un avertissement indique où les récupérer + * @return void + */ public function testFichiersDockerManquants(): void { $this->commandeTester->execute([]); @@ -63,6 +82,10 @@ public function testFichiersDockerManquants(): void $this->assertStringContainsString('vendor/studoo/edu-framework/', $output); } + /** + * Un Dockerfile sans stage frankenphp_prod déclenche un avertissement + * @return void + */ public function testDockerfileSansStageProd(): void { foreach (['Dockerfile', 'docker-entrypoint.sh', 'Caddyfile', '.dockerignore'] as $file) { @@ -74,12 +97,16 @@ public function testDockerfileSansStageProd(): void $this->assertStringContainsString('frankenphp_prod', $this->commandeTester->getDisplay()); } + /** + * Dans le conteneur de développement (/app), l'option --name est obligatoire + * @return void + */ public function testNomRequisDansLeConteneur(): void { // Dans le conteneur de développement, le projet est monté dans /app : // le nom "app" ne doit pas être déduit du dossier $appPath = dirname($this->projectPath) . '/app/'; - mkdir($appPath); + mkdir($appPath, 0700); (new ConfigCore(['base_path' => $appPath])); $this->assertEquals(Command::FAILURE, $this->commandeTester->execute([])); @@ -90,6 +117,10 @@ public function testNomRequisDansLeConteneur(): void $this->assertEquals('mon-app', Yaml::parseFile($appPath . 'frankendeploy.yaml')['name']); } + /** + * Sans domaine ni base de données : aucune clé database ni domain générée + * @return void + */ public function testSansDomaineNiBase(): void { $this->commandeTester->execute(['--name' => 'demo', '--database' => 'none']); @@ -100,6 +131,10 @@ public function testSansDomaineNiBase(): void $this->assertArrayNotHasKey('domain', $config['deploy']); } + /** + * Base SQLite : chemin dans var/sqlite et une commande env set par variable + * @return void + */ public function testSqlite(): void { $this->commandeTester->execute(['--database' => 'sqlite']); @@ -114,17 +149,29 @@ public function testSqlite(): void $this->assertStringContainsString('env set prod DB_TYPE=sqlite', $content); } + /** + * Une base de données inconnue est refusée sans générer de fichier + * @return void + */ public function testBaseInconnue(): void { $this->assertEquals(Command::FAILURE, $this->commandeTester->execute(['--database' => 'oracle'])); $this->assertFileDoesNotExist($this->projectPath . 'frankendeploy.yaml'); } + /** + * Un nom de domaine invalide est refusé + * @return void + */ public function testDomaineInvalide(): void { $this->assertEquals(Command::FAILURE, $this->commandeTester->execute(['--domain' => 'pas un domaine'])); } + /** + * Un frankendeploy.yaml existant n'est écrasé qu'avec --force + * @return void + */ public function testFichierExistant(): void { file_put_contents($this->projectPath . 'frankendeploy.yaml', "name: existant\n"); diff --git a/tests/Core/LoadEnvTest.php b/tests/Core/LoadEnvTest.php index 0717dfb..3164852 100644 --- a/tests/Core/LoadEnvTest.php +++ b/tests/Core/LoadEnvTest.php @@ -25,10 +25,14 @@ class LoadEnvTest extends TestCase private string $envPath; + /** + * Crée un dossier temporaire et retire les variables testées de l'environnement + * @return void + */ protected function setUp(): void { $this->envPath = sys_get_temp_dir() . '/edu-framework-test-env-' . uniqid() . '/'; - mkdir($this->envPath); + mkdir($this->envPath, 0700); foreach (self::KEYS as $key) { if (array_key_exists($key, $_ENV) === true) { @@ -40,6 +44,10 @@ protected function setUp(): void (new ConfigCore([])); } + /** + * Restaure les variables d'environnement et supprime le dossier temporaire + * @return void + */ protected function tearDown(): void { foreach (self::KEYS as $key) { @@ -52,6 +60,10 @@ protected function tearDown(): void (new Filesystem())->remove($this->envPath); } + /** + * Sans fichier .env (image de production), le chargement ne lève pas d'exception + * @return void + */ public function testSansFichierEnv(): void { // En production, l'image Docker ne contient pas de .env : pas d'exception @@ -59,6 +71,10 @@ public function testSansFichierEnv(): void $this->assertFalse(ConfigCore::existEnv('EDU_TEST_VAR')); } + /** + * Les variables du fichier .env sont chargées + * @return void + */ public function testFichierEnv(): void { file_put_contents($this->envPath . '.env', "EDU_TEST_VAR=depuis-env\n"); @@ -66,6 +82,10 @@ public function testFichierEnv(): void $this->assertEquals('depuis-env', ConfigCore::getEnv('EDU_TEST_VAR')); } + /** + * Le fichier .env.local est prioritaire sur le fichier .env + * @return void + */ public function testEnvLocalPrioritaireSurEnv(): void { file_put_contents($this->envPath . '.env', "EDU_TEST_VAR=depuis-env\nEDU_TEST_LOCAL=env\n"); @@ -77,6 +97,10 @@ public function testEnvLocalPrioritaireSurEnv(): void $this->assertEquals('env', ConfigCore::getEnv('EDU_TEST_LOCAL')); } + /** + * Une variable passée par Docker (absente de $_ENV) est lue par getenv() + * @return void + */ public function testEnvironnementProcessus(): void { // Variable passée par Docker (docker run -e) : absente de $_ENV @@ -85,6 +109,10 @@ public function testEnvironnementProcessus(): void $this->assertEquals('depuis-docker', ConfigCore::getEnv('EDU_TEST_VAR')); } + /** + * Une variable de l'environnement du processus est prioritaire sur les fichiers .env + * @return void + */ public function testEnvironnementProcessusPrioritaireSurFichiers(): void { // Variable visible uniquement par getenv() (php -S ou mod_php avec variables_order=GPCS) @@ -95,12 +123,20 @@ public function testEnvironnementProcessusPrioritaireSurFichiers(): void $this->assertEquals('depuis-docker', ConfigCore::getEnv('EDU_TEST_VAR')); } + /** + * Une variable absente n'existe pas et vaut une chaîne vide + * @return void + */ public function testVariableAbsente(): void { $this->assertFalse(ConfigCore::existEnv('EDU_TEST_VAR')); $this->assertEquals('', ConfigCore::getEnv('EDU_TEST_VAR')); } + /** + * Les variables DB_* sont déduites de la DATABASE_URL fournie par FrankenDeploy + * @return void + */ public function testDatabaseUrl(): void { // Format de l'URL générée par FrankenDeploy pour une base MariaDB gérée @@ -116,6 +152,10 @@ public function testDatabaseUrl(): void $this->assertEquals('mon_app', ConfigCore::getEnv('DB_NAME')); } + /** + * Une variable DB_* déjà définie n'est pas écrasée par la DATABASE_URL + * @return void + */ public function testDatabaseUrlNEcrasePasLesVariablesDb(): void { putenv('DATABASE_URL=mysql://user:pass@db-host:3306/db_name'); @@ -127,6 +167,10 @@ public function testDatabaseUrlNEcrasePasLesVariablesDb(): void $this->assertEquals('db-host', ConfigCore::getEnv('DB_HOST')); } + /** + * Une DATABASE_URL PostgreSQL donne DB_TYPE=pgsql + * @return void + */ public function testDatabaseUrlPostgresql(): void { putenv('DATABASE_URL=postgresql://user:pass@db-host:5432/db_name?serverVersion=16'); @@ -136,6 +180,10 @@ public function testDatabaseUrlPostgresql(): void $this->assertEquals('5432', ConfigCore::getEnv('DB_SOCKET')); } + /** + * Une DATABASE_URL d'un autre type (SQLite) est ignorée + * @return void + */ public function testDatabaseUrlSchemaInconnu(): void { putenv('DATABASE_URL=sqlite:///var/data.db'); @@ -145,6 +193,10 @@ public function testDatabaseUrlSchemaInconnu(): void $this->assertFalse(ConfigCore::existEnv('DB_HOST_STATUS')); } + /** + * Une DATABASE_URL tronquée par un # lève une erreur de configuration explicite + * @return void + */ public function testDatabaseUrlTronqueeParUnDiese(): void { // FrankenDeploy n'entoure pas de guillemets une valeur contenant # : @@ -156,6 +208,10 @@ public function testDatabaseUrlTronqueeParUnDiese(): void ConfigCore::loadEnv($this->envPath); } + /** + * Un mot de passe encodé (%23) est décodé dans DB_PASSWORD + * @return void + */ public function testDatabaseUrlMotDePasseEncode(): void { file_put_contents($this->envPath . '.env.local', "DATABASE_URL=mysql://app:Xy7%23kP@db-host:3306/app\n");