help/content/reference/plugins/tests.en.md

311 lines
10 KiB
Markdown

---
title: "Writing a happyDomain Plugin"
description: "Technical guide for developing test plugins for happyDomain"
---
happyDomain supports external **test plugins** — shared libraries (`.so` files) that add domain or service health checks to a running instance. Plugins are loaded at startup without recompiling the server; the operator simply drops a `.so` file into a configured directory.
## How it works
A plugin receives a set of options assembled from several configuration scopes, runs a check (HTTP call, DNS query, …), and returns a result with a status level and an optional detailed report. Results are stored and displayed in the happyDomain UI alongside the domain or service they concern.
When happyDomain starts it scans every directory listed in the `plugins-directories` configuration option. For each file it finds, it:
1. Opens the shared library.
2. Looks up the exported symbol `NewTestPlugin`.
3. Calls `NewTestPlugin()` to obtain a plugin value.
4. Registers the plugin under each name returned by `PluginEnvName()`.
If the file is not a valid Go plugin, if `NewTestPlugin` is missing, or if it returns an error, a warning is logged and the file is skipped. The server always starts regardless of individual plugin load failures.
---
## The `TestPlugin` interface
Every plugin must implement four methods:
```go
type TestPlugin interface {
PluginEnvName() []string
Version() PluginVersionInfo
AvailableOptions() PluginOptionsDocumentation
RunTest(PluginOptions, map[string]string) (*PluginResult, error)
}
```
---
## Project structure
A plugin is a standalone Go module compiled with `-buildmode=plugin`. It must be in `package main` and export exactly one symbol:
```go
func NewTestPlugin() (happydns.TestPlugin, error)
```
Recommended layout:
```
myplugin/
├── go.mod
├── Makefile
└── plugin.go # (or split across multiple .go files)
```
### go.mod
```
module git.happydns.org/happyDomain/plugins/myplugin
go 1.25
require git.happydns.org/happyDomain v0.0.0
replace git.happydns.org/happyDomain => ../../
```
The `replace` directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types as the server.
{{< notice style="warning" >}}
A Go plugin and the host process share the same runtime. They **must** be compiled with the same Go toolchain version and the same versions of every shared dependency. Any mismatch produces a hard error at load time.
{{< /notice >}}
---
## Entry point
```go
package main
import "git.happydns.org/happyDomain/model"
func NewTestPlugin() (happydns.TestPlugin, error) {
return &MyPlugin{}, nil
}
```
The constructor is a good place to perform one-time initialisation (open config files, create an HTTP client, …). Return an error if the plugin cannot function.
---
## Naming — `PluginEnvName()`
Returns one or more short, lowercase identifiers. These names are used to look up the plugin via the API and to key its stored configuration.
```go
func (p *MyPlugin) PluginEnvName() []string {
return []string{"myplugin"}
}
```
Choose names that are unlikely to collide (e.g. `"zonemaster"`, `"matrixim"`) and keep them **stable across versions** because they are persisted alongside user configuration. If two loaded plugins claim the same name, the second one is skipped and a conflict is logged.
---
## Version and availability — `Version()`
Describes the plugin and controls where it appears in the UI:
```go
func (p *MyPlugin) Version() happydns.PluginVersionInfo {
return happydns.PluginVersionInfo{
Name: "My Plugin",
Version: "1.0",
AvailableOn: happydns.PluginAvailability{
ApplyToDomain: true,
ApplyToService: false,
LimitToProviders: nil, // nil or empty = all providers
LimitToServices: []string{"abstract.MatrixIM"},
},
}
}
```
| Field | Type | Description |
|---|---|---|
| `ApplyToDomain` | `bool` | Plugin can be run against a whole domain |
| `ApplyToService` | `bool` | Plugin can be run against a specific DNS service |
| `LimitToProviders` | `[]string` | Restrict to certain DNS provider identifiers (empty = no restriction) |
| `LimitToServices` | `[]string` | Restrict to certain service type identifiers, e.g. `"abstract.MatrixIM"` (empty = no restriction) |
Both `ApplyToDomain` and `ApplyToService` may be `true` simultaneously.
---
## Options — `AvailableOptions()`
Options are key/value pairs (`map[string]any`) that configure each test run. They are declared grouped by **scope**, i.e. who sets them and how long they persist:
```go
func (p *MyPlugin) AvailableOptions() happydns.PluginOptionsDocumentation {
return happydns.PluginOptionsDocumentation{
RunOpts: []happydns.PluginOptionDocumentation{ /* … */ },
ServiceOpts: []happydns.PluginOptionDocumentation{ /* … */ },
DomainOpts: []happydns.PluginOptionDocumentation{ /* … */ },
UserOpts: []happydns.PluginOptionDocumentation{ /* … */ },
AdminOpts: []happydns.PluginOptionDocumentation{ /* … */ },
}
}
```
### Option scopes
| Scope | Who sets it | Storage key | Typical use |
|---|---|---|---|
| `RunOpts` | User, at test time | _(transient)_ | Per-invocation parameters |
| `ServiceOpts` | User | plugin + user + domain + service | Service-level configuration |
| `DomainOpts` | User | plugin + user + domain | Domain-level configuration |
| `UserOpts` | User | plugin + user | Personal preferences (e.g. language) |
| `AdminOpts` | Administrator | plugin | Instance-wide settings, shared credentials |
Before `RunTest` is called, happyDomain merges all scoped values from least specific (admin) to most specific (run-time). More-specific values silently override less-specific ones. `RunTest` always receives a single flat map and does not need to know which scope each value came from.
### Option fields
Each option is a `PluginOptionDocumentation` (an alias for `Field`):
| Field | Type | Description |
|---|---|---|
| `Id` | `string` | **Required.** Key used in the `PluginOptions` map inside `RunTest` |
| `Type` | `string` | Input type: `"string"`, `"select"` |
| `Label` | `string` | Human-readable label shown in the UI |
| `Placeholder` | `string` | Placeholder text for the input field |
| `Default` | `any` | Default value pre-filled in the form |
| `Choices` | `[]string` | Options for `"select"` inputs |
| `Required` | `bool` | Whether the field must be filled before running |
| `Secret` | `bool` | Marks the field as sensitive (e.g. an API key) |
| `Hide` | `bool` | Hides the field from the user entirely |
| `Textarea` | `bool` | Renders a multiline text area |
| `Description` | `string` | Help text displayed below the field |
| `AutoFill` | `string` | Populate the field automatically from context (see below) |
### Auto-fill
When `AutoFill` is set, happyDomain populates the field from the test context; the user is not prompted:
| Constant | String value | Populated with |
|---|---|---|
| `happydns.AutoFillDomainName` | `"domain_name"` | FQDN of the domain under test, e.g. `"example.com."` |
| `happydns.AutoFillSubdomain` | `"subdomain"` | Subdomain relative to the zone, e.g. `"www"` — service-scoped tests only |
| `happydns.AutoFillServiceType` | `"service_type"` | Service type identifier, e.g. `"abstract.MatrixIM"` — service-scoped tests only |
```go
{
Id: "domainName",
Type: "string",
Label: "Domain name",
AutoFill: happydns.AutoFillDomainName,
Required: true,
}
```
---
## Running the check — `RunTest()`
`RunTest` receives the merged option map and a metadata map (reserved for future use), performs the check, and returns a `PluginResult`.
Always assert option values to a concrete type before use — the map holds `any`:
```go
func (p *MyPlugin) RunTest(opts happydns.PluginOptions, _ map[string]string) (*happydns.PluginResult, error) {
domain, ok := opts["domainName"].(string)
if !ok || domain == "" {
return nil, fmt.Errorf("domainName option is required")
}
// … perform the check …
return &happydns.PluginResult{
Status: happydns.PluginResultStatusOK,
StatusLine: "All good",
Report: myStructuredReport,
}, nil
}
```
Return a **non-nil error** only for unexpected failures (network errors, invalid configuration). For expected check failures — the monitored service is down, DNS records are wrong — return a `PluginResult` with an appropriate status and a human-readable `StatusLine`.
### Result fields
| Field | Type | Description |
|---|---|---|
| `Status` | `PluginResultStatus` | Overall result level (see below) |
| `StatusLine` | `string` | Short summary displayed in the UI |
| `Report` | `any` | Any JSON-serialisable value stored as structured diagnostic data |
### Status levels (worst → best)
| Constant | Meaning |
|---|---|
| `PluginResultStatusKO` | Check failed |
| `PluginResultStatusWarn` | Check passed with warnings |
| `PluginResultStatusInfo` | Informational, no action required |
| `PluginResultStatusOK` | Check fully passed |
---
## Building
```bash
go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
git.happydns.org/happyDomain/plugins/myplugin
```
Minimal `Makefile`:
```makefile
PLUGIN_NAME=myplugin
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)
```
The prefix `happydomain-plugin-test-` is a convention; happyDomain loads every file in the plugin directories regardless of its name.
---
## Deployment
### 1. Copy the `.so` file
```bash
cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/
```
### 2. Point happyDomain at the directory
`happydomain.conf`:
```
plugins-directories=/usr/lib/happydomain/plugins
```
Environment variable:
```bash
HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins
```
Multiple directories may be listed as a comma-separated value.
### 3. Check the logs
On a successful load:
```
Plugin My Plugin loaded (version 1.0)
```
On a name conflict or load error a warning is logged with the filename and reason.
---
## Reference implementations
Two plugins are bundled in this directory:
- **`matrix/`** — queries the Matrix federation tester API. Demonstrates `ApplyToService` with `LimitToServices` and `AdminOpts` for the backend URL.
- **`zonemaster/`** — drives the Zonemaster JSON-RPC API, polls for completion, and maps results to severity levels. Demonstrates `AutoFillDomainName`, `UserOpts` for language selection, and multi-level status mapping.