tempo di lettura: 18 minutes

Perché digitare i comandi docker a mano quando si può incapsulare tutto in task Gradle? Ecco come ho industrializzato la mia configurazione dual-Ollama Pro senza script shell esterni. Avvio, arresto, identità, sign-in, pull — tutto passa per`./gradlew`.

Introduzione

Nell’articolo precedente, ho mostrato come far coesistere due account Ollama Pro sulla stessa macchina isolando la seconda istanza in un contenitore Docker. Il montaggio funziona, ma la gestione quotidiana diventa rapidamente fastidiosa: ricordarsi il nome del contenitore, riavviare i comandi corretti`docker exec`, controlla quale porta è attiva…​

Conosci quel momento in cui hai 14 terminali aperti, che stai digitando`docker exec ollama-instance-b ollama list`per la quarta volta, e ti dici « deve esserci un modo più pulito »?

Questo mezzo è Gradle.

Non solo per costruire il codice. Gradle come un direttore d’orchestra dell’infrastruttura locale. Compiti che provisionano, verificano, testano e puliscono. Tutto versionato in un`build.gradle.kts`che vive accanto al`docker-compose.yml`.

architecture gradle ollama

Perché Gradle per l’Infra ?

La domanda è legittima. Gradle, è uno strumento di build. Normalmente, compila, testa, impacchetta. Non lancia container Docker.

Eccetto che Gradle è anche un motore di grafo di dipendenze — ed è lì che diventa interessante per il nostro caso.

Vogliamo:

  1. Che il contenitore venga avviato prima di un sign-in

  2. Che l’identità sia verificata prima di un pull di modello

  3. Un test API fallisce silenziosamente se il contenitore è inattivo

Dal puro grafo delle dipendenze. Gradle è su misura per questo. Il DSL Kotlin rende la scrittura delle attività fluida, e il`doLast`consente di eseguire comandi di sistema come se fossero codice applicativo.

E soprattutto: nessun bashrc da mantenere, nessun script che giace in`~/bin`, nessun Makefile accanto a docker-compose. Tutto è contenuto in un unico file, nello stesso posto del codice del progetto.

Il build.gradle.kts completo

Ecco il file che ho finito per scrivere. Fa tutto, dal provisioning del volume al curl di verifica, passando per il sign-in interattivo.

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())
    }
}

Ecco. Dodici compiti, un unico punto di ingresso e zero attrito.

Anatomia delle attività chiave

startOllamaB` — Il Lanciatore

Niente di difficile: un`docker run -d`con i parametri giusti. Il trucco è nel`isIgnoreExitValue = true`.

isIgnoreExitValue = true

Se il contenitore esiste già, il comando`docker run`non riesce (exit code ≠ 0). Senza questa proprietà, Gradle interromperebbe l’intero build in errore. Qui, l’errore è silenzioso — e il`doLast`rileva se era un "già esistente" o un vero errore.

Le `doLast`è fondamentale : si esegue dopo l’esecuzione del comando, ciò permette di ispezionare`executionResult.get().exitValue`e di registrare un messaggio appropriato.

signInB` — L’Interattivo

È l’unica task che richiede interazione umana. Gradle trasmette`System.in`al processo affinché il prompt`ollama signin`possa essere visualizzato normalmente :

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

Senza ciò, l’accesso si bloccherebbe perché stdin sarebbe chiuso e non vedresti mai il prompt.

pullModelB` — Il configurabile

Un’attività generica che accetta un parametro via`-P`:

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

Il fallback è importante :`project.findProperty("model") as? String ?: "qwen3:0.6b"`garantisce che l’operazione non scoppi se si dimentica il parametro.

statusOllamaAll` — Il Diagnostico

Due`curl`in salute sui porti`11434` et 11435, con un fallback in`runCatching`per non bloccarsi se una delle istanze è down. L’output è strutturato :

✓ 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 colpo d’occhio, e sai esattamente cosa funziona e cosa non va.

cleanOllamaB` — la pulizia che preserva l’identità

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

Il contenitore è eliminato, ma il volume`~/ollama-b-data`# JBake CLI Commands ` # Initialize a new JBake project jbake -i

Bake (generate) the site jbake -b

Bake and serve locally jbake -b -s

Bake and watch for changes jbake -b --reset

Specify source and destination jbake source_folder output_folder

Clear the output directory before baking jbake -b . output --resetconservato. L’identità SSH sopravvive alla pulizia. Al prossimo`startOllamaB`, le stesse chiavi verranno riutilizzate — non c’è bisogno di rifare il sign-in né di re-registrare la Device Key.

cleanOllamaB`non toccare il volume. Se vuoi cancellare l’identità B (e ricominciare da zero), è necessario eliminare~/ollama-b-data`manualmente. È volontario — un`clean`non dovrebbe distruggere le credenziali.

Workflow Completo — Da Zero a Due Sessioni

Ecco la sequenza ideale per qualcuno che arriva sul progetto e vuole impostare tutto senza aprire la documentazione:

# 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

Meno di due minuti, tutto è provisionato. Nessun README da leggere, nessuna variabile d’ambiente da impostare. Il compito`helpOllama`fa da documentazione integrata.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 8) ]

@startuml




