TeamCity
Powerful CI/CD for DevOps-centric teams
Configuration as Code, Part 2: Working with Kotlin Scripts
This is part two of the six-part series on configuration as code: working with Kotlin to create build configurations for TeamCity.
- Getting started with Kotlin DSL
- Working with configuration scripts
- Creating build configurations dynamically
- Extending Kotlin DSL
- Using libraries
- Testing configuration scripts
In the first part of the series, we have seen how to get started with Kotlin DSL for TeamCity. Now we’ll dive a little deeper into the DSL and see what it provides us with in terms of building configuration scripts.
An important thing to note is that TeamCity 2018.x uses Kotlin version 1.2.50.
Configuration script
Because the configuration is actually a valid Kotlin program, we get to use all the assistance from the IDE – code completion, refactoring, and navigation.
As we saw in the previous post, the entry point to the configuration is the project {...}
function call defined in settings.kts. Let’s examine the individual blocks of the script.
Project
The top-level project element in the settings.kts file represents a top-level context for all the build configurations and subprojects that are declared within the scope.
project { }
A top-level project does not need to have an id
or a name
. These attributes are defined when we register a new project in TeamCity.
A project in TeamCity may include sub-projects and build configurations. A sub-project should be registered in the main context using the subProject function:
project { subProject(MyProject) }
We also can register a few other entities, like one or more VCS roots and build configurations.
project { vcsRoot(PetclinicVcs) buildType(Build) }
The above exactly matches our demo project configuration script. PetclinicVcs
and Build
objects represent a VCS root and build configuration – this is where the build happens!
Build configuration
The build configuration is represented by a BuildType
class in TeamCity’s Kotlin DSL. To define a new build configuration we define an object that derives from the BuildType
.
object Build: BuildType({ })
The constructor of BuildType
receives a block of code, enclosed within the curly braces {}
. This is where we define all the required attributes for the build configuration.
object Build: BuildType({ id(“Build”) name = “Build” vcs { root(PetclinicVcs) } steps { maven { goals = “clean package” } } triggers { vcs {} } })
Let’s examine the individual configuration blocks in the example above.
The id
and name
The first lines in the Build object denote its id
and name
. The id
, if not specified explicitly, will be derived from the object name.
Version control settings
The vcs{}
block is used to define the version control settings, including the list of VCS roots and other attributes.
Build steps
After VCS settings block you will find the most important block, steps{}
, where we define all the required build steps.
In our example, for a simple Maven project, we only define one Maven build step with clean and package goals.
steps { maven { goals = “clean package” //Other options dockerImage = “maven:3.6.0-jdk-8” jvmArgs = “-Xmx512” //etc } }
Besides Maven, there are plenty of other build steps to choose from in TeamCity. Here are a few examples:
Gradle build step
gradle { tasks = “clean build” }
Command line runner
script { scriptContent = “echo %build.number%” }
Ant task with inline build script
ant { mode = antScript { content = """ """.trimIndent() } targets = "sayHello" }
Docker command
dockerCommand { commandType = build { source = path { path = "Dockerfile" } namesAndTags = "antonarhipov/myimage:%build.number%" commandArgs = "--pull" } }
Finding a DSL snippet for a build step
Despite the IDE support for Kotlin it might still be a bit challenging for new users to configure something in the code. “How do I know how to configure the desired build step and what are the configuration options for that?” Have no fear – TeamCity can help with that! For a build configuration, find the View DSL toggle on the left-hand side of the screen:
The toggle will provide a preview of the given build configuration, here you can locate the build step that you want to configure. Say, we’d like to add a new build step for building a Maven module.
Add a new build step, choose Maven, fill in the attributes, and without saving the build step – click on the toggle to preview the build configuration. The new build step will be highlighted as follows:
You can now copy the code snippet and paste it to the configuration script opened in your IDE.
Please note that If you save the new build step for a project that is already configured via Kotlin DSL scripts — this is allowed — TeamCity will generate a patch and commit it to the settings VCS root. It is then the user’s responsibility to merge the patch into the main Kotlin script.
The VCS root
Besides the build configuration, our demo project also includes a VCS root definition that the build configuration depends on.
object PetclinicVcs : GitVcsRoot({ name = "PetclinicVcs" url = "https://github.com/spring-projects/spring-petclinic.git" })
This is a minimal definition of the VCS root for a Git repository. The id
attribute is not explicitly specified here, hence it is calculated automatically from the object’s name. The name
attribute is required for displaying in the UI, and the url
defines the location of the sources.
Depending on the kind of VCS repository we have, we may specify other attributes as well.
Modifying the build process
Let’s extend this build a little bit. TeamCity provides a feature called Build Files Cleaner, also known as Swabra. Swabra makes sure that files left by the previous build are removed before running new builds.
We can add it using the features function. As we start to type, we can see that the IDE provides us with completion:
The features
function takes a series of feature functions, each of which adds a particular feature. In our case, the code we’re looking for is
features { swabra { } }
In UI, you will find the result in the Build Features view:
We have now modified the build configuration, and it works well. The problem is, if we want to have this feature for every build configuration, we’re going to end up repeating the code. Let’s refactor it to a better solution.
Refactoring the DSL
What we’d ideally like is to have every build configuration automatically have the Build Files Cleaner feature, without having to manually add it. In order to do this, we could introduce a function that wraps every instance of BuildType
with this feature. In essence, instead of having the Project call
buildType(Build) buildType(AnotherBuild) buildType(OneMoreBuild)
we would have it call
buildType(cleanFiles(Build)) buildType(cleanFiles(AnotherBuild)) buildType(cleanFiles(OneMoreBuild))
For this to work, we’d need to create the following function
fun cleanFiles(buildType: BuildType): BuildType { buildType.features { swabra {} } return buildType }
The new function essentially takes a BuildType
, adds a feature to it, and then returns the BuildType
. Given that Kotlin allows top-level functions (i.e. no objects or classes are required to host a function), we can put it anywhere in the code or create a specific file to hold it.
We can improve the code a little so that it only adds the feature if it doesn’t already exist:
fun cleanFiles(buildType: BuildType): BuildType { if (buildType.features.items.find { it.type == "swabra" } == null) { buildType.features { swabra { } } } return buildType }
Generalizing feature wrappers
The above function is great in that it allows us to add a specific feature to all the build configurations. What if we wanted to generalize this so that we could define the feature ourselves? We can do so by passing a block of code to our cleanFiles
function, which we’ll also rename to something more generic.
What we’re doing here is creating what’s known as a higher-order function, a function that takes another function as a function. In fact, this is exactly what features, feature, and many of the other TeamCity DSL’s are.
fun wrapWithFeature(buildType: BuildType, featureBlock: BuildFeatures.() -> Unit): BuildType { buildType.features { featureBlock() } return buildType }
One particular thing about this function, however, is that it’s taking a special function as a parameter, which is an extension function in Kotlin. When passing in this type of parameter, we refer to it as Lambdas with Receivers (i.e. there is a receiver object that the function is applied on).
buildType(wrapWithFeature(Build){ swabra {} })
This then allows us to make calls to this function in a nice way, referencing feature directly.
Summary
In this post, we’ve seen how we can modify TeamCity configuration scripts using the extensive Kotlin-based DSL. What we have in our hands is a full programming language along with all the features and power that it provides. We can encapsulate functionality in functions to re-use, we can use higher-order functions as well as other things that open up many possibilities.
In the next post, we’ll see how to use some of this to dynamically create scripts.