Lets build docker mode - Part 1

At work and while working on stuff at home I usually use docker for various things. We bundle all our services into a docker container that we push up to our servers, start a database for local development etc etc. One of my mantras in life is that I should “do it in Emacs” as much as I can. Git (magit), shell (eshell), email (notmuch) and a bunch of other stuff. Wouldn’t it be nice if I could also control docker from emacs? Yes, yes it would be.

And that is what I’ll try to do in this series. Build a magit inspired interface for working with docker from within emacs. I don’t know how to actually build a major mode in emacs and I don’t write that much elisp so this should be a great learning experience.

Program Sketch

When I start working on something I usually find it helpful to sketch out what it is I’m aiming for.

Let’s list some things that we want for our 1.0 release. What I want to do is to have an interface where I can show all images, start them from emacs. Here it would also be good to be able to filter images since sometimes I will have a lot of images on my machine. Here we should also be able to remove images etc. This will be the interface to docker-images. I also want to be able to see all running docker containers, stop them, and the piece the resistance would be to have the functionality to jump into a docker container. This willbe the interface to docker-ps.

The last thing I need is to have the ability to build and start docker images based on a Dockerfile in the current project I’m working in. This should also support docker-compose.

Okay, we have a basic idea on what we want to accomplish and can get started on actually building something!

Wait, how do you build a major mode?

Let’s ask emacs.

C-h i m elisp RET m major modes RET

As usual we find some good information here!

The easiest way to write a major mode is to use the macro ‘define-derived-mode’, which sets up the new mode as a variant of an existing major mode. See Derived Modes.

So we need to find a good mode that we can derive from. If we look at the output of docker images and docker ps we see that it is pretty much a table. So if we can find a mode that handles tabulated data that should be a good starting point. After a little digging we find that there is something called tabulated list mode, bingo!

In this major mode, the buffer is divided into multiple columns, which are labeled using the header line. Each non-empty line belongs to one “entry”, and the entries can be sorted according to their column values.

Sounds like the exact thing we need! So what do wee need to do to make this work? Again the documentation has our backs.

An inheriting mode should usually do the following in their body:

  • Set ‘tabulated-list-format’, specifying the column format.
  • Set ‘tabulated-list-revert-hook’, if the buffer contents need
    to be specially recomputed prior to ‘revert-buffer’.
  • Maybe set a ‘tabulated-list-entries’ function (see below).
  • Maybe set ‘tabulated-list-printer’ (see below).
  • Maybe set ‘tabulated-list-padding’.
  • Call ‘tabulated-list-init-header’ to initialize ‘header-line-format’
    according to ‘tabulated-list-format’.

Seems like we at least need to set the tabulated-list-format and tabulated-list-revert-hook variables. Given that I haven’t written a major mode, right about now it would be good to find an example mode that uses the tabulated-list-mode. I did some digging around and found the process-menu-mode that can be invoked with list-processes.

Let’s start building our mode!

We define a new mode with define-derived-mode, we then need to set some of the variables listed above.

(define-derived-mode docker-images-mode tabulated-list-mode "Docker Images"
  "Major mode for listing docker images."
  (setq tabulated-list-format [("REPOSITORY" 15 t)
				   ("TAG"        15 t)
				   ("IMAGES ID"  15 t)
				   ("CREATED"    15 t)
				   ("SIZE"       15 t)])
  (setq tabulated-list-sort-key (cons "CREATED" nil))
  (add-hook 'tabulated-list-revert-hook 'docker-images--refresh nil t))

Here we set the format of the table to the format produced by docker images, we then say to sort on the CREATED column. The last add-hook call sets the function that will update the table which by convention is called major-mode-name--refresh.

(defun docker-images--refresh ()
  "Run docker images again."
  (setq tabulated-list-entries nil)
  (dolist (line (cdr (process-lines "docker" "images")))
	(push (list nil (vconcat [] (split-string line "[[:blank:]]\\{2,\\}" t " ")))
	  tabulated-list-entries))
  (tabulated-list-init-header))

Here we clear the tabulated-list-entries list to prepare it for new entries. Next we use the process-lines function to get a list of all the lines outputed by docker images and remove the first line which is just headers.

The next line was the line that took me the most time…why?

The line that we get is just a long string "postgres latest c12289de6f88 3 weeks ago 288MB" and we want to split this into the columns. The function split-string is the tool we want to use. If we don’t provide a regular expression to this function it will almost work, but it will also split the “3 weeks ago” into three different parts. So easy right? We just create a regular expression to match 2 or more space characters: “[[:blank:]]{2,}”, now this regular expression works if you pass it to highlight-regexp but doesn’t work on split-string so after 1 hour of banging my head against my keyboard I discovered that you have to also escape the slash: “[[:blank:]]\{2,\}”.

The final thing in this function is to call tabulated-list-init-header to print the header of the table.

Well with that done we almost have a functional mode! All we need is do define a function that we can call that creates a buffer and turns on our new major mode.

(defun docker-images (&optional buffer)
  "Displays a list of all docker images.
Optinal argument BUFFER specifies a buffer to use instead
of \"*Docker Images*\"."
  (interactive)
  (unless (bufferp buffer)
	(setq buffer (get-buffer-create "*Docker Images*")))
  (with-current-buffer buffer
	(docker-images-mode)
	(docker-images--refresh)
	(tabulated-list-print))
  (display-buffer buffer)
  nil)

Here we create a new buffer called *Docker Images*, sets the major mode to our docker-images-mode. Calls docker-images--refresh so that our list gets populated, calls the function that prints that list and finally displays the buffer!

Neat.

That is a good place to make a commit and a good place to end this post. There are some things that needs fixing. For example our sorting is not working as we want to since it is sorting on strings and not on dates. I foresee a future where we lift some of the mode creation to a top-level mode since it will probably be similar for other docker commands. We also want to add some magit like interactive feature to start containers etc. But for now this works.

See you next time!