Intégrer une nouvelle application dans libre.sh — Notes développeur
Public visé : développeurs Go souhaitant rendre une application FLOSS déployable par libre.sh, en suivant les conventions du dépôt.
Module Go :
libre.shRéférentiel : https://forge.heeboo.org/thunerbl/libre.sh
1. Vue d’ensemble de l’architecture
libre.sh est un opérateur Kubernetes (controller-runtime) qui rend des
applications FLOSS déployables via des Custom Resources (CRs).
L’opérateur principal (cmd/main.go) enregistre deux familles de
contrôleurs :
| Famille | Fonction | Démarrage |
|---|---|---|
setupClusterControllers | Infra cluster : Postgres, Redis, Bucket, Mailbox, OIDCClient, SAMLClient, Keycloak, portabilité, PVC… | Toujours |
setupTenantControllers | Applications locataires : Nextcloud, Forgejo, Hedgedoc, Synapse, Visio,votre app… | Mode tenant uniquement |
Chaque application suit le même schéma à cinq couches :
api/apps/v1alpha1/<app>_types.go ← définition CRD (Spec/Status)
internal/controller/apps/<app>_controller*.go ← logique de réconciliation
cmd/main.go ← enregistrement dans l'opérateur
cmd/app-operator/<app>/main.go ← opérateur standalone (optionnel)
config/samples/apps_v1alpha1_<app>.yaml ← exemple utilisateur
2. Primitives disponibles (core.libre.sh)
Avant d’écrire une ligne de contrôleur, identifiez les primitives dont votre application a besoin. Elles sont toutes déjà opérationnelles :
| Ressource | Kind | Usage typique | Méthode clé |
|---|---|---|---|
| Base de données | Postgres | Toute app avec état | pg.SecretName() → <nom>.postgres.libre.sh |
| Cache | Redis | Sessions, workers | redis.SecretName() → <nom>.redis.libre.sh |
| Stockage objet | Bucket | Fichiers, assets, backups | bucket.SecretName() → <nom>.bucket.libre.sh |
| SMTP | Mailbox | Notifications e-mail | mailbox.SecretName() → <nom>.mailbox.libre.sh |
| SSO | OIDCClient | Authentification Keycloak | oidc.SecretName() → <nom>.oidc.sso.libre.sh |
| SSO (SAML) | SAMLClient | Alternative SAML | saml.SecretName() → <nom>.saml.sso.libre.sh |
Chaque secret contient des clés standardisées définies dans
api/meta/v1alpha1 :
lshmeta.SecretHostKey // "host"
lshmeta.SecretPortKey // "port"
lshmeta.SecretPathKey // "path" (base de données ou bucket)
lshmeta.SecretUsernameKey // "username"
lshmeta.SecretPasswordKey // "password"
lshmeta.SecretURLKey // "url" (URL complète, pour Postgres)
lshmeta.SecretAddressKey // "address" (pour Mailbox)3. Étape 1 — Définir le type CRD
Créez api/apps/v1alpha1/<app>_types.go en respectant ces règles :
// +lsh:gen:api ← déclenche la génération des helpers lsh
type MonAppSpec struct {
lshmeta.Spec `json:",inline"` // donne GetSuspend/SetSuspend
// +kubebuilder:validation:Required
Host string `json:"host"` // TOUJOURS présent
// +kubebuilder:validation:Required
Image string `json:"image"` // tag unique, l'opérateur construit les images complètes
// +kubebuilder:validation:Required
MailboxRef corev1.LocalObjectReference `json:"mailboxRef"`
// Optionnel : sources d'env supplémentaires
EnvFrom []corev1.EnvFromSource `json:"envFrom,omitempty"`
// Optionnel : config avancée fusionnée par CUE
Config json.RawMessage `json:"config,omitempty"`
}
type MonAppStatus struct {
lshmeta.Status `json:",inline"` // donne Conditions + Version
}Règles immuables :
- Toujours intégrer
lshmeta.Specen ligne dans le Spec. - Toujours intégrer
lshmeta.Statusen ligne dans le Status. - Ajouter
// +lsh:gen:apisur le struct principal pour la génération. - Ajouter les annotations
+kubebuilder:printcolumnpourHost,Ready,Status,Version,Suspended(voirforgejo_types.gocomme modèle).
Après modification, régénérer :
make manifests generate4. Étape 2 — Nommage des ressources dépendantes
Le package pkg/controller-runtime fournit une fonction de nommage
déterministe et partagée par tous les contrôleurs :
// Nom d'une dépendance sans suffixe (Postgres, Redis, Bucket, OIDC) :
// → "<nom-cr>--<code-court>"
// Exemple pour un Penpot nommé "acme" → "acme--pe"
lshr.SetResourceNamespacedName(parent, &dep)
// Nom avec suffixe (backend, frontend, exporter...) :
// → "<nom-cr>--<code-court>-<suffixe>"
// Exemple → "acme--pe-backend"
lshr.SetResourceNamespacedName(parent, &deploy, "backend")Le code court est les 2 premières lettres du Kind en minuscules, sauf
exceptions déclarées dans shortCodes (postgres → pg,
nextcloud → nc, etc.).
À retenir pour les tests : calculez à l’avance le nom attendu des ressources créées par votre contrôleur pour rédiger des assertions précises.
5. Étape 3 — Structure du contrôleur
5.1 Squelette type
Créez internal/controller/apps/<app>_controller.go :
// +lsh:gen:app-operator ← génère cmd/app-operator/<app>/main.go
type MonAppReconciler struct {
client.Client
}
type monAppResources struct {
postgres *lshcore.Postgres
redis *lshcore.Redis
bucket *lshcore.Bucket
mailbox *lshcore.Mailbox
oidcClient *lshcore.OIDCClient
secret *corev1.Secret
}5.2 Boucle de réconciliation (ordre à respecter)
func (r *MonAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 1. Initialisation : fetch du CR, finalizer, suspend
var app lshapps.MonApp
patcher, result := lshr.Initialize(ctx, r, req, &app)
if result != nil {
return result.Unwrap()
}
// 2. Finalisation (suppression propre)
if lshr.IsFinalizing(&app) {
return lshr.Finalize(ctx, r, patcher, &app, func() error {
// nettoyage spécifique (ex: libérer un Bucket)
return nil
})
}
res := monAppResources{}
// 3. Réseau en premier (ACME/cert-manager peut commencer tôt)
if _, err := r.reconcileIngress(ctx, &app); err != nil {
return ctrl.Result{}, err
}
// 4. Réconcilier les dépendances
if err := r.reconcilePostgres(ctx, &app, &res); err != nil {
return ctrl.Result{}, err
}
// ... redis, bucket, oidc...
// 5. Fetch Mailbox (non créée par l'opérateur, juste lue)
if err := r.fetchMailbox(ctx, &app, &res); err != nil {
return ctrl.Result{}, err
}
// 6. Conditions de dépendance → sortie anticipée si pas prêtes
lshr.SetDependencyCondition(&app, res.postgres, res.redis, res.bucket, res.mailbox, res.oidcClient)
if err := lshr.Patch(ctx, r, patcher, &app, lshr.PatchOpts{}); err != nil {
return ctrl.Result{}, err
}
if lshr.IsDependencyNotReady(&app) || lshr.IsImporting(&app) {
return ctrl.Result{}, nil
}
// 7. Secret applicatif (CUE → map d'env vars)
if err := r.reconcileSecret(ctx, &app, &res); err != nil {
return ctrl.Result{}, err
}
// 8. Workloads (Deployments, Services)
deploy, err := r.reconcileDeployment(ctx, &app, &res)
if err != nil {
return ctrl.Result{}, err
}
// 9. Condition de readiness des workloads
lshr.SetWorkloadCondition(&app, deploy)
if err := lshr.Patch(ctx, r, patcher, &app, lshr.PatchOpts{}); err != nil {
return ctrl.Result{}, err
}
// 10. Finalisation de la réconciliation (supprime Reconciling/Stalled)
return ctrl.Result{}, lshr.Complete(ctx, r, patcher, &app, lshr.PatchOpts{})
}5.3 Hooks de cycle de vie (optionnel)
Utilisez OnInstall / OnUpgrade uniquement si votre application nécessite
un Job de migration ou d’initialisation externe (comme Nextcloud ou Visio).
Les applications qui migrent d’elles-mêmes au démarrage (Forgejo, Penpot)
n’en ont pas besoin.
if err := lshr.OnInstall(&app, func() error {
return r.reconcileMigrateJob(ctx, &app)
}); err != nil {
return ctrl.Result{}, err
}
lshr.SetHookJobCondition(&app, migrateJob)
if lshr.IsHookJobNotComleted(&app) {
return ctrl.Result{}, nil
}6. Étape 4 — Configuration via CUE
Le pattern CUE est préféré au câblage manuel d’env vars car il est
idempotent, testable et modifiable à chaud via spec.config.
6.1 Principe
- Créez
internal/controller/apps/<app>_controller_config.cue. - Le schéma CUE reçoit les données des secrets de dépendances via
WithValue("_postgres", postgresSecret.Data), etc. - Il produit un map JSON de clés/valeurs correspondant exactement aux variables d’environnement de l’application.
GenerateConfigfusionne en dernier les valeurs despec.config(les clés utilisateur gagnent toujours).- Le Secret applicatif est rempli clé par clé depuis ce map.
6.2 Annotations CUE disponibles
// Génère une valeur aléatoire à la première réconciliation,
// puis la conserve (idempotent via PreviousValue).
SECRET_KEY: string @lsh(generate:key) // chaîne aléatoire 32 chars
DB_UUID: string @lsh(generate:uuid) // UUID v4
TOKEN: string @lsh(generate:ulid) // ULID6.3 Fusion des overrides utilisateur
// Toujours en dernière position dans le schéma :
if _app.spec.config != _|_ {
for k, v in _app.spec.config {
"\(k)": v
}
}L’utilisateur peut donc surcharger n’importe quelle variable via
spec.config sans modifier le code du contrôleur.
7. Étape 5 — Enregistrer le contrôleur
Dans l’opérateur principal (cmd/main.go)
Ajoutez dans setupTenantControllers :
if err = (&appscontroller.MonAppReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "MonApp")
os.Exit(1)
}Opérateur standalone (cmd/app-operator/<app>/main.go)
Généré automatiquement si // +lsh:gen:app-operator est présent sur le
struct du réconciliateur. Structure minimale à créer manuellement si la
génération n’est pas encore câblée :
package main
import appoperator "libre.sh/internal/app-operator"
func main() {
appoperator.Run(func(mgr manager.Manager) error {
return (&apps.MonAppReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr)
})
}8. Étape 6 — Tests
Structure des tests (convention libre.sh)
Les tests utilisent Ginkgo/Gomega + envtest avec un k8sClient simple
(client.New, sans manager). Le réconciliateur est appelé directement
via Reconcile(), de manière synchrone.
internal/controller/apps/
├── suite_test.go ← BeforeSuite/AfterSuite, schémas enregistrés
└── <app>_controller_test.go ← scénarios Ginkgo par Context/It
Schémas à enregistrer dans suite_test.go
err = appsv1alpha1.AddToScheme(scheme.Scheme) // CRDs apps libre.sh
err = corev1alpha1.AddToScheme(scheme.Scheme) // CRDs core libre.sh
err = appsv1.AddToScheme(scheme.Scheme) // Deployments Kubernetes
err = netv1.AddToScheme(scheme.Scheme) // Ingress KubernetesAnatomie d’un test (deux scenarios minimaux)
Scenario 1 — premier appel à Reconcile
Vérifier que les CRs de dépendance sont créés et que la condition
DependenciesNotReady est posée.
Scenario 2 — deuxième appel après dépendances prêtes
Pré-créer les cinq Secrets de dépendances (simule les contrôleurs core),
marquer les CRs de dépendance Ready via Status().Update, appeler
Reconcile à nouveau, puis vérifier le Secret applicatif, les
Deployments et les Services.
// Modèle de pre-création de secret de dépendance
Expect(k8sClient.Create(ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pg.SecretName(), // ex: "acme--pe.postgres.libre.sh"
Namespace: "default",
},
Data: map[string][]byte{
lshmeta.SecretHostKey: []byte("postgres.default.svc"),
lshmeta.SecretPortKey: []byte("5432"),
lshmeta.SecretPathKey: []byte("myapp"),
lshmeta.SecretUsernameKey: []byte("myapp"),
lshmeta.SecretPasswordKey: []byte("secret"),
lshmeta.SecretURLKey: []byte("postgresql://..."),
},
})).To(Succeed())Commandes de test
# Suite complète (avec régénération)
make test
# Package apps uniquement
export KUBEBUILDER_ASSETS="$(./bin/setup-envtest use 1.28.0 --bin-dir ./bin -p path)"
go test ./internal/controller/apps/... -v
# Un seul contrôleur
go test ./internal/controller/apps/... -v -run "MonApp"
# Un seul scénario Ginkgo
go test ./internal/controller/apps/... -v -run "MonApp" \
--ginkgo.focus "second reconcile"9. Cycle de développement quotidien
| Action | Commande |
|---|---|
Modification d’un *_controller.go uniquement | go test ./internal/controller/apps/... -v -run "<App>" |
Modification d’un *_types.go (champs, markers) | make manifests generate puis go test ... |
| Ajout d’un nouveau Kind | make manifests generate (CRD YAML + deepcopy) |
| Formater le code | make fmt (équivalent gofmt ./...) |
| Vérifier lint | make vet && make lint |
| Lancer l’opérateur en local sur un vrai cluster | make install && make run |
| Reconstruire l’image opérateur | make docker-build IMG=<repo>:<tag> |
10. Checklist d’intégration
Avant d’ouvrir une PR, vérifiez chaque point :
-
api/apps/v1alpha1/<app>_types.go— Spec aveclshmeta.Specinline, Status aveclshmeta.Statusinline,+lsh:gen:api, printcolumns Host/Ready/Status/Version/Suspended -
make manifests generaterelancé — CRD YAML etzz_generated.deepcopy.goà jour - Contrôleur splitté en fichiers thématiques (
_postgres.go,_redis.go,_bucket.go,_oidc.go,_secret.go,_deploy.go,_net.go) -
lshr.Initializeen début deReconcile,lshr.Completeen fin -
lshr.SetDependencyConditionappelé avant toute création de workload -
lshr.SetWorkloadConditionappelé après création des Deployments -
controllerutil.SetControllerReferencesur chaque ressource créée -
SetupWithManagerdéclareOwns()pour chaque type créé - Enregistrement dans
setupTenantControllers(cmd/main.go) -
cmd/app-operator/<app>/main.gocréé ou généré -
config/samples/apps_v1alpha1_<app>.yamlfourni -
suite_test.goenregistrecorev1alpha1.AddToSchemesi pas déjà fait -
<app>_controller_test.gocouvre le scenario 1 (deps créées) et le scenario 2 (workloads créés) -
make fmt && make vet && make lintpassent sans erreur -
make testpasse
11. Référence rapide des helpers pkg/controller-runtime
| Fonction | Rôle |
|---|---|
lshr.Initialize | Fetch du CR, ajout finalizer, gestion suspend, observation generation |
lshr.Finalize | Suppression propre avec callback, retire le finalizer |
lshr.IsFinalizing | Vrai si DeletionTimestamp est posé |
lshr.Complete | Supprime les conditions Reconciling/Stalled, met à jour le patch |
lshr.SetDependencyCondition | Pose/supprime DependenciesNotReady selon l’état des dépendances |
lshr.IsDependencyNotReady | Retourne vrai si au moins une dépendance n’est pas Ready |
lshr.IsImporting | Vrai si une opération d’import est en cours |
lshr.SetWorkloadCondition | Pose/supprime WorkloadsNotReady selon l’état des Deployments |
lshr.OnInstall | Exécute un hook uniquement à la première installation |
lshr.OnUpgrade | Exécute un hook à chaque mise à jour de version |
lshr.SetHookJobCondition | Pose HookJobNotComleted si un Job n’est pas terminé |
lshr.IsHookJobNotComleted | Retourne vrai si le Job de hook n’est pas fini |
lshr.GetResourceName | Calcule le nom déterministe d’une ressource enfant |
lshr.SetResourceNamespacedName | Applique nom + namespace sur une ressource enfant |
lshr.ApplyLabels | Pose les labels app.kubernetes.io/* standards |
lshr.ExtractLabelSelector | Extrait le sélecteur depuis les labels d’une ressource |
lshr.CreateOrPatch | Create-or-patch idempotent autour d’un callback mutate |
lshr.Patch | Patch status/metadata via SerialPatcher |
12. Exemple de PR minimale
Pour une application FooBar simple (une image, Postgres, Redis, SMTP) :
api/apps/v1alpha1/foobar_types.go
api/apps/v1alpha1/zz_generated.foobar_lsh.go ← généré
api/apps/v1alpha1/zz_generated.deepcopy.go ← mis à jour
config/crd/bases/apps.libre.sh_foobars.yaml ← généré
config/samples/apps_v1alpha1_foobar.yaml
internal/controller/apps/foobar_controller.go
internal/controller/apps/foobar_controller_postgres.go
internal/controller/apps/foobar_controller_redis.go
internal/controller/apps/foobar_controller_secret.go
internal/controller/apps/foobar_controller_config.cue
internal/controller/apps/foobar_controller_deploy.go
internal/controller/apps/foobar_controller_net.go
internal/controller/apps/foobar_controller_test.go
cmd/main.go ← +1 ligne dans setupTenantControllers
cmd/app-operator/foobar/main.go
Soit 11 nouveaux fichiers et 1 ligne modifiée.