Il y a quelques jours j'ai publié mon premier bundle open source pour Symfony : Shizuku-Feature-Flags qui permet de gérer des feature flags de manière indépendante, uniquement avec Doctrine et la CLI de Symfony.

Cette première expérience m'a donné l’idée de faire un tuto pour faire son premier bundle, en espérant que ça permette à d'autres de contribuer à la communauté open source et PHP/Symfony.

1. Pourquoi faire un bundle ?

C'est vrai, rien n'oblige personne à en faire. Mais il y a deux bonnes raisons de s'y intéresser.

L'open source d'abord. C'est ce qui fait tourner une grande partie des projets qu'on utilise au quotidien. Open source ne veut pas dire "sans bug" ni "sans faille de sécurité" mais ça veut dire partage et entraide. Libre à toi d'y adhérer ou non, d'ailleurs tu peux très bien créer des bundles privés et ne jamais les publier. Si tu es pudique, je peux comprendre que ça peut faire peur de mettre ton code en public !

Le DRY ensuite, et c'est là que ça devient vraiment intéressant. Don't Repeat Yourself, c'est un principe en développement. On dit souvent qu'il ne faut pas réinventer la roue, et c'est exactement pour ça que les bundles existent.

On s'est déjà retrouvé à recopier le même controller d'un projet à l'autre ? À reconfigurer les mêmes services ? À réécrire la même logique de zéro ? Alors ce code mérite de devenir un bundle.

