زمان خواندن : 18 minutes

چرا دستورات docker را به صورت دستی تایپ کنیم وقتی می‌توانیم همه را را در وظایف Gradle بسته‌بندی کنیم؟ این است که چگونه من پیکربندی dual-Ollama Pro خود را با صفر اسکریپت شل خارجی صنعت‌سازی کردم. شروع، توقف، هویت، ورود، pull — همه از طریق`./gradlew`.

مقدمه

در مقاله قبلی، نشان دادم چگونه دو حساب Ollama Pro را روی یک ماشین به اشتراک بگذارید با قرار دادن نمونه دوم آن در یک conteneur Docker. راه‌اندازی کار می‌کند اما مدیریت روزمره به‌سرعت مشکل‌زا می‌شود: به یاد آوردن نام conteneur و اجرای دستورات مناسب.docker exec, بررسی کن کدام پورت فعال است…​

می‌دانید آن لحظه است که 14 ترمینال باز دارید که می‌زنید`docker exec ollama-instance-b ollama list`برای بار چهارم، و خودتان می‌گویید «باید روشی تمیزتر وجود داشته باشد»؟

این روش، Gradle است.

فقط برای ساخت کد نیست. Gradle به عنوان یک رهبر اورکسترا زیرساخت محلی عمل می‌کند. وظایفی که فراهم می‌سازند، بررسی می‌کنند، تست می‌زنند و پاک می‌کنند. تمام آن نسخه‌گذاری شده در یک`build.gradle.kts`که نزدیک به`docker-compose.yml`.

@startuml
!theme plain

skinparam handwritten false
skinparam shadowing false
skinparam BoxPadding 8
skinparam DefaultFontSize 11
skinparam ArrowThickness 1.5

left to right direction

actor "توسعه‌دهنده" as Dev

package "build.gradle.kts" as GradleFile {

  usecase "startOllamaB" as Start
  usecase "stopOllamaB" as Stop
  usecase "بررسی هویت" as Check
  usecase "signInB" as SignIn
  usecase "pullModelB" as Pull
  usecase "تستاولاماب" as Test
  usecase "statusOllamaAll" as Status
  usecase "cleanOllamaB" as Clean
}

package "Docker Daemon" as DockerDaemon {

  node "ollama-instance-b" as ContainerB {
    component ":11435" as Port
    database "~/ollama-b-data" as Volume
  }
}

package "Ollama Native" as Native {
  component ":11434" as NatPort
}

Dev --> Start
Dev --> SignIn
Dev --> Pull
Dev --> Test
Dev --> Status
Dev --> Check
Dev --> Stop
Dev --> Clean

Start ..> ContainerB : docker run -d
Stop ..> ContainerB : docker stop
Check ..> Volume : cat id_ed25519.pub
SignIn ..> ContainerB : ollama signin
Pull ..> ContainerB : ollama pull
Test ..> ContainerB : curl /v1/chat/completions
Status ..> ContainerB : docker ps
Status ..> Native : curl :11434
Clean ..> ContainerB : docker rm -f

note bottom of GradleFile
  Zéro script bash externe.
  Tout est dans le build.gradle.kts,
  versionné avec le projet.
end note

@enduml

چرا Gradle برای اینفرا؟

سوال مشروع است. Gradle، یک ابزار ساخت است. معمولاً، آن را کامپایل می‌کند، تست می‌گیرد و بسته‌بندی می‌کند. نه، آن کانتینرهای Docker را راه‌اندازی نمی‌کند.

اما Gradle همچنین یک موتور گراف وابستگی است — و اینجاست که برای مورد ما جالب می‌شود.

می‌خواهیم :

  1. که کانتینر قبل از یک sign-in شروع شود

  2. هویت باید قبل از pull مدل بررسی شود

  3. اگر یک تست API به‌صورت ساکت ناموفق شود زمانی که کانتینر آفلاین باشد

