TeamCity
Powerful CI/CD for DevOps-centric teams
Kotlin Configuration Scripts: Working with Configuration Scripts
This is part two of the five-part series on working with Kotlin to create Configuration Scripts for TeamCity.
- An Introduction to Configuration Scripts
- Working with Configuration Scripts
- Creating Configuration Scripts dynamically
- Extending the TeamCity DSL
- Testing Configuration Scripts
In the first part we saw the basics of Configuration Scripts and how to get started. Now we’ll dive a little deeper into TeamCity’s DSL and see what it provides us in terms of building configuration scripts.
Examining the DSL
The DSL comes in a series of packages with the main one being the actual DSL which is contained in the configs-dsl-kotlin-{version}.jar file. Examining it, we see that it has a series of classes that describe pretty much the entire TeamCity user interface.
At any time you can navigate to a definition using for instance IntelliJ IDEA, and it will prompt you to download sources or decompile. The sources are available from the actual TeamCity server your pom.xml file points to, and they’re a great way to see all the potential of the DSL.
The entry point to a project, as we saw in the previous post, is the Project object. From here we can define all the settings such as the basic project properties, the VCS roots, build steps, etc. There are a few basic parameters that should be set:
- Uuid: it’s the internal ID TeamCity maintains. It is unique across the server. It’s not recommended that this value be changed as TeamCity uses it internally to associate data with the project.
- extId: this is the user-friendly ID used in URLs, in the UI, etc. and can be changed if required.
- parentId: represents the extId of a project where this project belongs, defaulting to the value _Root for top-level projects.
- name: the name of the project
and optionally
- description: description for the project
Beyond the above, everything else is pretty much optional. Of course, if that’s the only thing we define, then not much is going to happen during the build process.
Modifying the build process
The code below is an excerpt from the configuration script for the Spek project (parts of the configuration are omitted for brevity). This particular build compiles the code and runs some tests using Gradle.
steps { gradle { name = "Snapshot Build" tasks = "clean jar test" jdkHome = "%env.JDK_18_x64%" } } triggers { vcs { branchFilter = "+:<default>" perCheckinTriggering = true groupCheckinsByCommitter = true } }
Let’s extend this build to add a feature to that TeamCity has, Build Files Cleaner also known as Swabra. This build features 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 in turn a series of feature functions, each of which adds a particular feature. In our case, the code we’re looking for is
features { feature { type = "swabra" } }
The resulting code should look like
steps { gradle { name = "Snapshot Build" tasks = "clean jar test" useGradleWrapper = true enableStacktrace = true jdkHome = "%env.JDK_18_x64%" } } triggers { vcs { branchFilter = "+:<default>" perCheckinTriggering = true groupCheckinsByCommitter = true } } features { feature { type = "swabra" } }
And this works well. The problem is that, 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 build type with this feature. In essence, instead of having the Project call
buildType(Spek_Documentation) buildType(Spek_Publish) buildType(Spek_BuildAndTests)
we would have it call
buildType(cleanFiles(Spek_Documentation)) buildType(cleanFiles(Spek_Publish)) buildType(cleanFiles(Spek_BuildAndTests))
For this to work, we’d need to create the following function
fun cleanFiles(buildType: BuildType): BuildType { buildType.features { feature { type = "swabra" } } return buildType }
Which essentially takes a build type, adds a feature to it, and returns the build type. Given that Kotlin allows top-level functions (i.e. no objects or classes are required to host a function), we can place it anywhere or create a specific file to hold it.
Now let’s extend this function to allow us to pass parameters to our feature, such as the rules to use when cleaning files.
fun cleanFiles(buildType: BuildType, rules: List<String>): BuildType { buildType.features { feature { type = "swabra" param("swabra.rules", rules.joinToString("\n")) } } return buildType }
We pass in a list of files which are then passed in as parameter to the feature. The joinToString function allows us to concatenate a list of strings using a specific separator, in our case the carriage return.
We can improve the code a little so that it only adds the feature if it doesn’t already exist:
fun cleanFiles(buildType: BuildType, rules: List<String>): BuildType { if (buildType.features.items.find { it.type == "swabra" } == null) { buildType.features { feature { type = "swabra" param("swabra.rules", rules.joinToString("\n")) } } } return buildType }
Generalizing feature wrappers
The above function is great in that it allows us to add a specific feature to all build configurations. What if we wanted to generalize this so that we could define the feature ourselves? We can do that by passing a block of code to our cleanFiles function, which we’ll also rename to something more generic.
fun wrapWithFeature(buildType: BuildType, featureBlock: BuildFeatures.() -> Unit): BuildType { buildType.features { featureBlock() } return buildType }
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.
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).
This then allows us to make the calls to this function in a nice way referencing feature directly
buildType(wrapWithFeature(Spek_BuildAndTests, { feature { type = "swabra" } }))
Summary
In this post we’ve seen how we can modify TeamCity configuration scripts using the extensive Kotlin-based DSL. What we really 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 part we’ll see how to use some of this to create scripts dynamically.