Atomically Directory Population in Go

At work I’m building a little tool to write data from AWS Secrets Manager to a directory on disk. I wrote a little package to write the secrets atomically, because that seemed safest at the time. In retrospect just writing each file atomically probably would have been good enough. Code and discussion are below.

The code follows, but I’ll give a quick overview of how it works first:

  • Ensure that what we’re updating is actually a symlink (or doesn’t exist)
  • Build a tempdir next to the symlink (to be sure it’s on the same filesystem)
  • Enqueue a defer to recursively remove realdir, which initially is the new tempdir
  • Call populate to fill the new dir
  • Update the symlink to point at the new dir
  • Update realdir so the defer recursively removes the old directory

package atomicdir // import "go.zr.org/secrets/esoterica/atomicdir"

import (
	"io/ioutil"
	"os"
	"path/filepath"

	"go.zr.org/common/go/errors"
)

// ErrNotSymlink is when the symlink to update is some other kind of file
var ErrNotSymlink = errors.New("Not a symlink")

// Fill populates a directory with populate then atomically points d at
// the directory.
func Fill(d string, populate func(string) error) error {
	fi, err := os.Lstat(d)
	if err != nil && !os.IsNotExist(err) {
		return errors.Wrap(err, "os.Lstat", "file", d)
	}

	var old string

	if fi != nil {
		if fi.Mode()&os.ModeSymlink == 0 {
			return errors.WithDetails(ErrNotSymlink, "file", d)
		}

		old, err = os.Readlink(d)
		if err != nil {
			return errors.Wrap(err, "os.Readlink", "file", d)
		}
	}

	dir, file := filepath.Split(d)

	realdir, err := ioutil.TempDir(dir, file+"-")
	if err != nil {
		return errors.Wrap(err, "os.Mkdir", "dir", realdir)
	}
	defer func() { _ = os.RemoveAll(realdir) }() // updated later to the old path

	err = populate(realdir)
	if err != nil {
		return errors.Wrap(err, "populate", "dir", realdir)
	}

	tmplink := filepath.Join(dir, file+".tmp")
	err = os.Symlink(realdir, tmplink)
	if err != nil {
		return errors.Wrap(err, "os.Symlink", "dir", realdir, "file", tmplink)
	}
	err = os.Rename(tmplink, d)
	if err != nil {
		return errors.Wrap(err, "os.Rename", "old", tmplink, "file", d)
	}

	realdir = old // now defer will delete the old symlink path

	return nil
}

For the most part this was straightforward, but the defer trick to clean up the correct thing was definitely not obvious from the start.


(The following includes affiliate links.)

If you don’t already know Go, you should definitely check out The Go Programming Language. It’s not just a great Go book but a great programming book in general with a generous dollop of concurrency.

For information on how to write code like the above in Unix systems in general, Advanced Programming in the UNIX Environment is a great option. More typically just called “Stevens,” it gives a solid overview of Unix in general. I haven’t read this updated version myself, but I’ve definitely learned a lot from the older editions.

Posted Tue, Sep 18, 2018

If you're interested in being notified when new posts are published, you can subscribe here; you'll get an email once a week at the most.