Exploring Nix Flakes: Usable Go Plugins

Part 2: APIs and Dependencies

In the previous part we created a plugin with an init function so that it runs some code when compiled into the mainapp. Of course, that does give the plugin no practical possibility to interact with the mainapp. To do this, we’ll need a plugin API that defines how the main application and the plugins communicate.

To be able to define an API, we need to give our application some functionality. For this article, the functionality will be that it creates an image and serves it via HTTP; plugins can add to the image before it is served. This functionality is chosen for two reasons:

The API we need cannot be part of our mainapp’s Go module since that would create a circular dependency (remember that our mainapp imports the plugin’s module). Thus, we will create a separate directory api inside our root. Inside that directory, create a Go module and setup the go-cairo dependency:

nix run nixpkgs#go mod init example.com/api
nix run nixpkgs#go -- get -d github.com/ungerik/go-cairo

We’ll give the API a Plugin type that defines the entry point of our plugin. Create a file api/plugin.go with the following content:

package api

import "github.com/ungerik/go-cairo"

// Plugin is the interface of mainapp plugins.
type Plugin interface {
	Paint(surface *cairo.Surface) error
}

In an actual application, you’d probably publish the API and thus could reference it directly in go.mod with its module path. But to keep it local – and also because we don’t have control over example.com – we’ll make this a Nix Flake just like everything else (remember how I said in part 1 that we could manage standard dependencies with Nix Flakes? That’s what we’ll be doing with the API).

Create a file api/flake.nix:

{
  inputs = {
    nix-filter.url = github:numtide/nix-filter;
  };
  outputs = {self, nix-filter}: {
    src = nix-filter.lib.filter {
      root = ./.;
      exclude = [ ./flake.nix ./flake.lock ];
    };
  };
}

This gives us a flake whose src output is simply the API module’s sources (this is a non-standard output but that’s fine for our local setup).

Now we need the main application that implements our functionality. To set it apart from our earlier iteration, create a directory image-server in the root directory. In it, create another module:

nix run nixpkgs#go mod init example.com/image-server
nix run nixpkgs#go -- get -d github.com/ungerik/go-cairo

Now let’s write our application. Put this in image-server/main.go (this is a modified version of the go-cairo example, extended with an HTTP server and plugin interaction).

package main

import (
	"log"
	"net/http"
	
	"example.com/api"
	"github.com/ungerik/go-cairo"
)

var plugins []api.Plugin

func main() {
	log.Println("serving at http://localhost:8080")
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		surface := cairo.NewSurface(cairo.FORMAT_ARGB32, 240, 80)
		surface.SelectFontFace(
			"serif", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
		surface.SetFontSize(32.0)
		surface.SetSourceRGB(0.0, 0.0, 1.0)
		surface.MoveTo(10.0, 50.0)
		surface.ShowText("Hello World")
		for _, p := range plugins {
			if err := p.Paint(surface); err != nil {
				panic(err)
			}
		}
		png, _ := surface.WriteToPNGStream()
		w.Write(png)
		surface.Finish()
	})
	
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The plugins variable is to hold our plugins. To populate it, we’ll extend the code we generate into plugins.go. Create a new template image-server/plugins.go.nix to do this:

lib: plugins: with builtins; ''
package main

// Code generated by Nix. DO NOT EDIT.

import (
	"log"
	${concatStringsSep "\n\t"
	  (lib.imap1 (i: p: "p${toString i} \"${p.goPlugin.goModName}\"")
		           plugins)}
)

func init() {
	${concatStringsSep "\n\t"
	  (lib.imap1
		 (i: p: "plugins = append(plugins, p${toString i}.Plugin())")
		 plugins)}
	log.Println("plugins have been initialized.")
} 
''

Compared to our previous iteration, we now give the imported plugin packages actual names. The generated code will look like this:

import (
	"log"
	p1 "<plugin 1 path>"
	p2 "<plugin 2 path>"
)

func init() {
	plugins = append(plugins, p1.Plugin())
	plugins = append(plugins, p2.Plugin())
}

We generate package names p1, p2 etc to avoid any possibility for name collisions. The code assumes that the root package of any plugin provides a function Plugin() which returns an api.Plugin. This is akin to the entry point of a classical C dynamic-library-plugin.

