temps de lecture : 18 minutes

Pourquoi taper des commandes docker à la main quand on peut tout encapsuler dans des tâches Gradle ? Voici comment j’ai industrialisé ma config dual-Ollama Pro avec zéro script shell externe. Démarrage, arrêt, identité, sign-in, pull — tout passe par ./gradlew.

1. Introduction

Dans l’article précédent, j’ai montré comment faire cohabiter deux comptes Ollama Pro sur la même machine en isolant la deuxième instance dans un conteneur Docker. Le montage fonctionne, mais la gestion au quotidien est vite pénible : se souvenir du nom du conteneur, relancer les bonnes commandes docker exec, checker quel port est actif…​

Vous connaissez ce moment où vous avez 14 terminaux ouverts, que vous tapez docker exec ollama-instance-b ollama list pour la quatrième fois, et que vous vous dites « il doit bien y avoir un moyen plus propre » ?

Ce moyen, c’est Gradle.

Pas juste pour builder du code. Gradle comme un chef d’orchestre d’infrastructure locale. Des tâches qui provisionnent, vérifient, testent, et nettoient. Le tout versionné dans un build.gradle.kts qui vit à côté du docker-compose.yml.

architecture gradle ollama

2. Pourquoi Gradle Pour de l’Infra ?

La question est légitime. Gradle, c’est un build tool. Normalement, il compile, il teste, il package. Pas il lance des conteneurs Docker.

Sauf que Gradle est aussi un moteur de graphe de dépendances — et c’est là que ça devient intéressant pour notre cas.

On veut : . Que le conteneur soit lancé avant un sign-in . Que l’identité soit vérifiée avant un pull de modèle . Qu’un test API échoue silencieusement si le conteneur est down

Du graphe de dépendances pur. Gradle est taillé pour ça. Le DSL Kotlin rend l’écriture des tâches fluide, et le doLast permet d’exécuter des commandes système comme si c’était du code applicatif.

Et surtout : pas de bashrc à maintenir, pas de scripts qui traînent dans ~/bin, pas de Makefile à côté du docker-compose. Tout vit dans un seul fichier, au même endroit que le code du projet.

3. Le build.gradle.kts Complet

Voici le fichier que j’ai fini par écrire. Il fait tout, du provisionnement du volume jusqu’au curl de vérification, en passant par le sign-in interactif.

import java.io.ByteArrayOutputStream

plugins {
    base
}

// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
val instanceName   = "ollama-instance-b"
val instancePort   = 11435
val instanceImage  = "ollama/ollama:0.20.2"
val dataDir        = file("${System.getProperty("user.home")}/ollama-b-data")
val nativePort     = 11434

// Modèles à gérer — cloud Pro + un modèle local gratuit pour les tests
val proModels      = listOf("deepseek-v4-pro:cloud", "gemma4:31b-cloud")
val freeModels     = listOf("qwen3:0.6b")

// -------------------------------------------------------------------------
// Tâche préparatoire — création du volume si absent
// -------------------------------------------------------------------------
val prepareVolume by tasks.registering {
    group       = "ollama"
    description = "Crée le répertoire de volume pour l'instance B si absent"
    doLast {
        if (!dataDir.exists()) {
            dataDir.mkdirs()
            println("Volume créé : ${dataDir.absolutePath}")
        } else {
            println("Volume existant : ${dataDir.absolutePath}")
        }
    }
}

// -------------------------------------------------------------------------
// Démarrage du conteneur Docker
// -------------------------------------------------------------------------
val startOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Lance le conteneur Docker pour l'instance Ollama B"
    dependsOn(prepareVolume)

    commandLine(
        "docker", "run", "-d",
        "--name", instanceName,
        "-p", "$instancePort:11434",
        "-v", "${dataDir.absolutePath}:/root/.ollama",
        "-e", "OLLAMA_HOST=0.0.0.0",
        "--restart", "always",
        instanceImage
    )
    isIgnoreExitValue = true

    doLast {
        val alreadyRunning = executionResult.get().exitValue != 0
        if (alreadyRunning) {
            logger.lifecycle("Le conteneur '$instanceName' existe déjà — tentative de démarrage")
        } else {
            logger.lifecycle("Conteneur '$instanceName' lancé")
        }
    }
}

