وقت القراءة : 18 minutes

لماذا تكتب أوامر Docker يدويًا بينما يمكن تغليف كل شيء في مهام Gradle؟ هذا هو كيف صنّفت إعداد dual-Ollama Pro الخاص بي دون أي سكريبت شِل خارجي. البدء، الإيقاف، الهوية، تسجيل الدخول، السحب — كل شيء يمر عبر`./gradlew`.

مقدمة

في المقال السابق، أظهرت كيفية جعل حسابين Ollama Pro يتعايشان على نفس الجهاز من خلال عزل المثيل الثاني في حاوية Docker. يعمل التثبيت، لكن الإدارة اليومية تصبح مملة بسرعة: تذكر اسم الحاوية، وإعادة تشغيل الأوامر الصحيحة`docker exec`, checker ما هو المنفذ النشط…​

أنت تعرف تلك اللحظة عندما يكون لديك 14 محطات طرفية مفتوحة، وتكتب`docker exec ollama-instance-b ollama list`للمرّة الرابعة، وتقول لنفسك «يجب أن تكون هناك طريقة أنظف».

هذا الوسيلة هي Gradle.

ليس مجرد لبناء الكود. Gradle مثل قائد أوركسترا للبنية التحتية المحلية. مهام تُجهِّد، تتحقق، تختبر، وتنظف. الكل مُصدَّر في un`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 "checkIdentity" as Check
  usecase "signInB" as SignIn
  usecase "pullModelB" as Pull
  usecase "testOllamaB" 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 تسجيل الدخول
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
لا يوجد سكريبت باش خارجي.
كل شيء في build.gradle.kts,
مُصدَّر مع المشروع.
end note

@enduml

لماذا Gradle للابنية التحتية؟

السؤال مشروع. جريلد هو أداة بناء. عادةً ما يجمع، يختبر، يحزم. لا يُطلق حاويات Docker.

إلا أن Gradle هو أيضًا محرك رسم بياني للاعتمادات — وهنا يصبح الأمر مثيرًا لاهتمامنا في حالتنا.

نريد:

  1. سواء كان يتم تشغيل الحاوية قبل تسجيل الدخول

  2. يجب التحقق من الهوية قبل سحب النموذج

  3. إذا فشل اختبار API بصمت عندما يكون الحاوية متوقفًا

من الرسم البياني النقي للاعتمادات. Gradle مُصمَّم لذلك. يتيح DSL Kotlin كتابة المهام بشكل سلس، وال`doLast`يسمح بتنفيذ أوامر النظام كما لو كان كود تطبيق.

وأهم من ذلك : لا يوجد bashrc للصيانة، ولا سكريبتات متروكة في`~/bin`لا يوجد ملف Makefile بجانب docker-compose. كل شيء موجود في ملف واحد، في نفس مكان كود المشروع.

build.gradle.kts الكامل

هذا هو الملف الذي كتبته في النهاية. إنه يفعل كل شيء، من تهيئة الحجم إلى 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

بدون ذلك، سيتوقف تسجيل الدخول لأن stdin سيكون مغلقًا ولن ترى المطالبة أبدًا.

pullModelB` — القابل للتكوين

مهمة عامة تقبل معلمة عبر`-P`:

./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`لتجنب حدوث تعطل إذا كان أحد المثيلات متوقفًا . الإخراج منظم :

✓ 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`, سيتم إعادة استخدام نفس المفاتيح — لا حاجة لإعادة تسجيل الدخول ولا لإعادة تسجيل مفتاح الجهاز

cleanOllamaB`لا تلمس الحجم. إذا كنت تريد مسح هوية ب (والبدء من الصفر مرة أخرى)، فيجب عليك الحذف.~/ollama-b-data`يدويًا. هذا اختياري —`clean`لا ينبغي تدمير بيانات الاعتماد

سير عمل كامل — من الصفر إلى جلستين

هذا هو التسلسل المثالي لشخص يصل إلى المشروع ويريد إعداد كل شيء دون فتح أي وثيقة

# 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\n(build.gradle.kts)" as Gradle
participant "Docker\nالدايمون" as Docker
participant "Ollama\nالمثيل B" as OllamaB
participant "Ollama\nالسحابة" as Cloud

== Provisionnement ==
Dev -> Gradle : helpOllama
Gradle --> Dev : مساعدة وتعليمات

Dev -> Gradle : startOllamaB
Gradle -> Docker : docker run -d ollama/ollama:0.20.2
Docker -> OllamaB : إنشاء المفاتيح SSH (id_ed25519)
Docker --> Gradle : حاوية تم إطلاقها
Gradle --> Dev : حسنا

Dev -> Gradle : checkIdentity
Gradle -> Docker : قطة id_ed25519.pub
Gradle --> Dev : مفتاح عام SSH