Now we need the flake of our new application at image-server/flake.nix:

{
  description = "demo image server";
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-21.11;
    utils.url = github:numtide/flake-utils;
    nix-filter.url = github:numtide/nix-filter;
    api.url = path:../api;
  };
  outputs = {self, nixpkgs, utils, nix-filter, api}:
  let
    buildApp = { system, vendorSha256, plugins ? [] }:
      let
        pkgs = nixpkgs.legacyPackages.${system};
        requireFlake = modName: ''
          require ${modName} v0.0.0
          replace ${modName} => ./vendor-nix/${modName}
        '';
        vendorFlake = modName: src: ''
          mkdir -p $(dirname vendor-nix/${modName})
          cp -r ${src} vendor-nix/${modName}
        '';
        sources = pkgs.stdenvNoCC.mkDerivation {
          name = "image-server-with-plugins-source";
          src = nix-filter.lib.filter {
            root = ./.;
            exclude = [ ./flake.nix ./flake.lock ];
          };
          nativeBuildInputs = plugins;
          phases = [ "unpackPhase" "buildPhase" "installPhase" ];
          PLUGINS_GO = import ./plugins.go.nix nixpkgs.lib plugins;
          GO_MOD_APPEND = builtins.concatStringsSep "\n"
            ((builtins.map (p: requireFlake p.goPlugin.goModName)
             plugins) ++ [(requireFlake "example.com/api")]);
          buildPhase = ''
            mkdir vendor-nix
            ${builtins.concatStringsSep "\n"
              ((builtins.map (p: vendorFlake p.goPlugin.goModName
                              "${p}/src") plugins)
              ++ [(vendorFlake "example.com/api" api.src)])}
            printenv PLUGINS_GO >plugins.go
            echo "" >>go.mod # newline
            printenv GO_MOD_APPEND >>go.mod
          '';
          installPhase = ''
            mkdir -p $out/src
            cp -r -t $out/src *
          '';
        };
      in pkgs.buildGoModule rec {
        name = "image-server";
        src = builtins.trace "sources at ${sources}" sources;
        modRoot = "src";
        subPackages = [ "." ];
        inherit vendorSha256;
        nativeBuildInputs = [ pkgs.pkg-config ];
        buildInputs = [ pkgs.cairo ];
        preBuild = ''
          export PATH=$PATH:${pkgs.lib.makeBinPath buildInputs}
        '';
      };
  in (utils.lib.eachDefaultSystem (system: rec {
    packages.app = buildApp {
      inherit system;
      vendorSha256 = "yII94225qx8EAMizoPA9BSRP9lz0JL/UoPDNYROcvNw=";
    };
    defaultPackage = packages.app;
  })) // {
    lib = {
      inherit buildApp;
      pluginMetadata = goModFile: {
        goModName = with builtins; head
          (match "module ([^[:space:]]+).*" (readFile goModFile));
      };
    };
  };
}

Compared to our previous iteration, we integrated the API code and give nixpkgs.lib to plugins.go.nix. We also added pkg-config, made it available in the PATH, and added cairo as dependency. go-cairo is configured to use pkg-config to discover how to link to cairo, which is why we need those dependencies.

As always, in image-server, check in everything and run:

git add . ../api
nix flake update
git add flake.lock
git commit -a -m "image server"
nix run

After we see our log line, visit http://localhost:8080 in your browser to query the image we generate. Stop the server with ^C. This sums up our updated main application.

Count Plugin

It’s the plugin that counts!

Our current implementation always creates the same image. We will now write a plugin that adds to the image the number of times the image has been queried. Create a directory count-plugin and do the usual initialization in it:

nix run nixpkgs#go mod init example.com/count-plugin
nix run nixpkgs#go -- get -d github.com/ungerik/go-cairo

Write the plugin’s implementation into count-plugin/plugin.go:

package count

import (
	"example.com/api"
	"github.com/ungerik/go-cairo"
	"log"
	"strconv"
)

type CountPlugin struct {
	count int
}

func (cp *CountPlugin) Paint(surface *cairo.Surface) error {
	cp.count += 1
	surface.SetFontSize(24.0)
	surface.MoveTo(200.0, 20.0)
	surface.ShowText(strconv.Itoa(cp.count))
	return nil
}

