Serving Grafana Private Plugin

The journey of installing a private plugin in an isolated Grafana installation (isolated as in there is no egress internet connectivity) is not for the faint hearted. Fear not, I have gone through the journey so you don’t have to.

What’s the issue?

TBD

TL;DR

The post will be long-ish as I am capturing my process in working this out. But here is the TL;DR - see I am giving you value straight up :) (no money asked!):

  • If you install your Grafana using Grafana helm chart - you can put the plugin zip URL directly under the plugins section. For example:
plugins:
  - http://plugin-repo.plugin-demo.svc.cluster.local/hello-world/versions/1.0.0/hellworld-1.0.0.zip;hello-world
  • The next big question is how/where to host your plugin? Two requirements:
    • Obviously it is a URL that your Grafana has access to.
    • Secondly it should not be under any authentication mechanism - Grafana can’t handle authenticated download.

Ok that’s it!

Now, if you want to read my deep dive into the Grafana private plugin or if you are interested in installing your plugin using grafana cli keep on reading.

Environment Setup

If you want to check out the code I used in this post, check out this repo.

I am using minikube for my testing cluster and I install Grafana locally using Grafana helm chart as mentioned above, this is the command:

helm install grafana grafana/grafana -f grafana/values.yaml --namespace grafana --version 6.59.4

values.yaml is from the repo above, but you will see what it contains further down the post.

Create a Grafana Plugin

Follow the Grafana plugin documentation - should give you some idea on how to create one. Although to be honest, the documentation can be made much clearer.

Grafana plugin is mostly a “frontend” plugin (and an optional backend component). You will need a NodeJS environment setup for this as this is what the plugin will be based on. The backend component is a go app.

This is how I created a plugin:

> npx @grafana/create-plugin@latest
? What is going to be the name of your plugin? hello-world
? What is the organization name of your plugin? passionate-development
? How would you describe your plugin? an app displaying hello-world
? What type of plugin would you like? app
? Do you want a backend part of your plugin? No
? Do you want to add Github CI and Release workflows? No
? Do you want to add a Github workflow for automatically checking "Grafana API compatibility" on PRs? No

The usual install & build for a Node app.

yarn install
yarn build

yarn build will create a /dist directory and we are meant to package the plugin as a .zipfile like so:

mv dist/ hello-world && zip hellworld-1.0.0.zip hello-world -r && rm -rf hello-world/

Signing a plugin

Signing a plugin is out of scope for my experimentation. It needs an account on Grafana cloud, which I couldn’t be bothered of creating at the moment.

To load an unsigned plugin in Grafana, this has to be white listed in the Grafana config (more on this later).

Loading a private plugin to Grafana

So you have built a plugin now you need to load it to your Grafana, how would you do this?

You can install your plugin at any time using the grafana cli command. This command is included in the Grafana installation. As mentioned earlier, I’ve deployed Grafana in Kubernetes hence I would kubectl exec -it into the Grafana pod and run something like grafana cli plugins install my-plugin

The downside of this approach is, you’d need to restart your Grafana after the installation. I’d imagine it’s more useful to the plugin loaded when Grafana started.

To achieve this you need to pass in the GF_INSTALL_PLUGINS environment variables to Grafana. If you are using Grafana helm charts, the helm chart provides plugins directive that will set the env var for you. There are 2 options on passing through the plugin URL.

The easiest way and the one that I’d highly recommend is to pass in the full URL for the plugin URL like this:

plugins:
  - http://plugin-repo.plugin-demo.svc.cluster.local/hello-world/versions/1.0.0/hellworld-1.0.0.zip;my-little-pony

Note the GF_INSTALL_PLUGINS in the background will just issue grafana cli plugins install {plugin} when Grafana starts as stated in the doc - in the doc it describes 3 options for a private plugin you really want to use option 3 - which is the full URL to the plugin.

The more convoluted way, which I have described on the next session is to setup your own private plugin hosting repo. I don’t see much reason on doing this unless you want to manage your plugin using grafana cli. The private plugin hosting way:

plugins:
  - http://plugin-repo.plugin-demo.svc.cluster.local/plugins/hello-world 1.0.0;my-little-pony

