|
| 1 | +# Driving Buildkite Pipelines with CUE |
| 2 | +<sup>by [Jonathan Matthews](https://jonathanmatthews.com)</sup> |
| 3 | + |
| 4 | +This guide explains how to convert Buildkite pipeline files from YAML to CUE, |
| 5 | +check those pipelines are valid, and then use CUE's tooling layer to regenerate |
| 6 | +YAML. |
| 7 | + |
| 8 | +This allows you to switch to CUE as a source of truth for Buildkite pipelines |
| 9 | +and perform client-side validation, without Buildkite needing to know you're |
| 10 | +managing your pipelines with CUE. |
| 11 | + |
| 12 | +## Prerequisites |
| 13 | + |
| 14 | +- You have |
| 15 | + [CUE installed](https://alpha.cuelang.org/docs/introduction/installation/) |
| 16 | + locally. This allows you to run `cue` commands. |
| 17 | +- You have a set of Buildkite pipeline files. The examples shown in this guide |
| 18 | + use a specific commit from the Buildkite |
| 19 | + [dependent-pipeline-example](https://github.com/buildkite/dependent-pipeline-example/tree/a7419a24bee24068d4d79399bccd9093569c3013/.buildkite) |
| 20 | + repository, *but you don't need to use that repository in any way.* |
| 21 | +- You have [`git` installed](https://git-scm.com/downloads). |
| 22 | +<!-- TODO: curl isn't needed until the doc includes fetching the upstream schema |
| 23 | +- You have [`curl` installed](https://curl.se/dlwiz/), or can fetch a file from |
| 24 | + a website some other way. |
| 25 | +--> |
| 26 | + |
| 27 | +## Steps |
| 28 | + |
| 29 | +### Convert YAML pipelines to CUE |
| 30 | + |
| 31 | +#### :arrow_right: Begin with a clean git state |
| 32 | + |
| 33 | +Change directory into the root of the repository that contains your Buildkite |
| 34 | +pipeline files, and ensure you start this process with a clean git state, with |
| 35 | +no modified files. For example: |
| 36 | + |
| 37 | +:computer: `terminal` |
| 38 | +```sh |
| 39 | +cd dependent-pipeline-example # our example repository |
| 40 | +git status # should report "working tree clean" |
| 41 | +``` |
| 42 | + |
| 43 | +#### :arrow_right: Initialise a CUE module |
| 44 | + |
| 45 | +Initialise a CUE module named after the organisation and repository you're |
| 46 | +working with. For example: |
| 47 | + |
| 48 | +:computer: `terminal` |
| 49 | +```sh |
| 50 | +cue mod init github.com/buildkite/dependent-pipeline-example |
| 51 | +``` |
| 52 | + |
| 53 | +#### :arrow_right: Import YAML pipelines |
| 54 | + |
| 55 | +Use `cue` to import your YAML pipeline files: |
| 56 | + |
| 57 | +:computer: `terminal` |
| 58 | +```sh |
| 59 | +cue import ./.buildkite/*.yml --with-context -p buildkite -f -l pipelines: -l 'strings.TrimSuffix(path.Base(filename),path.Ext(filename))' |
| 60 | +``` |
| 61 | + |
| 62 | +Check that a CUE file has been created for each YAML pipeline in the |
| 63 | +`.buildkite` directory. For example: |
| 64 | + |
| 65 | +:computer: `terminal` |
| 66 | +```sh |
| 67 | +ls .buildkite/ |
| 68 | +``` |
| 69 | + |
| 70 | +Your output should look similar to this, with matching pairs of YAML and CUE |
| 71 | +files: |
| 72 | + |
| 73 | +```text |
| 74 | +pipeline.cue pipeline.deploy.cue pipeline.deploy.yml pipeline.yml |
| 75 | +``` |
| 76 | + |
| 77 | +Observe that each pipeline has been imported into the `pipelines` struct, at a |
| 78 | +location derived from its original file name: |
| 79 | + |
| 80 | +:computer: `terminal` |
| 81 | +```sh |
| 82 | +head .buildkite/*.cue |
| 83 | +``` |
| 84 | + |
| 85 | +The output should reflect your pipelines. In our example: |
| 86 | + |
| 87 | +```text |
| 88 | +==> .buildkite/pipeline.cue <== |
| 89 | +package buildkite |
| 90 | +
|
| 91 | +pipelines: pipeline: steps: [{ |
| 92 | + command: "echo 'Tests'" |
| 93 | + label: ":hammer:" |
| 94 | +}, |
| 95 | + "wait", { |
| 96 | + trigger: "dependent-pipeline-example-deploy" |
| 97 | + label: ":rocket:" |
| 98 | + branches: "master" |
| 99 | +
|
| 100 | +==> .buildkite/pipeline.deploy.cue <== |
| 101 | +package buildkite |
| 102 | +
|
| 103 | +pipelines: "pipeline.deploy": steps: [{ |
| 104 | + command: "echo 'Deploy'" |
| 105 | + label: ":rocket:" |
| 106 | + concurrency_group: "$BUILDKITE_PIPELINE_SLUG-deploy" |
| 107 | + concurrency: 1 |
| 108 | + branches: "master" |
| 109 | +}] |
| 110 | +``` |
| 111 | + |
| 112 | +#### :arrow_right: Store CUE pipelines in a dedicated directory |
| 113 | + |
| 114 | +Create a directory called `buildkite` to hold your CUE-based Buildkite pipeline |
| 115 | +files. For example: |
| 116 | + |
| 117 | +:computer: `terminal` |
| 118 | +```sh |
| 119 | +mkdir -p internal/ci/buildkite |
| 120 | +``` |
| 121 | + |
| 122 | +You may change the hierarchy and naming of `buildkite`'s **parent** directories |
| 123 | +to suit your repository layout. If you do so, you will need to adapt some |
| 124 | +commands and CUE code as you follow this guide. |
| 125 | + |
| 126 | +Move the newly-created CUE files into their dedicated directory. For example: |
| 127 | + |
| 128 | +:computer: `terminal` |
| 129 | +```sh |
| 130 | +mv ./.buildkite/*.cue internal/ci/buildkite |
| 131 | +``` |
| 132 | + |
| 133 | +### Validate workflows |
| 134 | + |
| 135 | +<!-- TODO: upstream JSONSchema isn't usable |
| 136 | +cf. https://github.com/cue-lang/cue/issues/2698 |
| 137 | +cf. https://github.com/cue-lang/cue/issues/2699 |
| 138 | +
|
| 139 | +#### :arrow_right: Fetch a pipeline schema |
| 140 | +
|
| 141 | +Fetch a schema for Buildkite pipelines, as defined by Buildkite themselves, and |
| 142 | +place it in the `internal/ci/buildkite` directory: |
| 143 | +
|
| 144 | +:computer: `terminal` |
| 145 | +```sh |
| 146 | +curl -o internal/ci/buildkite/buildkite.pipeline.schema.json https://raw.githubusercontent.com/buildkite/pipeline-schema/6396f68d5e983e0d2acbf829c565027a4cfd69bc/schema.json |
| 147 | +``` |
| 148 | +
|
| 149 | +We use a specific commit from the upstream repository to make sure that this |
| 150 | +process is reproducible. |
| 151 | +
|
| 152 | +#### :arrow_right: Import the schema |
| 153 | +
|
| 154 | +Import the schema into CUE: |
| 155 | +
|
| 156 | +:computer: `terminal` |
| 157 | +```sh |
| 158 | +cue import -f -l '#Pipeline:' internal/ci/buildkite/buildkite.pipeline.schema.json |
| 159 | +``` |
| 160 | +--> |
| 161 | + |
| 162 | +#### :arrow_right: Create a pipeline schema |
| 163 | + |
| 164 | +Create a very basic CUE schema for Buildkite pipelines, adapted from |
| 165 | +[Buildkite's documentation](https://buildkite.com/docs/pipelines/configuration-overview), |
| 166 | +and place it in the `internal/ci/buildkite` directory: |
| 167 | + |
| 168 | +:floppy_disk: `internal/ci/buildkite/buildkite.pipeline.schema.cue` |
| 169 | + |
| 170 | +```CUE |
| 171 | +package buildkite |
| 172 | +
|
| 173 | +#Pipeline: { |
| 174 | + steps!: [...] |
| 175 | + env?: [string]: string |
| 176 | + agents?: {[string]: string} | [ ..._#kv] |
| 177 | + _#kv: =~".+=" |
| 178 | + notify?: [...] |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +| :grey_exclamation: Info :grey_exclamation: | |
| 183 | +|:------------------------------------------- | |
| 184 | +| It would be great if we could use [Buildkite's authoritative pipeline schema](https://github.com/buildkite/pipeline-schema) here. Unfortunately, CUE's JSONSchema support can't currently import it. This is being tracked in CUE Issues [#2698](https://github.com/cue-lang/cue/issues/2698) and [#2699](https://github.com/cue-lang/cue/issues/2699), and this guide should be updated once the schema is useable. |
| 185 | + |
| 186 | +#### :arrow_right: Apply the schema |
| 187 | + |
| 188 | +We need to tell CUE to apply the schema to each pipeline. |
| 189 | + |
| 190 | +To do this we'll create a file at `internal/ci/buildkite/pipelines.cue` in our |
| 191 | +example. |
| 192 | + |
| 193 | +However, **if the pipeline imports that you performed earlier *already* created |
| 194 | +a file with that same path and name**, then simply select a different CUE |
| 195 | +filename that *doesn't* already exist. Place the file in the |
| 196 | +`internal/ci/buildkite/` directory. |
| 197 | + |
| 198 | +:floppy_disk: `internal/ci/buildkite/pipelines.cue` |
| 199 | + |
| 200 | +``` |
| 201 | +package buildkite |
| 202 | +
|
| 203 | +// each member of the pipelines struct must be a valid #Pipeline |
| 204 | +pipelines: [_]: #Pipeline |
| 205 | +``` |
| 206 | + |
| 207 | +### Generate YAML from CUE |
| 208 | + |
| 209 | +#### :arrow_right: Create a CUE tool file |
| 210 | + |
| 211 | +Create a CUE "tool" file at `internal/ci/buildkite/ci_tool.cue` and adapt the |
| 212 | +element commented with `TODO`: |
| 213 | + |
| 214 | +:floppy_disk: `internal/ci/buildkite/ci_tool.cue` |
| 215 | +```CUE |
| 216 | +package buildkite |
| 217 | +
|
| 218 | +import ( |
| 219 | + "path" |
| 220 | + "encoding/yaml" |
| 221 | + "tool/file" |
| 222 | +) |
| 223 | +
|
| 224 | +_goos: string @tag(os,var=os) |
| 225 | +
|
| 226 | +// Regenerate all pipeline files |
| 227 | +command: regenerate: { |
| 228 | + pipeline_files: { |
| 229 | + // TODO: update _toolFile to reflect the directory hierarchy containing this file. |
| 230 | + let _toolFile = "internal/ci/buildkite/ci_tool.cue" |
| 231 | + let _pipelineDir = path.FromSlash(".buildkite", path.Unix) |
| 232 | + let _donotedit = "Code generated by \(_toolFile); DO NOT EDIT." |
| 233 | +
|
| 234 | + clean: { |
| 235 | + glob: file.Glob & { |
| 236 | + glob: path.Join([_pipelineDir, "*.yml"], _goos) |
| 237 | + files: [...string] |
| 238 | + } |
| 239 | + for _, _filename in glob.files { |
| 240 | + "Delete \(_filename)": file.RemoveAll & {path: _filename} |
| 241 | + } |
| 242 | + } |
| 243 | +
|
| 244 | + create: { |
| 245 | + for _pipelineName, _pipeline in pipelines |
| 246 | + let _filename = _pipelineName + ".yml" { |
| 247 | + "Generate \(_filename)": file.Create & { |
| 248 | + $after: [ for v in clean {v}] |
| 249 | + filename: path.Join([_pipelineDir, _filename], _goos) |
| 250 | + contents: "# \(_donotedit)\n\n\(yaml.Marshal(_pipeline))" |
| 251 | + } |
| 252 | + } |
| 253 | + } |
| 254 | + } |
| 255 | +} |
| 256 | +``` |
| 257 | + |
| 258 | +Make the modification indicated by the `TODO` comment. |
| 259 | + |
| 260 | +This tool will export each CUE-based pipeline back into its required YAML file, |
| 261 | +on demand. |
| 262 | + |
| 263 | +#### :arrow_right: Test the CUE tool file |
| 264 | + |
| 265 | +With the modified `ci_tool.cue` file in place, check that the `regenerate` |
| 266 | +command is available **from a shell sitting at the repo root**. For example: |
| 267 | + |
| 268 | +:computer: `terminal` |
| 269 | +```sh |
| 270 | +cd $(git rev-parse --show-toplevel) # make sure we're sitting at the repository root |
| 271 | +cue help cmd regenerate ./internal/ci/buildkite # the "./" prefix is required |
| 272 | +``` |
| 273 | + |
| 274 | +Your output **must** begin with the following: |
| 275 | + |
| 276 | +```text |
| 277 | +Regenerate all pipeline files |
| 278 | +Usage: |
| 279 | + cue cmd regenerate [flags] |
| 280 | +[... output continues ...] |
| 281 | +``` |
| 282 | + |
| 283 | +| :exclamation: WARNING :exclamation: | |
| 284 | +|:--------------------------------------- | |
| 285 | +| If you *don't* see the usage explanation for the `regenerate` command (or if you receive an error message) then your tool file isn't set up as CUE requires. Double check the contents of the `ci_tool.cue` file and the modifications you made to it, as well as its location in the repository. Ensure the filename is *exactly* `ci_tool.cue`. Make sure you've followed all the steps in this guide, and that you invoked the `cue help` command from the root of the repository. |
| 286 | + |
| 287 | +#### :arrow_right: Regenerate the YAML pipeline files |
| 288 | + |
| 289 | +Run the `regenerate` command to produce YAML pipeline files from CUE. For |
| 290 | +example: |
| 291 | + |
| 292 | +:computer: `terminal` |
| 293 | +```sh |
| 294 | +cue cmd regenerate ./internal/ci/buildkite # the "./" prefix is required |
| 295 | +``` |
| 296 | + |
| 297 | +#### :arrow_right: Audit changes to the YAML pipeline files |
| 298 | + |
| 299 | +Check that each YAML pipeline file has a single *material* change from the |
| 300 | +original: |
| 301 | + |
| 302 | +:computer: `terminal` |
| 303 | +```sh |
| 304 | +git diff .buildkite/ |
| 305 | +``` |
| 306 | + |
| 307 | +Your output should look similar to the following example: |
| 308 | + |
| 309 | +```diff |
| 310 | +diff --git a/.buildkite/pipeline.deploy.yml b/.buildkite/pipeline.deploy.yml |
| 311 | +index 4af2b9d..e0fc010 100644 |
| 312 | +--- a/.buildkite/pipeline.deploy.yml |
| 313 | ++++ b/.buildkite/pipeline.deploy.yml |
| 314 | +@@ -1,6 +1,8 @@ |
| 315 | ++# Code generated by internal/ci/buildkite/ci_tool.cue; DO NOT EDIT. |
| 316 | ++ |
| 317 | + steps: |
| 318 | + - command: echo 'Deploy' |
| 319 | +diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml |
| 320 | +index 7446201..10cefad 100644 |
| 321 | +--- a/.buildkite/pipeline.yml |
| 322 | ++++ b/.buildkite/pipeline.yml |
| 323 | +@@ -1,12 +1,14 @@ |
| 324 | ++# Code generated by internal/ci/buildkite/ci_tool.cue; DO NOT EDIT. |
| 325 | ++ |
| 326 | + steps: |
| 327 | + - command: echo 'Tests' |
| 328 | + label: ':hammer:' |
| 329 | + - wait |
| 330 | + - trigger: dependent-pipeline-example-deploy |
| 331 | +``` |
| 332 | + |
| 333 | +The only *material* change in each YAML file is the addition of a header that |
| 334 | +warns the reader not to edit the file directly. |
| 335 | + |
| 336 | +#### :arrow_right: Add and commit files to git |
| 337 | + |
| 338 | +Add your files to git. For example: |
| 339 | + |
| 340 | +:computer: `terminal` |
| 341 | +```sh |
| 342 | +git add .buildkite/ internal/ci/buildkite/ cue.mod/module.cue |
| 343 | +``` |
| 344 | + |
| 345 | +Make sure to include your slightly modified YAML pipeline files in |
| 346 | +`.buildkite/` along with all the new files in `internal/ci/buildkite/` and |
| 347 | +your `cue.mod/module.cue` file. |
| 348 | + |
| 349 | +Commit your files to git, with an appropriate commit message: |
| 350 | + |
| 351 | +:computer: `terminal` |
| 352 | +```sh |
| 353 | +git commit -m "ci: create CUE sources for Buildkite pipelines" |
| 354 | +``` |
| 355 | + |
| 356 | +## Conclusion |
| 357 | + |
| 358 | +**Well done - your Buildkite pipeline files have been imported into CUE!** |
| 359 | + |
| 360 | +They can now be managed using CUE, leading to safer and more predictable |
| 361 | +changes. The use of a schema to check your pipelines means that you |
| 362 | +will catch and fix many types of mistake earlier than before, without waiting |
| 363 | +for the slow "git add/commit/push; check if CI fails" cycle. |
| 364 | + |
| 365 | +From now on, each time you make a change to a CUE pipeline file, immediately |
| 366 | +regenerate the YAML files required by Buildkite, and commit your changes |
| 367 | +to all the CUE and YAML files. For example: |
| 368 | + |
| 369 | +:computer: `terminal` |
| 370 | +```sh |
| 371 | +cue cmd regenerate ./internal/ci/buildkite/ # the "./" prefix is required |
| 372 | +git add .buildkite/ internal/ci/buildkite/ |
| 373 | +git commit -m "ci: added new release pipeline" # example message |
| 374 | +``` |
0 commit comments