func Plugin() api.Plugin {
	log.Println("initializing count-plugin")
	return &CountPlugin{count: 0}
}

We provide a func Plugin() that is the plugin’s entry point. We define a type CountPlugin that implements api.Plugin. In Paint, we add a running number to the image. That’s it.

Now we need count-plugin/flake.nix, which is very similar to our previous plugin’s Flake, apart from the inclusion of the API:

{
  description = "count plugin for image-server";
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-21.11;
    utils.url = github:numtide/flake-utils;
    nix-filter.url = github:numtide/nix-filter;
    image-server.url = path:../image-server;
    image-server.inputs = {
      nixpkgs.follows = "nixpkgs";
      utils.follows = "utils";
      nix-filter.follows = "nix-filter";
    };
  };
  outputs = {self, nixpkgs, utils, nix-filter, image-server}:
  utils.lib.eachDefaultSystem (system:
    let
      pkgs = nixpkgs.legacyPackages.${system};
    in rec {
      packages = rec {
        plugin = pkgs.stdenvNoCC.mkDerivation {
          pname = "image-server-count-plugin";
          version = "0.1.0";
          src = nix-filter.lib.filter {
            root = ./.;
            exclude = [ ./flake.nix ./flake.lock ];
          };
          passthru.goPlugin = image-server.lib.pluginMetadata ./go.mod;
          phases = [ "unpackPhase" "buildPhase" "installPhase" ];
          buildPhase = ''
            echo "\nrequire example.com/api v0.0.0" >>go.mod
          '';
          installPhase = ''
            mkdir -p $out/src
            cp -r -t $out/src *
          '';
        };
        app = image-server.lib.buildApp {
          inherit system;
          vendorSha256 = "US38BDmwhrrMxvZVzEq1ch65DGDS6Mq/IO4NvgyHsQU=";
          plugins = [ plugin ];
        };
      };
      defaultPackage = packages.app;
    }
  );
}

For this go.mod we only need the require directive – the replace in the main application’s go.mod will be honored. Let me stress again that we only refer to the API via flake to keep everything local and in actual code you most probably want to have a publicly available standard Go module as API.

Let’s finalize the plugin and run it:

git add .
nix flake update
git add flake.lock
git commit -a -m "count plugin"
nix run

Now when you access http://localhost:8080/ again, you will have a running number as part of the image, which increases when you reload. Browsers do background prefetching and caching, so you might not see every number.

Multiple Plugins

At this point, we have seen that we can create plugin-based applications and write usable plugins for them. What we haven’t talked about is what to do when we want to have multiple plugins active – currently, a plugin Flake simply defines an application where exactly this plugin is active. We did this mostly for convenience.

A tailored setup with a defined set of plugins would be written in an own Flake or as part of a system configuration, like this:

let
  refPlugin = url: {
    inherit url;
    inputs = {
      nixpkgs.follows = "nixpkgs";
      utils.follows = "utils";
      image-server.follows = "image-server";
    };
  };
in {
  description = "Tailored image-server";
  inputs = {
    nixpkgs.url = github:NixOS/nixpkgs/nixos-21.11;
    utils.url = github:numtide/flake-utils;
    image-server = {
      url = path:../image-server;
      inputs.nixpkgs.follows = "nixpkgs";
      inputs.utils.follows = "utils";
    };
    first = refPlugin path:../first-plugin;
    second = refPlugin path:../second-plugin;
  };
  outputs = {self, nixpkgs, utils, image-server, first, second}:
    utils.lib.eachDefaultSystem (system: {
      defaultPackage = image-server.lib.buildApp {
        inherit system;
        vendorSha256 = nixpkgs.lib.fakeSha256;
        plugins = [ first.packages.${system}.plugin
                    second.packages.${system}.plugin ];
      };
    });
}

Is it necessary to have platform-dependent packages for our plugins if they merely contain sources? For what we did here it isn’t, but it might be the case for other applications. The alternative would be to put them into some non-standard Flake output.

We now have explored how to make our main application and our plugins communicate via an API. We have also seen how to incorporate C dependencies with Nix. What remains now is to be able to target systems that don’t support Nix, most importantly Windows. This is what we’ll be doing in the next and final part.