// -------------------------------------------------------------------------
// Arrêt du conteneur
// -------------------------------------------------------------------------
val stopOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Arrête le conteneur Docker de l'instance B"

    commandLine("docker", "stop", instanceName)
    isIgnoreExitValue = true

    doLast {
        logger.lifecycle("Conteneur '$instanceName' arrêté")
    }
}

// -------------------------------------------------------------------------
// Affichage de la clé publique (Device Key)
// -------------------------------------------------------------------------
val checkIdentity by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Affiche la clé publique SSH de l'instance B"
    dependsOn(startOllamaB)

    commandLine("docker", "exec", instanceName, "cat", "/root/.ollama/id_ed25519.pub")
    standardOutput = ByteArrayOutputStream()

    doLast {
        val pubKey = standardOutput.toString().trim()
        println("=".repeat(60))
        println("Device Key (Instance B)")
        println("=".repeat(60))
        println(pubKey)
        println("=".repeat(60))
        println("→ Enregistre cette clé sur https://ollama.com/settings/keys")
        println("→ Puis exécute : ./gradlew signInB")
        println("=".repeat(60))
    }
}

// -------------------------------------------------------------------------
// Sign-in interactif (Ouvrira le navigateur)
// -------------------------------------------------------------------------
val signInB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Lance l'authentification interactive Ollama pour le compte B"
    dependsOn(startOllamaB)

    standardInput  = System.`in`
    standardOutput = System.out
    errorOutput    = System.err

    commandLine("docker", "exec", "-it", instanceName, "ollama", "signin")

    doFirst {
        logger.lifecycle("Authentification pour le Compte Pro B...")
        logger.lifecycle("Ouvre ton navigateur et connecte-toi avec l'email du compte B")
    }
}

// -------------------------------------------------------------------------
// Pull d'un modèle (paramétrable via propriété Gradle)
// -------------------------------------------------------------------------
val pullModelB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Pull un modèle sur l'instance B. Usage : ./gradlew pullModelB -Pmodel=nom:tag"
    dependsOn(startOllamaB)

    val modelName = project.findProperty("model") as? String ?: "qwen3:0.6b"

    commandLine("docker", "exec", instanceName, "ollama", "pull", modelName)

    doFirst {
        logger.lifecycle("Pull du modèle '$modelName' sur l'instance B...")
    }

    doLast {
        logger.lifecycle("Modèle '$modelName' pullé avec succès")
    }
}

// -------------------------------------------------------------------------
// Pull de tous les modèles Pro (batch)
// -------------------------------------------------------------------------
val pullAllProModels by tasks.registering {
    group       = "ollama"
    description = "Pull tous les modèles cloud Pro sur l'instance B"
    dependsOn(startOllamaB)

    doLast {
        proModels.forEach { model ->
            logger.lifecycle("→ Pull $model...")
            val process = ProcessBuilder("docker", "exec", instanceName, "ollama", "pull", model)
                .inheritIO()
                .start()
            process.waitFor()
        }
        logger.lifecycle("Tous les modèles Pro pullés")
    }
}

// -------------------------------------------------------------------------
// Pull de tous les modèles gratuits (batch / première install)
// -------------------------------------------------------------------------
val pullAllFreeModels by tasks.registering {
    group       = "ollama"
    description = "Pull tous les modèles locaux gratuits sur l'instance B"
    dependsOn(startOllamaB)

    doLast {
        freeModels.forEach { model ->
            logger.lifecycle("→ Pull $model...")
            val process = ProcessBuilder("docker", "exec", instanceName, "ollama", "pull", model)
                .inheritIO()
                .start()
            process.waitFor()
        }
        logger.lifecycle("Tous les modèles gratuits pullés")
    }
}

