Pulumi Kotlin

The missing piece in Kotlin multi-platform

Julia Plewa
VirtusLab
11 min readAug 31, 2023

--

This image shows a white puzzle piece falling into place.

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.

This image is a diagram displaying the state of Kotlin multi-platform. We have nodes representing server and client tools (available for Android, iOS, Desktop, and Web). There is also a node with the text “Infrastructure?” attached to the root of the diagram with a dotted line.

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:

A screenshot of the Stacks section on www.app.pulumi.com, which displays a stack with the name pulumi-kotlin-demo.
The newly created stack displayed on the Pulumi website

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.

A terminal screenshot of the command “pulumi up”. The outcome is a successful export of the stack variable “exampleOutput” with the value “example”.
The output of `pulumi up`

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:

This is a screenshot from www.app.pulumi.com with details of stack “pulumi-kotlin-demo”. In the section titled “Outputs”, there’s a key-value pair “exampleOutput” to “example”.
The exported output displayed 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:

The structure of the 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:

This is a screenshot from the terminal with the output of the command “pulumi up”. The creation of resources was successful. One of the outputs is called “servicePublicIp” and its value is an IP address.
The output of `pulumi up`

Knowing the public IP of our service, let’s check if our app is reachable:

This image is a screenshot from Postman making a request to the “/foo” endoint of the app hosted at the IP exported in the previous step. The status is 200 and the response is “bar” as expected.
A quick test of our app

Tear down

Don’t forget to tear down all of the resources by running pulumi destroy.

Useful links

  1. To see a comparison of different Kotlin Multiplatform client libraries, check out the excellent PeopleInSpace project on GitHub.
  2. 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.
  3. The GKE portion of this tutorial was based on the official docs.
  4. All code snippets from this article can be found in this repository.
  5. To learn more about Pulumi Kotlin, visit the official repository.

--

--