Pulumi Kotlin
The missing piece in Kotlin multi-platform
Introduction
Ever since its introduction in 2011, Kotlin has been spreading into different parts of the development pipeline. Similarly to Java, at its core Kotlin follows the concept of Write once, run anywhere, but its multi-platform capabilities have long surpassed those of its older sibling.
The benefits of multi-platform development include lower costs, code reusability, and consistency between platforms. This is especially valued in mobile development, with other tools such as Flutter or React Native rising to fame in recent years.
JetBrains packages Kotlin’s cross-platform capabilities under the umbrella of Kotlin Multiplatform. Let us take a brief look at some of its capabilities.
Client multi-platform
Mobile
In 2017, Google announced first-class support for Kotlin on Android. Just two years later, in 2019, Kotlin became the preferred development language for Android applications. In 2020, JetBrains released an alpha version of the Kotlin Multiplatform Mobile toolkit, which allowed developers to share business logic between Android and iOS apps. In 2021, Compose Multiplatform went into alpha, making it possible to create Android, desktop, and web apps with the same UI artifacts. In 2023, JetBrains announced experimental support for iOS in Compose Multiplatform.
Web
Ever since its first version, Kotlin has supported cross-compilation into both JVM and JS, with the latter known as Kotlin/JS. This feature was developed further in subsequent releases, with the option to reuse code between JVM and JavaScript targets added in Kotlin 1.2. In 2021, JetBrains announced the initial version of Compose HTML (at the time known as Compose for Web), a library for Kotlin/JS which allows users to create web UIs based on Compose components using HTML-like APIs. In 2023, the Kotlin Multiplatform SDK was expanded to include Compose for Web powered by WebAssembly.
Desktop
The UI of desktop applications can be written using Compose Multiplatform for Desktop, which supports Windows, macOS, and Linux. It was first introduced in 2020 under the name Jetpack Compose for Desktop and in 2021 it was released under the umbrella of Compose Multiplatform.
Server Multiplatform
Spring Framework, arguably the most popular web server library for Java, introduced support for Kotlin in 2017. In 2019, they expanded their support to Kotlin coroutines. An alternative is Ktor, a web framework from JetBrains written in Kotlin and based on coroutines.
Miscellaneous
Scripting
Kotlin scripting gives users the ability to write Kotlin code and run it as a script without the need for prior compilation or packaging into executables. It’s gone through multiple changes with significant improvements made in 2018 and 2020. As an alternative, the kscript project has been in development since 2016. It’s also worth mentioning that the Kotlin DSL for Gradle build scripts has been available since 2018.
Data science
Another use case mentioned in Kotlin’s documentation is data science. Among the available tools there’s the Kotlin Jupyter kernel, the Kotlin Notebook IDE plugin, as well as a variety of other tools and libraries.
Infrastructure?
With Kotlin creeping into every area of software development you might be wondering: what about infrastructure?
The missing piece
This is where Pulumi Kotlin enters the stage. Per their website, Pulumi is an “open source infrastructure as code SDK (which) enables you to create, deploy, and manage infrastructure on any cloud, using your favorite languages.” The officially supported languages include TypeScript, JavaScript, Python, Go, . NET, Java, and YAML. Pulumi Kotlin is a Kotlin wrapper on top of the Java SDK.
Getting started with Pulumi Kotlin
The first step is installing Pulumi. On macOS this can be done with the command:
brew install pulumi/tap/pulumi
For other operating systems, please refer to the docs.
Next, you need to register on www.pulumi.com and log in in the terminal, using the command:
pulumi login
Now, let’s create a directory for this demo and initialize a Pulumi project:
mkdir pulumi-kotlin-demo && cd pulumi-kotlin-demo
pulumi new https://github.com/myhau/pulumi-templates/tree/add-template-for-java-gradle-with-kotlin-dsl/java-gradle-ktsm
Note: we’re using a custom Gradle Kotlin template, as the default template uses Maven.
While initializing your project, you’ll be asked to select a project name, project description, and stack name. You can go with the default values or input your own. After completing this step, you should be able to see your stack in the Stacks section on www.app.pulumi.com:
Now, let’s investigate the generated project. First, let’s take a look at the file Pulumi.yaml
:
name: pulumi-kotlin-demo
runtime: java
description: A minimal Java Pulumi program with Gradle builds (Kotlin DSL)
It contains the default properties of the project shared between stacks. When you add configuration properties to a specific stack, those will appear in a file named Pulumi.<stack-name>.yaml
. Note: the Kotlin SDK relies on the Java runtime, so the configuration file is correct.
Next, let’s take a look at the file App.java
:
package myproject;
import com.pulumi.Pulumi;
import com.pulumi.core.Output;
public class App {
public static void main(String[] args) {
Pulumi.run(ctx -> {
ctx.export("exampleOutput", Output.of("example"));
});
}
}
What does it do? It creates and exports a stack output named exampleOutput
with the value example
.
Let’s turn this example into Kotlin. To do so, we need to replace the com.pulumi:pulumi
dependency with org.virtuslab:pulumi-kotlin
. We also need to add some Kotlin-specific configuration:
plugins {
application
kotlin("jvm") version "1.9.0" // 1. Add Kotlin plugin
}
repositories {
// no changes here
}
dependencies {
implementation("org.virtuslab:pulumi-kotlin:0.9.4.0")
}
application {
mainClass.set(
project.findProperty("mainClass") as? String ?: "myproject.AppKt"
) // 3. Change the main class
}
Now, we can replace the file App.java
with the equivalent App.kt
:
package myproject
import com.pulumi.core.Output
import com.pulumi.kotlin.Pulumi
fun main() {
Pulumi.run { ctx ->
ctx.export("exampleOutput", Output.of("example"))
}
}
Let’s try to run this project now. To do this, execute the following command in the root directory of the project:
pulumi up
You will see a preview of the planned updates and you’ll be asked to confirm them.
We can see that the only action performed by this command is the creation of a stack variable. This variable can also be seen on the Pulumi website:
You can also check the value of your variable using the CLI:
pulumi stack output exampleOutput
Finally, to tear down all the resources managed by Pulumi, use the following command:
pulumi destroy
To remove the entire stack, run the command:
pulumi stack rm dev
A practical example
Now that you know how to set up a Pulumi Kotlin project, let’s do something more practical. We’re going to prepare a simple REST endpoint using Ktor and we’ll package it into a Docker image. Then, we’ll use Pulumi to publish this image to the GCP artifact registry and later deploy it to a Google Cloud Kubernetes cluster. All of the code written in this tutorial can be found in this repository.
Before we move on, let’s first make some changes to the structure of our project. First, let’s rename the app
module into something more fitting like infra
. To make sure Pulumi is able to run code from the correct module, infra
needs to become a standalone project attached to the main project via includeBuild
. This means that we need to add a settings.gradle.kts
file in the infra
directory and change include("infra")
to includeBuild("infra")
in the root settings.gradle.kts
file. We also need to instruct Pulumi about the directory in which it should be looking for the relevant code. We can do that by modifying Pulumi.yaml
in the following manner:
name: pulumi-kotlin-demo
runtime: java
description: A minimal Java Pulumi program with Gradle builds (Kotlin DSL)
main: infra
We can also go ahead and add the following Pulumi Kotlin dependencies to the infra
module:
dependencies {
implementation("org.virtuslab:pulumi-kubernetes-kotlin:4.1.1.1")
implementation("org.virtuslab:pulumi-gcp-kotlin:6.64.0.1")
implementation("org.virtuslab:pulumi-docker-kotlin:4.3.0.1")
}
Lastly, let’s create a new module called server
to host our server code. After these changes, this should be the structure of your project:
Ktor app
After attaching the necessary Ktor dependencies to the server
module, let’s write a simple GET endpoint.
package myproject
import io.ktor.server.application.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main(args: Array<String>): Unit = EngineMain.main(args)
@Suppress("unused")
fun Application.module() {
routing {
get("/foo") {
call.respondText("bar")
}
}
}
Configure the main module and the port that you’d like your application to use in application.conf
:
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ myproject.AppKt.module ]
}
}
In the root of this module, let’s place a Dockerfile
that will be used to create a Docker image:
FROM gradle:7-jdk17 AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
FROM openjdk:17
EXPOSE 8080:8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*.jar /app/pulumi-kotlin-demo.jar
ENTRYPOINT ["java","-jar","/app/pulumi-kotlin-demo.jar"]
Publishing a Docker image
Now, we’re going to add code to the infra
module that creates an artifact repository, builds our server app, and pushes the image to this repository.
private const val NAME = "pulumi-kotlin-demo"
private suspend fun uploadImage(): Image {
val repository = repository(NAME) {
args {
repositoryId(NAME)
format("docker")
}
}
val imageUrl = Output.all(repository.location, repository.project)
.applyValue { (zone, project) -> "$zone-docker.pkg.dev/$project/$NAME/$NAME-server" }
val image = image("$NAME-server") {
args {
build {
dockerfile("../server/Dockerfile")
context("../server")
platform("linux/amd64")
}
imageName(imageUrl)
}
}
return image
}
To run this example, we’ll need to set up authentication. This can be done in a few different ways, but since we’re running the app locally, let’s just authenticate in the terminal. To do so, we’ll need to download the Google Cloud CLI, which on macOS can be done in the following way:
brew install google-cloud-sdk
For other operating systems, please refer to the documentation.
After installing the SDK, you can authenticate with the following command:
gcloud auth login
You will also need to obtain new user credentials to use for Application Default Credentials:
gcloud auth application-default login
Next, install the Kubernetes Client-go Credential Plugin:
gcloud components install gke-gcloud-auth-plugin
Finally, register gcloud
as a Docker credential helper:
gcloud auth configure-docker <gcp-time-zone>-docker.pkg.dev
Lastly, before you run the project, you need to set the id of your GCP project and the time zone of your choice in the Pulumi config:
pulumi config set gcp:project <your-gcp-project-id>
pulumi config set gcp:zone <gcp-time-zone>
After that, you should be able to run this example using the commands described in the previous section.
Deploying the Docker image to a GKE cluster
Creating a cluster
Let’s begin by creating a GKE cluster. Keep in mind that the selected machine type has to be available in the time zone you chose in the previous step. You can check that here.
private suspend fun createCluster(): Cluster {
val engineVersion = ContainerFunctions.getEngineVersions().latestMasterVersion
return cluster(NAME) {
args {
initialNodeCount(2)
minMasterVersion(engineVersion)
nodeVersion(engineVersion)
nodeConfig {
machineType("n1-standard-1")
oauthScopes(
"https://www.googleapis.com/auth/compute",
"https://www.googleapis.com/auth/devstorage.read_only",
"https://www.googleapis.com/auth/logging.write",
"https://www.googleapis.com/auth/monitoring"
)
}
}
}
}
Creating a kubeconfig
Next, let’s create a GKE-style kubeconfig
file to set up access to the cluster. In this example, we’ll be using gcloud
-based authentication instead of client cert/key authentication.
private fun createKubeconfig(cluster: Cluster): Output<String> =
Output.all(cluster.name, cluster.endpoint, cluster.masterAuth.applyValue { it.clusterCaCertificate })
.applyValue { (name, endpoint, caCertificate) ->
val gcpConfig = Config()
val project = gcpConfig.project().orElseThrow()
val timeZone = gcpConfig.zone().orElseThrow()
val context = "${project}_${timeZone}_${name}"
"""apiVersion: v1
|clusters:
|- cluster:
| certificate-authority-data: $caCertificate
| server: https://${endpoint}
| name: $context
|contexts:
|- context:
| cluster: $context
| user: $context
| name: $context
|current-context: $context
|kind: Config
|preferences: {}
|users:
|- name: $context
| user:
| exec:
| apiVersion: client.authentication.k8s.io/v1beta1
| command: gke-gcloud-auth-plugin
| installHint: Install gke-gcloud-auth-plugin for use with kubectl by following
| https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke
| provideClusterInfo: true"""
.trimMargin()
}
Creating a provider
Next, initialize a Pulumi Kubernetes provider with the kubeconfig
created in the previous section. By using a provider, we can work with our Kubernetes cluster independently of the underlying cloud provider.
val kubernetesProvider = kubernetesProvider(NAME) {
args {
kubeconfig(kubeconfig)
}
}
Creating a namespace
Create a Kubernetes namespace. Make sure to pass the Kubernetes provider created in the previous step.
val namespace = namespace(NAME) {
opts {
provider(kubernetesProvider)
}
}
Creating a deployment
Finally, create a deployment utilizing the Docker image that you created in one of the previous sections. Pass the namespace defined in the previous step, as well as the Kubernetes provider. To make sure your app is reachable, don’t forget to fill in the correct port number.
val appLabels = mapOf("appClass" to NAME)
private suspend fun createDeployment(
namespace: Namespace,
appLabels: Map<String, String>,
image: Image,
kubernetesProvider: KubernetesProvider
) = deployment(NAME) {
args {
metadata {
namespace(namespace.metadata.applyValue { it.name })
labels(appLabels)
}
spec {
replicas(2)
selector {
matchLabels(appLabels)
}
template {
metadata {
labels(appLabels)
}
spec {
containers {
name(NAME)
image(image.imageName)
ports {
name("http")
containerPort(8080)
}
}
}
}
}
}
opts {
provider(kubernetesProvider)
}
}
Creating a load-balancing service
To expose our deployment to the outside world, let’s create a load-balancing service.
private suspend fun createService(
appLabels: Map<String, String>,
namespace: Namespace,
kubernetesProvider: KubernetesProvider
) = com.pulumi.kubernetes.core.v1.kotlin.service(NAME) {
args {
metadata {
labels(appLabels)
namespace(namespace.metadata.applyValue { it.name })
}
spec {
type(ServiceSpecType.LoadBalancer)
ports {
port(8080)
targetPort("http")
}
selector(appLabels)
}
}
opts {
provider(kubernetesProvider)
}
}
Running the example
Before we run Pulumi, let’s export the public IP of our load-balancing service so that we can test our app.
val servicePublicIp = service.status?.applyValue { it.loadBalancer?.ingress?.first()?.ip }
ctx.export("servicePublicIp", servicePublicIp)
To enable gcloud
-based GKE authentication, you’ll need to install an additional gcloud
component:
gcloud components install gke-gcloud-auth-plugin
Note: you might need to add gcloud
components to your PATH
manually, as per this StackOverflow post.
Finally, let’s run pulumi up
:
Knowing the public IP of our service, let’s check if our app is reachable:
Tear down
Don’t forget to tear down all of the resources by running pulumi destroy
.
Useful links
- To see a comparison of different Kotlin Multiplatform client libraries, check out the excellent PeopleInSpace project on GitHub.
- The part of this article that discusses Docker image publication was loosely based on this tutorial, however I replaced the deprecated Container Registry with Artifact Registry.
- The GKE portion of this tutorial was based on the official docs.
- All code snippets from this article can be found in this repository.
- To learn more about Pulumi Kotlin, visit the official repository.