از گراف وابستگی‌های خالص. Gradle برای این تنظیم شده است. DSL Kotlin نوشتن وظایف را روان می‌سازد، و`doLast`اجازهٔ اجرای دستورات سیستم را می‌دهد، به‌طوری که گویی کد برنامه‌ای است.

و به‌ویژه: هیچ bashrc برای نگهداری وجود ندارد و هیچ اسکریپتی که در …​ گمشده باشد`~/bin`, هیچ Makefile‌ای کنار docker-compose نیست. همه چیز در یک فایل واحد زندگی می‌کند، در همان مکان که کد پروژه قرار دارد.

build.gradle.kts کامل

این فایلی است که سرانجام نوشتم. این فایل همه کاری را از تهیه-volume تا curl بررسی، شامل لاگین تعاملی، انجام می‌دهد.

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

در اینجا. دوازده وظیفه، یک نقطه ورود یکتا و بدون احتكاك.

تشریح وظایف کلیدی

startOllamaB` — لینچر

هیچ جادویی نیست : یک`docker run -d`با پارامترهای مناسب. راز در`isIgnoreExitValue = true`.

isIgnoreExitValue = true

اگر کانتینر قبلاً موجود باشد، دستور`docker run`ناموفق می‌شود (کد خروج ≠ 0). بدون این ویژگی، Gradle کل ساخت را با خطا متوقف می‌کند. اینجا، شکست ساکت است — و`doLast`تشخیص می‌دهد اگر بود یک "موجود قبلی" یا یک خطای واقعی.

Le `doLast`ضروری است: آن بعد از اجرای دستور اجرا می‌شود، که این اجازه را برای بازرسی می‌دهد.`executionResult.get().exitValue`و ثبت یک پیام مناسب.

signInB` — التعاملی

این تنها کاری است که نیاز به تعامل انسانی دارد. Gradle می‌فرستد`System.in`به فرآیند تا پرامپت`ollama signin`نمایش عادی شود :

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

بدون آن، ورود به سیستم (sign-in) به دلیل بستن stdin مسدود می‌شود و هرگز پیام را نمی‌بینید.

pullModelB` — قابل تنظیم

یک وظیفهٔ عمومی که یک پارامتر را از طریق می‌پذیرد`-P`(Empty output)

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

فالباک مهم است :`project.findProperty("model") as? String ?: "qwen3:0.6b"`اطمینان می‌دهد که کار در صورت فراموش کردن پارامتر، خراب نشود.

statusOllamaAll` — تشخیص

دو`curl`در صحة بر بندرها`11434` et 11435, به‌عنوان یک fallback در`runCatching`برای جلوگیری از کرش، اگر یکی از نمونه‌ها در وضعیت down باشد. خروجی ساختاریافته است:

✓ 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

یک نگاه، و دقیقاً می‌دانید چه چیزی در حال چرخش است و چه چیزی دچار مشکل است.

cleanOllamaB` — پاکسازی که هویت را حفظ می‌کند

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

کنتینر حذف شده است، اما حجم`~/ollama-b-data`استمحفوظ. هویت SSH پس از پاکسازی زنده می‌ماند. در بعدی`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`حجم را لمس نکن. اگر می‌خواهید هویت B را پاک کنید (و همه چیز را از ابتدا شروع کنید)، باید حذف کنید~/ollama-b-data`به‌صورت دستی. به‌صورت داوطلبانه — یک`clean`نباید اعتبارها را حذف کند.

Workflow کامل — از صفر تا دو جلسه

این دنباله ایده‌آل برای کسی است که به پروژه می‌رسد و می‌خواهد همه چیز را بدون باز کردن مستندات راه‌اندازی کند :

# 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

کمتر از دو دقیقه، همه چیز فراهم می‌شود. هیچ READMEی برای خواندن نیست، هیچ متغیر محیطی برای تنظیم نیست. وظیفه`helpOllama`به‌عنوان مستندات داخلی عمل می‌کند.

@startuml
!theme plain
skinparam DefaultFontSize 11