...
... ( skipping 128 lines )
...
  BorderColor black
}
skinparam StereotypeI {
  BackgroundColor white
  BorderColor black
}
skinparam StereotypeN {
  BackgroundColor white
  BorderColor black
}
skinparam UseCaseStereoType {
  FontColor black
  FontName Verdana
}
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle\n(build.gradle.kts)" as Gradle
participant "Docker\nDaemon" as Docker
participant "Ollama
^^^^^
 Syntax Error? (Assumed diagram type: sequence)

@startuml
!theme plain
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle\n(build.gradle.kts)" as Gradle
participant "Docker\nDaemon" as Docker
participant "Ollama
Istanza B" as OllamaB
participant "Ollama\nCloud" as Cloud

== Provisionnement ==
Dev -> Gradle : helpOllama
Gradle --> Dev : Aide et instructions

Dev -> Gradle : startOllamaB
Gradle -> Docker : docker run -d ollama/ollama:0.20.2
Docker -> OllamaB : Génération clés SSH (id_ed25519)
Docker --> Gradle : conteneur lancé
Gradle --> Dev : OK

Dev -> Gradle : checkIdentity
Gradle -> Docker : cat id_ed25519.pub
Gradle --> Dev : Clé publique SSH

Dev -> Cloud : Enregistrement clé SSH\n(sur ollama.com/settings/keys)

== Authentification ==
Dev -> Gradle : signInB
Gradle -> Docker : ollama signin (interactif)
Dev <-> Cloud : Login navigateur (compte B)
Docker -> Cloud : Échange token ⇔ Device Key
Gradle --> Dev : Authentifié

== Vérification ==
Dev -> Gradle : pullModelB
Gradle -> Docker : ollama pull qwen3:0.6b
Gradle --> Dev : Modèle pullé

Dev -> Gradle : testOllamaB
Gradle -> Docker : curl /v1/chat/completions
Gradle --> Dev : ✓ Instance B OK

== Production ==
Dev -> Gradle : pullAllProModels
Gradle -> Docker : ollama pull deepseek-v4-pro:cloud
Gradle -> Docker : ollama pull gemma4:31b-cloud
Gradle --> Dev : Modèles Pro pullés

Dev -> Gradle : statusOllamaAll
Gradle --> Dev : ✓ Native ✓ Docker B
@enduml

Perché non farne un vero plugin Gradle?

Domanda legittima. Estrarre questo`build.gradle.kts`in un plugin Gradle autonomo avrebbe dei vantaggi: riutilizzo tra progetti, configurazione dichiarativa, estensione DSL.

Ma per ora, non ha senso. Questo build non ha una logica di business complessa — è un wrapper attorno a comandi Docker e curl. Lo strato aggiuntivo di un plugin Gradle completo (con estensione, test unitari, pubblicazione) sarebbe sproporzionato per 12 attività utili.

Le `build.gradle.kts`inline vive nel progetto che ne ha bisogno. È banale da copiare altrove. Se un giorno avrò tre progetti che ne dipendono, lo trasformerò in un plugin. Per ora, KISS.

Il giorno in cui hai tre`build.gradle.kts`JBake CLI Commands ` # Initialize a new JBake project jbake -i # Bake (generate) the site jbake -b # Bake and serve locally jbake -b -s # Bake and watch for changes jbake -b --reset # Specify source and destination jbake source_folder output_folder # Clear the output directory before baking jbake -b . output --reset ` identici in tre progetti, è il segnale che è necessario estrarre un plugin. Prima di allora, la duplicazione è meno costosa dell’astrazione. Saggezza di dev.

Lezioni apprese

  1. Gradle non è solo uno strumento di build— Il motore del grafo delle dipendenze ne fa un orchestratore di infrastrutture temibile.doLast, isIgnoreExitValue, dependsOn: questi tre primitivi sono sufficienti per pilotare un container Docker con la stessa affidabilità di una pipeline CI.

  2. isIgnoreExitValue` è tuo amico— In un mondo ideale,docker run`su un contenitore già esistente restituirebbe un avviso invece di un codice di uscita di errore. Nel mondo reale, tu metti`isIgnoreExitValue = true`e tu gestisci la diagnosi in`doLast.

  3. Le attività configurabili uccidono l’ambiguità — Un pullModelB`generico con-Pmodel=…​`sostituisce N attività specializzate. Un solo compito, una sola documentazione, un solo comportamento.

  4. runCatching` per i controlli di statoQuando testi N endpoint, il crash del primo non deve impedire di testare gli altri.runCatching+`getOrElse`per endpoint, e la diagnosi rimane completa anche in caso di guasto parziale.

  5. Il compito helpOllama come documentazione eseguibile — Un `println()`ben formattato sostituisce un README di tre paragrafi. È auto-documentato, sempre aggiornato e accessibile senza uscire dal terminale.

Conclusione

Siamo passati da comandi docker da digitare manualmente a un workflow Gradle completo, versionato, riutilizzabile. Dodici attività coprono l’intero ciclo di vita: provisioning, identità, sign-in, pull, test, diagnostico, pulizia.

Il combo docker-compose + build.gradle.kts + opencode.json forma una trinità pulita: Docker per l’isolamento, Gradle per l’orchestrazione, OpenCode per il consumo. Ogni livello ha la propria responsabilità, e nessuno sconfinisce sull’altro.

Prossimo passo? Automatizzare il ciclo di vita nella CI affinché le istanze Ollama siano disponibili negli ambienti di sviluppo effimeri. Ma questo è un altro articolo.

Articoli correlati