How to wrap the juju client in a strictly confined snap

Recently I set out to write a snap for jhack, a python cli tool that requires juju client access. In other words, it needs to be able to make direct juju cli calls such as juju status --relations, and it uses python-libjuju for some other calls.

I got a prototype working in devmode, but I wanted to release it with strict confinement to make it easier for people to trust it and install it.

Here is a small tutorial with some tips and lessons learned which should make your life easier if you are attempting to do something similar.

Prerequisites

  • A rough understanding of how snaps work and how to create one.
  • snap and snapcraft install.

jujuwrapper

Instead of slowly building up to the final product, I’m going to show the TLDR first:

strict-juju-wrapper-template

This is a template/demo project that shows how to go about creating a snap that wraps the juju client snap and give a starting point to create similar snaps.

The README should get you started quickly.

Setting up the snap

This is not a tutorial on how to write a snap. I’ll assume you know how to write one, and instead focus on how to get it to talk to your local, snapped, juju client.

So, fast-forward to when you have a snap/snapcraft.yaml containing your snap metadata.

The first thing you want to do is add:

    stage-snaps:
      - juju/3.0/beta

to one of your parts.

For example, if you’re snapping a python-repo-based tool, your parts directive could be as simple as:

parts:
  mypart:
    plugin: python
    source: https://github.com/YourName/repo.git
    source-branch: main
    python-packages:
      # any other requirements
      - juju==2.9.7
    stage-snaps:
      - juju/3.0/beta  # juju 3 is strictly confined

Adding the juju snap as a stage-snap is basically adding it as a dependency of your snap. It will be packaged together with it and your snap will have its own embedded juju client binary (as a snap package). We’ll call that ‘the inner juju snap’.

Defining the interfaces

First and foremost, the snap needs network access to be able to talk to your local microk8s controller, or remote clouds.

So your apps directive will at least include network and network-bind. So unless you have them already, add the following plugs:

    plugs:
      - network
      - network-bind

Secondly, the outer juju install you are trying to wrap might have some clouds, controllers, etc… already bootstrapped and active. We want our strictly confined snap to be able to see them just like your local juju does. For that, we need access to the so-called jujudata, a folder typically under ~/.local/share/juju which contains credentials, controller info, model info (which model is the “current” model…), etc…

A strictly-confined snap can’t just read/write what it wants, so we need to add some interfaces to it.

Crucially, read access is not enough. The juju client needs write access to jujudata because

  1. commands like juju switch will change the client state by writing data to some of those files
  2. the juju client writes some lock files in that folder, and will not run most commands unless it manages to acquire them

The juju snap offers a juju-client-observe interface that gives us read access, but no write.

There are two ways I considered to achieve what I needed.

Copy jujudata over to snap

Firstly, I could use the juju-client-observe interface offered by the juju snap to get read access to the client configuration, then add a hook to copy that directory over to some snap-controlled filesystem location (under whatever “~” is for the snap), and prefix every juju command with JUJU_DATA="~/path/to/data". This would give the embedded juju snap awareness of the clouds, controllers etc… available to the ‘outer’ snap.

The app would have looked like:

apps:
  myapp:
    command: bin/myapp_main
    plugs:
      - network
      - network-bind
      # read-only access to .local/share/juju (JUJU_DATA)
      - juju-client-observe

And you’d have to add a snap/hooks/install hook with something like:

cp -r /home/hans/.local/share/juju ~/jdata

You might need to pass the username or home root as a config option, too.

However, I was worried that the two directories would go out of sync. What happens if the inner juju unregisters a controller, and the outer juju doesn’t know about it? What happens if the inner juju switches its current model? What happens if you bootstrap a new controller?

I’m not entirely sure these worries are justified, but also, this felt like cheating.

Shared jujudata

The second option felt safer: to explicitly grant my snap write access to the user’s jujudata. That way, two clients could share state and sidestep any desync issues.

The only way I could find to do that is via a personal-files interface: I copied what the juju snap itself does and added a dot-local-share-juju plug defined as:

  dot-local-share-juju:
    interface: personal-files
    write:
      - $HOME/.local/share/juju

This means the inner juju snap will be able to see the same controllers, clouds, etc… as your outer one, and they will be completely in sync.

The fact that they share state also means that they might lock each other out of running certain operations in parallel.

Summing up

The final snapcraft.yaml would then contain:

[...]

parts:
  my-part:
    [...]  # bulk of your part definition
    stage-snaps:
      - juju/3.0/beta

apps:
  my-app:
    plugs:
      - network
      - network-bind
      # read-write access to .local/share/juju (JUJU_DATA)
      - juju-client-observe
      - dot-local-share-juju

plugs:
  dot-local-share-juju:
    interface: personal-files
    write:
      - $HOME/.local/share/juju

And this is all that you need to wrap the juju client.

As previously announced, there is a little demo project that doubles up as a template: strict-juju-wrapper-template. If you’re snapping a python-based package, it should offer a good starting point.

3 Likes