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.sh Ré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 :

FamilleFonctionDémarrage
setupClusterControllersInfra cluster : Postgres, Redis, Bucket, Mailbox, OIDCClient, SAMLClient, Keycloak, portabilité, PVC…Toujours
setupTenantControllersApplications locataires : Nextcloud, Forgejo, Hedgedoc, Synapse, Visio,votre appMode 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 :

RessourceKindUsage typiqueMéthode clé
Base de donnéesPostgresToute app avec étatpg.SecretName()<nom>.postgres.libre.sh
CacheRedisSessions, workersredis.SecretName()<nom>.redis.libre.sh
Stockage objetBucketFichiers, assets, backupsbucket.SecretName()<nom>.bucket.libre.sh
SMTPMailboxNotifications e-mailmailbox.SecretName()<nom>.mailbox.libre.sh
SSOOIDCClientAuthentification Keycloakoidc.SecretName()<nom>.oidc.sso.libre.sh
SSO (SAML)SAMLClientAlternative SAMLsaml.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.Spec en ligne dans le Spec.
  • Toujours intégrer lshmeta.Status en ligne dans le Status.
  • Ajouter // +lsh:gen:api sur le struct principal pour la génération.
  • Ajouter les annotations +kubebuilder:printcolumn pour Host, Ready, Status, Version, Suspended (voir forgejo_types.go comme modèle).

Après modification, régénérer :

make manifests generate

4. É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 (postgrespg, nextcloudnc, 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

  1. Créez internal/controller/apps/<app>_controller_config.cue.
  2. Le schéma CUE reçoit les données des secrets de dépendances via WithValue("_postgres", postgresSecret.Data), etc.
  3. Il produit un map JSON de clés/valeurs correspondant exactement aux variables d’environnement de l’application.
  4. GenerateConfig fusionne en dernier les valeurs de spec.config (les clés utilisateur gagnent toujours).
  5. 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)  // ULID

6.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 Kubernetes

Anatomie 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

ActionCommande
Modification d’un *_controller.go uniquementgo test ./internal/controller/apps/... -v -run "<App>"
Modification d’un *_types.go (champs, markers)make manifests generate puis go test ...
Ajout d’un nouveau Kindmake manifests generate (CRD YAML + deepcopy)
Formater le codemake fmt (équivalent gofmt ./...)
Vérifier lintmake vet && make lint
Lancer l’opérateur en local sur un vrai clustermake install && make run
Reconstruire l’image opérateurmake 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 avec lshmeta.Spec inline, Status avec lshmeta.Status inline, +lsh:gen:api, printcolumns Host/Ready/Status/Version/Suspended
  • make manifests generate relancé — CRD YAML et zz_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.Initialize en début de Reconcile, lshr.Complete en fin
  • lshr.SetDependencyCondition appelé avant toute création de workload
  • lshr.SetWorkloadCondition appelé après création des Deployments
  • controllerutil.SetControllerReference sur chaque ressource créée
  • SetupWithManager déclare Owns() pour chaque type créé
  • Enregistrement dans setupTenantControllers (cmd/main.go)
  • cmd/app-operator/<app>/main.go créé ou généré
  • config/samples/apps_v1alpha1_<app>.yaml fourni
  • suite_test.go enregistre corev1alpha1.AddToScheme si pas déjà fait
  • <app>_controller_test.go couvre le scenario 1 (deps créées) et le scenario 2 (workloads créés)
  • make fmt && make vet && make lint passent sans erreur
  • make test passe

11. Référence rapide des helpers pkg/controller-runtime

FonctionRôle
lshr.InitializeFetch du CR, ajout finalizer, gestion suspend, observation generation
lshr.FinalizeSuppression propre avec callback, retire le finalizer
lshr.IsFinalizingVrai si DeletionTimestamp est posé
lshr.CompleteSupprime les conditions Reconciling/Stalled, met à jour le patch
lshr.SetDependencyConditionPose/supprime DependenciesNotReady selon l’état des dépendances
lshr.IsDependencyNotReadyRetourne vrai si au moins une dépendance n’est pas Ready
lshr.IsImportingVrai si une opération d’import est en cours
lshr.SetWorkloadConditionPose/supprime WorkloadsNotReady selon l’état des Deployments
lshr.OnInstallExécute un hook uniquement à la première installation
lshr.OnUpgradeExécute un hook à chaque mise à jour de version
lshr.SetHookJobConditionPose HookJobNotComleted si un Job n’est pas terminé
lshr.IsHookJobNotComletedRetourne vrai si le Job de hook n’est pas fini
lshr.GetResourceNameCalcule le nom déterministe d’une ressource enfant
lshr.SetResourceNamespacedNameApplique nom + namespace sur une ressource enfant
lshr.ApplyLabelsPose les labels app.kubernetes.io/* standards
lshr.ExtractLabelSelectorExtrait le sélecteur depuis les labels d’une ressource
lshr.CreateOrPatchCreate-or-patch idempotent autour d’un callback mutate
lshr.PatchPatch 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.