In this article we will show how we leveraged the Dhall language to build a list of jobs for Fedora Zuul CI based on a matrix of values.
Fedora Zuul CI
FZCI is an effort to provide Zuul CI for Fedora. Main goals, as stated in the project's wiki page, are:
- Bring CI infrastructure based on Zuul for projects hosted on pagure.io and src.fedoraproject.org.
- Provide jobs and workflow of jobs around Pull Requests for Fedora packages (distgits on src.fedoraproject.org).
Dhall
According to the Dhall project's page on GitHub, Dhall is a programmable configuration language optimized for maintainability. You can think of Dhall as: JSON + functions + types + imports.
Problem statement
Until recently the Fedora Zuul CI ran Koji scratch build jobs for the X86_64 architecture only. But it was decided to add build jobs for each supported Fedora architecture.
The scratch build job is composed of four variants, one for each Fedora branch/version plus epel8 (master/rawhide, f33, f32, epel8). It means we have to describe the rpm-scratch-build job, with its variants, as follow:
- job: name: rpm-scratch-build parent: common-koji-rpm-build branches: - master final: true provides: - repo vars: arches: x86_64 fetch_artifacts: true release: master scratch_build: true target: rawhide - job: name: rpm-scratch-build parent: common-koji-rpm-build branches: - f33 final: true provides: - repo vars: arches: x86_64 fetch_artifacts: true release: f33 scratch_build: true target: f33 # And so on for the other supported branches ...
For the default architecture job (x86_64), we need four variants. We also need to support five additional architectures, with an exception for epel8 branch where three architectures are supported. Thus we need to describe a total of 21 jobs (3 branches * 6 architectures) + (1 branch * 3 architectures).
Furthermore, we need to adapt the job's variables based on the architecture. For instance, non x86_64 jobs do not provide a repository.
Here is the job definition called rpm-scratch-build-s390x for the master branch and the S390X architecture:
- job: name: rpm-scratch-build-s390x parent: common-koji-rpm-build branches: - master dependencies: - check-for-arches final: true vars: arches: s390x fetch_artifacts: false release: master scratch_build: true target: rawhide
To manage that complexity we decided to use dhall-lang to benefit from nice helper functions such as map, filter and merge but also from strong typing.
Implementation of the jobs.dhall
We started by defining what are the Architectures and the Branches.
dhall definition of Architectures
We define the architectures in the Arches.dhall file, whose content is copied below. We'll follow with an explanation of the contents of the file.
let Union = < X86_64 | S390X | PPC64LE | I686 | ARMV7HL | AARCH64 > let eq_def = { X86_64 = False , S390X = False , PPC64LE = False , I686 = False , ARMV7HL = False , AARCH64 = False } in { Type = Union , default = Union.X86_64 , fedora = [ Union.X86_64 , Union.S390X , Union.PPC64LE , Union.I686 , Union.ARMV7HL , Union.AARCH64 ] , epel8 = [ Union.X86_64, Union.PPC64LE, Union.AARCH64 ] , show = \(arch : Union) -> merge { X86_64 = "x86_64" , S390X = "s390x" , PPC64LE = "ppc64le" , I686 = "i686" , ARMV7HL = "armv7hl" , AARCH64 = "aarch64" } arch , isX86_64 = \(arch : Union) -> merge (eq_def // { X86_64 = True }) arch }
Arches.dhall provides, through the in statement, a record of data and functions that can be seen as a module.
The Union let binding is an Union type where we defined the possible values of an Architecture.
The eq_def binding is a base record that we will use to do pattern matching on the Union. This is used by the isX86_64 function that takes an arch and returns True if the arch's union value is X86_64. Note the use of the merge function to do the pattern matching on the union.
The show function takes an arch and return the corresponding string that we will use to render the final yaml.
Here are some usages of our new module.
$ dhall <<< "(./Arches.dhall).show (./Arches.dhall).default" "x86_64" $ dhall <<< "let Arches = ./Arches.dhall in [{ job = { architecture = Arches.Type.PPC64LE }}]" [ { job.architecture = < AARCH64 | ARMV7HL | I686 | PPC64LE | S390X | X86_64 >.PPC64LE } ] $ dhall-to-yaml <<< "let arch=(./Arches.dhall).show (./Arches.dhall).default in [{job = { architecture = arch}}]" - job: architecture: x86_64
dhall definition of Branches
The same way we have defined architectures, we define branches in the Branches.dhall file, whose content is copied below.
We'll follow with an explanation of the contents of the file.
let Prelude = https://prelude.dhall-lang.org/v17.0.0/package.dhall sha256:10db3c919c25e9046833df897a8ffe2701dc390fa0893d958c3430524be5a43e let Arches = ./Arches.dhall let Union = < Master | F32 | F33 | Epel8 > let eq_def = { Master = False, F32 = False, F33 = False, Epel8 = False } let show = \(branch : Union) -> merge { Master = "master", F32 = "f32", F33 = "f33", Epel8 = "epel8" } branch let all = [ Union.Master, Union.F33, Union.F32, Union.Epel8 ] in { Type = Union , default = Union.Master , all , allText = Prelude.List.map Union Text show all , show , target = \(branch : Union) -> merge { Master = "rawhide", F32 = "f32", F33 = "f33", Epel8 = "epel8" } branch , arches = \(branch : Union) -> merge { Master = Arches.fedora , F32 = Arches.fedora , F33 = Arches.fedora , Epel8 = Arches.epel8 } branch , isMaster = \(branch : Union) -> merge (eq_def // { Master = True }) branch , isEpel8 = \(branch : Union) -> merge (eq_def // { Epel8 = True }) branch }
The Prelude let binding is the Dhall core library.
Note that we include the Arches.dhall via a let binding. This way we can define the arches function that take a branch as argument and return the branch's supported architectures.
$ dhall-to-yaml <<< "(./Branches.dhall).arches < Epel8 | F32 | F33 | Master >.Epel8" - X86_64 - PPC64LE - AARCH64
jobs.dhall
Now let's use this two new modules to write the jobs.dhall file whose content is copied below. Then using dhall-to-yaml command we'll be able to create the jobs.yaml.
let Zuul = ~/git/softwarefactory-project.io/software-factory/dhall-zuul/package.dhall ? https://softwarefactory-project.io/cgit/software-factory/dhall-zuul/plain/package.dhall let Prelude = https://prelude.dhall-lang.org/v17.0.0/package.dhall sha256:10db3c919c25e9046833df897a8ffe2701dc390fa0893d958c3430524be5a43e let Branches = ./Branches.dhall let Arches = ./Arches.dhall let generateRpmBuildJobName : Arches.Type -> Text = \(arch : Arches.Type) -> let suffix = if Arches.isX86_64 arch then "" else "-" ++ Arches.show arch in "rpm-scratch-build" ++ suffix let Arches = Arches // { extras = Prelude.List.filter Arches.Type ( \(arch : Arches.Type) -> Prelude.Bool.not (Arches.isX86_64 arch) ) Arches.fedora , scratch-job-names = Prelude.List.map Arches.Type Text (\(arch : Arches.Type) -> generateRpmBuildJobName arch) } let check_for_arches = Zuul.Job::{ , name = "check-for-arches" , description = Some "Check the packages needs arches builds" , branches = Some Branches.allText , run = Some "playbooks/rpm/check-for-arches.yaml" , vars = Some ( Zuul.Vars.object ( toMap { arch_jobs = Zuul.Vars.array ( Prelude.List.map Text Zuul.Vars.Type Zuul.Vars.string (Arches.scratch-job-names Arches.extras) ) } ) ) , nodeset = Some (Zuul.Nodeset.Name "fedora-33-container") } let common_koji_rpm_build = Zuul.Job::{ , name = "common-koji-rpm-build" , abstract = Some True , protected = Some True , description = Some "Base job for RPM build on Fedora Koji" , timeout = Some 21600 , nodeset = Some (Zuul.Nodeset.Name "fedora-33-container") , roles = Some [ { zuul = "zuul-distro-jobs" } ] , run = Some "playbooks/koji/build-ng.yaml" , secrets = Some [ Zuul.Job.Secret::{ name = "krb_keytab", secret = "krb_keytab" } ] } let setVars = \(target : Text) -> \(release : Text) -> \(arch : Text) -> \(fetch_artifacts : Bool) -> Zuul.Vars.object ( toMap { fetch_artifacts = Zuul.Vars.bool fetch_artifacts , scratch_build = Zuul.Vars.bool True , target = Zuul.Vars.string target , release = Zuul.Vars.string release , arches = Zuul.Vars.string arch } ) let doFetchArtifact : Arches.Type -> Bool = \(arch : Arches.Type) -> Arches.isX86_64 arch let generateRpmBuildJob = \(branch : Branches.Type) -> \(arch : Arches.Type) -> Zuul.Job::{ , name = generateRpmBuildJobName arch , parent = Some (Zuul.Job.getName common_koji_rpm_build) , final = Some True , provides = if Arches.isX86_64 arch then Some [ "repo" ] else None (List Text) , dependencies = if Arches.isX86_64 arch then None (List Zuul.Job.Dependency.Union) else Some [ Zuul.Job.Dependency.Name "check-for-arches" ] , branches = Some [ Branches.show branch ] , vars = Some ( setVars (Branches.target branch) (Branches.show branch) (Arches.show arch) (doFetchArtifact arch) ) } let generateRpmScratchBuildJobs : List Zuul.Job.Type = let forBranch = \(branch : Branches.Type) -> Prelude.List.map Arches.Type Zuul.Job.Type (generateRpmBuildJob branch) (Branches.arches branch) in Prelude.List.concatMap Branches.Type Zuul.Job.Type forBranch Branches.all let Jobs = [ check_for_arches, common_koji_rpm_build ] # generateRpmScratchBuildJobs in Zuul.Job.wrap Jobs
To write this file we used the Dhall-Zuul Binding library. We import the library using the Zuul let binding.
The in statement uses the wrap function provided dhall-zuul to wrap the list of Zuul.Jobs.Type to make this list consumable by Zuul.
The check-for-arches is a "conditional job" that control the triggering of dependent jobs. It needs to be triggered on branches defined in Branches.dhall. The job's playbook expects a variable called arch_jobs that is the list of architecture dependent jobs names. The list is built based on "Arches.dhall".fedora.
Note the use of toMap, List.map, and List.filter functions.
The common_koji_rpm_build is the parent job of all scratch build jobs. The Zuul configuration loader will make all child jobs inherit from its attributes.
The Jobs list is extended (using the # operator) with generateRpmScratchBuildJobs.
generateRpmScratchBuildJobs is a list of Zuul.Job.Type built from two encapsulted iterations over the Branches.all and Branches.arches <branch>. Note the use of concatMap to flatten the resulting nested lists.
At each iteration the generateRpmBuildJob function is called by taking the branch and the architecture as arguments.
generateRpmBuildJob defined a Zuul.Job.Type by setting the job' parameters based on the branch and arch context. The dependencies attributes is built using if/then/else statements. The name attribute is defined by the generateRpmBuildJobName function call as well as vars is defined by a call to setVars.
Let's run dhall-to-yaml command to get the YAML output.
$ dhall-to-yaml <<< ./jobs.dhall | zuulfmt
Here is the generated jobs.yaml .
Note the use of zuulfmt thats is a tool to format a Zuul config YAML definition.
Fedora distgits master branch removal
On February 3rd, the Fedora community ran the migration to remove the master branch from the distgit repositories. For Zuul configuration, this required some small changes to ensure PRs on main and rawhide branches are handled by Zuul.
To handle this change, we acted in three steps:
Support of Fedora f34 branch
On February 9th, the branching of Fedora 34 from rawhide happened. Each distgit repository got a f34 branch. For Zuul configuration, this required new job variants to support this new branch. To do so we only changed some dhall files then regenerated the yaml files.
Bellow are the three changes that was required.
Pros and cons
Let's see the pros and cons regarding the dhall-lang usage to manage the FZCI jobs:
Cons
- New language to learn for contributors.
- Less welcoming for contributors with no previous Dhall experiences.
- Not as simple as editing a YAML file.
Pros
- Dhall-Zuul prevents invalid Zuul job definition. For instance a typo in a job's attribute or using a string as value attribute where a list of strings is expected will be caught by the Dhall interpreter.
- Dhall IDE integration provides type checking and completion. For instance my VSCode IDE will list the available Branches (from "Branches.dhall".Type) and prevents me to use one not part of the Union.
- No more YAML formating issue.
- Adding a branch (ex. f34) is less error prone. For instance it is not possible to miss a job for a given Arch, neither setting the wrong jobs' vars.
- No more YAML / code duplication as it is easy to write functions.
- Allow modularization and code reusability.
To conclude
Thanks to that effort, adding and removing an architecture or a branch is easier because it is significantly less error prone. We have also started to modularize the base definitions (branches, arches) so it will be easy to extend the jobs we provide through FZCI.
Thank you for reading!