Have I not persuade you enough not to go through the craziness of setting up a private plugin hosting? Ok, your choice continue reading.


Create a private hosting repo

You only need a static page hosting basically - but remember it cannot be an authenticated endpoint as Grafana doesn’t have the ability to pass in any auth information.

For this example - let’s configure an nginx server to serve our plugin. But you could configure any static hosting of your choice, like AWS S3 for example.

You can use this manifest to install an nginx pod with a service.

kubectl apply -f plugin-repo.yaml

Grafana plugin repo structure

This is the bit that is the hardest to work out, but the TL;DR here is a plugin repo structure:

root@plugin-repo:/usr/share/nginx/html# tree
.
|-- 50x.html
|-- index.html
`-- plugins
    |-- hello-world
    |   `-- versions
    |       `-- 1.0.0
    |           `-- download # this a zip file 🤷🏻‍♂️
    `-- repo
        |-- hello-world
        |   `-- index.html # this is a JSON file 🤷🏻‍♂️
        `-- index.html     # this is a JSON file 🤷🏻‍♂️

Shout out to Volkov Labs which wrote installing grafana plugins from a private repo article - of which I based a lot of my experiment of.

Let’s go to your Grafana pod and test connectivity to the local

$ k exec -it grafana-d55f9bc77-6k5kl -- /bin/bash

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ curl -v http://plugin-repo.plugin-demo.svc.cluster.local:8080/index.html

Let check list the plugins on the repo:

$ k exec -it grafana-d55f9bc77-6k5kl -- /bin/bash

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins list-remote
Failed to send requesterror404 not found errorError: ✗ Failed to send request: 404 not found error

It is a good idea to tail the nginx log:

2023/09/23 10:35:10 [error] 30#30: *2 open() "/usr/share/nginx/html/plugins/repo" failed (2: No such file or directory), client: 10.244.0.19, server: localhost, request: "GET /plugins/repo HTTP/1.1", host: "plugin-repo.plugin-demo.svc.cluster.local"

So the cli is looking for /plugins/repo - the tricky part is the repo needs to return a JSON file, yes /repo. It would clearer if the cli was to look for a file with an extension like /repo/index.json - but it is what it is.

To be technically correct, I should create an index.json file and make it as a default file for the directory - this needs to be configured in nginx. But since it is a demo, I can’t be bothered - let’s create an index.html - which an actually a JSON file like this one below:

{
  "plugins": [
    {
      "id": "hello-world",
      "type": "app",
      "url": "https://github.com/cemeng/grafana-private-plugin-demo",
      "versions": [
        {
          "version": "1.0.0"
        }
      ]
    }
  ]
}

Back to the Grafana pod:

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins list-remote
id: hello-world version: 1.0.0


grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins list-versions hello-world
Error: ✗ Failed to find requested plugin, check if the plugin_id (hello-world) is correct: 404 not found error

Looking at Nginx log:

2023/09/23 10:42:37 [error] 32#32: *4 open() "/usr/share/nginx/html/plugins/repo/hello-world" failed (2: No such file or directory), client: 10.244.0.19, server: localhost, request: "GET /plugins/repo/hello-world HTTP/1.1", host: "plugin-repo.plugin-demo.svc.cluster.local"

So the cli is expecting /plugins/repo/hello-world to return a JSON file like the one below, we will create the the file but naming it index.html for the same reason as previous.

In the Nginx pod:

root@plugin-repo:/usr/share/nginx/html/plugins# mkdir repo/hello-world
root@plugin-repo:/usr/share/nginx/html/plugins# cat > repo/hello-world/index.html
{
  "versions": [
    {
      "arch": {
        "any": {}
      },
      "version": "1.0.0"
    }
  ]
}

Let’s try again from Grafana pod:

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins list-versions hello-world
1.0.0

Ok that works! now let’s try installing it!

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins install hello-world 1.0.0
Error: ✗ failed to download plugin archive: 404: <html>
<head><title>404 Not Found</title></head>

Looking at Nginx log:

10.244.0.19 - - [23/Sep/2023:10:46:53 +0000] "GET /plugins/hello-world/versions/1.0.0/download HTTP/1.1" 404 153 "-" "grafana 10.1.1" "-"
2023/09/23 10:46:53 [error] 29#29: *7 open() "/usr/share/nginx/html/plugins/hello-world/versions/1.0.0/download" failed (2: No such file or directory), client: 10.244.0.19, server: localhost, request: "GET /plugins/hello-world/versions/1.0.0/download HTTP/1.1", host: "plugin-repo.plugin-demo.svc.cluster.local"

Let’s setup the plugin directory in Nginx pod:

root@plugin-repo:/usr/share/nginx/html/plugins# mkdir hello-world
root@plugin-repo:/usr/share/nginx/html/plugins# mkdir hello-world/versions
root@plugin-repo:/usr/share/nginx/html/plugins# mkdir hello-world/versions/1.0.0

Copy the plugin into the pod as a file named download without any extension, yes you read that right!

You can use the sample plugin that I have created here - need to zip it yourselves and copy it to the Nginx pod like so:

$ cd passionatedevelopment-helloworld-app
$ npm run build # this will create /dist folder which contains the plugin
$ zip -r helloworld-1.0.0.zip  dist/
$ kubectl cp hellworld-1.0.0.zip plugin-repo:/usr/share/nginx/html/plugins/hello-world/versions/1.0.0/download -n plugin-demo

This is the /download being a zip file is the bit that still bothers me, seems really odd convention. But if you read the code - it does expect /download to be the zip file. 🤷🏻‍♂️

func (m *Manager) downloadURL(pluginID, version string) string {
	return fmt.Sprintf("%s/%s/versions/%s/download", m.baseURL, pluginID, version)
}

But anyway let’s push on - let’s install the plugin:

grafana-d55f9bc77-6k5kl:/usr/share/grafana$ grafana cli --repo http://plugin-repo.plugin-demo.svc.cluster.local/plugins plugins install hello-world 1.0.0
✔ Downloaded and extracted passionatedevelopment-helloworld-app v1.0.0 zip successfully to /var/lib/grafana/plugins/hello-world

Please restart Grafana after installing or removing plugins. Refer to Grafana documentation for instructions if necessary.

So to conclude if you want to serve your private plugin from a repo this is how you’d structure it:

root@plugin-repo:/usr/share/nginx/html# tree
.
|-- 50x.html
|-- index.html
`-- plugins
    |-- hello-world
    |   `-- versions
    |       `-- 1.0.0
    |           `-- download # this a zip file 🤷🏻‍♂️
    `-- repo
        |-- hello-world
        |   `-- index.html # this is a JSON file 🤷🏻‍♂️
        `-- index.html # this is a JSON file 🤷🏻‍♂️