open source
(https://i.programmerhumor.io)

On va créer un bundle qui met en place un endpoint /healthcheck et retourne pas mal d'informations dont on aurait besoin pour checker l'état de santé de notre application à distance avec UpTime Kuma par exemple.

C'est un endpoint que je fais souvent alors c'est l'occasion de le généraliser en bundle.

Ce tuto va nous permettre de passer en revue les points suivants :

  • L'initialisation du bundle
  • Faire fonctionner son bundle en local
  • Paramétrer des services et des routes depuis le bundle
  • Développer les services
  • Déployer son bundle

2. L’initialisation du bundle

On commence par télécharger un template Symfony pour avoir une base de test pour notre bundle : composer create-project symfony/skeleton:"8.0.*" healthcheck.

De cette manière on a une base "classique" de style API.

On crée ensuite à la racine un dossier "bundles" puis un sous-dossier contenant le nom de notre bundle. Pour moi ce sera MyakuHealthCheck.

dossier

On initialise ensuite à l'intérieur de ce sous-dossier un composer.json avec composer init.

Une série de questions pour customiser son bundle arrive.

composer init

Pour la dernière question, sur les dépendances, on va installer les dépendances suivantes :

  • symfony/http-kernel
  • symfony/http-foundation
  • symfony/routing
  • symfony/dependency-injection

Les dépendances ci-dessus permettent d'indiquer à celui qui voudra utiliser le bundle : “Pour fonctionner, j'ai besoin d’installer tout ça”.

À la fin, on obtient donc ça :

{
    "name": "devexploris/myaku-health-check",
    "description": "Provide an endpoint for health check of application",
    "type": "symfony-bundle",
    "require": {
        "symfony/http-foundation": "^7.4",
        "symfony/http-kernel": "^7.4",
        "symfony/routing": "^7.4",
        "symfony/dependency-injection": "^7.4",
        "php": "^8.2"
    },
    "license": "MIT",
    "autoload": {
        "psr-4": {
            "Devexploris\\MyakuHealthCheck\\": "src/"
        }
    },
    "authors": [
        {
            "name": "devexploris"
        }
    ],
    "minimum-stability": "stable"
}

Si tu regardes bien, l'autoload respecte la norme PSR-4, qui ici va nous permettre d’utiliser notre nom de bundle comme namespace à partir de src/ :

"autoload": {
    "psr-4": {
        "Devexploris\\MyakuHealthCheck\\": "src/"
    }
},

psr-4

C'était l'étape indispensable pour tout bundle ou paquet (symfony, ou juste PHP).

Mais on va rajouter un fichier indispensable aux bundles Symfony, c’est le fichier XXXbundle.php.

<?php

namespace Devexploris\MyakuHealthCheck;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

class MyakuHealthCheckBundle extends AbstractBundle
{
    public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
    {

	}
}

Ce fichier, c'est comme la tour de contrôle de notre bundle.

Grâce à la méthode loadExtension() on va pouvoir inclure plein de configurations liées à notre bundle. C'est elle qui enregistre les services, les commandes, les fichiers de configs YAML …

L'initialisation est OK, on va maintenant passer au lancement de notre bundle en local.

3. Installer son bundle

Pour installer un bundle sur son projet il y a plusieurs façons avec composer :

  • utiliser composer require mon/bundle
  • utiliser composer require wikimedia/composer-merge-plugin

La méthode classique du composer require mon/bundle c'est rapide si le bundle est répertorié sur Packagist ou sur Github en public.

En privé sur Github, il faut aller dans le composer.json de notre application, indiquer une URL Github, lui donner le bon token d'accès, ça peut être vite contraignant juste pour tester.

En local, c’est encore plus simple, soit on utilise le bundle wikimedia/composer-merge-plugin qui va fusionner les packages nécessaires au fonctionnement de notre bundle (méthode obsolète) mais à présent on peut juste lui donner le path local dans le composer.json :

"repositories": [
    {
        "type": "path",
        "url": "./bundles/MyakuHealthCheck"
    }
]

On peut lancer un composer install (on supprime avant le .lock sinon il va renvoyer une erreur).

On voit que Symfony a utilisé son bundle flex pour installer notre bundle, il a enregistré automatiquement dans config/bundles.php.

Symfony flex

De cette façon, on peut dev dans notre bundle directement en local, sans souci !

4. Instanciation et routing

Après la base de notre bundle, on peut s’attaquer à l'instanciation des différents éléments et le routing pour notre endpoint.

Petit point sur la situation, voici le plan :

Schéma du bundle

Notre bundle est installé, on tape sur /healthcheck et on arrive sur notre controller, on vérifie le token.

Une fois fait, on aura juste à appeler nos différents services de contrôle, j’ai dans l’idée de faire un check :

  • de la DB
  • du cache
  • du mailer
  • de l'espace disque et de la RAM

On aura la possibilité de sortir des versions avec plus de services gérés en fonction des cas d'usage.

On va créer notre controller pour pouvoir atterrir dans notre bundle par un endpoint.

Comme une app symfony, on crée un dossier et un controller Controller/HealthCheckController :

<?php

namespace Devexploris\MyakuHealthCheck\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class HealthCheckController extends AbstractController
{
    #[Route('/healthcheck', name: 'myaku_health_check')]
    public function __invoke(): Response
    {
        dd("premier branchement de controller !");
    }
}

La configuration côté utilisateur sera d'indiquer dans config/routes.yaml les controllers de notre bundle afin que l'app symfony sache qu'elle doit utiliser notre ressource via le système d'attribute:

myaku_health_check:
    resource: Devexploris\MyakuHealthCheck\Controller\HealthCheckController
    type:     attribute

De cette manière on dit à notre bundle qu’il peut utiliser symfony/routing et on a ainsi accès aux attributs.

On pourrait plus tard le faire si on veut faire un endpoint custom que le user pourrait changer en fonction de sa propre config perso.

La doc sur les routes et les custom loaders est ici : https://symfony.com/doc/current/routing/custom_route_loader.html

On peut vérifier en se rendant sur notre navigateur avec /healthcheck et là … c'est le drame !

Erreur Symfony

Notre route est bien déclarée, mais côté bundle, on doit ajouter à notre container notre controller.

Dans notre fichier MyakuHealthCheckBundle, on enregistre notre controller :

$container->services()
    ->set(HealthCheckController::class)
    ->autowire()
    ->autoconfigure();

Tadaaaam !

Tadaaaam

Finalement qu'est-ce qu’on vient de faire là ?

On vient de brancher un contrôleur à notre bundle et de lui dire "charge-toi" car oui … les bundles sont des feignasses et ils ont besoin qu’on leur dise tout … C’est souvent source d’erreurs au début, mais c’est formateur !

C'est un petit bundle, on pourrait laisser comme ça, mais on est là pour apprendre, on va donc faire une méthode un peu plus automatique pour éviter de déclarer chaque Controller ou chaque Service un par un.

On crée un config/services.yaml et on va dire à notre bundle d’autoloader tout.

services:
    Devexploris\MyakuHealthCheck\:
        resource: '../src/*'
        autowire: true
        autoconfigure: true
        exclude: '../src/{Entity,Exception}'

Et on importe ce fichier dans notre bundle :

public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
{
    // Avant
    //        $container->services()
    //            ->set(HealthCheckController::class)
    //            ->autowire()
    //            ->autoconfigure();

    // Après
    $container->import('../config/services.yaml');
}

On recharge et hop ! Pas de changement, on est encore branché à notre controller.

Plus besoin de déclarer nos futurs fichiers de Service.

Quand on fait un bundle, il ne faut pas non plus oublier de construire la documentation pour aider le user qui va devoir faire quelques branchements. Perso je prends l’habitude de mettre toutes ces étapes dans un petit README.md qui sera mis en forme un peu plus tard et qui fera office de "doc officielle".

5. Développer les services

OK on a fait le plus dur, paramétrer le squelette du bundle et maintenant ça se comporte quasi de la même façon qu'une app Symfony.

Démarrons notre premier HealthChecker.

5.1. L'espace disque

 Il faut créer un service (quelle surprise) : src/Service/SpaceChecker avec une méthode getSpace() qui se chargera de retourner les différentes valeurs de notre disque. 

class SpaceChecker
{
    const GigaBytes = 1073741824;

    public function getSpace()
    {
        return [

        ];
    }
}

J’ai ajouté une constante pour convertir les bytes en gigabytes.

On l’injecte dans notre Controller, très classique :

public function __construct(private readonly SpaceChecker $spaceChecker) {}

#[Route('/health', name: 'health_check')]
public function __invoke(): Response
{
    $spaceHealth = $this->spaceChecker->getSpace();
    dd($spaceHealth);
}

Et grâce à notre fichier services.yaml tout est branché, c’est automatique !

On peut s’attaquer à la logique, j'aimerais un retour de tableau de ce genre :

return [
    "free" => $this->getFreeSpace(),
    "used" => $this->getUsedSpace(),
    "total" => $this->getTotalSpace(),
    "threshold" => null,
    "threshold_targeted" => null
];

On reviendra plus tard sur les deux dernières clés "threshold" et "threshold_targeted" qui permettront à l'utilisateur d'ajouter des points de contrôle.

On va créer les trois méthodes qui permettront de récupérer l'espace disque libre, utilisé et total :

  • getFreeSpace()
  • getUsedSpace()
  • getTotalSpace()
private function getFreeSpace()
{
    $freeBytes = disk_free_space('/');

    return $this->convertBytesToGo($freeBytes);
}

private function getTotalSpace()
{
    $totalBytes = disk_total_space('/');

    return $this->convertBytesToGo($totalBytes);
}

private function getUsedSpace()
{
    $usedBytes = disk_total_space('/') - disk_free_space('/');

    return $this->convertBytesToGo($usedBytes);
}

Et pour convertir nos bytes en Go, on va faire une conversion en ajoutant convertBytesToGo() et utiliser la constante de départ :

private function convertBytesToGo(float $bytes): string
{
    return sprintf("%s Go", number_format($bytes / self::GigaBytes, 2));
}

J’aimerais que l'utilisateur puisse paramétrer un peu plus son utilisation, pour ça il va pouvoir indiquer un seuil "critique" d’espace disque inutilisé.

On va devoir faire un peu de yaml.

On se rend à la racine de notre app et on crée un fichier config/packages/myaku_health_check.yaml :

myaku_health_check:
    threshold:
        space: 10 # %

Symfony de manière automatique par le nom de ce yaml sait qu'il appartient à notre bundle.

On va pouvoir y indiquer un seuil de surveillance, d'alerte.

Dans notre bundle on indique que des paramètres yaml sont à charger. Dans MyakuHealthCheckBundle.php on y ajoute cette fonction :

public function configure(DefinitionConfigurator $definition): void
{
    $definition->rootNode() // myaku_health_check
	    ->children()
	    ->arrayNode('threshold')->canBeEnabled()
	    ->children()
	    ->integerNode('space')->defaultNull()->end()
	    ->end()
	    ->end()
	    ->end();
}

Pour ceux qui n’ont pas l'habitude de manipuler du yaml, c’est un peu comme des tableaux en PHP mais très déclaratif.

On ajoute dans notre loadExtension() le paramètre à envoyer à notre checker :

public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
{
    $configurator->import('../config/services.yaml');
    $configurator->services()
        ->get(SpaceChecker::class)
        ->arg(0, $config['threshold']['space']);
}

Il ne nous reste qu’à ajouter notre variable dans notre checker et à y créer la fonction d’alerte : 

public function __construct(private ?int $spaceThreshold) {
    if ($this->spaceThreshold && $this->spaceThreshold < 0)
        $this->spaceThreshold = 0;

    if($this->spaceThreshold && $this->spaceThreshold > 100)
        $this->spaceThreshold = 100;

}

private function warning(): bool
{
    $usedPercent = (disk_total_space('/') - disk_free_space('/')) / disk_total_space('/') * 100;
    return $usedPercent >= $this->spaceThreshold;
}

Dans ce __construct(), on fait une petite vérification si jamais un user met un seuil incohérent (hors des valeurs limites).

Ensuite on met à jour notre fonction de récupération :

public function getSpace()
{
    $spaces = [
        "free" => $this->getFreeSpace(),
        "used" => $this->getUsedSpace(),
        "total" => $this->getTotalSpace(),
    ];

    if($this->spaceThreshold) {
        $spaces['threshold'] = $this->spaceThreshold . "%";
        $spaces['threshold_targeted'] = $this->warning();
    }

    return $spaces;
}

On a notre retour avec le seuil indiqué par l'utilisateur dans son fichier de config.

Si l'utilisateur n'en renseigne pas alors les deux lignes ne provoqueront pas d’erreurs et ne s'afficheront pas. C’est propre comme ça.

 

Il faudra aussi mettre à jour la doc avec ce premier checker !

 

5.2. Le token de sécurité

Ce genre d’endpoint c'est souvent sensible, on ne va pas le mettre à dispo en PUBLIC_ACCESS.

Mais parfois on n'a pas forcément besoin d’un bazooka comme le bundle Security de Symfony, par conséquent on ne va pas l'utiliser pour éviter d’alourdir la configuration.

On part sur une configuration yaml et vérifier à l’appel de notre endpoint si un token est présent, juste une comparaison token === token sera suffisante.

On va aussi indiquer de manière facultative la possibilité de mettre une whitelist d’IP.

On commence à créer notre section yaml à la racine de l’app: config/packages/myaku_health_check.yaml :

myaku_health_check:
    threshold: # optional
        space: 20 # alerte si disque utilisé à plus de X% (0-100)
    security:
        token: "%env(APP_MYAKU_TOKEN)%" # generate with openssl rand -hex 24

On mettra notre token dans notre .env.local avec la clé "APP_MYAKU_TOKEN".

Ensuite on enregistre nos nouvelles clés dans notre bundle :

public function configure(DefinitionConfigurator $definition): void
{
    $definition->rootNode()
        ->children()
        ->arrayNode('threshold')->canBeEnabled()
        ->children()
        ->integerNode('space')->defaultNull()->end()
        ->end()
        ->end()
        ->arrayNode('security')
        ->children()
        ->stringNode('token')->end()
        ->end()
        ->end();
}

Puis on crée notre SecurityChecker :

class SecurityChecker
{
    public function __construct(private readonly string $token) {}

    public function compareToken(Request $request): bool
    {
        $tokenProvided = $request->headers->get('x-myaku-token');

        if (!$tokenProvided || $this->token !== $tokenProvided)
            return false;

        return true;
    }
}

Rien de bien compliqué, on enregistre l’argument $token du __construct() dans notre bundle :

public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
{
    $configurator->import('../config/services.yaml');
    $configurator->services()
        ->get(SpaceChecker::class)
        ->arg(0, $config['threshold']['space'])
        ->get(SecurityChecker::class)
        ->arg(0, $config['security']['token']);
}

On teste avec Postman pour ajouter le header "x-myaku-token" après avoir mis à jour notre controller : 

public function __construct(private readonly SpaceChecker $spaceChecker, private readonly SecurityChecker $securityChecker) {}
#[Route('/health', name: 'health_check')]
public function __invoke(Request $request): Response
{
    if (!$this->securityChecker->compareToken($request))
        return new Response("Access error", Response::HTTP_FORBIDDEN);

On évite bien entendu de détailler le type d’erreur de token, on ne sait jamais qui peut aller s'amuser à taper /health

Avec Token
Avec le token
Sans token
Sans le token

On n'est pas mal ! Mais j'aimerais qu'on ajoute une whitelist pour améliorer encore un peu plus la sécurité.

Pour ça on va proposer de mettre un tableau d'IP à whitelister dans notre yaml à dispo du user :

myaku_health_check:
    threshold:
        space: 20 # alerte si disque utilisé à plus de X% (0-100)
    security:
        token: "%env(APP_MYAKU_TOKEN)%" # generate with openssl rand -hex 24
        whitelist: ["172.21.0.1", "127.0.0.1"]

On met aussi à jour le bundle :

public function configure(DefinitionConfigurator $definition): void
{
    $definition->rootNode()
        ->children()
        ->arrayNode('threshold')->canBeEnabled()
        ->children()
        ->integerNode('space')->defaultNull()->end()
        ->end()
        ->end()
        ->arrayNode('security')
        ->children()
        ->stringNode('token')->end()
        ->arrayNode('whitelist')->defaultValue([])
        ->scalarPrototype()->end()
        ->end()
        ->end()
        ->end();
}

public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
{
    $configurator->import('../config/services.yaml');
    $configurator->services()
        ->get(SpaceChecker::class)
        ->arg(0, $config['threshold']['space'])
        ->get(SecurityChecker::class)
        ->arg(0, $config['security']['token'])
        ->arg(1, $config['security']['whitelist']);
}

Et dans le SecurityChecker on modifie le __construct() :

public function __construct(private readonly string $token, private readonly array $whitelist) {}

public function ipIsAllowed(Request $request): bool
{
    if(empty($this->whitelist))
        return true;

    return in_array($request->getClientIp(), $this->whitelist);

}

Et le contrôleur associé :

if (!$this->securityChecker->ipIsAllowed($request))
    return new Response("You are not allowed", Response::HTTP_FORBIDDEN);

Et voilà ! On a un endpoint plutôt bien sécurisé, on peut même changer le token quand on le souhaite.

 

Pour éviter que ce tuto soit trop long, je vais faire de mon côté les autres idées que j’ai en tête côté développement, on va passer directement côté déploiement.

 

Voici ce que j’ai mis en plus dans le bundle :

  • Adaptation du code response en fonction du threshold_targeted
  • Ajout d’un DB checker, d’un cache checker et d’un memory checker (mémoire RAM).

Pour utiliser les services Symfony directement comme la connexion à la base de données ou au cache, on utilise dans le loadExtension() du bundle une référence au service que l’on souhaite injecter :

->get(DatabaseChecker::class)
    ->arg(0, new Reference('doctrine.dbal.default_connection', ContainerInterface::NULL_ON_INVALID_REFERENCE))

De cette façon on a accès à la variable $connection de la DB et on peut aller ensuite faire un select pour vérifier que la connexion est bien OK.

6. Déployer son bundle

Avant de déployer on va vérifier certaines choses du composer.json.

Déjà, bien vérifier que l’email est renseigné pour notre bundle, c’est important si tu souhaites être contacté pour un problème de compatibilité, une issue etc …

6.1. Partie Git et Github

Toujours dans composer.json :

"suggest": {
    "doctrine/dbal": "Required to enable database connectivity check",
    "symfony/cache": "Required to enable cache connectivity check"
},

Le bundle peut utiliser une connexion Doctrine, tester le cache. Par conséquent on recommande de l’avoir avec la clé "suggest".

Puis un champ "keywords" pour la partie SEO de Packagist.

"keywords": ["symfony", "bundle", "health-check", "monitoring", "healthcheck", "endpoint"],

On oublie pas de faire une belle documentation pour donner envie d’installer le bundle (combien de fois j’ai eu la flemme d’installer un bundle car il n’y avait pas de doc …).

Rendez-vous sur Github et on crée un nouveau repo public. Pour ma part : Myaku-Health-Check.

Je me positionne ensuite en CLI dans le bundle puis la célèbre suite de commandes git :

git init
git add .
git commit -m "Initial commit"
git remote add origin ...
git branch -M main
git push -u origin main

Notre bundle est maintenant sur github !

D’un point de vue déploiement, c'est une étape de franchie car il suffirait que pour chaque projet, on spécifie l'URL publique de notre dépôt pour qu'il s'installe. 

C’est pas mal mais on préfère utiliser la recherche automatique de Packagist qui est plus pratique. On va créer une release de notre code car Packagist se réfère aux releases :

Github
Dépôt Github
Les tags
Création du tag

Et enfin, on "Publish Release".

De cette manière, on fait un snapshot de notre code via une release que l'on met à dispo pour Packagist.

Notre release

6.2. Partie Packagist

On se rend chez Packagist. Si pas de compte, on s'en crée un puis on clique sur "submit", on link le lien du dépôt public et on clique sur "check".

On envoie sur Packagist
On envoie sur Packagist

On confirme une seconde fois et voilà c’est en ligne !

Pour s'en assurer, on peut aller dans notre projet, retirer config/packages/myaku_health_check.yaml, le routing dans config/routes.yaml et l'ajout manuel dans le composer.json du projet.

Puis un coup de composer remove devexploris/myaku-health-check pour supprimer le lien.

On tente de l'installer via packagist maintenant : composer require devexploris/myaku-health-check.

composer require

Je pense que c'est une bonne conclusion pour terminer ! J'espère que cet article t'aura donné envie de faire de l’open source 🙂

OpenSource
(forum.endeavouros.com)