Starting with GARM version v0.1.5
, we now have a new websocket endpoint that allows us to subscribe to some events that are emited by the database watcher. Whenever a database entity is created, updated or deleted, the database watcher will notify all interested consumers that an event has occured and as part of that event, we get a copy of the database entity that was affected.
For example, if a new runner is created, the watcher will emit a Create
event for the Instances
entity and in the Payload
field, we will have a copy of the Instance
entity that was created. Internally, this will be a golang struct, but when exported via the websocket endpoint, it will be a JSON object, with all sensitive info (passwords, keys, secrets in general) stripped out.
This document will focus on the websocket endpoint and the events that are exported by it.
Virtually all database entities are exposed through the events endpoint. These entities are defined in the database common package. Each of the entity types represents a database table in GARM.
Those entities are:
repository
- represents a repository in the databaseorganization
- represents an organization in the databaseenterprise
- represents an enterprise in the databasepool
- represents a pool in the databaseuser
- represents a user in the database. Currently GARM is not multi tenant so we just have the "admin" userinstance
- represents a runner instance in the databasejob
- represents a recorded github workflow job in the databasecontroller
- represents a controller in the database. This is the GARM controller.github_credentials
- represents a github credential in the database (PAT, Apps, etc). No sensitive info (token, keys, etc) is ever returned by the events endpoint.github_endpoint
- represents a github endpoint in the database. This holds the github.com default endpoint and any GHES you may add.
The operations hooked up to the events endpoint and the databse wather are:
create
- emitted when a new entity is createdupdate
- emitted when an entity is updateddelete
- emitted when an entity is deleted
The event structure is defined in the database common package. The structure for a change payload is marshaled into a JSON object as follows:
{
"entity-type": "repository",
"operation": "create"
"payload": [object]
}
Where the payload
will be a JSON representation of one of the entities defined above. Essentially, you can expect to receive a JSON identical to the one you would get if you made an API call to the GARM REST API for that particular entity.
Note that in some cases, the delete
operation will return the full object prior to the deletion of the entity, while others will only ever return the ID
of the entity. This will probably be changed in future releases to only return the ID
in case of a delete
operation, for all entities. You should operate under the assumption that in the future, delete operations will only return the ID
of the entity.
By default the events endpoint returns no events. All events are filtered by default. To start receiving events, you need to emit a message on the websocket connection indicating the entities and/or operations you're interested in.
This gives you the option to get fine grained control over what you receive at any given point in time. Of course, you can opt to receive everything and deal with the potential deluge (depends on how busy your GARM instance is) on your own.
The filter is defined as a JSON that you write over the websocket connections. That JSON must adhere to the following schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/cloudbase/garm/apiserver/events/options",
"$ref": "#/$defs/Options",
"$defs": {
"Filter": {
"properties": {
"operations": {
"items": {
"type": "string",
"enum": [
"create",
"update",
"delete"
]
},
"type": "array",
"title": "operations",
"description": "A list of operations to filter on"
},
"entity-type": {
"type": "string",
"enum": [
"repository",
"organization",
"enterprise",
"pool",
"user",
"instance",
"job",
"controller",
"github_credentials",
"github_endpoint"
],
"title": "entity type",
"description": "The type of entity to filter on",
"default": "repository"
}
},
"additionalProperties": false,
"type": "object"
},
"Options": {
"properties": {
"send-everything": {
"type": "boolean",
"title": "send everything",
"default": false
},
"filters": {
"items": {
"$ref": "#/$defs/Filter"
},
"type": "array",
"title": "filters",
"description": "A list of filters to apply to the events. This is ignored when send-everything is true"
}
},
"additionalProperties": false,
"type": "object"
}
}
}
But I realize a JSON schema is not the best way to explain how to use the filter. The following examples should give you a better idea of how to use the filter.
{
"send-everything": true
}
{
"send-everything": false,
"filters": [
{
"entity-type": "repository",
"operations": ["create"]
}
]
}
{
"send-everything": false,
"filters": [
{
"entity-type": "repository",
"operations": ["create", "update"]
},
{
"entity-type": "instance",
"operations": ["delete"]
}
]
}
You can use any websocket client, written in any programming language to interact with the events endpoint. In the following exmple I'll show you how to do it from go.
Before we start, we'll need a JWT token to access the events endpoint. Normally, if you use the CLI, you should have it in your ~/.local/share/garm-cli
folder. But if you know your username and password, we can fetch a fresh one using curl
:
# Read the password from the terminal
read -s PASSWD
# Get the token
curl -s -X POST -d '{"username": "admin", "password": "'$PASSWD'"}' \
https://garm.example.com/api/v1/auth/login | jq -r .token
Save the token, we'll need it for later.
Now, let's write a simple go program that connects to the events endpoint and subscribes to all events. We'll use the reader that was added to garm-provider-common
in version v0.1.3
, to make this easier:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
garmWs "github.com/cloudbase/garm-provider-common/util/websocket"
"github.com/gorilla/websocket"
)
// List of signals to interrupt the program
var signals = []os.Signal{
os.Interrupt,
syscall.SIGTERM,
}
// printToConsoleHandler is a simple function that prints the message to the console.
// In a real world implementation, you can use this function to decide how to properly
// handle the events.
func printToConsoleHandler(_ int, msg []byte) error {
fmt.Println(string(msg))
return nil
}
func main() {
// Set up the context to listen for signals.
ctx, stop := signal.NotifyContext(context.Background(), signals...)
defer stop()
// This is the JWT token you got from the curl command above.
token := "superSecretJWTToken"
// The base URL of your GARM server
baseURL := "https://garm.example.com"
// This is the path to the events endpoint
pth := "/api/v1/ws/events"
// Instantiate the websocket reader
reader, err := garmWs.NewReader(ctx, baseURL, pth, token, printToConsoleHandler)
if err != nil {
fmt.Println(err)
return
}
// Start the loop.
if err := reader.Start(); err != nil {
fmt.Println(err)
return
}
// Set the filter to receive all events. You can use a more fine grained filter if you wish.
reader.WriteMessage(websocket.TextMessage, []byte(`{"send-everything":true}`))
fmt.Println("Listening for events. Press Ctrl+C to stop.")
// Wait for the context to be done.
<-ctx.Done()
}
If you run this program and change something in the GARM database, you should see the event being printed to the console:
gabriel@rossak:/tmp/ex$ go run ./main.go
{"entity-type":"pool","operation":"update","payload":{"runner_prefix":"garm","id":"8ec34c1f-b053-4a5d-80d6-40afdfb389f9","provider_name":"lxd","max_runners":10,"min_idle_runners":0,"image":"ubuntu:22.04","flavor":"default","os_type":"linux","os_arch":"amd64","tags":[{"id":"76781c93-e354-402e-907a-785caab36207","name":"self-hosted"},{"id":"2ff4a89e-e3b4-4e78-b977-6c21e83cca3d","name":"x64"},{"id":"5b3ffec6-0402-4322-b2a9-fa7f692bbc00","name":"Linux"},{"id":"e95e106d-1a3d-11ee-bd1d-00163e1f621a","name":"ubuntu"},{"id":"3b54ae6c-5e9b-4a81-8e6c-0f78a7b37b04","name":"repo"}],"enabled":true,"instances":[],"repo_id":"70227434-e7c0-4db1-8c17-e9ae3683f61e","repo_name":"gsamfira/scripts","runner_bootstrap_timeout":20,"extra_specs":{"disable_updates":true,"enable_boot_debug":true},"github-runner-group":"","priority":10}}
In the above example, you can see an update
event on a pool
entity. The payload
field contains the full, updated pool
entity.