Dev -> Cloud : تسجيل المفتاحSSH\n(على ollama.com/settings/keys)

== Authentification ==
Dev -> Gradle : signInB
Gradle -> Docker : ollama تسجيل الدخول (تفاعلي)
Dev <-> Cloud : تسجيل الدخول للمتصفح (الحساب ب)
Docker -> Cloud : إبدال الرمز ⇔ مفتاح الجهاز
Gradle --> Dev : مُوثّق

== Vérification ==
Dev -> Gradle : pullModelB
Gradle -> Docker : ollama سحب qwen3:0.6b
Gradle --> Dev : نموذج مُسحب

Dev -> Gradle : testOllamaB
Gradle -> Docker : curl /v1/chat/completions
Gradle --> Dev : ✓ مثيل B OK

== Production ==
Dev -> Gradle : pullAllProModels
Gradle -> Docker : ollama اسحب deepseek-v4-pro:cloud
Gradle -> Docker : ollama pull gemma4:31b-cloud
Gradle --> Dev : النماذج الاحترافية المسحوبة

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

لماذا لا نجعلها إضافة حقيقية لـ Gradle ?

سؤال مشروع. استخراج هذا`build.gradle.kts`أن وجود برنامج إضافي Gradle مستقل سيكون له مزايا : إعادة الاستخدام بين المشاريع، التكوين التصريحي، توسيع DSL .

لكن في الوقت الحالي، لا معنى لذلك. لا يحتوي هذا البناء على منطق عمل معقد—إنه مجرد غلاف حول أوامر Docker وcurl. إضافة طبقة من مكوّن Gradle كامل (مع امتداد، واختبارات وحدة، ونشر) ستكون غير متناسبة لـ 12 مهمة مساعدة.

Le `build.gradle.kts`إنّها تعيش في المشروع الذي يحتاج إليها. من السهل نسخها في أي مكان آخر. إذا كان لدي يومًا ما ثلاثة مشاريع تعتمد عليها، سأحوّلها إلى إضافة. حتى الآن، KISS.

اليوم الذي لديك ثلاثة`build.gradle.kts`متطابقة في ثلاثة مشاريع، هذه الإشارة تعني أنه يجب استخراج مكوّن إضافي. قبل ذلك، الاستنساخ أقل تكلفة من التجريد. حكمة المطور.

الدروس المستفادة

  1. Gradle ليس مجرد أداة بناء— محرك رسم بياني للتبعيات يجعله منسقاً مخيفاً للبنية التحتية.doLast, isIgnoreExitValue, `dependsOn`هذه الثلاث بدائل كافية لتدارة حاوية Docker بنفس موثوقية خط أنابيب CI.

  2. isIgnoreExitValue` هو صديقك— في عالم مثالي,docker run`إذا تم تطبيقه على حاوية موجودة مسبقًا، سيعيد تحذيرًا بدلاً من رمز خروج خطأ. في العالم الحقيقي، أنت تضع`isIgnoreExitValue = true`أنت تتعامل مع التشخيص في`doLast.

  3. المهام القابلة للتخصيص تقضي على الغموض — Un pullModelB`عام مع-Pmodel=…​`يستبدل N مهمات متخصصة. مهمة واحدة فقط، مستند واحد فقط، سلوك واحد فقط.

  4. runCatching` لفحوصات الصحةعند اختبار N نقاط النهاية، لا يجب أن يمنع تعطل الأول من اختبار الباقية.runCatching + `getOrElse`لكل نقطة نهاية، ويظل التشخيص شاملاً حتى في حالة عطل جزئي.

  5. المهمة helpOllama كتوثيق قابل للتنفيذ — Un `println()`المصمم بشكل جيد يستبدل ملف README المكون من ثلاث فقرات. إنه موثق ذاتيًا، دائمًا محدثًا، ومتاح دون الحاجة إلى مغادرة الطرفية.

خاتمة

انتقلنا من أوامر Docker التي تُكتب يدويًا إلى سير عمل Gradle كامل، مُصدَّر، قابل لإعادة الاستخدام. تغطي اثنتي عشرة مهمة دورة الحياة بالكامل: الإعداد، الهوية، تسجيل الدخول، السحب، الاختبار، التشخيص، التنظيف.

docker-compose + build.gradle.kts + opencode.json يشكل ثُلاثيًّ نظيفًا: Docker للعزل، Gradle للتنسيق، OpenCode للاستهلاك. كل طبقة لها مسؤولية، ولا أي منها يتجاوز الأخرى.

الخطوة التالية؟ أتمتة دورة الحياة في CI لتوفير إمكانية وصول مثيلات Ollama في بيئات التطوير المؤقتة. لكن ذلك مقال آخر.

مقالات ذات صلة