Guix and Elm, part 3: Deploying an Elm web application

This is the third in a series of articles on working with Elm in Guix. See the first article for an introduction. The outline is:

  1. packaging the Elm compiler
  2. developing Guix infrastructure for building Elm apps, and using this to package the Elm reactor, and the full Elm tool
  3. deploying an Elm web application on Guix System

In part 2 of this series, we ended up with a Guix package which outputs the compiled Javascript of our sample Elm applications:

$ cat puzzle-draw.scm
(define-module (puzzle-draw)
  ...)

(define-public puzzle-draw-elm
  (package
    (name "puzzle-draw-elm")
    (version "current")
    (source "/home/rob/puzzle-draw/web")
    (build-system elm-application-build-system)
    (arguments
      `(#:elm-modules ((("Main.elm") . "web.js")))))
    ...))

$ guix build -L . puzzle-draw-elm
[...]
/gnu/store/dd3i77nimvp17vgrak14yshd10mfm66c-puzzle-draw-elm-current
$ ls /gnu/store/dd3i77nimvp17vgrak14yshd10mfm66c-puzzle-draw-elm-current
web.js

Below, we’ll figure out how to serve this up on Guix System via nginx:

  1. Wrap puzzle-draw-elm in a package puzzle-draw-frontend that bundles a web server and static assets.
  2. Define a Shepherd service to run that web server.
  3. Configure the system to hook this server up to nginx, and redeploy it.

A full package for the puzzle-draw web application

puzzle-draw comes with a web server executable that serves a backend API next to static content: src/serve/Main.hs, so we’ll go ahead and package the two together. Alternatively, we might package the static files separately from the backend API, and hook both up to the public-facing web server independently below.

Let’s assume we’ve packaged puzzle-draw already as a Haskell project. Part 1 goes into details of how to do that with the example of the elm tool.

(define-public puzzle-draw
  (package
    (name "puzzle-draw")
    (version "current")
    (source "/home/rob/puzzle-draw")
    (build-system haskell-build-system)
    ...
    (synopsis
     "Creating graphics for pencil puzzles")
    (description
     "puzzle-draw is a library and tool for drawing pencil puzzles using Diagrams.
    It aims to provide a utility layer on top of Diagrams to help with drawing arbitrary
    puzzles, and to support several specific puzzles types In addition, it includes
    functionality for parsing puzzle data from a YAML file format.")
    (license license:expat)))

Then we can combine puzzle-draw-elm and puzzle-draw by providing a builder script to the trivial build system:

(define-public puzzle-draw-frontend
  (package
    (name "puzzle-draw-frontend")
    (version "current")
    (source "/home/rob/puzzle-draw")
    (build-system trivial-build-system)
    (inputs
     `(("puzzle-draw" ,puzzle-draw)
       ("bash" ,bash)))
    (native-inputs
     `(("puzzle-draw-elm" ,puzzle-draw-elm)))
    (arguments
     `(#:modules ((guix build utils))
       #:builder
       (begin
         (use-modules (guix build utils))
         (let* ((elm (assoc-ref %build-inputs "puzzle-draw-elm"))
                (draw (assoc-ref %build-inputs "puzzle-draw"))
                (source (assoc-ref %build-inputs "source"))
                (bash (assoc-ref %build-inputs "bash"))
                (out (assoc-ref %outputs "out")))
           (copy-recursively (string-append source "/static") (string-append out "/static"))
           (copy-recursively (string-append source "/tests/examples") (string-append out "/tests/examples"))
           (copy-recursively elm (string-append out "/static"))
           (install-file
            (string-append draw "/bin/servepuzzle")
            (string-append out "/bin"))
           (setenv "PATH" (string-append (getenv "PATH") ":" bash "/bin"))
           (wrap-program             (string-append out "/bin/servepuzzle")
             `("PUZZLE_DRAW_ROOT" ":" = (,out)))))))
    (description #f)
    (synopsis #f)
    (license #f)
    (home-page #f)))

The trivial build system does nothing but run its builder argument. That’s a Guile script that has access to source, inputs and output directory, as well as any explicitly provide Guile modules.

We provide both puzzle-draw and puzzle-draw-elm as inputs, as well as bash since we’ll generate a wrapper shell script via wrap-program that will reference it.

Guix always provides us with a Guile interpreter; via the modules argument, we instruct it to make the (guix build utils module available to our script.

The script itself then merely copies our various ingredients to the output path, before wrapping servepuzzle in a shell-script that sets an environment variable to let the server know where to find its static files.

A puzzle-draw Shepherd service

The GNU Shepherd is Guix System’s init. It’s a Guile daemon originating as dmd with GNU Hurd.

To define this service within Guix, we define a new service type:

(define-public puzzle-draw-service-type
  (service-type
   (name 'puzzle-draw)
   (extensions
    (list (service-extension shepherd-root-service-type
                             puzzle-draw-shepherd-service)
          (service-extension account-service-type
                             (const %puzzle-draw-accounts))))
   (default-value (puzzle-draw-configuration))))

This references a couple of things. The puzzle-draw-configuration in this case is pure boilerplate, that’s used to pass in our base Guix package puzzle-draw-frontend while allowing it to be overridden:

(define-record-type* <puzzle-draw-configuration>
  puzzle-draw-configuration make-puzzle-draw-configuration
  puzzle-draw-configuration?
  (puzzle-draw puzzle-draw-configuration-puzzle-draw (default puzzle-draw-frontend)))

Other than that, there are two “service extensions”; these are defined as functions that take the service configuration and return a suitable “extension” of the given service type. In the case of account-service-type, that’s just a list of groups and accounts that doesn’t care about the configuration (whence const):

(define %puzzle-draw-accounts
  (list (user-group (name "pzldraw") (system? #t))
        (user-account
         (name "pzldraw")
         (group "pzldraw")
         (system? #t)
         (comment "puzzle-draw server user")
         (home-directory "/var/empty")
         (shell (file-append shadow "/sbin/nologin")))))

This sets up a dedicated for the puzzle-draw daemon; we could easily have skipped that part and run things as an existing user below.

The other extension is to shepherd-service-type:

(define puzzle-draw-shepherd-service
  (match-lambda
    (($ <puzzle-draw-configuration> puzzle-draw-frontend)
     (list (shepherd-service
            (provision '(puzzle-draw))
            (documentation "Run the puzzle-draw daemon.")
            (requirement '(user-processes))
            (start #~(make-forkexec-constructor
                      '(#$(logger-wrapper "puzzle-draw" (file-append puzzle-draw-frontend "/bin/servepuzzle")
                                          "-b" "127.0.0.1" "-p" "8765"))
                      #:user "pzldraw"
                      #:group "pzldraw"))
            (stop #~(make-kill-destructor)))))))

This is mostly just us telling shepherd to run the servepuzzle executable, whose path we obtain from the puzzle-draw-frontend field in the configuration.

There’s one extra change we made here, namely wrapping the executable with logger:

(define* (logger-wrapper name exec . args)
  "Return a derivation that builds a script to start a process with
standard output and error redirected to syslog via logger."
  (define exp
    #~(begin
        (use-modules (ice-9 popen))
        (let* ((pid    (number->string (getpid)))
               (logger #$(file-append inetutils "/bin/logger"))
               (args   (list "-t" #$name (string-append "--id=" pid)))
               (pipe   (apply open-pipe* OPEN_WRITE logger args)))
          (dup pipe 1)
          (dup pipe 2)
          (execl #$exec #$exec #$@args))))
  (program-file (string-append name "-logger") exp))

This is required because, by default, Shepherd is happy to let its child processes dump their output to Shepherd’s stdout, i.e. the console, where it will never be seen. Note that the #~(begin, #$, #$@ magic here is the G-expressions version of Scheme quasi-quoting, allowing us to mix host and target code.

Configuring the system

Now to bring our new service live. Our Guix system is defined by an operating-system record which comes with a list of services:

(operating-system
  (host-name "garp")
  (timezone "Europe/Berlin")
  (locale "en_US.utf8")
  ...
  (services
   (append
    (list
     (static-networking-service "eth0" ...)
     (service openssh-service-type ...)
     ...)
    %base-services)))

To set up nginx and puzzle-draw, we add two services:

     (service openssh-service-type ...)
     (service nginx-service-type
              (nginx-configuration
               (extra-content
                #~(string-append
                   (string-append "    include " #$(local-file "nginx/garp") ";\n")))))
     (service puzzle-draw-service-type)
     ...

The nginx include directive allows us to write a plain nginx fragment, rather than do everything in Guile. Here’s nginx/garp:

server {
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        server_name garp.vllmrt.net;
        ssl_certificate /etc/letsencrypt/live/garp.vllmrt.net/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/garp.vllmrt.net/privkey.pem;

        ...

        location /puzzles/draw/ {
                proxy_pass http://127.0.0.1:8765/;
        }
}

Ideally, we’d template this fragment somehow so we don’t have to hard-code the port number in two places, but this works for now.

Finally, we can reconfigure the system:

$ sudo guix system reconfigure config.scm
$ sudo herd restart nginx

Done! Draw some puzzles!