// -------------------------------------------------------------------------
// Liste des modèles disponibles sur l'instance B
// -------------------------------------------------------------------------
val listModelsB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Liste les modèles disponibles sur l'instance B"
    dependsOn(startOllamaB)

    commandLine("docker", "exec", instanceName, "ollama", "list")
}

// -------------------------------------------------------------------------
// Health check — test API sur les deux instances
// -------------------------------------------------------------------------
val testOllamaB by tasks.registering {
    group       = "ollama"
    description = "Teste la connectivité API de l'instance B avec un simple chat"
    dependsOn(startOllamaB)

    doLast {
        val model = project.findProperty("model") as? String ?: "qwen3:0.6b"
        logger.lifecycle("Test de l'instance B avec le modèle '$model'...")

        val payload = """
            {
              "model": "$model",
              "messages": [{"role": "user", "content": "Réponds uniquement par OK"}],
              "max_tokens": 5
            }
        """.trimIndent()

        val process = ProcessBuilder(
            "curl", "-s", "http://localhost:$instancePort/v1/chat/completions",
            "-H", "Content-Type: application/json",
            "-d", payload
        ).start()

        val response = process.inputStream.bufferedReader().readText()
        process.waitFor()

        if (response.contains("\"id\":\"chatcmpl") || response.contains("\"choices\"")) {
            println("✓ Instance B OK — port $instancePort répond")
            println("  Response: ${response.take(120)}...")
        } else {
            println("✗ Instance B INACTIVE — vérifie le conteneur avec './gradlew statusOllamaAll'")
            println("  Raw: $response")
        }
    }
}

// -------------------------------------------------------------------------
// Health check sur les deux instances simultanément
// -------------------------------------------------------------------------
val statusOllamaAll by tasks.registering {
    group       = "ollama"
    description = "Vérifie le statut des deux instances Ollama (native + Docker)"

    doLast {
        // Instance native
        runCatching {
            val process = ProcessBuilder(
                "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
                "http://localhost:$nativePort/api/tags"
            ).start()
            val code = process.inputStream.bufferedReader().readText().trim()
            process.waitFor()
            println(if (code == "200") "✓ Instance Native   — port $nativePort OK (HTTP $code)"
                    else "✗ Instance Native — port $nativePort (HTTP $code)")
        }.getOrElse {
            println("✗ Instance Native — injoignable sur le port $nativePort")
        }

        // Instance Docker
        runCatching {
            val process = ProcessBuilder(
                "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
                "http://localhost:$instancePort/api/tags"
            ).start()
            val code = process.inputStream.bufferedReader().readText().trim()
            process.waitFor()
            println(if (code == "200") "✓ Instance Docker B — port $instancePort OK (HTTP $code)"
                    else "✗ Instance Docker B — port $instancePort (HTTP $code)")
        }.getOrElse {
            println("✗ Instance Docker B — injoignable sur le port $instancePort")
        }

        // Info conteneur
        runCatching {
            val process = ProcessBuilder("docker", "ps", "--filter", "name=$instanceName",
                "--format", "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}").start()
            val status = process.inputStream.bufferedReader().readText()
            process.waitFor()
            println()
            println("Conteneur Docker :")
            println(status)
        }
    }
}

// -------------------------------------------------------------------------
// Nettoyage complet
// -------------------------------------------------------------------------
val cleanOllamaB by tasks.registering(Exec::class) {
    group       = "ollama"
    description = "Arrête et supprime le conteneur Docker de l'instance B"
    dependsOn(stopOllamaB)

    commandLine("docker", "rm", instanceName)
    isIgnoreExitValue = true

    doLast {
        logger.lifecycle("Conteneur '$instanceName' supprimé")
        logger.lifecycle("Le volume ${dataDir.absolutePath} est conservé (identité persistante)")
    }
}

