GNU Guix: Demystifying complex configurations

Planet GNU ·

Guix system and Guix home introduce the concept of services. These provide users with a way to control background processes, commonly refereed as daemons , as well as ways of controlling the setup of files. For example, openssh-service-type is a service which controls a SSH daemon. In contrast, etc-service-type is a service that populates the contents of the /etc directory. One peculiarity of Guix services is that it's customary to provide Scheme bindings for the different fields. By that I mean that the different fields of the configuration of most services will be a type. The benefit of this is that users get a uniform configuration language for their services, at the cost of additional complexity when developing the service. Which is why, for a long time, Guix users seem to struggle with defining complex configurations. There are a number of reasons for this, we will try to close the gap today by going through defining a configuration for Goimapnotify . This blog post assumes that the reader is somewhat familiar with Guix and knows how to setup a development environment for it. If that's not the case, read The Perfect Setup and Using Guix interactively . A Goimapnotify configuration is written in YAML . Therefore, we will need to serialize the different Guile Scheme fields to this format. Let's take the example configuration that the author gives in the project's README.md : configurations: - host: example.com port: 143 tls: true tlsOptions: rejectUnauthorized: false starttls: true idleLogoutTimeout: 15 username: USERNAME alias: ExampleCOM password: PASSWORD xoAuth2: false boxes: - mailbox: INBOX onNewMail: 'mbsync examplecom:INBOX' onChangedMail: 'mbsync examplenet:INBOX' onChangedMailPost: SKIP onNewMailPost: SKIP - hostCMD: COMMAND_TO_RETRIEVE_HOST port: 993 tls: true tlsOptions: rejectUnauthorized: true starttls: true username: '' usernameCMD: '' password: '' passwordCMD: '' xoAuth2: false onNewMail: '' onNewMailPost: '' onChangedMail: '' onChangedMailPost: '' onDeletedMail: '' onDeletedMailPost: '' boxes: - mailbox: INBOX onNewMail: 'mbsync examplenet:INBOX' onNewMailPost: SKIP onChangedMail: 'mbsync examplenet:INBOX' - mailbox: Junk onNewMail: 'mbsync examplenet:Junk' onNewMailPost: SKIP Just by looking at the hierarchy we can already envision how to organize our scheme records. We will need the following: home-goimapnotify-configuration-fields goimapnotify-configuration goimapnotify-box-configuration goimapnotify-tls-options-configuration The final configuration that the service for Goimapnotify will rely on, will be home-goimapnotify-configuration-fields . It will contain a configurations field where each item will be a goimapnotify-configuration ; each box in those configurations will be a goimapnotify-box-configuration . Additionally, each of those configurations will have an optional goimapnotify-tls-options-configuration . The (gnu services configuration) module provides us with the API that we need to define these configurations. The most important helpers for defining configurations are: define-configuration : For configurations that need to serialize fields into a different format, generally configuration files. define-configuration/no-serialization : For configurations that do not need to emit any files. Generally all the fields are consumed by the Shepherd services that rely on the configuration but no translation from Scheme to a different format is needed. In our case, we need to translate the different configuration fields to YAML, so we will need to use define-configuration . The following sections are written so you can follow along, you are encouraged to drop into a REPL (short for read-eval-print loop ) and import the required module: ,use (gnu services configuration) goimapnotify-tls-options-configuration To make it easy for ourselves, we will start form the in-out, after all, you wouldn't want to build a house from the rooftop, would you? (define-configuration goimapnotify-tls-options-configuration (reject-unauthorized? (boolean #f) "Whether to reject unauthorized TLS certificates.") (starttls? (boolean #f) "Whether to use STARTTLS.") (prefix goimapnotify-)) evaluating the above snippet will throw an unbound variable exception, bear with me. Refer to the manual for an in-depth explanation of the syntax of define-configuration . The first argument is the name of the configuration object, goimapnotify-tls-options-configuration in this case. After it, we define the different typed fields. We are defining boolean fields that are, by default, set to false. The biggest source of confusion when defining configurations comes because define-configuration is a macro that introduces identifiers that do not appear in the source code—it's an unhygienic macro . That means that the macro will expand to code which defines symbols that are not visible when reading the source file. One can inspect the expansion of the macro by using the ,expand REPL command. It will be quite verbose, so don't try to read all of it, instead search through it; you will find some revealing things, such as: scheme@(gnu home services mail)> ,expand (define-configuration goimapnotify-tls-options-configuration (reject-unauthorized? (boolean #f) "Whether to reject unauthorized TLS certificates.") (starttls? (boolean #f) "Whether to use STARTTLS.") (prefix goimapnotify-)) $20 = (begin ... (define goimapnotify-tls-options-configuration? ...) (define goimapnotify-tls-options-configuration-reject-unauthorized? ...) (define goimapnotify-tls-options-configuration-starttls? ...) ... (define <goimapnotify-tls-options-configuration> ...) ... (define goimapnotify-tls-options-configuration ...) (define goimapnotify-tls-options-configuration-fields ((@@ (gnu services configuration) list) (let* ((name (let ((x 'reject-unauthorized?)) x)) ... (serializer (let ((x goimapnotify-serialize-boolean)) x)) ...) ...) (let* ((name (let ((x 'starttls?)) x)) ... (serializer (let ((x goimapnotify-serialize-boolean)) x)) ...) ...)))) In the previous snippet, ... represents omitted code. As you can see from the macro expansion, define-configuration introduces quite a few identifiers. You may recognize the shared prefix in those identifiers, that's right, it's the prefix specified by that last field that we didn't explain from the configuration definition, (prefix goimapnotify-) . By inspecting the macro expansion, it's easy to understand what happens under the hood. When a prefix is specified, we instruct the macro to append that prefix to all the generated identifiers. This is useful to avoid naming collisions; for example, when defining the configuration in a module with other configuration records that serialize to different formats. After all, it's not the same to serialize to YAML than to INI , or any other format a tool may require. Remember that unbound variable problem I mentioned before? If you paid close attention to the macro expansion, you may have noticed those references to goimapnotify-serialize-boolean , those are our unbound variables. The macro expects us to define these serialization procedures, let's do that. Defining the missing serializers Since the configuration machinery knows nothing about the output format, we must define how this translation happens. In our case, we are translating to YAML. By looking at the example configuration provided by the Goimapnotify developers, we can see that the field names are written in camel case, and their respective values are separated from the names through the : character. Helpers Let's start by making a function that takes a symbol and transforms it into a string that's a camelized version of that symbol. We will use object->camel-case-string from the (gnu home services utils) module: ,use (gnu home services utils) (define (camelize-field-name field-name) (let ((str (object->camel-case-string field-name))) (if (string-suffix? "?" str) (string-drop-right str 1) str))) ;; Usage: (camelize-field-name 'reject-unauthorized?) => "rejectUnauthorized" In YAML, there is no convention of suffixing booleans with a ? , so our camelizer drops it when found. camelize-field-name gives us a field name, but we want to serialize the value too. Let's define a field serialization procedure to help us: (define (goimapnotify-serialize-field field-name val) "The mapping is used to serialize certain FIELD-NAMES specially." (let* ((field-name-mapping '((host-command . hostCmd) (user-name . username) (user-name-command . usernameCmd) (password-command . passwordCmd))) (field-name* (or (assq-ref field-name-mapping field-name) field-name))) (format #f "~a: ~s~%" (camelize-field-name field-name*) val))) ;; Usage: (goimapnotify-serialize-field 'reject-unauthorized? 'true) => "rejectUnauthorized: true\n" Notice how we introduced field-name-mapping to tailor the field name passed to camelize-field-name to emit the specific naming that Goimapnotify expects. In Guix, we have a specific naming convention, so we want fields like user-name to map to username (instead of userName ) and fields like password-command to map to passwordCmd (instead of passwordCommand ). That's enough to serialize most values to YAML, but there's an extra Guix-specific feature we should make use of to make the service more convenient to users: G-Expressions . Are you familiar with them? If not, I encourage you to read this wonderful trilogy of blog posts: Dissecting Guix . It took me some time to wrap my head around these concepts, but once you do, I think you will like them too. In any case, that serializer is using those expressions because we want users of our configuration to be able to intermingle packages and other file-like objects in their fields. Since this is a blog post about writing configurations, I won't dive deep into G-Expressions, but let's try to get you a sense for them. You will need to import (guix gexp) for the following snippet to work. (define (goimapnotify-serialize-field field-name val) "The mapping is used to serialize certain FIELD-NAMES specially." (let* ((field-name-mapping '((host-command . hostCmd) (user-name . username) (user-name-command . usernameCmd) (password-command . passwordCmd))) (field-name* (or (assq-ref field-name-mapping field-name) field-name))) #~(format #f "~a: ~s~%" #$(camelize-field-name field-name*) #$val))) That's not so bad, is it? What was that? 6 more characters? Surely you wouldn't be scared of that, but just in case, let me give you some extra reassurance on what's happening here. This G-Expression thing, also known as gexp, is just the way you let Guix know that this code is for later. "When is later?", you may wonder. Simplifying it, that "later" is when Guix knows where file-like objects will be located in your disk; that long path on the store you may have seen before. For example, a package is a file-like object, so when you build the package Guix will tell you where it was stored: $ guix build cowsay /gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4 Preceding an expression with #~ (or gexp ), one effectively tells Guix "don't evaluate this code until you know what this expression should expand to". And that other syntax, #$ (or ungexp ), is telling Guix to replace the file-like object for its lowered representation, usually a path in the store. For a package like cowsay , that would be that /gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4 paths we saw before. With that said, if you are like me, you won't be comfortable if you cannot debug this expressions, so let me give you some supper powers. In your REPL, import the (guix) module. This will augment the REPL with some additional commands that will simplify our life: scheme@(guile-user)> ,use (guix) scheme@(guile-user)> ,help guix Guix Commands [abbrev]: ,run-in-store EXP - Run EXP through the store monad. ,verbosity LEVEL - Change build verbosity to LEVEL. ,lower OBJECT - Lower OBJECT into a derivation or store file and return it. ,build OBJECT [BUILD-MODE] - Lower OBJECT and build it, returning its output file name(s). ,build-options OPTIONS - Set build options to OPTIONS. Print previous value (to allow easy restore). ,build-graft GRAFT? - Set whether grafts should be performed. ,enter-store-monad - Enter a REPL for values in the store monad. ,phases - Return the build phases of the package defined by FORM. ,configure-flags - Return the configure flags of the package defined by FORM. ,make-flags - Return the make flags of the package defined by FORM. Let's see what that gexp is up to: scheme@(gnu home services mail)> (goimapnotify-serialize-field 'reject-unauthorized? ''true) $6 = #<gexp (format #f "~a: ~s\n" #<gexp-input "rejectUnauthorized":out> #<gexp-input (quote true):out>) gnu/home/services/mail.scm:252:2 7f4c7d9deb40> There is a little helper to approximate a gexp to it's output: scheme@(gnu home services mail)> (gexp->approximate-sexp $6) $7 = (format #f "~a: ~s\n" "rejectUnauthorized" (quote true)) scheme@(gnu home services mail)> (primitive-eval $7) $8 = "rejectUnauthorized: true\n" That gets us an idea of what will be emitted to disk, but for more complex procedures, this is not going to cut it, specially if there are multiple gexps combined. So let's build that gexp: ;; 'gexp->file' comes from the '(guix gexp)' module. You can ask the REPL more ;; information about a symbol through ',a SYMBOL'. scheme@(gnu home services mail)> ,a gexp->file (guix gexp): gexp->file #<procedure gexp->file (name exp #:key guile set-load-path? module-path splice? system target)> scheme@(gnu home services mail)> (gexp->file "test.scm" $6) $8 = #<procedure 7f3f7d456ea0 at guix/gexp.scm:2098:2 (state)> In order for Guix to build a value, it needs to be something Guix can lower—an object that can be "compiled" down to a file in the store (these are referred to as file-like objects, because they can be inserted in any piece of code that expects a file name). A gexp cannot be built by itself, because there is nowhere to output it to. The above snippet creates a file-like object that will be emitted to a file named test.scm in the store. That file will contain our expression. As you see, we've got a procedure. The REPL printer tells us that this procedure needs some state ; this is just a way of indicating you that this is a monadic procedure that can only be run in the context of a store connection. Read The Store Monad for more information. I will build it manually just once for demonstration purposes, don't blink: scheme@(gnu home services mail)> ,use (guix store) scheme@(gnu home services mail)> (run-with-store (open-connection) $8) $9 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7f3f7e2de000> Where we are saying "run the procedure we got in the context of a store connection", we opened that connection through open-connection . Let's not do that again... Fortunately we have those useful REPL commands I just told you about, so we can archive the same result by doing this: scheme@(gnu home services mail)> ,run-in-store $8 $10 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7fcd92f7f0f0> That's better, isn't it? Now, this is a derivation . That's something Guix can build: scheme@(gnu home services mail)> ,build $10 building /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv... $11 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm" What's that? You don't want this magic command? Okay, the same thing can be done with: scheme@(gnu home services mail)> (run-with-store (open-connection) (built-derivations (list $10))) $12 = #t scheme@(gnu home services mail)> (derivation->output-path $10) $13 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm" At last, a file! What's in there? $ cat /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm (format #f "~a: ~a\n" "rejectUnauthorized" "true") Okay that seems about right. We can even run it: scheme@(gnu home services mail)> (call-with-input-file $13 (lambda (port) (primitive-eval (read port)))) $14 = "rejectUnauthorized: true\n" You've seen the secret sauce, let's continue with what brought us here. (define (goimapnotify-serialize-boolean field-name val) (goimapnotify-serialize-field field-name (if val ''true ''false))) With this our configuration won't complain about the missing serializer. Let's continue. One last thing, notice how, after moving to the gexp version of goimapnotify-serialize-field , we started double quoting the symbols true and false , this is because the #$ syntax will replace the value in place, and the value of 'true is true , without the quote. If we didn't double quote, the staged code after expansion would look like this: (format #f "~a: ~s\n" "rejectUnauthorized" true) When what we really want is to expand to this: (format #f "~a: ~s\n" "rejectUnauthorized" 'true) The reason for this is that we want users to be able to write staged code on the different fields. If one of the values of a field where to be something like this: (goimapnotify-serialize-field 'favorite-game #~(string-append #$cowsay "/bin/cowsay")) The expansion would be this: (format #f "~a: ~s\n" "favoriteGame" (string-append "/gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4" "/bin/cowsay")) The string-append procedure is evaluated right before the format call, not when the gexp is getting lowered. Serializing The (gnu services configuration) module we imported earlier provides us with serialize-configuration , it takes two arguments, a configuration object and the fields that compose that configuration. The define-configuration macro defined the goimapnotify-tls-options-configuration constructor for us, we can create a configuration with the default values like this: scheme@(gnu home services mail)> (goimapnotify-tls-options-configuration) $10 = #<<goimapnotify-tls-options-configuration> reject-unauthorized?: #f starttls?: #f %location: #f> Then, we can serialize it like this: scheme@(gnu home services mail)> (serialize-configuration $10 goimapnotify-tls-options-configuration-fields) $11 = #<gexp gnu/services/configuration.scm:165:2 7ff1fd2cbf60> If you recall form the macro expansion we saw earlier, goimapnotify-tls-options-configuration-fields , was one of those identifiers that got generated. You should already know how to build that gexp we've got: scheme@(gnu home services mail)> (gexp->file "test.yaml" $11) $12 = #<procedure 7efd8b10c2d0 at guix/gexp.scm:2098:2 (state)> scheme@(gnu home services mail)> ,use (guix) scheme@(gnu home services mail)> ,run-in-store $12 $13 = #<derivation /gnu/store/z59sf3nhh80668z8njljfxcymgi071pr-test.yaml.drv => /gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml 7efd8a80fd70> scheme@(gnu home services mail)> ,build $13 $14 = "/gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml" scheme@(gnu home services mail)> (call-with-input-file $14 (lambda (port) (display (primitive-eval (read port))))) rejectUnauthorized: false starttls: false That's some nice YAML syntax... We better speed up the pace or I will retire before finishing up this blog post. goimapnotify-box-configuration Next in line is goimapnotify-box-configuration , you know the drill. We start by declaring the configuration with define-configuration , specifying each field name, type and docstring: (define-configuration goimapnotify-box-configuration (mailbox string "The mailbox to monitor.") (on-new-mail maybe-string-or-gexp "The command to execute when new mail arrives.") (on-new-mail-post maybe-string-or-gexp "The command to execute after the new-mail command.") (on-changed-mail maybe-string-or-gexp "The command to execute when mail is changed.") (on-changed-mail-post maybe-string-or-gexp "The command to execute after the changed-mail command.") (on-deleted-mail maybe-string-or-gexp "The command to execute when mail is deleted.") (on-deleted-mail-post maybe-string-or-gexp "The command to execute after the deleted-mail command.") (prefix goimapnotify-)) You may have noticed that we have two new types. strings , easy enough, and maybe-string-or-gexp , not so easy; right? Worry not, here comes the explanation. Let's start by defining the string type: (define-maybe string (prefix msmtp-configuration-)) You may be very confused right now. What has msmtp-configuration to do with our goimapnotify-configuration example? Well, I want this blog post to get you ready for the real world, and in the wild, you will make configurations in modules that contain other configurations. The example we are looking up today is a narration of my adventures making the Goimapnotify service from the (gnu services mail) module, in that module, there is already a configuration for msmtp . With that said, if we tried to do this: (define-maybe string (prefix goimapnotify-)) We would be surprised with a warning that looks something like this: $ make ... [ 94%] GUILEC gnu/home/services/mail.go gnu/home/services/mail.scm:67:0: warning: shadows previous definition of `maybe-string?' at gnu/home/services/mail.scm:63:0 gnu/home/services/mail.scm:278:0: warning: shadows previous definition of `goimapnotify-serialize-maybe-string' at gnu/home/services/mail.scm:67:0 That's unexpected, isn't it? Nothing we've seen so far points to a maybe-string? procedure, let's look at what is going under the hood of that define-maybe macro: scheme@(gnu home services mail)> ,expand (define-maybe string (prefix msmtp-configuration-)) $15 = (begin (define (maybe-string? val) (or ((@@ (gnu services configuration) not) ((@@ (gnu services configuration) maybe-value-set?) val)) (string? val))) (define (msmtp-configuration-serialize-maybe-string field-name val) (if (string? val) (msmtp-configuration-serialize-string field-name val) ""))) Do you see it? The macro is expanding to some code that introduces two new procedures into the module, one following the prefix, that would be msmtp-configuration-serialize-maybe-string , and one that just declares the predicate for the maybe type. Given that, the warning is now apparent. If we call again that macro with a string as the first argument, we will get the same predicate after the expansion, leading to the redefinition warning. So, for this particular case, instead of using the macro, we will define manually the two procedures required for our configuration: ;; The module already had this helper defined. (define (string-or-gexp? obj) (or (string? obj) (gexp? obj))) ;; ... omitted lines ... (define (goimapnotify-serialize-string field-name val) (goimapnotify-serialize-field field-name val)) (define (goimapnotify-serialize-maybe-string field-name val) (if (maybe-value-set? val) (goimapnotify-serialize-string field-name val) "")) (define goimapnotify-serialize-string-or-gexp goimapnotify-serialize-string) (define (goimapnotify-serialize-maybe-string-or-gexp field-name val) (if (and (maybe-value-set? val) (string-or-gexp? val)) (goimapnotify-serialize-string-or-gexp field-name val) "")) The above snippet defines all the serializers we need for the new types. Notice that they are just some simple wrappers around the goimapnotify-serialize-field helper. That procedure is doing all the heavy lifting here, and fortunately for us, it already handles gexps. Therefore, the fields that have a type that accepts a gexp are straightforward to declare. That would be it for the goimapnotify-box-configuration declaration. As showed in the previous section, the REPL is your friend. I had never written such a complex configuration before, but thanks to this "elegant weapon for a more civilized age" , I could find my way by poking things around. Let's continue! goimapnotify-configuration You know the drill, we start by defining the fields we need. Again, I'm doing this just by looking at the README example provided by the developers of Goimapnotify: (define-configuration goimapnotify-configuration (host string "The IMAP server hostname.") (host-command maybe-string-or-gexp "The command to retrieve the IMAP server hostname.") (port (integer 993) "The port that the IMAP server listens on.") (tls? (boolean #f) "Enable or disable TLS.") (tls-options maybe-goimapnotify-tls-options-configuration "TLS options for the IMAP connection." (serializer serialize-maybe-goimapnotify-tls-options-configuration)) (idle-logout-timeout maybe-integer "The idle logout timeout in minutes.") (user-name maybe-string "The user-name for authentication.") (user-name-command maybe-string-or-gexp "The command to retrieve the user-name.") (alias maybe-string "An alias for the account.") (password maybe-string "The password for authentication.") (password-command maybe-string-or-gexp "The command to retrieve the password.") (xo-auth2? (boolean #f) "Enable or disable XOAUTH2 authentication.") (wait maybe-integer "The delay in seconds before the mail syncing is triggered.") (boxes list-of-goimapnotify-boxes-configurations "The mailboxes to monitor." (serializer serialize-list-of-goimapnotify-boxes-configurations)) (prefix goimapnotify-)) You are already familiar with most of those types, but there are some new things here: integer and maybe-integer maybe-goimapnotify-tls-options-configuration list-of-goimapnotify-boxes-configurations Let's start with the obvious ones first. integer The module already had a maybe definition for the integer. If you recall from the macro expansion earlier, that means that there is already a symbol for the predicate maybe-integer? : (define-maybe integer (prefix msmtp-configuration-)) We still need a serializer that follows our prefix: (define (goimapnotify-serialize-integer field-name val) (goimapnotify-serialize-field field-name val)) (define (goimapnotify-serialize-maybe-integer field-name val) (if (maybe-value-set? val) (goimapnotify-serialize-integer field-name val) "")) Simple enough. Moving on! maybe-goimapnotify-tls-options-configuration (define (serialize-goimapnotify-tls-options-configuration field-name val) (let ((serialization (serialize-configuration val goimapnotify-tls-options-configuration-fields))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~a~%" '#$(camelize-field-name field-name) (string-replace-substring #$serialization "\n" "\n "))))) (define-maybe goimapnotify-tls-options-configuration) You are already familiar with the define-maybe macro. The serializer is also quite simple. Since we already defined a record that contains within all the required information to serialize it, we just need to make a simple wrapper around serialize-configuration . Before moving forward, notice how the serializers from this section doesn't contain that goimapnotify- prefix. This is an arbitrary decision, but since there is already goimapnotify in the symbol name, I think it's a bit redundant to add it. For the configuration definition to know which serializer to use, we have to be specific in the serializer argument of the fields, that's why in the configuration we had this declaration specifying a (serializer ...) for the field: (define-configuration goimapnotify-configuration ;; ... omitted lines ... (tls-options maybe-goimapnotify-tls-options-configuration "TLS options for the IMAP connection." (serializer serialize-maybe-goimapnotify-tls-options-configuration)) ;; ... omitted lines ... (prefix goimapnotify-)) list-of-goimapnotify-boxes-configurations First we need a prefix to know if we have a list of goimapnotify-box-configuration objects, that one is simple: (define (list-of-goimapnotify-boxes-configurations? lst) (and (not (null? lst)) (every goimapnotify-box-configuration? lst))) Now we need a serializer that knows how to handle a list of these objects: (define (serialize-list-of-goimapnotify-boxes-configurations field-name value) (let ((serializations (cons 'list (map (cut serialize-configuration <> goimapnotify-box-configuration-fields) value)))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~{ - ~a~%~}" '#$(camelize-field-name field-name) (map (lambda (s) (string-replace-substring s "\n" "\n ")) #$serializations))))) Looks a bit daunting, but it's just staged code, remember our earlier explanation of gexps. This code is only constructing a string with the shape we need. As a reminder, we are serializing to YAML, that means that lists are prefixed by a - character. Following the Goimapnotify README, the indentation would be something like this: boxes: - mailbox: INBOX onNewMail: 'mbsync examplenet:INBOX' onNewMailPost: SKIP onChangedMail: 'mbsync examplenet:INBOX' - mailbox: Junk onNewMail: 'mbsync examplenet:Junk' onNewMailPost: SKIP This is what that format call is doing. Refer to Formatted-Output for more information. We are only lacking a way to generate a complete configuration, remember that goimapnotify-configuration is an object for a single configuration. According to the README of Goimapnotify, the configuration file can take a list of configurations. home-goimapnotify-configuration This is the last configuration we will need, it will be used directly by the service: (define-configuration home-goimapnotify-configuration (goimapnotify (file-like goimapnotify) "The @code{goimapnotify} package to use." empty-serializer) (configurations (list-of-goimapnotify-configurations) "A list of @code{goimapnotify-configuration} records which contain information about all your accounts configurations.")) Simple enough, the first field is the Guix package that provides the goimapnotify program. This one is used by the service to start the process. Since it doesn't need to appear in any configuration file, we don't need to serialize it, hence the empty-serializer . On last serializer: (define (serialize-list-of-goimapnotify-configurations field-name value) (let ((serializations (cons 'list (map (cut serialize-configuration <> goimapnotify-configuration-fields) value)))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~{ - ~a~%~}" '#$(camelize-field-name field-name) (map (lambda (s) (string-replace-substring s "\n" "\n ")) #$serializations))))) The rationale for this code is the same as the one explained for list-of-goimapnotify-boxes-configurations . Defining the Shepherd service The service definition; at last! (define (home-goimapnotify-shepherd-service config) (let ((log-file #~(string-append %user-log-dir "/goimapnotify.log"))) (list (shepherd-service (provision '(goimapnotify)) (modules '((shepherd support))) ;for '%user-log-dir' (documentation "Run a goimapnotify process") (start #~(make-forkexec-constructor (list #$(file-append (home-goimapnotify-configuration-goimapnotify config) "/bin/goimapnotify") "-conf" #$(mixed-text-file "goimapnotify.yaml" (serialize-configuration config home-goimapnotify-configuration-fields))) #:log-file #$log-file)) (stop #~(make-kill-destructor)))))) Refer to Shepherd Services for more information on how to write Shepherd services. I will only highlight how to handle the configuration we just wrote. We have already done all the hard work, the configuration declaration contains all the information needed to perform the serialization of the different fields to YAML, we just need to call serialize-configuration . For example: scheme@(gnu home services mail)> (define test-config (home-goimapnotify-configuration (configurations (list (goimapnotify-configuration (host "test.example.com") (boxes (list (goimapnotify-box-configuration (mailbox "Test"))))))))) scheme@(gnu home services mail)> (serialize-configuration test-config home-goimapnotify-configuration-fields) $6 = #<gexp gnu/services/configuration.scm:165:2 7f3448e678a0> That serialization gives a gexp ready to be wrapped in a file-like so it can be lowered to the store. That's what mixed-text-file is doing: scheme@(gnu home services mail)> (mixed-text-file "goimapnotify.yaml" (serialize-configuration test-config home-goimapnotify-configuration-fields)) $7 = #<<computed-file> name: "goimapnotify.yaml" gexp: #<gexp guix/gexp.scm:2171:6 7f343915ef60> guile: #f options: (#:local-build? #t)> scheme@(gnu home services mail)> We've got ourselves a service, but there is one last thing before we go! Defining the service type As mentioned in the previous section, refer to Defining Services for what's going on here. The last thing we need do is declare the relation that this service has with respect to others: (define home-goimapnotify-service-type (service-type (name 'home-goimapnotify) (extensions (list (service-extension home-shepherd-service-type home-goimapnotify-shepherd-service))) (description "Configures the @code{goimapnotify} IMAP Mailbox notifier."))) We did it! Closing words Still here? That was long... But here we are, we defined our complex configuration . Congratulations on reading till the end, by this time you should already be an expert on defining Guix configurations. We acknowledge that there are some improvements to do on the API for defining configurations. We would like to have a way to specify symbol mapping for field names in the declaration, so that serializers can be generalized better. It would also be nice if we could refactor some of our configuration definitions so the serializers that can be generalized are reused between declarations. After all, there are many configuration files in similar formats. For all of these improvements, we are counting on you! I wrote this blog post to empower you to participate in the development. If this is something that resonates with you, come join the fun!

Guix system and Guix home introduce the concept of services. These provide users with a way to control background processes, commonly refereed as daemons , as well as ways of controlling the setup of files. For example, openssh-service-type is a service which controls a SSH daemon. In contrast, etc-service-type is a service that populates the contents of the /etc directory. One peculiarity of Guix services is that it's customary to provide Scheme bindings for the different fields. By that I mean that the different fields of the configuration of most services will be a type. The benefit of this is that users get a uniform configuration language for their services, at the cost of additional complexity when developing the service. Which is why, for a long time, Guix users seem to struggle with defining complex configurations. There are a number of reasons for this, we will try to close the gap today by going through defining a configuration for Goimapnotify . This blog post assumes that the reader is somewhat familiar with Guix and knows how to setup a development environment for it. If that's not the case, read The Perfect Setup and Using Guix interactively . A Goimapnotify configuration is written in YAML . Therefore, we will need to serialize the different Guile Scheme fields to this format. Let's take the example configuration that the author gives in the project's README.md : configurations: - host: example.com port: 143 tls: true tlsOptions: rejectUnauthorized: false starttls: true idleLogoutTimeout: 15 username: USERNAME alias: ExampleCOM password: PASSWORD xoAuth2: false boxes: - mailbox: INBOX onNewMail: 'mbsync examplecom:INBOX' onChangedMail: 'mbsync examplenet:INBOX' onChangedMailPost: SKIP onNewMailPost: SKIP - hostCMD: COMMAND_TO_RETRIEVE_HOST port: 993 tls: true tlsOptions: rejectUnauthorized: true starttls: true username: '' usernameCMD: '' password: '' passwordCMD: '' xoAuth2: false onNewMail: '' onNewMailPost: '' onChangedMail: '' onChangedMailPost: '' onDeletedMail: '' onDeletedMailPost: '' boxes: - mailbox: INBOX onNewMail: 'mbsync examplenet:INBOX' onNewMailPost: SKIP onChangedMail: 'mbsync examplenet:INBOX' - mailbox: Junk onNewMail: 'mbsync examplenet:Junk' onNewMailPost: SKIP Just by looking at the hierarchy we can already envision how to organize our scheme records. We will need the following: home-goimapnotify-configuration-fields goimapnotify-configuration goimapnotify-box-configuration goimapnotify-tls-options-configuration The final configuration that the service for Goimapnotify will rely on, will be home-goimapnotify-configuration-fields . It will contain a configurations field where each item will be a goimapnotify-configuration ; each box in those configurations will be a goimapnotify-box-configuration . Additionally, each of those configurations will have an optional goimapnotify-tls-options-configuration . The (gnu services configuration) module provides us with the API that we need to define these configurations. The most important helpers for defining configurations are: define-configuration : For configurations that need to serialize fields into a different format, generally configuration files. define-configuration/no-serialization : For configurations that do not need to emit any files. Generally all the fields are consumed by the Shepherd services that rely on the configuration but no translation from Scheme to a different format is needed. In our case, we need to translate the different configuration fields to YAML, so we will need to use define-configuration . The following sections are written so you can follow along, you are encouraged to drop into a REPL (short for read-eval-print loop ) and import the required module: ,use (gnu services configuration) goimapnotify-tls-options-configuration To make it easy for ourselves, we will start form the in-out, after all, you wouldn't want to build a house from the rooftop, would you? (define-configuration goimapnotify-tls-options-configuration (reject-unauthorized? (boolean #f) "Whether to reject unauthorized TLS certificates.") (starttls? (boolean #f) "Whether to use STARTTLS.") (prefix goimapnotify-)) evaluating the above snippet will throw an unbound variable exception, bear with me. Refer to the manual for an in-depth explanation of the syntax of define-configuration . The first argument is the name of the configuration object, goimapnotify-tls-options-configuration in this case. After it, we define the different typed fields. We are defining boolean fields that are, by default, set to false. The biggest source of confusion when defining configurations comes because define-configuration is a macro that introduces identifiers that do not appear in the source code—it's an unhygienic macro . That means that the macro will expand to code which defines symbols that are not visible when reading the source file. One can inspect the expansion of the macro by using the ,expand REPL command. It will be quite verbose, so don't try to read all of it, instead search through it; you will find some revealing things, such as: scheme@(gnu home services mail)> ,expand (define-configuration goimapnotify-tls-options-configuration (reject-unauthorized? (boolean #f) "Whether to reject unauthorized TLS certificates.") (starttls? (boolean #f) "Whether to use STARTTLS.") (prefix goimapnotify-)) $20 = (begin ... (define goimapnotify-tls-options-configuration? ...) (define goimapnotify-tls-options-configuration-reject-unauthorized? ...) (define goimapnotify-tls-options-configuration-starttls? ...) ... (define <goimapnotify-tls-options-configuration> ...) ... (define goimapnotify-tls-options-configuration ...) (define goimapnotify-tls-options-configuration-fields ((@@ (gnu services configuration) list) (let* ((name (let ((x 'reject-unauthorized?)) x)) ... (serializer (let ((x goimapnotify-serialize-boolean)) x)) ...) ...) (let* ((name (let ((x 'starttls?)) x)) ... (serializer (let ((x goimapnotify-serialize-boolean)) x)) ...) ...)))) In the previous snippet, ... represents omitted code. As you can see from the macro expansion, define-configuration introduces quite a few identifiers. You may recognize the shared prefix in those identifiers, that's right, it's the prefix specified by that last field that we didn't explain from the configuration definition, (prefix goimapnotify-) . By inspecting the macro expansion, it's easy to understand what happens under the hood. When a prefix is specified, we instruct the macro to append that prefix to all the generated identifiers. This is useful to avoid naming collisions; for example, when defining the configuration in a module with other configuration records that serialize to different formats. After all, it's not the same to serialize to YAML than to INI , or any other format a tool may require. Remember that unbound variable problem I mentioned before? If you paid close attention to the macro expansion, you may have noticed those references to goimapnotify-serialize-boolean , those are our unbound variables. The macro expects us to define these serialization procedures, let's do that. Defining the missing serializers Since the configuration machinery knows nothing about the output format, we must define how this translation happens. In our case, we are translating to YAML. By looking at the example configuration provided by the Goimapnotify developers, we can see that the field names are written in camel case, and their respective values are separated from the names through the : character. Helpers Let's start by making a function that takes a symbol and transforms it into a string that's a camelized version of that symbol. We will use object->camel-case-string from the (gnu home services utils) module: ,use (gnu home services utils) (define (camelize-field-name field-name) (let ((str (object->camel-case-string field-name))) (if (string-suffix? "?" str) (string-drop-right str 1) str))) ;; Usage: (camelize-field-name 'reject-unauthorized?) => "rejectUnauthorized" In YAML, there is no convention of suffixing booleans with a ? , so our camelizer drops it when found. camelize-field-name gives us a field name, but we want to serialize the value too. Let's define a field serialization procedure to help us: (define (goimapnotify-serialize-field field-name val) "The mapping is used to serialize certain FIELD-NAMES specially." (let* ((field-name-mapping '((host-command . hostCmd) (user-name . username) (user-name-command . usernameCmd) (password-command . passwordCmd))) (field-name* (or (assq-ref field-name-mapping field-name) field-name))) (format #f "~a: ~s~%" (camelize-field-name field-name*) val))) ;; Usage: (goimapnotify-serialize-field 'reject-unauthorized? 'true) => "rejectUnauthorized: true\n" Notice how we introduced field-name-mapping to tailor the field name passed to camelize-field-name to emit the specific naming that Goimapnotify expects. In Guix, we have a specific naming convention, so we want fields like user-name to map to username (instead of userName ) and fields like password-command to map to passwordCmd (instead of passwordCommand ). That's enough to serialize most values to YAML, but there's an extra Guix-specific feature we should make use of to make the service more convenient to users: G-Expressions . Are you familiar with them? If not, I encourage you to read this wonderful trilogy of blog posts: Dissecting Guix . It took me some time to wrap my head around these concepts, but once you do, I think you will like them too. In any case, that serializer is using those expressions because we want users of our configuration to be able to intermingle packages and other file-like objects in their fields. Since this is a blog post about writing configurations, I won't dive deep into G-Expressions, but let's try to get you a sense for them. You will need to import (guix gexp) for the following snippet to work. (define (goimapnotify-serialize-field field-name val) "The mapping is used to serialize certain FIELD-NAMES specially." (let* ((field-name-mapping '((host-command . hostCmd) (user-name . username) (user-name-command . usernameCmd) (password-command . passwordCmd))) (field-name* (or (assq-ref field-name-mapping field-name) field-name))) #~(format #f "~a: ~s~%" #$(camelize-field-name field-name*) #$val))) That's not so bad, is it? What was that? 6 more characters? Surely you wouldn't be scared of that, but just in case, let me give you some extra reassurance on what's happening here. This G-Expression thing, also known as gexp, is just the way you let Guix know that this code is for later. "When is later?", you may wonder. Simplifying it, that "later" is when Guix knows where file-like objects will be located in your disk; that long path on the store you may have seen before. For example, a package is a file-like object, so when you build the package Guix will tell you where it was stored: $ guix build cowsay /gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4 Preceding an expression with #~ (or gexp ), one effectively tells Guix "don't evaluate this code until you know what this expression should expand to". And that other syntax, #$ (or ungexp ), is telling Guix to replace the file-like object for its lowered representation, usually a path in the store. For a package like cowsay , that would be that /gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4 paths we saw before. With that said, if you are like me, you won't be comfortable if you cannot debug this expressions, so let me give you some supper powers. In your REPL, import the (guix) module. This will augment the REPL with some additional commands that will simplify our life: scheme@(guile-user)> ,use (guix) scheme@(guile-user)> ,help guix Guix Commands [abbrev]: ,run-in-store EXP - Run EXP through the store monad. ,verbosity LEVEL - Change build verbosity to LEVEL. ,lower OBJECT - Lower OBJECT into a derivation or store file and return it. ,build OBJECT [BUILD-MODE] - Lower OBJECT and build it, returning its output file name(s). ,build-options OPTIONS - Set build options to OPTIONS. Print previous value (to allow easy restore). ,build-graft GRAFT? - Set whether grafts should be performed. ,enter-store-monad - Enter a REPL for values in the store monad. ,phases - Return the build phases of the package defined by FORM. ,configure-flags - Return the configure flags of the package defined by FORM. ,make-flags - Return the make flags of the package defined by FORM. Let's see what that gexp is up to: scheme@(gnu home services mail)> (goimapnotify-serialize-field 'reject-unauthorized? ''true) $6 = #<gexp (format #f "~a: ~s\n" #<gexp-input "rejectUnauthorized":out> #<gexp-input (quote true):out>) gnu/home/services/mail.scm:252:2 7f4c7d9deb40> There is a little helper to approximate a gexp to it's output: scheme@(gnu home services mail)> (gexp->approximate-sexp $6) $7 = (format #f "~a: ~s\n" "rejectUnauthorized" (quote true)) scheme@(gnu home services mail)> (primitive-eval $7) $8 = "rejectUnauthorized: true\n" That gets us an idea of what will be emitted to disk, but for more complex procedures, this is not going to cut it, specially if there are multiple gexps combined. So let's build that gexp: ;; 'gexp->file' comes from the '(guix gexp)' module. You can ask the REPL more ;; information about a symbol through ',a SYMBOL'. scheme@(gnu home services mail)> ,a gexp->file (guix gexp): gexp->file #<procedure gexp->file (name exp #:key guile set-load-path? module-path splice? system target)> scheme@(gnu home services mail)> (gexp->file "test.scm" $6) $8 = #<procedure 7f3f7d456ea0 at guix/gexp.scm:2098:2 (state)> In order for Guix to build a value, it needs to be something Guix can lower—an object that can be "compiled" down to a file in the store (these are referred to as file-like objects, because they can be inserted in any piece of code that expects a file name). A gexp cannot be built by itself, because there is nowhere to output it to. The above snippet creates a file-like object that will be emitted to a file named test.scm in the store. That file will contain our expression. As you see, we've got a procedure. The REPL printer tells us that this procedure needs some state ; this is just a way of indicating you that this is a monadic procedure that can only be run in the context of a store connection. Read The Store Monad for more information. I will build it manually just once for demonstration purposes, don't blink: scheme@(gnu home services mail)> ,use (guix store) scheme@(gnu home services mail)> (run-with-store (open-connection) $8) $9 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7f3f7e2de000> Where we are saying "run the procedure we got in the context of a store connection", we opened that connection through open-connection . Let's not do that again... Fortunately we have those useful REPL commands I just told you about, so we can archive the same result by doing this: scheme@(gnu home services mail)> ,run-in-store $8 $10 = #<derivation /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv => /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm 7fcd92f7f0f0> That's better, isn't it? Now, this is a derivation . That's something Guix can build: scheme@(gnu home services mail)> ,build $10 building /gnu/store/gq71r3sgilfvsicfnifln8n0wfksam13-test.scm.drv... $11 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm" What's that? You don't want this magic command? Okay, the same thing can be done with: scheme@(gnu home services mail)> (run-with-store (open-connection) (built-derivations (list $10))) $12 = #t scheme@(gnu home services mail)> (derivation->output-path $10) $13 = "/gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm" At last, a file! What's in there? $ cat /gnu/store/c3hxw7pgnxchw1zldnc9nrb8iwwrqf78-test.scm (format #f "~a: ~a\n" "rejectUnauthorized" "true") Okay that seems about right. We can even run it: scheme@(gnu home services mail)> (call-with-input-file $13 (lambda (port) (primitive-eval (read port)))) $14 = "rejectUnauthorized: true\n" You've seen the secret sauce, let's continue with what brought us here. (define (goimapnotify-serialize-boolean field-name val) (goimapnotify-serialize-field field-name (if val ''true ''false))) With this our configuration won't complain about the missing serializer. Let's continue. One last thing, notice how, after moving to the gexp version of goimapnotify-serialize-field , we started double quoting the symbols true and false , this is because the #$ syntax will replace the value in place, and the value of 'true is true , without the quote. If we didn't double quote, the staged code after expansion would look like this: (format #f "~a: ~s\n" "rejectUnauthorized" true) When what we really want is to expand to this: (format #f "~a: ~s\n" "rejectUnauthorized" 'true) The reason for this is that we want users to be able to write staged code on the different fields. If one of the values of a field where to be something like this: (goimapnotify-serialize-field 'favorite-game #~(string-append #$cowsay "/bin/cowsay")) The expansion would be this: (format #f "~a: ~s\n" "favoriteGame" (string-append "/gnu/store/gz170ppi2hxssp4h3yw5jh4gdw6x9ws0-cowsay-3.8.4" "/bin/cowsay")) The string-append procedure is evaluated right before the format call, not when the gexp is getting lowered. Serializing The (gnu services configuration) module we imported earlier provides us with serialize-configuration , it takes two arguments, a configuration object and the fields that compose that configuration. The define-configuration macro defined the goimapnotify-tls-options-configuration constructor for us, we can create a configuration with the default values like this: scheme@(gnu home services mail)> (goimapnotify-tls-options-configuration) $10 = #<<goimapnotify-tls-options-configuration> reject-unauthorized?: #f starttls?: #f %location: #f> Then, we can serialize it like this: scheme@(gnu home services mail)> (serialize-configuration $10 goimapnotify-tls-options-configuration-fields) $11 = #<gexp gnu/services/configuration.scm:165:2 7ff1fd2cbf60> If you recall form the macro expansion we saw earlier, goimapnotify-tls-options-configuration-fields , was one of those identifiers that got generated. You should already know how to build that gexp we've got: scheme@(gnu home services mail)> (gexp->file "test.yaml" $11) $12 = #<procedure 7efd8b10c2d0 at guix/gexp.scm:2098:2 (state)> scheme@(gnu home services mail)> ,use (guix) scheme@(gnu home services mail)> ,run-in-store $12 $13 = #<derivation /gnu/store/z59sf3nhh80668z8njljfxcymgi071pr-test.yaml.drv => /gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml 7efd8a80fd70> scheme@(gnu home services mail)> ,build $13 $14 = "/gnu/store/xam1210yl1vdxix0bgpfalf6drpv6xbx-test.yaml" scheme@(gnu home services mail)> (call-with-input-file $14 (lambda (port) (display (primitive-eval (read port))))) rejectUnauthorized: false starttls: false That's some nice YAML syntax... We better speed up the pace or I will retire before finishing up this blog post. goimapnotify-box-configuration Next in line is goimapnotify-box-configuration , you know the drill. We start by declaring the configuration with define-configuration , specifying each field name, type and docstring: (define-configuration goimapnotify-box-configuration (mailbox string "The mailbox to monitor.") (on-new-mail maybe-string-or-gexp "The command to execute when new mail arrives.") (on-new-mail-post maybe-string-or-gexp "The command to execute after the new-mail command.") (on-changed-mail maybe-string-or-gexp "The command to execute when mail is changed.") (on-changed-mail-post maybe-string-or-gexp "The command to execute after the changed-mail command.") (on-deleted-mail maybe-string-or-gexp "The command to execute when mail is deleted.") (on-deleted-mail-post maybe-string-or-gexp "The command to execute after the deleted-mail command.") (prefix goimapnotify-)) You may have noticed that we have two new types. strings , easy enough, and maybe-string-or-gexp , not so easy; right? Worry not, here comes the explanation. Let's start by defining the string type: (define-maybe string (prefix msmtp-configuration-)) You may be very confused right now. What has msmtp-configuration to do with our goimapnotify-configuration example? Well, I want this blog post to get you ready for the real world, and in the wild, you will make configurations in modules that contain other configurations. The example we are looking up today is a narration of my adventures making the Goimapnotify service from the (gnu services mail) module, in that module, there is already a configuration for msmtp . With that said, if we tried to do this: (define-maybe string (prefix goimapnotify-)) We would be surprised with a warning that looks something like this: $ make ... [ 94%] GUILEC gnu/home/services/mail.go gnu/home/services/mail.scm:67:0: warning: shadows previous definition of `maybe-string?' at gnu/home/services/mail.scm:63:0 gnu/home/services/mail.scm:278:0: warning: shadows previous definition of `goimapnotify-serialize-maybe-string' at gnu/home/services/mail.scm:67:0 That's unexpected, isn't it? Nothing we've seen so far points to a maybe-string? procedure, let's look at what is going under the hood of that define-maybe macro: scheme@(gnu home services mail)> ,expand (define-maybe string (prefix msmtp-configuration-)) $15 = (begin (define (maybe-string? val) (or ((@@ (gnu services configuration) not) ((@@ (gnu services configuration) maybe-value-set?) val)) (string? val))) (define (msmtp-configuration-serialize-maybe-string field-name val) (if (string? val) (msmtp-configuration-serialize-string field-name val) ""))) Do you see it? The macro is expanding to some code that introduces two new procedures into the module, one following the prefix, that would be msmtp-configuration-serialize-maybe-string , and one that just declares the predicate for the maybe type. Given that, the warning is now apparent. If we call again that macro with a string as the first argument, we will get the same predicate after the expansion, leading to the redefinition warning. So, for this particular case, instead of using the macro, we will define manually the two procedures required for our configuration: ;; The module already had this helper defined. (define (string-or-gexp? obj) (or (string? obj) (gexp? obj))) ;; ... omitted lines ... (define (goimapnotify-serialize-string field-name val) (goimapnotify-serialize-field field-name val)) (define (goimapnotify-serialize-maybe-string field-name val) (if (maybe-value-set? val) (goimapnotify-serialize-string field-name val) "")) (define goimapnotify-serialize-string-or-gexp goimapnotify-serialize-string) (define (goimapnotify-serialize-maybe-string-or-gexp field-name val) (if (and (maybe-value-set? val) (string-or-gexp? val)) (goimapnotify-serialize-string-or-gexp field-name val) "")) The above snippet defines all the serializers we need for the new types. Notice that they are just some simple wrappers around the goimapnotify-serialize-field helper. That procedure is doing all the heavy lifting here, and fortunately for us, it already handles gexps. Therefore, the fields that have a type that accepts a gexp are straightforward to declare. That would be it for the goimapnotify-box-configuration declaration. As showed in the previous section, the REPL is your friend. I had never written such a complex configuration before, but thanks to this "elegant weapon for a more civilized age" , I could find my way by poking things around. Let's continue! goimapnotify-configuration You know the drill, we start by defining the fields we need. Again, I'm doing this just by looking at the README example provided by the developers of Goimapnotify: (define-configuration goimapnotify-configuration (host string "The IMAP server hostname.") (host-command maybe-string-or-gexp "The command to retrieve the IMAP server hostname.") (port (integer 993) "The port that the IMAP server listens on.") (tls? (boolean #f) "Enable or disable TLS.") (tls-options maybe-goimapnotify-tls-options-configuration "TLS options for the IMAP connection." (serializer serialize-maybe-goimapnotify-tls-options-configuration)) (idle-logout-timeout maybe-integer "The idle logout timeout in minutes.") (user-name maybe-string "The user-name for authentication.") (user-name-command maybe-string-or-gexp "The command to retrieve the user-name.") (alias maybe-string "An alias for the account.") (password maybe-string "The password for authentication.") (password-command maybe-string-or-gexp "The command to retrieve the password.") (xo-auth2? (boolean #f) "Enable or disable XOAUTH2 authentication.") (wait maybe-integer "The delay in seconds before the mail syncing is triggered.") (boxes list-of-goimapnotify-boxes-configurations "The mailboxes to monitor." (serializer serialize-list-of-goimapnotify-boxes-configurations)) (prefix goimapnotify-)) You are already familiar with most of those types, but there are some new things here: integer and maybe-integer maybe-goimapnotify-tls-options-configuration list-of-goimapnotify-boxes-configurations Let's start with the obvious ones first. integer The module already had a maybe definition for the integer. If you recall from the macro expansion earlier, that means that there is already a symbol for the predicate maybe-integer? : (define-maybe integer (prefix msmtp-configuration-)) We still need a serializer that follows our prefix: (define (goimapnotify-serialize-integer field-name val) (goimapnotify-serialize-field field-name val)) (define (goimapnotify-serialize-maybe-integer field-name val) (if (maybe-value-set? val) (goimapnotify-serialize-integer field-name val) "")) Simple enough. Moving on! maybe-goimapnotify-tls-options-configuration (define (serialize-goimapnotify-tls-options-configuration field-name val) (let ((serialization (serialize-configuration val goimapnotify-tls-options-configuration-fields))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~a~%" '#$(camelize-field-name field-name) (string-replace-substring #$serialization "\n" "\n "))))) (define-maybe goimapnotify-tls-options-configuration) You are already familiar with the define-maybe macro. The serializer is also quite simple. Since we already defined a record that contains within all the required information to serialize it, we just need to make a simple wrapper around serialize-configuration . Before moving forward, notice how the serializers from this section doesn't contain that goimapnotify- prefix. This is an arbitrary decision, but since there is already goimapnotify in the symbol name, I think it's a bit redundant to add it. For the configuration definition to know which serializer to use, we have to be specific in the serializer argument of the fields, that's why in the configuration we had this declaration specifying a (serializer ...) for the field: (define-configuration goimapnotify-configuration ;; ... omitted lines ... (tls-options maybe-goimapnotify-tls-options-configuration "TLS options for the IMAP connection." (serializer serialize-maybe-goimapnotify-tls-options-configuration)) ;; ... omitted lines ... (prefix goimapnotify-)) list-of-goimapnotify-boxes-configurations First we need a prefix to know if we have a list of goimapnotify-box-configuration objects, that one is simple: (define (list-of-goimapnotify-boxes-configurations? lst) (and (not (null? lst)) (every goimapnotify-box-configuration? lst))) Now we need a serializer that knows how to handle a list of these objects: (define (serialize-list-of-goimapnotify-boxes-configurations field-name value) (let ((serializations (cons 'list (map (cut serialize-configuration <> goimapnotify-box-configuration-fields) value)))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~{ - ~a~%~}" '#$(camelize-field-name field-name) (map (lambda (s) (string-replace-substring s "\n" "\n ")) #$serializations))))) Looks a bit daunting, but it's just staged code, remember our earlier explanation of gexps. This code is only constructing a string with the shape we need. As a reminder, we are serializing to YAML, that means that lists are prefixed by a - character. Following the Goimapnotify README, the indentation would be something like this: boxes: - mailbox: INBOX onNewMail: 'mbsync examplenet:INBOX' onNewMailPost: SKIP onChangedMail: 'mbsync examplenet:INBOX' - mailbox: Junk onNewMail: 'mbsync examplenet:Junk' onNewMailPost: SKIP This is what that format call is doing. Refer to Formatted-Output for more information. We are only lacking a way to generate a complete configuration, remember that goimapnotify-configuration is an object for a single configuration. According to the README of Goimapnotify, the configuration file can take a list of configurations. home-goimapnotify-configuration This is the last configuration we will need, it will be used directly by the service: (define-configuration home-goimapnotify-configuration (goimapnotify (file-like goimapnotify) "The @code{goimapnotify} package to use." empty-serializer) (configurations (list-of-goimapnotify-configurations) "A list of @code{goimapnotify-configuration} records which contain information about all your accounts configurations.")) Simple enough, the first field is the Guix package that provides the goimapnotify program. This one is used by the service to start the process. Since it doesn't need to appear in any configuration file, we don't need to serialize it, hence the empty-serializer . On last serializer: (define (serialize-list-of-goimapnotify-configurations field-name value) (let ((serializations (cons 'list (map (cut serialize-configuration <> goimapnotify-configuration-fields) value)))) #~(begin (use-modules (ice-9 format) (ice-9 string-fun)) (format #f "~a: ~{ - ~a~%~}" '#$(camelize-field-name field-name) (map (lambda (s) (string-replace-substring s "\n" "\n ")) #$serializations))))) The rationale for this code is the same as the one explained for list-of-goimapnotify-boxes-configurations . Defining the Shepherd service The service definition; at last! (define (home-goimapnotify-shepherd-service config) (let ((log-file #~(string-append %user-log-dir "/goimapnotify.log"))) (list (shepherd-service (provision '(goimapnotify)) (modules '((shepherd support))) ;for '%user-log-dir' (documentation "Run a goimapnotify process") (start #~(make-forkexec-constructor (list #$(file-append (home-goimapnotify-configuration-goimapnotify config) "/bin/goimapnotify") "-conf" #$(mixed-text-file "goimapnotify.yaml" (serialize-configuration config home-goimapnotify-configuration-fields))) #:log-file #$log-file)) (stop #~(make-kill-destructor)))))) Refer to Shepherd Services for more information on how to write Shepherd services. I will only highlight how to handle the configuration we just wrote. We have already done all the hard work, the configuration declaration contains all the information needed to perform the serialization of the different fields to YAML, we just need to call serialize-configuration . For example: scheme@(gnu home services mail)> (define test-config (home-goimapnotify-configuration (configurations (list (goimapnotify-configuration (host "test.example.com") (boxes (list (goimapnotify-box-configuration (mailbox "Test"))))))))) scheme@(gnu home services mail)> (serialize-configuration test-config home-goimapnotify-configuration-fields) $6 = #<gexp gnu/services/configuration.scm:165:2 7f3448e678a0> That serialization gives a gexp ready to be wrapped in a file-like so it can be lowered to the store. That's what mixed-text-file is doing: scheme@(gnu home services mail)> (mixed-text-file "goimapnotify.yaml" (serialize-configuration test-config home-goimapnotify-configuration-fields)) $7 = #<<computed-file> name: "goimapnotify.yaml" gexp: #<gexp guix/gexp.scm:2171:6 7f343915ef60> guile: #f options: (#:local-build? #t)> scheme@(gnu home services mail)> We've got ourselves a service, but there is one last thing before we go! Defining the service type As mentioned in the previous section, refer to Defining Services for what's going on here. The last thing we need do is declare the relation that this service has with respect to others: (define home-goimapnotify-service-type (service-type (name 'home-goimapnotify) (extensions (list (service-extension home-shepherd-service-type home-goimapnotify-shepherd-service))) (description "Configures the @code{goimapnotify} IMAP Mailbox notifier."))) We did it! Closing words Still here? That was long... But here we are, we defined our complex configuration . Congratulations on reading till the end, by this time you should already be an expert on defining Guix configurations. We acknowledge that there are some improvements to do on the API for defining configurations. We would like to have a way to specify symbol mapping for field names in the declaration, so that serializers can be generalized better. It would also be nice if we could refactor some of our configuration definitions so the serializers that can be generalized are reused between declarations. After all, there are many configuration files in similar formats. For all of these improvements, we are counting on you! I wrote this blog post to empower you to participate in the development. If this is something that resonates with you, come join the fun!

Источник: Planet GNU