As I go through the grafana code and docs https://grafana.com/docs/grafana/latest/cli/#override-default-plugin-zip-url https://github.com/grafana/grafana/blob/40c12c17bffcec8c3512c597e7f228b296b0c218/pkg/plugins/repo/service.go#L93

grafana cli --pluginUrl http://plugin-repo.plugin-demo.svc.cluster.local/hello-world/versions/1.0.0/hellworld-1.0.0.zip plugins install hello-world 1.0.0
✔ Downloaded and extracted passionatedevelopment-helloworld-app v1.0.0 zip successfully to /var/lib/grafana/plugins/hello-world

Please restart Grafana after installing or removing plugins. Refer to Grafana documentation for instructions if necessary.
k exec -it plugin-repo -- /bin/bash
cd

Install via helm

https://github.com/grafana/helm-charts/blob/main/charts/grafana/values.yaml#L541

  ## You can also use other plugin download URL, as long as they are valid zip files,
  ## and specify the name of the plugin after the semicolon. Like this:
  # - https://grafana.com/api/plugins/marcusolsson-json-datasource/versions/1.3.2/download;marcusolsson-json-datasource
plugins:
  - http://plugin-repo.plugin-demo.svc.cluster.local/hello-world/versions/1.0.0/hellworld-1.0.0.zip;my-little-pony

grafana.ini:
  plugins:
    allow_loading_unsigned_plugins: passionatedevelopment-helloworld-app