Для решения задач автоматизации рутинных процессов для системных администраторов и DevOps (которые, кроме всего прочего, нередко занимаются созданием сборочных скриптов, которые могут не только подготовить базовую среду выполнения, но и могут взаимодействовать с другими системами для обеспечения полного цикла CI/CD) чаще всего используются или bash-сценарии (zsh, ash или язык любой другой оболочки) или python. Первое решение косвенно используется и в описании Dockerfile, поскольку сценарий исполняемых команд принципиально ничем не отличается от запуска скрипта в какой-либо shell, второй подход чаще ассоциируется с автоматизацией, связанных с взаимодействием с хранилищами данных — например, для создания учетных записей в LDAP или базе данных, отправки уведомлений и тд.

Но несправедливо было бы обойти стороной возможность создания исполняемых сценариев на языке Kotlin, которые могут стать полноценной заменой bash-сценариям и могут использовать не только в сочетании с Gradle, но и как самостоятельные решения автоматизации. В этой статье мы рассмотрим несколько примеров использования Kotlin Scripting (KTS) для автоматизации в распределенной системе, будем использовать долгоживущие скрипты с ожиданием заданий через RabbitMQ, а также поработаем с файловой системой, внешними сервисами, а также попробуем использовать KTS для сборки Docker-контейнеров.

Прежде всего нужно отметить, что Kotlin Scripting (далее KTS) — не новая технология и она достаточно давно используется для описания сценария сборки приложений с использованием gradle (они могут быть созданы как для мобильной платформы, так и для бэкэнда, при этом исходный текст может быть написан на любой технологии, под которую есть поддержка в gradle, в том числе Java, Groovy, Scala и даже Python с проектов pygradle). При этом она может использоваться и без gradle и запускаться через консольный вариант компилятора Kotlin. Начнем с установки компилятора (например, через brew):

brew install kotlin

