Skip to content

Latest commit

 

History

History
254 lines (206 loc) · 9.8 KB

events.md

File metadata and controls

254 lines (206 loc) · 9.8 KB

GARM database events

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.

Entities and operations

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 database
  • organization - represents an organization in the database
  • enterprise - represents an enterprise in the database
  • pool - represents a pool in the database
  • user - represents a user in the database. Currently GARM is not multi tenant so we just have the "admin" user
  • instance - represents a runner instance in the database
  • job - represents a recorded github workflow job in the database
  • controller - 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 created
  • update - emitted when an entity is updated
  • delete - emitted when an entity is deleted

Event structure

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.

Subscribing to events

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 message

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.

Example 1: Send all events

{
  "send-everything": true
}

Example 2: Send only create events for repository entities

{
  "send-everything": false,
  "filters": [
    {
      "entity-type": "repository",
      "operations": ["create"]
    }
  ]
}

Example 3: Send create and update for repositories and delete for instances

{
  "send-everything": false,
  "filters": [
    {
      "entity-type": "repository",
      "operations": ["create", "update"]
    },
    {
      "entity-type": "instance",
      "operations": ["delete"]
    }
  ]
}

Connecting to the events endpoint

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.