// -------------------------------------------------------------------------
// Affichage des instructions
// -------------------------------------------------------------------------
val helpOllama by tasks.registering {
    group       = "ollama"
    description = "Affiche l'aide des tâches Ollama disponibles"

    doLast {
        println("""
        ╔══════════════════════════════════════════════════════════════╗
        ║         Tâches Gradle — Pilotage Dual Ollama Pro            ║
        ╠══════════════════════════════════════════════════════════════╣
        ║  ./gradlew startOllamaB      Démarrer l'instance Docker B   ║
        ║  ./gradlew stopOllamaB       Arrêter l'instance Docker B    ║
        ║  ./gradlew checkIdentity     Afficher la clé SSH publique   ║
        ║  ./gradlew signInB           Sign-in interactif compte B    ║
        ║  ./gradlew pullModelB        Pull un modèle (-Pmodel=...)   ║
        ║  ./gradlew pullAllProModels  Pull tous les modèles Pro      ║
        ║  ./gradlew pullAllFreeModels Pull tous les modèles gratuits ║
        ║  ./gradlew listModelsB       Lister les modèles instance B  ║
        ║  ./gradlew testOllamaB       Test API avec un chat simple   ║
        ║  ./gradlew statusOllamaAll   Santé des 2 instances          ║
        ║  ./gradlew cleanOllamaB      Supprimer le conteneur         ║
        ║  ./gradlew helpOllama        Cette aide                     ║
        ╚══════════════════════════════════════════════════════════════╝
        """.trimIndent())
    }
}

Voilà. Douze tâches, un point d’entrée unique, et zéro friction.

4. Anatomie des Tâches Clés

4.1. startOllamaB — Le Lanceur

Rien de sorcier : un docker run -d avec les bons paramètres. L’astuce est dans isIgnoreExitValue = true.

isIgnoreExitValue = true

Si le conteneur existe déjà, la commande docker run échoue (exit code ≠ 0). Sans cette propriété, Gradle arrêterait tout le build en erreur. Ici, l’échec est silencieux — et le doLast détecte si c’était un "déjà existant" ou une vraie erreur.

Le doLast est crucial : il tourne après l’exécution de la commande, ce qui permet d’inspecter executionResult.get().exitValue et de logger un message approprié.

4.2. signInB — L’Interactif

C’est la seule tâche qui nécessite une interaction humaine. Gradle transmet System.in au process pour que le prompt ollama signin puisse s’afficher normalement :

standardInput  = System.`in`
standardOutput = System.out
errorOutput    = System.err

Sans ça, le sign-in bloquerait car stdin serait fermé et vous ne verriez jamais le prompt.

4.3. pullModelB — Le Paramétrable

Une tâche générique qui accepte un paramètre via -P :

./gradlew pullModelB -Pmodel=deepseek-v4-pro:cloud
./gradlew pullModelB                           # fallback : qwen3:0.6b

Le fallback est important : project.findProperty("model") as? String ?: "qwen3:0.6b" garantit que la tâche ne pète pas si on oublie le paramètre.

4.4. statusOllamaAll — Le Diagnostic

Deux curl en santé sur les ports 11434 et 11435, avec un fallback en runCatching pour ne pas crasher si l’une des instances est down. La sortie est structurée :

✓ Instance Native   — port 11434 OK (HTTP 200)
✓ Instance Docker B — port 11435 OK (HTTP 200)

Conteneur Docker :
NAMES               STATUS         PORTS
ollama-instance-b   Up 3 hours     0.0.0.0:11435->11434/tcp

Un coup d’œil, et tu sais exactement ce qui tourne et ce qui cloche.

4.5. cleanOllamaB — Le Nettoyage qui Préserve l’Identité

commandLine("docker", "rm", instanceName)
isIgnoreExitValue = true

Le conteneur est supprimé, mais le volume ~/ollama-b-data est conservé. L’identité SSH survit au nettoyage. Au prochain startOllamaB, les mêmes clés seront réutilisées — pas besoin de refaire le sign-in ni de ré-enregistrer la Device Key.

cleanOllamaB ne touche pas au volume. Si tu veux effacer l’identité B (et tout recommencer à zéro), il faut supprimer ~/ollama-b-data manuellement. C’est volontaire — un clean ne devrait pas détruire des credentials.