Сам файл сценария является обычным исходным текстом на Kotlin, но с несколькими важными дополнениями:

  • поскольку KTS является самодостаточным и не использует систему сборки для подготовки к выполнению, то все зависимости указывается через аннотации @file:DependsOn("…") , в этом случае для запуска должен использоваться kscript.

  • код сценария выполняется сразу (и напоминает режим REPL), при этом мы можем использовать определения функций, классов, задействовать объекты классов любых подключенных зависимостей

  • как и в обычном Kotlin-приложении можно использовать корутины (если подключить зависимость org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1

  • сценарий может быть быть интегрирован в существующее Kotlin-приложение (через запуск ScriptingHost и использование метода eval для выполнения кода).

  • KTS-сценарий может использовать все возможности библиотек по взаимодействию с базами данных, другими серверами (например, LDAP через JNDI или вспомогательные библиотеки), а также встроенные возможности по манипуляции объектами файловой системы из java.nio.

Создадим простой KTS и попробуем его запустить, как обычно запускается bash-сценарий.

import java.io.*

val username = System.getProperty("user.name")
println("Hello $username")
val home = System.getProperty("user.dir")
println("Home dir is $home")
val profile = File(home+"/.profile")
println("Profile file is exists ${profile.exists()}")
if (profile.exists()) {
    println("Total lines is ${profile.readLines().size}")
}

Этот сценарий использует возможности доступа к файлам (проверка наличия, чтение содержимого) и извлечения информации об пользователе, запустившем java-процесс. Для запуска сценария используем консольную утилиту kotlinc:

kotlinc -script test.kts

Но можно использовать и более привычный для сценариев синтаксис, для этого добавим в первую строку инструкцию для запуска (shebang):

#!/usr/bin/env -S kotlinc -script

Теперь файл может сделать исполняемым и запустить как любое другое приложение:

chmod +x test.kts
./test.kts

Аналогично могут быть выполнены другие операции с файловой системой (создание и удаление каталогов и файлов, копирование и перемещение файлов), при этом если запускать процесс от имени другого пользователя (например, с использованием SUID-флага и заменой владельца), то сценарий может получить доступ на модификацию и к каталогам за пределами домашнего каталога пользователя и /tmp. Рассмотрим более сложный случай, когда внутри сценария может быть выполнен какой-либо внешний процесс, для этого можно использовать класс Runtime.

val runtime = Runtime.getRuntime()
val result = runtime.exec("ls -1 $home")
val r = String(result.inputStream.readAllBytes(), Charsets.UTF_8).split("\n")
println("Execution result: $r")

Из result также можно получить errorStream для чтения из потока ошибок и outputStream для отправки данных в стандартный поток ввода (stdin) в запущенном приложении.

Далее попробуем извлечь аргументы командной строки в сценарии, они будут доступны через предопределенную переменную args в тексте сценария (обратите внимание, что первый аргумент доступен по индексу 0, а не 1 как было бы в bash):

val filename = args[0]
println("File $filename contains ${File(filename).readLines().size}")

Важно, что для взаимодействия с системными службами нам необязательно запускать внешние команды и, например, для управления Docker-контейнерами можно использовать Java/Kotlin реализацию API. Также можно взаимодействовать с графическим интерфейсом (например, отправлять уведомления) и управлять системными службами (systemd) через D-Bus (например, можно использовать этот API).

Аналогично могут быть использованы драйверы JDBC и JNDI для выполнения миграций базы данных (например, можно применить Flyway), управления каталогом учетных данных на основе LDAP (например, LDAPtive), взаимодействие с API (например, через okhttp или Ktor Client), а также можно использовать интерактивный режим через вызов readln() или иные формы работы с потоками ввода-вывода (можно даже использовать изменение цвета отображаемого в консоли текста через эту библиотеку).

Для выполнения сценариев внутри Docker можно использовать образ с предустановленным kscript (может также использоваться на основной системе):

docker run -i kscripting/kscript - < script.kts

Наиболее интересным выглядит сценарий фонового выполнения длительного задания, например подписки на задания из очереди сообщений RabbitMQ. Такие задачи разумно запускать в отдельном треде (через ThreadPoolExecutor) или использовать корутины.

В первом случае

val workerPool: ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
workerPool.submit {
  //код задачи
}

Для корутин можно сделать простую реализацию метода launch:

@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")

import java.io.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.intrinsics.createCoroutineUnintercepted
import kotlinx.coroutines.delay

fun launch(block: suspend () -> Unit) {
  val callback = object : Continuation<Unit> {
    override val context: CoroutineContext = EmptyCoroutineContext
    override fun resumeWith(result: Result<Unit>) {}
  }
  block.createCoroutineUnintercepted(callback).resumeWith(Result.success(Unit))
}

launch {
  delay(1000)
  println("From coroutine")
}

Тут можно будет заметить проблему, что корутина не будет ожидать завершения и при завершении выполнения основного кода в kts и здесь можно использовать встроенный способ запуска runBlocking:

@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

runBlocking {
  delay(10)
  println("From coroutine")
}

Создадим скрипт для выполнения задач на сервере, которые поступают через очередь сообщений (в этом случае RabbitMQ), для этого будем завершать корутину когда consumer отключается:

@file:Repository("https://jcenter.bintray.com")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
@file:DependsOn("com.rabbitmq:amqp-client:5.9.0")

import java.io.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.suspendCoroutine

runBlocking {
  suspendCoroutine<Unit> { coroutine ->
    val factory = ConnectionFactory()
    val connection = factory.newConnection("amqp://guest:guest@localhost:5672/")
    val channel = connection.createChannel()
    channel.queueDeclare("tasks")

    println("Waiting for messages...")
    val deliverCallback = DeliverCallback { consumerTag: String?, delivery: Delivery ->
      val message = String(delivery.body, StandardCharsets.UTF_8)
      //обработка задания из сообщения
    }
    val cancelCallback = CancelCallback { consumerTag: String? ->
      println("[$consumerTag] was canceled")
      coroutine.resumeWith()
    }
    channel.basicConsume(QUEUE_NAME, true, "worker", deliverCallback, cancelCallback)
  }
}

Для запуска миграций или автоматизации внутри Dockerfile также можно использовать KTS:

FROM kscripting/kscript

ADD migrate.kts /tmp

kscript /tmp/migrate.kts

Мы рассмотрели несколько простых сценариев использования KTS для автоматизации задач на сервере, которые также могут быть интегрированы в CI/CD и позволяет выполнять сложные задачи по манипуляции с файлами и объектами операционной системы, применения миграции, управления внешними системами, при этом остаются все возможности языка Kotlin и любых подключаемых библиотек, совместимых с JVM.

В заключение порекомендую бесплатный открытый урок, посвященный архитектуре бэкенд-приложения в рисковом проекте. Что будет обсуждаться на встрече:

  • архитектурные и частично организационные меры, позволяющие снизить риски при разработке;

  • инструменты PMBoK и TDD/MDD;

  • элементы чистой архитектуры: модульная разработка, DI, DDD, шаблоны разработки.

Упор будет сделан на практических аспектах, включая ситуации, когда идеальные условия работы недостижимы и приходится жертвовать некоторыми из принципов. Записаться можно по ссылке.

Комментарии (13)


  1. AcckiyGerman
    16.06.2023 11:11
    +3

    Python


  1. radtie
    16.06.2023 11:11
    -2

    powershell.kts


  1. dyadyaSerezha
    16.06.2023 11:11

    А есть библиотеки по работе с сервисами, процессами и прочим на других машинах в сети?


    1. sshikov
      16.06.2023 11:11

      val connection = factory.newConnection("amqp://guest:guest@localhost:5672/")

      а вот это по вашему, не другая машина? В смысле, тут конечно та же самая, но это ведь и есть то что вы просите, по сети. Если другие машины это поддерживают — то уж на платформе JVM с библиотеками редко когда бывают проблемы.


    1. dmitriizolotov Автор
      16.06.2023 11:11

      Можно например использовать https://github.com/jkelly467/kossh для ssh-подключения на другую машину и там уже выполнять команды и разбирать ответ, можно даже сделать обертки для извлечения необходимой информации из текстового ответа. Непосредственно подключаться к POSIX на удаленном системе без ssh не позволяет безопасность, но если кто-то знает способ выполнения POSIX-запросов на другую linux-машину, то напишите пожалуйста.


  1. Leetc0deMonkey
    16.06.2023 11:11
    +2

    Как говорится, как умею, так и пою. Спасибо что не C++. Поддерживать вот это потом всё кто будет?


    1. sshikov
      16.06.2023 11:11
      +1

      А какие проблемы поддерживать код на котлине? Нынче разработчиков на нем полно (потому что андроид).


      Я вам так скажу — я лично тоже для таких же примерно целей уже много лет выбираю груви. Ну, просто так исторически сложилось — он появился несколько раньше (в 2003, я пользуюсь примерно с 2006). Я много раз переписывал баш на груви, и ни разу за много лет об этом не пожалел. И те кто потом этим пользовался и поддерживал — тоже не жаловались. Не вижу ровно никаких причин, почему на котлине будет не так. Это не значит что вариант универсален, но он вполне широко применим.


      1. Leetc0deMonkey
        16.06.2023 11:11

        А какие проблемы поддерживать код на котлине? Нынче разработчиков на нем полно (потому что андроид).

        Разработчиков, или девопсов?


        1. sshikov
          16.06.2023 11:11

          Ну да, возможно это не одни и те же люди. Я не хочу сказать, что найти таких наверняка будет так же легко, как тех, кто пишет на том же питоне. Но и не так сложно, как на хаскеле или эрланге, к примеру.


          1. Leetc0deMonkey
            16.06.2023 11:11
            -1

            А зачем? Уже есть устоявшийся де-факто стандарт автоматизации bash или ps. Ну вот зачем изобретать велосипеды на ваших любимых языках? Просто потому что можете? Ну, ОК, в качестве академического эксперимента выложенное на гите вполне сгодится. Но, пожалуйста, не надо всё это тащить в промышленный прод.


            1. sshikov
              16.06.2023 11:11

              Ну вот возьмем опять же груви (просто, у меня опыта с котлином мало, а как по мне — это очень близкие инструменты) — тут даже особо и доказывать не надо, на нем основаны такие широко распространенные инструменты, как jenkins и gradle, и тем инструментам уже десятки лет. Так что это далеко не велосипеды, и вовсе не мои.


              Но, пожалуйста, не надо всё это тащить в промышленный прод.

              Мы вроде в ваш прод ничего и не тащим. А наш прод уже давно на этом основан, о чем я вам и толкую с самого начала.


              Уже есть устоявшийся де-факто стандарт автоматизации bash или ps.

              Ну вот просто представьте, что вам это кажется с вашей личной (и вероятно даже вполне верной для вас) точки зрения? И все будет более понятно. Я видел кучу компаний, где ни то ни другое не является "стандартом автоматизации". Более того, лично я в своих проектах искореняю попытки использования баша как средства разработки чего либо более 100 строк размером. У нас просто с вами разные проекты, мои не такие как вы делаете, а ваши не такие как мои.


  1. randomsimplenumber
    16.06.2023 11:11

    Однострочники на kotlin получаются ещё более громоздкие чем на python;)


  1. PavelG89
    16.06.2023 11:11

    Поддерживаю вопрос dyadyaSerezha, актуально.