Creating a simple build server in F# to execute FAKE scripts

15 Jul 2014

Now that we have created a FAKE script to check out the latest code from TFS, build it, and deploy it to IISExpress, the next step is to run the script at regular intervals to ensure the build stays in good shape. We could use TFS for this by installing a build agent on a local server or install another build tool.

Alternatively, if we don't require anything fancy, we could write a build server ourselves. A simple mail with the log file attached and a summary in the mail body whenever the build fails might do.

For the CiServer.fsx script, first we need to set various configuration options:

open System
open System.IO
open System.Diagnostics
open System.Threading
open System.Net
open System.Net.Mail
open System.Text
open System.Collections.Generic

let mailFrom = "foo@bugfree.dk"
let mailTo = ["bar@bugfree.dk"; "baz@bugfree.dk"]
let smtpServer = "smtp.gmail.com"
let smtpUsername = mailFrom
let smtpPassword = "password"
let failureSummaryLength = 30
let maxBuildDuration = 1000 * 60 * 10
let timeBetweenBuilds = 1000 * 60 * 15
let fakeExe = __SOURCE_DIRECTORY__ + "/packages/FAKE.2.15.4/tools/fake.exe"

As we want CiServer to send a mail notification upon build failure, we need to configure the outgoing SMTP server. The mailTo allows for the specification of a list of recipients to notify when the build fails, including in the mail the last failureSummaryLength lines of the log file. These lines often suffice to fix the build without having to look at the entire attached log file.

Since we want CiServer to spawn Fake.exe, we need to ensure Fake.exe doesn't get stuck for whatever reason, i.e., we need the ability to terminate the build if it takes longer than maxBuildDuration. We also want to run the build in an infinite loop with timeBetweenBuilds between when one build finishes and the next build starts.

Now for the crux of the CiServer:

let formatOutput message =
    sprintf "%s: %s" (DateTime.Now.ToString("MM-dd-yyyy HH:mm:ss")) message

let logToConsole message =
    message |> formatOutput |> printfn "%s"

let output = List()
let logToFile message =
    message |> output.Add

let build() =
    let nl = Environment.NewLine
    let p = 
        new Process(
            StartInfo = 
                ProcessStartInfo(
                    FileName = fakeExe, 
                    Arguments = __SOURCE_DIRECTORY__ + "/Build.fsx deploy",
                    UseShellExecute = false,
                    RedirectStandardOutput = true,
                    RedirectStandardError = true))

    let logOutput (e: DataReceivedEventArgs) = 
        logToConsole e.Data
        logToFile e.Data

    p.OutputDataReceived.Add(logOutput)
    p.ErrorDataReceived.Add(logOutput)
    p.Start() |> ignore
    p.BeginOutputReadLine()
    p.BeginErrorReadLine()

    let exited = p.WaitForExit(maxBuildDuration)
    p.CancelOutputRead()
    p.CancelErrorRead()
    if not exited then logToConsole "Maximum build time exceeded. Build killed"

    if p.ExitCode <> 0 then
        logToConsole "Build failed. Sending mail" 
        let tail = 
            output 
            |> Seq.skip(output.Count - min failureSummaryLength output.Count)
            |> Seq.reduce (fun acc s -> acc + nl + s)

        use message = 
            new MailMessage(
                From = MailAddress(mailFrom),
                Subject = "AcmeApp build failure",
                Body = "See attachment for complete build log." + nl + nl +
                       "Below is the last part of the build log:" + nl + nl +
                       tail)
        mailTo |> List.iter (fun r -> message.To.Add(MailAddress(r)))
        let bytes = Encoding.UTF8.GetBytes(String.Join(nl, output))
        message.Attachments.Add(new Attachment(new MemoryStream(bytes), "Log.txt"))
        (new SmtpClient(
            Host = smtpServer,
            Credentials = NetworkCredential(smtpUsername, smtpPassword),
            EnableSsl = true)).Send(message)

The build function is what spawns the Fake process and attaches listeners to its standard error and standard output. What Fake targets print while they execute typically go to standard output while any errors in the build go to standard error.

To make the build run in an infinite loop, we add the following piece of code at the end of the script. Because it's outside a function, it's executed immediately:

while true do
    logToConsole "Build started"
    build()
    logToConsole "Build ended. Going to sleep"
    Thread.Sleep(timeBetweenBuilds)

To invoke the CiServer, open up a console window, navigate to the folder holding the CiServer.fsx script and execute it. Within my project, this script is located at the root of the Visual Studio solution, next to the solution file:

PS> .\packages\FAKE.2.15.4\tools\Fsi.exe .\CiRunner.fsx

Now the CiServer executes the deploy target of the aforementioned Fake build file at regular intervals and notifies a list of recipients of build failures.

The Fake script runs regardless of any changes to the code since last time around. One improvement would be to query TFS, using one of its many web services, and only kick off the build if anything was checked in since the last run.

On my previous project of six developers, this simple build server was sufficient for our needs. We didn't have an existing build infrastructure in place and running the CiServer on any machine was quick and easy.