actor Développeur as Dev
participant "Gradle
(build.gradle.kts)" as Gradle
participant "Docker\nدیامون" as Docker
participant "Ollama
نمونه B" as OllamaB
participant "Ollama
ابر" 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

چرا آن را به‌عنوان یک پلاگین Gradle واقعی نمی‌سازیم؟

سوال مشروع. استخراج این`build.gradle.kts`دارن یک پلاگین Gradle مستقل می‌تواند مزایا داشته باشد: استفاده مجدد بین پروژه‌ها، تنظیمات اعلامی، افزودن DSL.

اما در حال حاضر، این معنی ندارد. این بیلد منطق کاری پیچیده‌ای ندارد — این یک wrapper حول دستورات Docker و curl است. افزودن لایه‌ای کامل از پلاگین Gradle (با افزونه، تست‌های واحد، انتشار) برای ۱۲ کار utility بی‌تناسب خواهد بود.

Le `build.gradle.kts`inline در پروژه‌ای که به آن نیاز دارد وجود دارد. کپی کردن آن در جای دیگر ساده است. اگر روزی سه پروژه‌ای داشته باشم که به آن وابسته باشند، آن را به پلاگین تبدیل خواهم کرد. در حال حاضر، KISS.

روز که سه داری`build.gradle.kts`همان در سه پروژه، این نشانه است که باید یک پلاگین استخراج کنیم. پیش از آن، تکرار کمتر هزینه‌بارتر از انتزاع است. حکمت توسعه‌دهنده.

دروس آموخته

  1. Gradle صرف یک ابزار ساخت نیست.— موتور گراف وابستگی آن را یک orquestratör زیرساخت ترسناک می‌سازد.doLast, isIgnoreExitValue, dependsOn: این سه اولیه کافی است تا یک کانتینر Docker را با همان قابلیت اعتماد یک خط لوله CI کنترل کند.

  2. isIgnoreExitValue` دوستت است— در یک جهان ایده‌آل,docker run`در یک کنтэینر موجود، به جای کد خروجی خطا، یک هشدار برمی‌گرداند. در دنیای واقعی، تو قرار می‌دهی`isIgnoreExitValue = true`و شما تشخیص را در مدیریت می‌کنید`doLast.

  3. وظائف پارامتر‌پذیر ambiguity را نابود می‌کنند — Un pullModelB`عمومی با-Pmodel=…​`N وظیفه تخصصی را جایگزین می‌کند. یک وظیفه، یک مستند، یک رفتار.

  4. runCatching` برای بررسی‌های سلامت— وقتی N endpoint را تست می‌کنید، خراب شدن اولین endpoint نباید مانع تست بقیه شود.runCatching+`getOrElse`بر اساس هر endpoint، تشخیص کامل می‌ماند حتی در صورت خرابی جزئی.

  5. وظیفه helpOllama به‌عنوان مستندات قابل اجرا — Un `println()`فرمت‌بندی بهتر، یک README سه‌پاراگرافی را جایگزین می‌کند. خود اسناد دارد، همیشه به‌روز است و بدون خروج از ترمینال قابل دسترسی است.

نتیجه

ما از دستورات داکر که به صورت دستی تایپ می‌شدیم به یک workflow کامل Gradle، نسخهٔ‌دار و قابل استفاده مجدد، منتقل شدیم. دوازده وظیفه تمام چرخه زندگی را پوشش می‌دهند: تأمین، هویت، sign-in، pull، تست، تشخیص (diagnostic) و پاکسازی.

ترکیب docker-compose + build.gradle.kts + opencode.json یک سهگانه تمیز است: Docker برای عزل، Gradle برای همگام‌سازی، OpenCode برای مصرف. هر لایه مسئولیت خود را دارد و هیچ کدام بر لایهٔ دیگری ریزش نمی‌کند.

مرحله بعدی؟ خودکارسازی چرخه عمر در CI برای این که مثال‌های Ollama در محیط‌های توسعه موقت در دسترس باشند. اما این، مقاله دیگریست.

مقالات مرتبط