5. Workflow Complet — De Zéro à Deux Sessions

Voici la séquence idéale pour quelqu’un qui arrive sur le projet et veut tout mettre en place sans ouvrir de documentation :

# 1. Premier contact — que faire ?
$ ./gradlew helpOllama

# 2. On démarre l'instance B
$ ./gradlew startOllamaB

# 3. On récupère la Device Key et on l'enregistre sur ollama.com
$ ./gradlew checkIdentity
# → copier-coller de la clé sur https://ollama.com/settings/keys

# 4. Authentification interactive
$ ./gradlew signInB

# 5. Pull d'un petit modèle gratuit pour tester la plomberie
$ ./gradlew pullModelB

# 6. Test API
$ ./gradlew testOllamaB

# 7. Si tout est vert, pull des modèles Pro
$ ./gradlew pullAllProModels

# 8. Vérification finale des deux instances
$ ./gradlew statusOllamaAll

Moins de deux minutes, tout est provisionné. Pas de README à lire, pas de variables d’environnement à setter. La tâche helpOllama fait office de documentation intégrée.

workflow complet sequence

6. Pourquoi Ne Pas en Faire un Vrai Plugin Gradle ?

Question légitime. Extraire ce build.gradle.kts en un plugin Gradle autonome aurait des avantages : réutilisation entre projets, configuration déclarative, extension DSL.

Mais pour l’instant, ça n’a pas de sens. Ce build n’a pas de logique métier complexe — c’est un wrapper autour de commandes Docker et curl. La surcouche d’un plugin Gradle complet (avec extension, tests unitaires, publication) serait disproportionnée pour 12 tâches utilitaires.

Le build.gradle.kts inline vit dans le projet qui en a besoin. Il est trivial à copier ailleurs. Si un jour j’ai trois projets qui en dépendent, je le transformerai en plugin. Pour l’instant, KISS.

Le jour où tu as trois build.gradle.kts identiques dans trois projets, c’est le signal qu’il faut extraire un plugin. Avant ça, la duplication est moins coûteuse que l’abstraction. Sagesse de dev.

7. Leçons Apprises

  1. Gradle n’est pas qu’un build tool — Le moteur de graphe de dépendances en fait un orchestrateur d’infrastructure redoutable. doLast, isIgnoreExitValue, dependsOn : ces trois primitives suffisent à piloter un conteneur Docker avec la même fiabilité qu’un pipeline CI.

  2. isIgnoreExitValue est ton ami — Dans un monde idéal, docker run sur un conteneur déjà existant renverrait un warning plutôt qu’un exit code d’erreur. Dans le monde réel, tu mets isIgnoreExitValue = true et tu gères le diagnostic dans doLast.

  3. Les tâches paramétrables tuent l’ambiguïté — Un pullModelB générique avec -Pmodel=…​ remplace N tâches spécialisées. Une seule tâche, une seule doc, un seul comportement.

  4. runCatching pour les health checks — Quand tu testes N endpoints, le crash du premier ne doit pas empêcher de tester les autres. runCatching + getOrElse par endpoint, et le diagnostic reste complet même en cas de panne partielle.

  5. La tâche helpOllama comme documentation exécutable — Un println() bien formaté remplace un README de trois paragraphes. C’est auto-documenté, toujours à jour, et accessible sans sortir du terminal.

8. Conclusion

On est passé de commandes docker à taper à la main à un workflow Gradle complet, versionné, réutilisable. Douze tâches couvrent tout le cycle de vie : provisionnement, identité, sign-in, pull, test, diagnostic, nettoyage.

Le combo docker-compose + build.gradle.kts + opencode.json forme une trinité propre : Docker pour l’isolation, Gradle pour l’orchestration, OpenCode pour la consommation. Chaque couche a sa responsabilité, et aucune ne déborde sur l’autre.

Prochaine étape ? Automatiser le cycle de vie dans la CI pour que les instances Ollama soient disponibles dans les environnements de dev éphémères. Mais ça, c’est un autre article.

Articles connexes