Compare commits

...

28 commits

Author SHA1 Message Date
33660e619e checker: add ObservationSharer interface for result mutualisation
Add an optional ObservationSharer interface that an ObservationProvider can
implement to declare that its observation depends only on a subset of the
inputs. ShareKey returns a stable, pure key derived from the inputs that
affect the result; two collections with the same key produce identical data,
letting the host serve one from the other instead of recollecting per target.

This is the contract a host (happyDomain) uses to mutualise expensive probes
(e.g. a single ping per address set) across every check target that resolves
to the same key, while providers that depend on the target (HTTP host, ...)
simply do not implement it and keep per-target behaviour.
2026-06-18 15:06:21 +09:00
d387cd629b checker: add CheckEnabler interface for data-driven eligibility
Add an optional CheckEnabler interface that lets a provider decide, from
the actual target data, whether running the checker is meaningful at all
(e.g. reverse-zone outside in-addr.arpa, delegation without DNSSEC). The
result is folded into the POST /definition response via new Eligible and
EligibilityReason fields, and the handler now tracks load since IsEligible
may perform I/O.
2026-05-29 23:53:35 +08:00
f203b2e573 checker: add POST /definition with precheck failures
Introduces an optional RulePrecheck interface so rules can declare
prerequisite checks against the current options (e.g. "missing API
key"). POST /definition mirrors GET /definition and adds a
precheck_failures map keyed by rule name, letting a UI fetch the
definition and precheck results in a single round-trip.
2026-05-20 13:59:45 +08:00
c72558e266 checker: pass EnabledRules to Collect via context
Providers can now skip optional work (network calls, paid API hits) for
rules the host has disabled. The /collect request grows an EnabledRules
field, and server.handleCollect attaches it to the context with
WithEnabledRules; providers read it via EnabledRulesFromContext or the
per-rule RuleEnabled helper.
2026-05-19 22:10:39 +08:00
c1de9aca1c checker: add JoinRelative helper for service-relative owner names 2026-04-30 08:47:29 +07:00
8f8dc3ca57 server: graceful shutdown on SIGINT/SIGTERM in ListenAndServe
Drains in-flight requests within a 10s timeout and stops the load-average
sampler before returning. Callers needing custom signal handling can still
opt out via Handler() and run their own http.Server.
2026-04-27 01:42:32 +07:00
660fda9c3a server: add -healthcheck flag for scratch-image Docker probes
Registered on the default FlagSet; ListenAndServe intercepts it and
exits 0/1 after probing /health on the -listen address. Lets checker-*
Dockerfiles add HEALTHCHECK without any main.go change, even though
the runtime image is scratch (no shell, no curl, no wget).
2026-04-26 10:59:31 +07:00
3382f62f57 checker: thread rule states into ReportContext
Reporters can now read rule output via ctx.States() instead of
re-deriving severity/hints from the raw payload, keeping the rules
screen and the HTML report aligned on a single source of truth.
2026-04-24 17:31:17 +07:00
89cc3f112b server: split HTTP scaffolding into its own subpackage
Move server.go and interactive.go (and their tests) from the root
checker/ package into checker/server/. Plugin and builtin consumers of
the SDK now import only checker/ and no longer drag net/http,
html/template, or the form-rendering code into their artifacts: on
checker-dane.so this drops the binary by ~1.2 MB and removes 170
html/template symbols along with the net/http contribution that came
from the SDK itself.

Breaking for standalone consumers (main.go):

  NewServer(p)                  -> server.New(p)
  CheckerInteractive            -> server.Interactive
  InteractiveRelatedProviders   -> server.Siblings

Providers that only satisfy the interactive interfaces structurally
(method set match, no explicit type reference) need no source change;
only main.go has to switch its import path and the constructor name.
2026-04-24 12:31:53 +07:00
c244ca48c6 interactive: let standalone providers compose sibling observations
Add an opt-in InteractiveRelatedProviders interface: a checker served
by /check can declare sibling ObservationProviders the SDK will run
in-process after the primary Collect. The SDK auto-fills any sibling
option tagged AutoFill==AutoFillDiscoveryEntries from the primary's
DiscoverEntries output, mirroring the host's AutoFill wiring so
siblings that work in-host work here too. Results are exposed through
ObservationGetter.GetRelated and ReportContext.Related, unblocking
cross-checker rules (e.g. DANE reading checker-tls probes) on the
standalone /check flow.
2026-04-24 11:47:57 +07:00
356e6cd8db Fix comments 2026-04-24 11:22:22 +07:00
c9ee6655ca checker: add RuleName field to CheckState instead of overloading Code
Rules can now set Code freely without the server clobbering it; the
originating rule is reported separately via RuleName.
2026-04-23 14:50:14 +07:00
199c7dea3f checker: add /check route for standalone human-facing web UI
Providers that implement the new CheckerInteractive interface
(RenderForm + ParseForm) get a built-in HTML form on GET /check and
a consolidated result page on POST /check that runs the standard
Collect -> Evaluate -> GetHTMLReport / ExtractMetrics pipeline. This
lets a checker be used directly from a browser outside of happyDomain,
with the checker itself resolving what the host would normally
auto-fill (typically via its own DNS queries).

Also guards NewServer against a nil Definition() so providers that
advertise CheckerDefinitionProvider without a ready definition no
longer panic at registration.
2026-04-23 12:24:56 +07:00
0c6a886e82 server: expose Handle/HandleFunc for custom checker routes
Lets plugins register auxiliary endpoints (debug pages, webhooks, UI
assets) on the SDK mux, with TrackWork as an opt-in for the /health
load signal.
2026-04-23 12:24:56 +07:00
d847c71a50 checker: let CheckRule.Evaluate return per-subject CheckStates
Rules that iterate over multiple elements (certificates, CAA records,
nameservers, …) previously had to squash per-element results into a
single concatenated message. Evaluate now returns []CheckState and
CheckState carries an opaque Subject, so each element gets its own
structured state. The server injects a StatusUnknown placeholder when
a rule returns nothing, to avoid silently dropping the rule.
2026-04-23 12:24:56 +07:00
7567271536 checker: cross-checker observation composition via ReportContext
Add the plumbing that lets a checker receive (at evaluation, report
rendering, and metrics extraction) observations produced by other
checkers on DiscoveryEntry records it originally published.

Surface changes:

  - RelatedObservation struct: one downstream observation, tagged with
    the producing CheckerID and the Ref matching the DiscoveryEntry
    it covers.

  - ObservationGetter gains GetRelated(ctx, key), so rules can opt in
    to cross-checker composition. mapObservationGetter (remote
    /evaluate path) returns empty; the host owns lineage resolution.

  - ReportContext interface: Data() + Related(key). Reporters consume
    it instead of a raw json.RawMessage, which collapses the former
    legacy/Ctx duplicate and gives one uniform signature:

        GetHTMLReport(ctx ReportContext) (string, error)
        ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error)

  - NewReportContext(data, related) and StaticReportContext(data) build
    fixed-payload contexts for entry points without an ObservationContext.

  - ExternalReportRequest gains a Related map so the host can ship
    pre-composed lineage to a remote checker over /report. The SDK's
    /report handler threads it through to the reporter via
    NewReportContext, closing the wire gap that previously forced
    remote reports to a StaticReportContext with no related data.

Tests cover the Related map round-trip end-to-end via a peeking provider.
2026-04-22 16:50:59 +07:00
087032f6cc checker: add DiscoveryPublisher interface for cross-checker discovery
Introduce a DiscoveryEntry struct and an optional DiscoveryPublisher
interface that providers can co-implement to declare things worth
probing by other checkers (TLS endpoints, HTTP probes, ACME challenges,
DNSSEC keys, ...) without having to re-parse raw observations.

DiscoveryEntry carries an opaque Payload: the SDK does not interpret
it. Producers and consumers agree on the Payload schema through a
separate contract (eg. a small shared Go package imported by
both) identified by the free-form Type string. This keeps the SDK
free of protocol-specific concepts; new entry families can appear
without touching it.

The /collect HTTP handler type-asserts the provider against
DiscoveryPublisher immediately after Collect and forwards the
resulting entries in ExternalCollectResponse.Entries.
2026-04-22 16:50:59 +07:00
6b96ee8c2f server: expose runtime metrics on /health for scheduler routing
Adds HealthResponse carrying inflight count, total requests, 1/5/15-min
EWMA load averages, uptime, and NumCPU so a scheduler can pick the least
busy worker. A background sampler updates the load averages every 5s,
stopped by a new idempotent Close method. Work endpoints (/collect,
/evaluate, /report) are wrapped with a trackWork middleware; /health
and /definition are excluded so polling traffic does not pollute the
signal.
2026-04-16 16:52:00 +07:00
fa5198f78c Revert "checker: reorder Status with negatives for good, JSON as string"
This reverts commit 6be3578c33.
2026-04-16 00:50:23 +07:00
6493589bb4 types: make CheckTarget.String() unambiguous by always emitting all fields
The previous implementation skipped empty fields, which meant targets
differing only in which fields were populated could produce the same
string (e.g. {UserId:"A"} and {DomainId:"A"} both gave "A"). This
caused key collisions when the string was used in storage index keys.
2026-04-15 19:54:17 +07:00
2cd323beed types: drop custom JSON marshalling for Status
Keep Status as a plain integer on the wire so that OpenAPI/swaggo
can generate correct enum definitions without needing to handle
the custom string encoding.  The String() method is preserved for
logging and debugging.
2026-04-11 17:22:06 +07:00
36a72f013a server: document lack of built-in authentication on Server type 2026-04-10 16:43:59 +07:00
2fa44f69a4 server: return 500 status on collect errors instead of 200
Errors from provider.Collect() and json.Marshal were returned with HTTP
200, making failures invisible to monitoring, proxies, and clients that
check status codes. Return 500 Internal Server Error so HTTP-level
tooling can detect failures without parsing the response body.
2026-04-10 16:43:56 +07:00
ef7fffd4b7 tests: add coverage for options, types, and HTTP server
- options_test.go: GetOption, GetFloatOption, GetIntOption, GetBoolOption
  with native types, JSON round-trips, missing keys, and wrong types
- types_test.go: Status JSON marshal/unmarshal (strings, legacy ints,
  round-trip, unknown values), CheckTarget.Scope/String, BuildRulesInfo,
  empty-ID rejection
- server_test.go: /health, /collect (success, error, bad body),
  /definition, /evaluate (all rules, disabled rule), /report (HTML,
  metrics, bad body), missing endpoints without CheckerDefinitionProvider
2026-04-10 16:35:50 +07:00
ec4efcf671 server: limit request body size on POST endpoints
Add io.LimitReader (1 MB cap) to /collect, /evaluate, and /report
handlers to prevent memory exhaustion from oversized requests.
2026-04-10 16:24:41 +07:00
688d32cc9f registry: reject checker registration with empty ID
Prevent silent bugs where a CheckerDefinition with an unset ID
would be registered under the empty string key, becoming unfindable
and potentially colliding with other empty-ID registrations.
2026-04-10 16:24:39 +07:00
6be3578c33 checker: reorder Status with negatives for good, JSON as string
Make StatusUnknown the zero value (0) so an uninitialized CheckState
reads as "no signal yet" rather than as healthy. Push StatusOK and
StatusInfo to negative values so the natural int ordering matches
severity ordering: aggregators can simply take max() to compute the
worst status, and Unknown correctly sits above OK/Info but below Warn.

Status now (un)marshals as its string name ("OK", "WARN", ...) so the
wire format is stable across any future renumbering. UnmarshalJSON
still accepts raw ints for backward compatibility with older snapshots
and clients.
2026-04-08 22:16:41 +07:00
8e2ba83a0d registry: refuse duplicate registrations with a warning
RegisterChecker and RegisterObservationProvider previously overwrote
existing entries silently, which lets a misconfigured plugin shadow a
built-in (or another plugin) without any signal. Refuse the duplicate
and keep the existing entry instead.

RegisterExternalizableChecker now performs the dedup check before
appending the "endpoint" AdminOpt, so a rejected re-registration no
longer mutates the live definition.
2026-04-08 20:42:25 +07:00
15 changed files with 3384 additions and 257 deletions

View file

@ -30,6 +30,53 @@ go get git.happydns.org/checker-sdk-go/checker
See [checker-dummy](https://git.happydns.org/checker-dummy) for a
fully working, documented template.
## Extending the server
`checker.Server` exposes the standard SDK routes (`/health`, `/collect`,
and, depending on the provider's optional interfaces, `/definition`,
`/evaluate`, `/report`). Plugins that need to serve auxiliary endpoints
(debug pages, webhooks, custom UI assets, …) can register them on the
same mux:
```go
srv := checker.NewServer(provider)
srv.HandleFunc("GET /debug/state", func(w http.ResponseWriter, r *http.Request) {
// …
})
// Opt a custom route into the in-flight / load-average signal
// reported on /health:
srv.Handle("POST /webhook", srv.TrackWork(myWebhookHandler))
log.Fatal(srv.ListenAndServe(":8080"))
```
Patterns that collide with built-in routes panic at registration:
pick non-overlapping paths. Custom handlers are not wrapped by the
load-tracking middleware unless you opt in via `TrackWork`.
## Standalone human UI (`/check`)
Providers that implement `CheckerInteractive` get a built-in human-facing
web form on `/check`, usable outside of happyDomain:
```go
type CheckerInteractive interface {
RenderForm() []CheckerOptionField
ParseForm(r *http.Request) (CheckerOptions, error)
}
```
- `GET /check` renders a form derived from `RenderForm()`.
- `POST /check` calls `ParseForm` to obtain `CheckerOptions`, runs the
standard `Collect``Evaluate``GetHTMLReport` / `ExtractMetrics`
pipeline, and returns a consolidated HTML page.
`ParseForm` is where the checker replaces what happyDomain would normally
auto-fill (zone records, service payload, …), typically by issuing its
own DNS queries from the human-supplied inputs.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).

52
checker/context.go Normal file
View file

@ -0,0 +1,52 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import "context"
type enabledRulesCtxKey struct{}
// WithEnabledRules returns a context carrying the host's per-rule enable map.
// The SDK server attaches it before calling ObservationProvider.Collect so
// providers can skip optional work (network calls, paid API hits, …) for
// rules the host has disabled. A nil map means "run everything".
func WithEnabledRules(ctx context.Context, enabled map[string]bool) context.Context {
if enabled == nil {
return ctx
}
return context.WithValue(ctx, enabledRulesCtxKey{}, enabled)
}
// EnabledRulesFromContext returns the enabled-rule map attached by
// WithEnabledRules, or nil if none. RuleEnabled is the usual access pattern.
func EnabledRulesFromContext(ctx context.Context) map[string]bool {
m, _ := ctx.Value(enabledRulesCtxKey{}).(map[string]bool)
return m
}
// RuleEnabled reports whether ruleName is enabled given the host's map.
// Absent rules default to enabled (nil map or rule not in map), matching
// the SDK server's evaluate-side semantics.
func RuleEnabled(ctx context.Context, ruleName string) bool {
m := EnabledRulesFromContext(ctx)
if m == nil {
return true
}
enabled, ok := m[ruleName]
if !ok {
return true
}
return enabled
}

37
checker/names.go Normal file
View file

@ -0,0 +1,37 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import "strings"
// JoinRelative treats name as relative to origin, as happyDomain encodes
// service-embedded record owners and subdomains. An empty or "@" name
// resolves to the origin itself; an empty origin returns the trimmed name
// unchanged. A name already suffixed by origin is returned as-is so that
// absolute encodings round-trip safely. Trailing dots are stripped.
func JoinRelative(name, origin string) string {
origin = strings.TrimSuffix(origin, ".")
name = strings.TrimSuffix(name, ".")
if origin == "" {
return name
}
if name == "" || name == "@" {
return origin
}
if name == origin || strings.HasSuffix(name, "."+origin) {
return name
}
return name + "." + origin
}

127
checker/options_test.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import (
"encoding/json"
"testing"
)
func TestGetOption_DirectType(t *testing.T) {
opts := CheckerOptions{"key": "hello"}
got, ok := GetOption[string](opts, "key")
if !ok || got != "hello" {
t.Errorf("GetOption[string] = (%q, %v), want (\"hello\", true)", got, ok)
}
}
func TestGetOption_MissingKey(t *testing.T) {
opts := CheckerOptions{}
got, ok := GetOption[string](opts, "missing")
if ok || got != "" {
t.Errorf("GetOption[string] missing key = (%q, %v), want (\"\", false)", got, ok)
}
}
func TestGetOption_JSONRoundTrip(t *testing.T) {
// Simulate what happens when options come from JSON: numbers become float64,
// structs become map[string]any.
type inner struct {
X int `json:"x"`
Y string `json:"y"`
}
opts := CheckerOptions{
"obj": map[string]any{"x": float64(42), "y": "test"},
}
got, ok := GetOption[inner](opts, "obj")
if !ok {
t.Fatal("GetOption[inner] returned false")
}
if got.X != 42 || got.Y != "test" {
t.Errorf("GetOption[inner] = %+v, want {X:42 Y:test}", got)
}
}
func TestGetOption_WrongType(t *testing.T) {
opts := CheckerOptions{"key": 123}
got, ok := GetOption[string](opts, "key")
// int 123 cannot be unmarshaled into string via JSON
if ok {
t.Errorf("GetOption[string] with int value = (%q, true), want (\"\", false)", got)
}
}
func TestGetFloatOption_NativeFloat(t *testing.T) {
opts := CheckerOptions{"f": 3.14}
if got := GetFloatOption(opts, "f", 0); got != 3.14 {
t.Errorf("GetFloatOption = %v, want 3.14", got)
}
}
func TestGetFloatOption_JSONNumber(t *testing.T) {
opts := CheckerOptions{"f": json.Number("2.718")}
if got := GetFloatOption(opts, "f", 0); got != 2.718 {
t.Errorf("GetFloatOption(json.Number) = %v, want 2.718", got)
}
}
func TestGetFloatOption_Missing(t *testing.T) {
opts := CheckerOptions{}
if got := GetFloatOption(opts, "f", 99.9); got != 99.9 {
t.Errorf("GetFloatOption missing = %v, want 99.9", got)
}
}
func TestGetFloatOption_WrongType(t *testing.T) {
opts := CheckerOptions{"f": "not a number"}
if got := GetFloatOption(opts, "f", 1.0); got != 1.0 {
t.Errorf("GetFloatOption wrong type = %v, want 1.0", got)
}
}
func TestGetIntOption(t *testing.T) {
opts := CheckerOptions{"i": float64(42)}
if got := GetIntOption(opts, "i", 0); got != 42 {
t.Errorf("GetIntOption = %v, want 42", got)
}
}
func TestGetIntOption_Missing(t *testing.T) {
opts := CheckerOptions{}
if got := GetIntOption(opts, "i", 10); got != 10 {
t.Errorf("GetIntOption missing = %v, want 10", got)
}
}
func TestGetBoolOption(t *testing.T) {
opts := CheckerOptions{"b": true}
if got := GetBoolOption(opts, "b", false); got != true {
t.Errorf("GetBoolOption = %v, want true", got)
}
}
func TestGetBoolOption_Missing(t *testing.T) {
opts := CheckerOptions{}
if got := GetBoolOption(opts, "b", true); got != true {
t.Errorf("GetBoolOption missing = %v, want true", got)
}
}
func TestGetBoolOption_WrongType(t *testing.T) {
opts := CheckerOptions{"b": "yes"}
if got := GetBoolOption(opts, "b", false); got != false {
t.Errorf("GetBoolOption wrong type = %v, want false", got)
}
}

View file

@ -27,8 +27,20 @@ var checkerRegistry = map[string]*CheckerDefinition{}
// keyed by ObservationKey.
var observationProviderRegistry = map[ObservationKey]ObservationProvider{}
// RegisterChecker registers a checker definition globally.
// RegisterChecker registers a checker definition globally. A second
// registration under the same ID is refused with a warning rather than
// silently overwriting the previous entry: in production this almost
// always indicates a deployment mistake (two plugins shipping the same
// checker, or a plugin shadowing a built-in).
func RegisterChecker(c *CheckerDefinition) {
if c.ID == "" {
log.Println("Warning: refusing to register checker with empty ID")
return
}
if _, exists := checkerRegistry[c.ID]; exists {
log.Printf("Warning: checker %q is already registered; ignoring duplicate registration", c.ID)
return
}
log.Println("Registering new checker:", c.ID)
c.BuildRulesInfo()
checkerRegistry[c.ID] = c
@ -38,7 +50,16 @@ func RegisterChecker(c *CheckerDefinition) {
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
// so the administrator can optionally configure a remote URL.
// When the endpoint is left empty, the checker runs locally as usual.
//
// The duplicate check happens before the AdminOpt append so that a
// rejected second registration does not mutate the in-memory definition
// of the already-registered checker (which a caller might still hold a
// pointer to).
func RegisterExternalizableChecker(c *CheckerDefinition) {
if _, exists := checkerRegistry[c.ID]; exists {
log.Printf("Warning: checker %q is already registered; ignoring duplicate registration", c.ID)
return
}
c.Options.AdminOpts = append(c.Options.AdminOpts,
CheckerOptionDocumentation{
Id: "endpoint",
@ -53,8 +74,15 @@ func RegisterExternalizableChecker(c *CheckerDefinition) {
}
// RegisterObservationProvider registers an observation provider globally.
// A second registration under the same key is refused with a warning for
// the same reason as RegisterChecker.
func RegisterObservationProvider(p ObservationProvider) {
observationProviderRegistry[p.Key()] = p
key := p.Key()
if _, exists := observationProviderRegistry[key]; exists {
log.Printf("Warning: observation provider %q is already registered; ignoring duplicate registration", key)
return
}
observationProviderRegistry[key] = p
}
// GetCheckers returns all registered checker definitions.

129
checker/registry_test.go Normal file
View file

@ -0,0 +1,129 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import (
"context"
"testing"
)
// resetRegistries clears the global registries between tests so that one
// test's registration cannot leak into the next. The package-level maps are
// the only shared state.
//
// Tests in this package MUST NOT use t.Parallel() because they mutate
// these shared maps without synchronization.
func resetRegistries() {
checkerRegistry = map[string]*CheckerDefinition{}
observationProviderRegistry = map[ObservationKey]ObservationProvider{}
}
type stubProvider struct {
key ObservationKey
}
func (s stubProvider) Key() ObservationKey { return s.key }
func (s stubProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
return nil, nil
}
func TestRegisterChecker_DuplicateIgnored(t *testing.T) {
resetRegistries()
first := &CheckerDefinition{ID: "dup", Name: "First"}
second := &CheckerDefinition{ID: "dup", Name: "Second"}
RegisterChecker(first)
RegisterChecker(second)
got := FindChecker("dup")
if got == nil {
t.Fatal("expected checker to remain registered after duplicate")
}
if got.Name != "First" {
t.Errorf("duplicate registration overwrote original: got Name=%q, want %q", got.Name, "First")
}
if len(GetCheckers()) != 1 {
t.Errorf("registry has %d entries, want 1", len(GetCheckers()))
}
}
func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) {
resetRegistries()
c := &CheckerDefinition{ID: "ext", Name: "Ext"}
RegisterExternalizableChecker(c)
if n := len(c.Options.AdminOpts); n != 1 {
t.Fatalf("first registration: AdminOpts has %d entries, want 1", n)
}
if c.Options.AdminOpts[0].Id != "endpoint" {
t.Errorf("expected first AdminOpt id %q, got %q", "endpoint", c.Options.AdminOpts[0].Id)
}
// Second registration of the same definition pointer must NOT append a
// second "endpoint" AdminOpt, the duplicate check has to fire before
// the append, otherwise we silently mutate the live definition.
RegisterExternalizableChecker(c)
if n := len(c.Options.AdminOpts); n != 1 {
t.Errorf("after duplicate registration: AdminOpts has %d entries, want 1", n)
}
}
func TestRegisterExternalizableChecker_DuplicateDifferentPointerIgnored(t *testing.T) {
resetRegistries()
first := &CheckerDefinition{ID: "ext", Name: "First"}
second := &CheckerDefinition{ID: "ext", Name: "Second"}
RegisterExternalizableChecker(first)
RegisterExternalizableChecker(second)
got := FindChecker("ext")
if got == nil || got.Name != "First" {
t.Errorf("expected first registration to win, got %+v", got)
}
// The rejected second definition must not have been mutated either.
if len(second.Options.AdminOpts) != 0 {
t.Errorf("rejected definition was mutated: AdminOpts=%+v", second.Options.AdminOpts)
}
}
func TestRegisterObservationProvider_DuplicateIgnored(t *testing.T) {
resetRegistries()
first := stubProvider{key: "dns.A"}
second := stubProvider{key: "dns.A"}
RegisterObservationProvider(first)
RegisterObservationProvider(second)
got := FindObservationProvider("dns.A")
if got == nil {
t.Fatal("expected observation provider to remain registered after duplicate")
}
// Identity check: the registry must still hold the first instance.
if _, ok := got.(stubProvider); !ok {
t.Fatalf("unexpected provider type %T", got)
}
if got != ObservationProvider(first) {
// Both stubProvider values compare equal by value, so this also
// guards against an accidental overwrite-then-restore pattern.
t.Errorf("registry no longer holds the first registration")
}
if len(GetObservationProviders()) != 1 {
t.Errorf("registry has %d entries, want 1", len(GetObservationProviders()))
}
}

View file

@ -1,224 +0,0 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
)
// Server is a generic HTTP server for external checkers.
// It always exposes /health and /collect. If the provider implements
// CheckerDefinitionProvider, it also exposes /definition and /evaluate.
// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter,
// it also exposes /report.
type Server struct {
provider ObservationProvider
definition *CheckerDefinition
mux *http.ServeMux
}
// NewServer creates a new checker HTTP server backed by the given provider.
// Additional endpoints are registered based on optional interfaces the provider implements.
func NewServer(provider ObservationProvider) *Server {
s := &Server{provider: provider}
s.mux = http.NewServeMux()
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("POST /collect", s.handleCollect)
if dp, ok := provider.(CheckerDefinitionProvider); ok {
s.definition = dp.Definition()
s.definition.BuildRulesInfo()
s.mux.HandleFunc("GET /definition", s.handleDefinition)
s.mux.HandleFunc("POST /evaluate", s.handleEvaluate)
}
if _, ok := provider.(CheckerHTMLReporter); ok {
s.mux.HandleFunc("POST /report", s.handleReport)
} else if _, ok := provider.(CheckerMetricsReporter); ok {
s.mux.HandleFunc("POST /report", s.handleReport)
}
return s
}
// Handler returns the http.Handler for this server, allowing callers
// to embed it in a custom server or add middleware.
func (s *Server) Handler() http.Handler {
return requestLogger(s.mux)
}
// ListenAndServe starts the HTTP server on the given address.
func (s *Server) ListenAndServe(addr string) error {
log.Printf("checker listening on %s", addr)
return http.ListenAndServe(addr, requestLogger(s.mux))
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start))
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.definition)
}
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
var req ExternalCollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
data, err := s.provider.Collect(r.Context(), req.Options)
if err != nil {
writeJSON(w, http.StatusOK, ExternalCollectResponse{
Error: err.Error(),
})
return
}
raw, err := json.Marshal(data)
if err != nil {
writeJSON(w, http.StatusOK, ExternalCollectResponse{
Error: fmt.Sprintf("failed to marshal result: %v", err),
})
return
}
writeJSON(w, http.StatusOK, ExternalCollectResponse{
Data: json.RawMessage(raw),
})
}
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
var req ExternalEvaluateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
obs := &mapObservationGetter{data: req.Observations}
var states []CheckState
for _, rule := range s.definition.Rules {
if len(req.EnabledRules) > 0 {
if enabled, ok := req.EnabledRules[rule.Name()]; ok && !enabled {
continue
}
}
state := rule.Evaluate(r.Context(), obs, req.Options)
if state.Code == "" {
state.Code = rule.Name()
}
states = append(states, state)
}
writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states})
}
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
var req ExternalReportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("invalid request body: %v", err),
})
return
}
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") {
reporter, ok := s.provider.(CheckerHTMLReporter)
if !ok {
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
return
}
html, err := reporter.GetHTMLReport(req.Data)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Default: JSON metrics.
reporter, ok := s.provider.(CheckerMetricsReporter)
if !ok {
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
return
}
metrics, err := reporter.ExtractMetrics(req.Data, time.Now())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to extract metrics: %v", err),
})
return
}
writeJSON(w, http.StatusOK, metrics)
}
// mapObservationGetter implements ObservationGetter backed by a static map.
type mapObservationGetter struct {
data map[ObservationKey]json.RawMessage
}
func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error {
raw, ok := g.data[key]
if !ok {
return fmt.Errorf("observation %q not available", key)
}
return json.Unmarshal(raw, dest)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View file

@ -0,0 +1,81 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"strings"
"time"
)
// healthcheckMode is registered on the default flag set so any consumer that
// calls flag.Parse() before ListenAndServe (the standard pattern in our
// checker mains) gets the behaviour for free. When set, ListenAndServe
// performs a short-lived HTTP probe against /health on the configured listen
// address and exits 0/1 instead of starting the server. This lets the same
// binary act as its own Docker HEALTHCHECK probe for scratch images, where
// no shell, curl or wget is available.
var healthcheckMode = flag.Bool(
"healthcheck",
false,
"probe /health on the server's listen address and exit 0 if healthy, 1 "+
"otherwise (intended as a Docker HEALTHCHECK for scratch-based images)",
)
// runHealthcheck performs a GET against http://<addr>/health with a short
// timeout. Returns nil on a 2xx response, an error otherwise. A bind address
// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface
// so the probe targets the local process.
func runHealthcheck(addr string) error {
host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr))
if err != nil {
return fmt.Errorf("invalid listen addr %q: %w", addr, err)
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode)
}
return nil
}
func normalizeHealthcheckAddr(a string) string {
if strings.HasPrefix(a, ":") {
return "127.0.0.1" + a
}
if strings.HasPrefix(a, "[::]:") {
return "[::1]:" + strings.TrimPrefix(a, "[::]:")
}
return a
}

View file

@ -0,0 +1,72 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRunHealthcheck_OK(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
if err := runHealthcheck(addr); err != nil {
t.Fatalf("runHealthcheck(%s) returned error: %v", addr, err)
}
}
func TestRunHealthcheck_NonOK(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
})
srv := httptest.NewServer(mux)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
if err := runHealthcheck(addr); err == nil {
t.Fatalf("runHealthcheck against 503 returned nil; want error")
}
}
func TestRunHealthcheck_Unreachable(t *testing.T) {
// Reserved-for-documentation port on loopback that nothing should bind.
if err := runHealthcheck("127.0.0.1:1"); err == nil {
t.Fatalf("runHealthcheck against unreachable port returned nil; want error")
}
}
func TestNormalizeHealthcheckAddr(t *testing.T) {
cases := map[string]string{
":8080": "127.0.0.1:8080",
"127.0.0.1:8080": "127.0.0.1:8080",
"0.0.0.0:8080": "0.0.0.0:8080",
"[::1]:8080": "[::1]:8080",
"[::]:8080": "[::1]:8080",
}
for in, want := range cases {
if got := normalizeHealthcheckAddr(in); got != want {
t.Errorf("normalizeHealthcheckAddr(%q) = %q, want %q", in, got, want)
}
}
}

View file

@ -0,0 +1,453 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"maps"
"net/http"
"time"
"git.happydns.org/checker-sdk-go/checker"
)
// Interactive is an optional interface that observation providers
// can implement to expose a human-facing web form usable standalone,
// outside of a happyDomain host. Detect support with a type assertion:
// _, ok := provider.(server.Interactive).
//
// When the provider implements it, Server binds GET and POST on /check.
// GET renders an HTML form built from RenderForm(). POST calls ParseForm
// to obtain the checker.CheckerOptions, then runs the standard pipeline
// (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a
// consolidated result page.
//
// Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed
// options from execution context, an Interactive implementation is
// responsible for resolving whatever it needs from the human inputs
// (typically via direct DNS queries) before Collect runs.
type Interactive interface {
// RenderForm returns the fields the human must fill in to bootstrap
// a check. Typically a minimal set (domain name, nameserver to
// query, …) that ParseForm expands into the full CheckerOptions
// that Collect expects.
RenderForm() []checker.CheckerOptionField
// ParseForm reads the submitted form and returns the CheckerOptions
// ready to feed Collect. It is the checker's responsibility to do
// whatever lookups or resolutions are needed to populate fields
// that would normally be auto-filled by happyDomain. Returning an
// error causes the SDK to re-render the form with the error
// displayed.
ParseForm(r *http.Request) (checker.CheckerOptions, error)
}
// Siblings is an optional interface an interactive ObservationProvider
// can co-implement to declare sibling providers whose Collect the SDK
// runs in-process during /check. Their results are exposed as
// RelatedObservations on ObservationGetter and ReportContext, mirroring
// the cross-checker lineage a happyDomain host resolves.
//
// For each sibling the SDK seeds options from the primary and, when the
// primary implements DiscoveryPublisher, writes its entries into any
// sibling option tagged AutoFill == checker.AutoFillDiscoveryEntries.
// Sibling errors are logged and skipped so the primary result still
// reaches the user.
type Siblings interface {
RelatedProviders() []checker.ObservationProvider
}
// checkResult holds everything the result page needs to render.
type checkResult struct {
Title string
States []checker.CheckState
Metrics []checker.CheckMetric
ReportHTML string
CollectErr string
ReportErr string
MetricsErr string
}
type checkFormPage struct {
Title string
Fields []checker.CheckerOptionField
Error string
}
func (s *Server) handleCheckForm(w http.ResponseWriter, r *http.Request) {
s.renderCheckForm(w, s.interactive.RenderForm(), "")
}
func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
fields := s.interactive.RenderForm()
if err := r.ParseForm(); err != nil {
s.renderCheckForm(w, fields, fmt.Sprintf("invalid form: %v", err))
return
}
opts, err := s.interactive.ParseForm(r)
if err != nil {
s.renderCheckForm(w, fields, err.Error())
return
}
result := &checkResult{Title: s.checkPageTitle()}
data, err := s.provider.Collect(r.Context(), opts)
if err != nil {
result.CollectErr = err.Error()
s.renderCheckResult(w, result)
return
}
raw, err := json.Marshal(data)
if err != nil {
result.CollectErr = fmt.Sprintf("failed to marshal collected data: %v", err)
s.renderCheckResult(w, result)
return
}
related := s.collectRelatedObservations(r.Context(), opts, data)
if s.definition != nil {
obs := &mapObservationGetter{
data: map[checker.ObservationKey]json.RawMessage{
s.provider.Key(): raw,
},
related: related,
}
result.States = s.evaluateRules(r.Context(), obs, opts, nil)
}
ctx := checker.NewReportContext(raw, related, result.States)
if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok {
html, rerr := reporter.GetHTMLReport(ctx)
if rerr != nil {
result.ReportErr = rerr.Error()
} else {
result.ReportHTML = html
}
}
if reporter, ok := s.provider.(checker.CheckerMetricsReporter); ok {
metrics, merr := reporter.ExtractMetrics(ctx, time.Now())
if merr != nil {
result.MetricsErr = merr.Error()
} else {
result.Metrics = metrics
}
}
s.renderCheckResult(w, result)
}
// collectRelatedObservations runs sibling providers declared via Siblings
// and returns their results keyed by the sibling's observation key.
// Sibling errors are logged and skipped.
func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation {
irp, ok := s.provider.(Siblings)
if !ok {
return nil
}
siblings := irp.RelatedProviders()
if len(siblings) == 0 {
return nil
}
var entries []checker.DiscoveryEntry
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
e, err := dp.DiscoverEntries(data)
if err != nil {
log.Printf("interactive: DiscoverEntries failed: %v", err)
} else {
entries = e
}
}
related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings))
for _, sp := range siblings {
sOpts := cloneOptions(opts)
siblingID := ""
if dp, ok := sp.(checker.CheckerDefinitionProvider); ok {
if def := dp.Definition(); def != nil {
siblingID = def.ID
if len(entries) > 0 {
fillDiscoveryEntryOption(sOpts, def, entries)
}
}
}
sData, err := sp.Collect(ctx, sOpts)
if err != nil {
log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err)
continue
}
raw, err := json.Marshal(sData)
if err != nil {
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
continue
}
related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{
CheckerID: siblingID,
Key: sp.Key(),
Data: raw,
CollectedAt: time.Now(),
})
}
return related
}
func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions {
out := make(checker.CheckerOptions, len(opts))
maps.Copy(out, opts)
return out
}
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries.
func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) {
scopes := [][]checker.CheckerOptionDocumentation{
def.Options.AdminOpts,
def.Options.UserOpts,
def.Options.DomainOpts,
def.Options.ServiceOpts,
def.Options.RunOpts,
}
for _, scope := range scopes {
for _, f := range scope {
if f.AutoFill == checker.AutoFillDiscoveryEntries {
opts[f.Id] = entries
}
}
}
}
func (s *Server) checkPageTitle() string {
if s.definition != nil && s.definition.Name != "" {
return s.definition.Name
}
return "Checker"
}
func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data any) {
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
log.Printf("render %s: %v", tpl.Name(), err)
http.Error(w, "failed to render page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
}
func (s *Server) renderCheckForm(w http.ResponseWriter, fields []checker.CheckerOptionField, errMsg string) {
status := http.StatusOK
if errMsg != "" {
status = http.StatusBadRequest
}
renderHTML(w, status, checkFormTemplate, checkFormPage{
Title: s.checkPageTitle(),
Fields: fields,
Error: errMsg,
})
}
func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) {
renderHTML(w, http.StatusOK, checkResultTemplate, result)
}
func statusClass(s checker.Status) string {
switch s {
case checker.StatusOK:
return "ok"
case checker.StatusInfo:
return "info"
case checker.StatusWarn:
return "warn"
case checker.StatusCrit:
return "crit"
case checker.StatusError:
return "error"
default:
return "unknown"
}
}
// defaultString avoids printing the literal "<nil>" for unset defaults.
func defaultString(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return ""
default:
return fmt.Sprintf("%v", v)
}
}
func defaultBool(v any) bool {
b, _ := v.(bool)
return b
}
var templateFuncs = template.FuncMap{
"statusClass": statusClass,
"statusString": checker.Status.String,
"defaultString": defaultString,
"defaultBool": defaultBool,
}
const baseCSS = `
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
h1, h2 { border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
form { display: grid; gap: 1rem; }
label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
.required::after { content: " *"; color: #c00; }
.desc { font-weight: normal; color: #666; font-size: 0.9rem; display: block; margin-top: 0.1rem; }
input[type=text], input[type=password], input[type=number], select, textarea {
width: 100%; padding: 0.5rem; border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; font: inherit;
}
textarea { min-height: 6rem; }
button { padding: 0.6rem 1.2rem; background: #0b63c5; color: #fff; border: 0; border-radius: 4px; font: inherit; cursor: pointer; }
button:hover { background: #084c98; }
.err { background: #fee; border: 1px solid #fbb; color: #900; padding: 0.6rem 0.8rem; border-radius: 4px; margin: 1rem 0; }
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1.5rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #eee; vertical-align: top; }
th { background: #f7f7f7; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; color: #fff; }
.badge.ok { background: #2a9d3c; }
.badge.info { background: #3277cc; }
.badge.warn { background: #d08a00; }
.badge.crit { background: #c0392b; }
.badge.error { background: #7a1f1f; }
.badge.unknown { background: #777; }
iframe.report { width: 100%; min-height: 480px; border: 1px solid #ccc; border-radius: 4px; }
.actions { margin-top: 1.5rem; }
.actions a { color: #0b63c5; text-decoration: none; }
.actions a:hover { text-decoration: underline; }
`
var checkFormTemplate = template.Must(template.New("form").Funcs(templateFuncs).Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} Check</title>
<style>` + baseCSS + `</style>
</head>
<body>
<h1>{{.Title}}</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<form method="POST" action="/check">
{{range .Fields}}{{if not .Hide}}
<div>
<label for="{{.Id}}" class="{{if .Required}}required{{end}}">{{if .Label}}{{.Label}}{{else}}{{.Id}}{{end}}
{{if .Description}}<span class="desc">{{.Description}}</span>{{end}}
</label>
{{if .Choices}}
<select id="{{.Id}}" name="{{.Id}}"{{if .Required}} required{{end}}>
{{$def := defaultString .Default}}
{{range .Choices}}<option value="{{.}}"{{if eq . $def}} selected{{end}}>{{.}}</option>{{end}}
</select>
{{else if eq .Type "bool"}}
<input type="checkbox" id="{{.Id}}" name="{{.Id}}" value="true"{{if defaultBool .Default}} checked{{end}}>
{{else if .Textarea}}
<textarea id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}"{{if .Required}} required{{end}}>{{defaultString .Default}}</textarea>
{{else if eq .Type "number"}}
<input type="number" step="any" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
{{else if eq .Type "uint"}}
<input type="number" min="0" step="1" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
{{else if .Secret}}
<input type="password" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}"{{if .Required}} required{{end}}>
{{else}}
<input type="text" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
{{end}}
</div>
{{end}}{{end}}
<div><button type="submit">Run check</button></div>
</form>
</body>
</html>`))
var checkResultTemplate = template.Must(template.New("result").Funcs(templateFuncs).Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{.Title}} Result</title>
<style>` + baseCSS + `</style>
</head>
<body>
<h1>{{.Title}}</h1>
{{if .CollectErr}}<div class="err"><strong>Collect failed:</strong> {{.CollectErr}}</div>{{end}}
{{if .States}}
<h2>Check states</h2>
<table>
<thead><tr><th>Status</th><th>Rule</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .States}}
<tr>
<td><span class="badge {{statusClass .Status}}">{{statusString .Status}}</span></td>
<td>{{.RuleName}}</td>
<td>{{.Code}}</td>
<td>{{.Subject}}</td>
<td>{{.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Metrics}}
<h2>Metrics</h2>
<table>
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Labels</th></tr></thead>
<tbody>
{{range .Metrics}}
<tr>
<td>{{.Name}}</td>
<td>{{.Value}}</td>
<td>{{.Unit}}</td>
<td>{{range $k, $v := .Labels}}{{$k}}={{$v}} {{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .MetricsErr}}<div class="err"><strong>Metrics error:</strong> {{.MetricsErr}}</div>{{end}}
{{if .ReportHTML}}
<h2>Report</h2>
<iframe class="report" sandbox srcdoc="{{.ReportHTML}}"></iframe>
{{end}}
{{if .ReportErr}}<div class="err"><strong>Report error:</strong> {{.ReportErr}}</div>{{end}}
<div class="actions"><a href="/check"> Run another check</a></div>
</body>
</html>`))

View file

@ -0,0 +1,439 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.happydns.org/checker-sdk-go/checker"
)
// interactiveProvider embeds testProvider and adds Interactive.
type interactiveProvider struct {
*testProvider
fields []checker.CheckerOptionField
parseFn func(r *http.Request) (checker.CheckerOptions, error)
parseErr error
}
func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField {
return p.fields
}
func (p *interactiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
if p.parseErr != nil {
return nil, p.parseErr
}
if p.parseFn != nil {
return p.parseFn(r)
}
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
}
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
// minimalProvider implements only ObservationProvider.
type minimalProvider struct{ key checker.ObservationKey }
func (m *minimalProvider) Key() checker.ObservationKey { return m.key }
func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, nil
}
func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) {
p := &minimalProvider{key: "test"}
srv := New(p)
defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code)
}
}
func TestCheck_Form_Renders(t *testing.T) {
p := &interactiveProvider{
testProvider: &testProvider{key: "test"},
fields: []checker.CheckerOptionField{
{Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"},
{Id: "verbose", Type: "bool", Label: "Verbose", Default: true},
{Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"},
{Id: "hidden", Type: "string", Hide: true},
},
}
srv := New(p)
defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /check = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`name="domain"`,
`placeholder="example.com"`,
`Domain name`,
`type="checkbox"`,
`name="verbose"`,
` checked`,
`<select id="flavor"`,
`<option value="b" selected>`,
} {
if !strings.Contains(body, want) {
t.Errorf("form body missing %q", want)
}
}
if strings.Contains(body, `name="hidden"`) {
t.Errorf("hidden field should not be rendered")
}
}
func TestCheck_Submit_Success(t *testing.T) {
definition := &checker.CheckerDefinition{
ID: "test",
Name: "Test Checker",
Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"},
},
}
p := &interactiveProvider{
testProvider: &testProvider{key: "test", definition: definition},
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
}
srv := New(p)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`Test Checker`,
`Check states`,
`rule1`,
`rule1 passed`,
`badge ok`,
`Metrics`,
`m1`,
`Report`,
`<iframe`,
} {
if !strings.Contains(body, want) {
t.Errorf("result body missing %q", want)
}
}
}
func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) {
p := &interactiveProvider{
testProvider: &testProvider{key: "test"},
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
parseErr: errors.New("domain is required"),
}
srv := New(p)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /check with bad input = %d, want 400", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "domain is required") {
t.Errorf("body missing error message, got: %s", body)
}
if !strings.Contains(body, `name="domain"`) {
t.Errorf("form not re-rendered on error")
}
}
func TestCheck_Submit_CollectError(t *testing.T) {
p := &interactiveProvider{
testProvider: &testProvider{
key: "test",
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, errors.New("boom")
},
},
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
}
srv := New(p)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200 (collect failure still renders a page)", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Collect failed") || !strings.Contains(body, "boom") {
t.Errorf("body missing Collect error, got: %s", body)
}
if strings.Contains(body, "Check states") {
t.Errorf("states section should not render when Collect failed")
}
}
func TestCheck_NoReporters(t *testing.T) {
// Provider implements Interactive and has a definition (so
// /evaluate-like logic runs) but no HTMLReporter / MetricsReporter.
bare := &bareInteractiveProvider{
key: "test",
def: &checker.CheckerDefinition{
ID: "test",
Rules: []checker.CheckRule{&dummyRule{name: "r", desc: "r"}},
},
}
srv := New(bare)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Check states") {
t.Errorf("body missing states section")
}
if strings.Contains(body, "<iframe") {
t.Errorf("body should not contain iframe when no HTML reporter")
}
if strings.Contains(body, "<h2>Metrics</h2>") {
t.Errorf("body should not contain metrics section when no metrics reporter")
}
}
// bareInteractiveProvider implements only the required interfaces
// (ObservationProvider, CheckerDefinitionProvider, Interactive),
// no reporters.
type bareInteractiveProvider struct {
key checker.ObservationKey
def *checker.CheckerDefinition
}
func (b *bareInteractiveProvider) Key() checker.ObservationKey { return b.key }
func (b *bareInteractiveProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"ok": "1"}, nil
}
func (b *bareInteractiveProvider) Definition() *checker.CheckerDefinition { return b.def }
func (b *bareInteractiveProvider) RenderForm() []checker.CheckerOptionField {
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
}
func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
}
type siblingProvider struct {
key checker.ObservationKey
id string
entriesOpt string
gotOpts checker.CheckerOptions
payload any
}
func (s *siblingProvider) Key() checker.ObservationKey { return s.key }
func (s *siblingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
s.gotOpts = opts
return s.payload, nil
}
func (s *siblingProvider) Definition() *checker.CheckerDefinition {
return &checker.CheckerDefinition{
ID: s.id,
Options: checker.CheckerOptionsDocumentation{
RunOpts: []checker.CheckerOptionDocumentation{
{Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries},
},
},
}
}
type primaryWithSibling struct {
key checker.ObservationKey
def *checker.CheckerDefinition
entries []checker.DiscoveryEntry
sibling checker.ObservationProvider
}
func (p *primaryWithSibling) Key() checker.ObservationKey { return p.key }
func (p *primaryWithSibling) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"primary": "ok"}, nil
}
func (p *primaryWithSibling) Definition() *checker.CheckerDefinition { return p.def }
func (p *primaryWithSibling) RenderForm() []checker.CheckerOptionField {
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
}
func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
}
func (p *primaryWithSibling) DiscoverEntries(data any) ([]checker.DiscoveryEntry, error) {
return p.entries, nil
}
func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider {
return []checker.ObservationProvider{p.sibling}
}
type relatedAssertRule struct {
key checker.ObservationKey
}
func (r *relatedAssertRule) Name() string { return "related_assert" }
func (r *relatedAssertRule) Description() string { return "" }
func (r *relatedAssertRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
related, err := obs.GetRelated(ctx, r.key)
if err != nil {
return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}}
}
if len(related) == 0 {
return []checker.CheckState{{Status: checker.StatusCrit, Message: "no related observation"}}
}
return []checker.CheckState{{Status: checker.StatusOK, Message: "saw related observation"}}
}
func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
sibling := &siblingProvider{
key: "sibling_key",
id: "sibling",
entriesOpt: "endpoints",
payload: map[string]string{"sibling": "ok"},
}
entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"}
primary := &primaryWithSibling{
key: "primary_key",
def: &checker.CheckerDefinition{
ID: "primary",
Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}},
},
entries: []checker.DiscoveryEntry{entry},
sibling: sibling,
}
srv := New(primary)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "saw related observation") {
t.Errorf("rule did not see related observation; body:\n%s", body)
}
got, ok := sibling.gotOpts[sibling.entriesOpt].([]checker.DiscoveryEntry)
if !ok {
t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt])
}
if len(got) != 1 || got[0].Ref != entry.Ref {
t.Errorf("sibling saw entries %v, want [%v]", got, entry)
}
if v, _ := sibling.gotOpts["domain"].(string); v != "example.com" {
t.Errorf("sibling did not receive primary domain opt, got %q", v)
}
}
// interactiveStatesPeekingProvider implements Interactive + HTMLReporter
// and captures the ReportContext.States() seen at GetHTMLReport time.
type interactiveStatesPeekingProvider struct {
key checker.ObservationKey
def *checker.CheckerDefinition
seen *[]checker.CheckState
}
func (p *interactiveStatesPeekingProvider) Key() checker.ObservationKey { return p.key }
func (p *interactiveStatesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"ok": "1"}, nil
}
func (p *interactiveStatesPeekingProvider) Definition() *checker.CheckerDefinition { return p.def }
func (p *interactiveStatesPeekingProvider) RenderForm() []checker.CheckerOptionField {
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
}
func (p *interactiveStatesPeekingProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
}
func (p *interactiveStatesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
if p.seen != nil {
*p.seen = ctx.States()
}
return "<p>ok</p>", nil
}
// TestCheck_Submit_ThreadsStatesIntoReport verifies that CheckStates
// produced by evaluateRules during POST /check are threaded into the
// ReportContext handed to GetHTMLReport. Without this wiring, the /check
// UI can show states in its own section but the embedded report would
// have to re-derive severity/hints from Data.
func TestCheck_Submit_ThreadsStatesIntoReport(t *testing.T) {
var seen []checker.CheckState
p := &interactiveStatesPeekingProvider{
key: "test",
def: &checker.CheckerDefinition{
ID: "test",
Rules: []checker.CheckRule{&dummyRule{name: "rule1", desc: "first"}},
},
seen: &seen,
}
srv := New(p)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200", rec.Code)
}
if len(seen) != 1 {
t.Fatalf("reporter saw %d states, want 1", len(seen))
}
if seen[0].RuleName != "rule1" {
t.Errorf("state RuleName = %q, want %q", seen[0].RuleName, "rule1")
}
if seen[0].Status != checker.StatusOK {
t.Errorf("state Status = %v, want %v", seen[0].Status, checker.StatusOK)
}
}
func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) {
p := &interactiveProvider{
testProvider: &testProvider{
key: "test",
definition: &checker.CheckerDefinition{
ID: "test",
Rules: []checker.CheckRule{&relatedAssertRule{key: "other"}},
},
},
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
}
srv := New(p)
defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
if rec.Code != http.StatusOK {
t.Fatalf("POST /check = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "no related observation") {
t.Errorf("rule should have seen no related observation; body:\n%s", body)
}
}

524
checker/server/server.go Normal file
View file

@ -0,0 +1,524 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package server provides the HTTP server scaffolding used by standalone
// checkers. It is separated from the core checker package so that plugin
// and builtin builds, which never expose an HTTP endpoint, do not pay the
// cost of net/http, html/template, and their transitive dependencies.
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"git.happydns.org/checker-sdk-go/checker"
)
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
const maxRequestBodySize = 1 << 20
// loadSampleInterval is how often the background sampler updates the
// exponentially weighted moving averages reported in HealthResponse.LoadAvg.
// 5 seconds matches the Unix kernel's loadavg cadence.
const loadSampleInterval = 5 * time.Second
// shutdownTimeout bounds how long ListenAndServe waits for in-flight
// requests to drain after receiving SIGINT or SIGTERM.
const shutdownTimeout = 10 * time.Second
// EWMA smoothing factors for 1, 5, and 15-minute windows sampled every
// loadSampleInterval. Derived as 1 - exp(-interval/window) so that the
// steady-state response to a constant InFlight of N converges to N.
var (
loadAlpha1 = 1 - math.Exp(-float64(loadSampleInterval)/float64(1*time.Minute))
loadAlpha5 = 1 - math.Exp(-float64(loadSampleInterval)/float64(5*time.Minute))
loadAlpha15 = 1 - math.Exp(-float64(loadSampleInterval)/float64(15*time.Minute))
)
// updateLoadAvg advances the three EWMAs by one tick given the current
// InFlight sample. It is a pure function to keep the sampler trivially testable.
func updateLoadAvg(prev [3]float64, sample float64) [3]float64 {
return [3]float64{
prev[0] + loadAlpha1*(sample-prev[0]),
prev[1] + loadAlpha5*(sample-prev[1]),
prev[2] + loadAlpha15*(sample-prev[2]),
}
}
// Server is a generic HTTP server for external checkers.
// It always exposes /health and /collect. If the provider implements
// checker.CheckerDefinitionProvider, it also exposes /definition and /evaluate.
// If the provider implements checker.CheckerHTMLReporter or checker.CheckerMetricsReporter,
// it also exposes /report. If the provider implements Interactive,
// it also exposes /check (a human-facing web form).
//
// Security: Server does not perform any authentication or authorization.
// It is intended to be run behind a reverse proxy or in a trusted network
// where access control is handled externally (e.g. by the happyDomain server).
type Server struct {
provider checker.ObservationProvider
definition *checker.CheckerDefinition
interactive Interactive
mux *http.ServeMux
// startTime is captured in New and used to compute uptime.
startTime time.Time
// inFlight counts work requests (/collect, /evaluate, /report) currently
// being processed. /health and /definition are not tracked.
inFlight atomic.Int64
// totalRequests is the cumulative number of work requests served.
totalRequests atomic.Uint64
// loadBits stores the 1, 5, 15-minute EWMAs of inFlight as float64 bit
// patterns (math.Float64bits) so reads and writes are tear-free and
// lock-free across the sampler goroutine and the /health handler.
loadBits [3]atomic.Uint64
// cancelSampler stops the background load-average sampler.
cancelSampler context.CancelFunc
// samplerDone is closed when the sampler goroutine returns.
samplerDone chan struct{}
// closeOnce guarantees Close is idempotent.
closeOnce sync.Once
}
// New creates a new checker HTTP server backed by the given provider.
// Additional endpoints are registered based on optional interfaces the provider implements.
//
// New also starts a background goroutine that samples the in-flight
// request count every loadSampleInterval to compute the load averages
// reported on /health. Call Close to stop it.
func New(provider checker.ObservationProvider) *Server {
ctx, cancel := context.WithCancel(context.Background())
s := &Server{
provider: provider,
startTime: time.Now(),
cancelSampler: cancel,
samplerDone: make(chan struct{}),
}
s.mux = http.NewServeMux()
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect)))
if dp, ok := provider.(checker.CheckerDefinitionProvider); ok {
if def := dp.Definition(); def != nil {
s.definition = def
s.definition.BuildRulesInfo()
s.mux.HandleFunc("GET /definition", s.handleDefinition)
s.mux.Handle("POST /definition", s.TrackWork(http.HandlerFunc(s.handlePrecheck)))
s.mux.Handle("POST /evaluate", s.TrackWork(http.HandlerFunc(s.handleEvaluate)))
}
}
if _, ok := provider.(checker.CheckerHTMLReporter); ok {
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
} else if _, ok := provider.(checker.CheckerMetricsReporter); ok {
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
}
if ip, ok := provider.(Interactive); ok {
s.interactive = ip
s.mux.HandleFunc("GET /check", s.handleCheckForm)
s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit)))
}
go s.runSampler(ctx)
return s
}
// Handler returns the http.Handler for this server, allowing callers
// to embed it in a custom server or add middleware.
func (s *Server) Handler() http.Handler {
return requestLogger(s.mux)
}
// Handle registers an auxiliary handler on the server's mux. Must be called
// before ListenAndServe or Handler(). Custom handlers are not tracked by
// TrackWork; wrap them explicitly if you want them counted in /health load.
func (s *Server) Handle(pattern string, handler http.Handler) {
s.mux.Handle(pattern, handler)
}
// HandleFunc is the http.HandlerFunc-flavoured counterpart of Handle.
func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
s.mux.HandleFunc(pattern, handler)
}
// ListenAndServe starts the HTTP server on the given address and blocks
// until the server stops.
//
// ListenAndServe installs a SIGINT/SIGTERM handler that triggers a graceful
// shutdown: new connections are refused and in-flight requests are given up
// to shutdownTimeout to complete. The background load-average sampler is
// stopped via Close before returning. Callers who need their own signal
// handling or shutdown semantics should use Handler() and run their own
// http.Server instead.
//
// If the consumer's flag.Parse() set the SDK-registered -healthcheck flag,
// ListenAndServe never starts the server: it probes /health on addr and calls
// os.Exit(0) on success or os.Exit(1) on failure. This is what lets a
// scratch-based Docker image use the binary itself as its HEALTHCHECK probe.
func (s *Server) ListenAndServe(addr string) error {
if *healthcheckMode {
if err := runHealthcheck(addr); err != nil {
fmt.Fprintln(os.Stderr, "healthcheck failed:", err)
os.Exit(1)
}
os.Exit(0)
}
srv := &http.Server{Addr: addr, Handler: requestLogger(s.mux)}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
shutdownErr := make(chan error, 1)
go func() {
sig, ok := <-sigCh
if !ok {
shutdownErr <- nil
return
}
log.Printf("checker received %s, shutting down (timeout %s)", sig, shutdownTimeout)
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
shutdownErr <- srv.Shutdown(ctx)
}()
log.Printf("checker listening on %s", addr)
err := srv.ListenAndServe()
signal.Stop(sigCh)
close(sigCh)
if err == http.ErrServerClosed {
if sErr := <-shutdownErr; sErr != nil {
err = sErr
} else {
err = nil
}
}
if cErr := s.Close(); cErr != nil && err == nil {
err = cErr
}
return err
}
// Close stops the background load-average sampler goroutine. It is safe to
// call multiple times; subsequent calls are no-ops. Close does not shut down
// any underlying http.Server, callers own that lifecycle.
func (s *Server) Close() error {
s.closeOnce.Do(func() {
s.cancelSampler()
<-s.samplerDone
})
return nil
}
// TrackWork wraps a handler with in-flight and total-request accounting,
// opting custom routes into the load signal reported on /health.
func (s *Server) TrackWork(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.inFlight.Add(1)
s.totalRequests.Add(1)
defer s.inFlight.Add(-1)
next.ServeHTTP(w, r)
})
}
// runSampler updates the load-average EWMAs every loadSampleInterval until
// ctx is canceled. It closes s.samplerDone on exit.
func (s *Server) runSampler(ctx context.Context) {
defer close(s.samplerDone)
ticker := time.NewTicker(loadSampleInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
var prev [3]float64
for i := range prev {
prev[i] = math.Float64frombits(s.loadBits[i].Load())
}
next := updateLoadAvg(prev, float64(s.inFlight.Load()))
for i := range next {
s.loadBits[i].Store(math.Float64bits(next[i]))
}
}
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start))
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
var load [3]float64
for i := range load {
load[i] = math.Float64frombits(s.loadBits[i].Load())
}
writeJSON(w, http.StatusOK, checker.HealthResponse{
Status: "ok",
Uptime: time.Since(s.startTime).Seconds(),
NumCPU: runtime.NumCPU(),
InFlight: s.inFlight.Load(),
TotalRequests: s.totalRequests.Load(),
LoadAvg: load,
})
}
func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.definition)
}
// handlePrecheck answers POST /definition: it returns the same
// definition body as GET /definition, plus a PrecheckFailures map
// listing rules whose prerequisites are unmet for the submitted
// options. Rules that do not implement checker.RulePrecheck, or whose
// Precheck returned nil, are omitted from that map.
func (s *Server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
var req checker.RulePrecheckRequest
if r.ContentLength != 0 {
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("invalid request body: %v", err),
})
return
}
}
failures := map[string]string{}
for _, rule := range s.definition.Rules {
pc, ok := rule.(checker.RulePrecheck)
if !ok {
continue
}
if err := pc.Precheck(r.Context(), req.Options); err != nil {
failures[rule.Name()] = err.Error()
}
}
resp := checker.RulePrecheckResponse{
CheckerDefinition: s.definition,
PrecheckFailures: failures,
}
if en, ok := s.provider.(checker.CheckEnabler); ok {
eligible, reason, err := en.IsEligible(r.Context(), req.Options)
if err != nil {
// Eligibility undetermined: leave Eligible nil so the host fails
// open (shows the checker), but surface the error for diagnostics.
log.Printf("IsEligible failed: %v", err)
resp.EligibilityReason = err.Error()
} else {
resp.Eligible = &eligible
resp.EligibilityReason = reason
}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
var req checker.ExternalCollectRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, checker.ExternalCollectResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
ctx := checker.WithEnabledRules(r.Context(), req.EnabledRules)
data, err := s.provider.Collect(ctx, req.Options)
if err != nil {
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
Error: err.Error(),
})
return
}
raw, err := json.Marshal(data)
if err != nil {
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
Error: fmt.Sprintf("failed to marshal result: %v", err),
})
return
}
resp := checker.ExternalCollectResponse{Data: json.RawMessage(raw)}
// Harvest discovery entries from the native Go value, before it goes
// out of scope. No re-parse; DiscoverEntries operates on the same
// object that was just marshaled above.
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
entries, derr := dp.DiscoverEntries(data)
if derr != nil {
log.Printf("DiscoverEntries failed: %v", derr)
} else {
resp.Entries = entries
}
}
writeJSON(w, http.StatusOK, resp)
}
// evaluateRules runs all definition rules against obs/opts, skipping any rule
// whose name maps to false in enabledRules (nil means run all).
func (s *Server) evaluateRules(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions, enabledRules map[string]bool) []checker.CheckState {
var states []checker.CheckState
for _, rule := range s.definition.Rules {
if len(enabledRules) > 0 {
if enabled, ok := enabledRules[rule.Name()]; ok && !enabled {
continue
}
}
ruleStates := rule.Evaluate(ctx, obs, opts)
if len(ruleStates) == 0 {
ruleStates = []checker.CheckState{{
Status: checker.StatusUnknown,
Message: fmt.Sprintf("rule %q returned no state", rule.Name()),
}}
}
for _, state := range ruleStates {
state.RuleName = rule.Name()
states = append(states, state)
}
}
return states
}
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
var req checker.ExternalEvaluateRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, checker.ExternalEvaluateResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
obs := &mapObservationGetter{data: req.Observations}
states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules)
writeJSON(w, http.StatusOK, checker.ExternalEvaluateResponse{States: states})
}
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
var req checker.ExternalReportRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("invalid request body: %v", err),
})
return
}
accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") {
reporter, ok := s.provider.(checker.CheckerHTMLReporter)
if !ok {
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
return
}
html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related, req.States))
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Default: JSON metrics.
reporter, ok := s.provider.(checker.CheckerMetricsReporter)
if !ok {
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
return
}
metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related, req.States), time.Now())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to extract metrics: %v", err),
})
return
}
writeJSON(w, http.StatusOK, metrics)
}
// mapObservationGetter implements checker.ObservationGetter backed by static maps.
// Both fields are optional: Get reads from data, GetRelated reads from
// related. Leaving related nil preserves the pre-existing "no lineage"
// behavior used by the remote /evaluate path.
type mapObservationGetter struct {
data map[checker.ObservationKey]json.RawMessage
related map[checker.ObservationKey][]checker.RelatedObservation
}
func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationKey, dest any) error {
raw, ok := g.data[key]
if !ok {
return fmt.Errorf("observation %q not available", key)
}
return json.Unmarshal(raw, dest)
}
// GetRelated returns the pre-resolved related observations for key, or nil
// when none were seeded. The remote /evaluate path leaves related nil
// because ExternalEvaluateRequest does not currently carry cross-checker
// lineage; the interactive /check path can seed it from sibling providers
// declared via Siblings.
func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) {
return g.related[key], nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View file

@ -0,0 +1,862 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.happydns.org/checker-sdk-go/checker"
)
// --- test doubles ---
type testProvider struct {
key checker.ObservationKey
collectFn func(ctx context.Context, opts checker.CheckerOptions) (any, error)
definition *checker.CheckerDefinition
htmlFn func(raw json.RawMessage) (string, error)
metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error)
}
func (p *testProvider) Key() checker.ObservationKey { return p.key }
func (p *testProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
if p.collectFn != nil {
return p.collectFn(ctx, opts)
}
return map[string]string{"result": "ok"}, nil
}
func (p *testProvider) Definition() *checker.CheckerDefinition { return p.definition }
func (p *testProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
if p.htmlFn != nil {
return p.htmlFn(ctx.Data())
}
return "<h1>report</h1>", nil
}
func (p *testProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
if p.metricsFn != nil {
return p.metricsFn(ctx.Data(), t)
}
return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
}
// dummyRule is a minimal CheckRule for testing evaluate.
type dummyRule struct {
name string
desc string
}
func (r *dummyRule) Name() string { return r.name }
func (r *dummyRule) Description() string { return r.desc }
func (r *dummyRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []checker.CheckState{{Status: checker.StatusOK, Message: r.name + " passed"}}
}
// codedRule emits a CheckState with a pre-set Code, to verify the server
// stamps RuleName without clobbering rule-provided codes.
type codedRule struct {
name, code string
}
func (r *codedRule) Name() string { return r.name }
func (r *codedRule) Description() string { return "" }
func (r *codedRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []checker.CheckState{{Status: checker.StatusWarn, Code: r.code, Message: "coded finding"}}
}
// stubProvider is a minimal ObservationProvider that does not implement
// CheckerDefinitionProvider, used to verify conditional endpoint registration.
type stubProvider struct {
key checker.ObservationKey
}
func (s stubProvider) Key() checker.ObservationKey { return s.key }
func (s stubProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, nil
}
// --- helpers ---
func newTestServer(p *testProvider) *Server {
return New(p)
}
func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
// --- tests ---
func TestServer_Health(t *testing.T) {
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p)
defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/health", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode /health: %v", err)
}
if resp.Status != "ok" {
t.Errorf("GET /health status = %q, want \"ok\"", resp.Status)
}
if resp.NumCPU <= 0 {
t.Errorf("NumCPU = %d, want > 0", resp.NumCPU)
}
if resp.Uptime < 0 {
t.Errorf("Uptime = %v, want >= 0", resp.Uptime)
}
if resp.InFlight != 0 {
t.Errorf("InFlight = %d on fresh server, want 0", resp.InFlight)
}
if resp.TotalRequests != 0 {
t.Errorf("TotalRequests = %d on fresh server, want 0", resp.TotalRequests)
}
if resp.LoadAvg != [3]float64{0, 0, 0} {
t.Errorf("LoadAvg = %v on fresh server, want all zero", resp.LoadAvg)
}
}
func TestServer_Health_TracksInFlight(t *testing.T) {
release := make(chan struct{})
var collectEntered sync.WaitGroup
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
collectEntered.Done()
<-release
return map[string]string{"ok": "1"}, nil
},
}
srv := newTestServer(p)
defer srv.Close()
handler := srv.Handler()
const n = 3
collectEntered.Add(n)
var clientsDone sync.WaitGroup
clientsDone.Add(n)
for i := 0; i < n; i++ {
go func() {
defer clientsDone.Done()
doRequest(handler, "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
}()
}
// Wait for all n handlers to be inside collectFn (== all n in-flight).
collectEntered.Wait()
// Record /health mid-flight. Also hammer it to verify /health polls
// do not inflate InFlight or TotalRequests.
var mid checker.HealthResponse
for i := 0; i < 5; i++ {
rec := doRequest(handler, "GET", "/health", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
}
if err := json.NewDecoder(rec.Body).Decode(&mid); err != nil {
t.Fatalf("decode /health: %v", err)
}
}
if mid.InFlight != n {
t.Errorf("mid-flight InFlight = %d, want %d", mid.InFlight, n)
}
if mid.TotalRequests != n {
t.Errorf("mid-flight TotalRequests = %d, want %d (health polls must not count)", mid.TotalRequests, n)
}
// Release all work and wait for clients to return.
close(release)
clientsDone.Wait()
rec := doRequest(handler, "GET", "/health", nil, nil)
var after checker.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&after); err != nil {
t.Fatalf("decode /health: %v", err)
}
if after.InFlight != 0 {
t.Errorf("post-flight InFlight = %d, want 0", after.InFlight)
}
if after.TotalRequests != n {
t.Errorf("post-flight TotalRequests = %d, want %d", after.TotalRequests, n)
}
if after.Uptime < mid.Uptime {
t.Errorf("Uptime went backwards: mid=%v after=%v", mid.Uptime, after.Uptime)
}
}
func TestUpdateLoadAvg(t *testing.T) {
load := [3]float64{0, 0, 0}
for i := 0; i < 20; i++ {
load = updateLoadAvg(load, 5)
}
if !(load[0] > load[1] && load[1] > load[2]) {
t.Errorf("expected load[0] > load[1] > load[2], got %v", load)
}
for i, v := range load {
if v <= 0 {
t.Errorf("load[%d] = %v, want > 0", i, v)
}
if v >= 5 {
t.Errorf("load[%d] = %v, want < 5 (not yet converged)", i, v)
}
}
// Constant sample of zero from a non-zero state must decay toward zero.
decaying := load
for i := 0; i < 50; i++ {
decaying = updateLoadAvg(decaying, 0)
}
for i := range decaying {
if decaying[i] >= load[i] {
t.Errorf("decaying[%d] = %v, want < %v", i, decaying[i], load[i])
}
}
}
func TestServer_Close_Idempotent(t *testing.T) {
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p)
done := make(chan error, 2)
go func() { done <- srv.Close() }()
go func() { done <- srv.Close() }()
for i := 0; i < 2; i++ {
select {
case err := <-done:
if err != nil {
t.Errorf("Close() returned %v, want nil", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Close() deadlocked")
}
}
}
func TestServer_Collect_Success(t *testing.T) {
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]int{"count": 42}, nil
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{
Key: "test",
Options: checker.CheckerOptions{"a": "b"},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.ExternalCollectResponse
json.NewDecoder(rec.Body).Decode(&resp)
if resp.Error != "" {
t.Errorf("POST /collect error = %q, want empty", resp.Error)
}
if resp.Data == nil {
t.Fatal("POST /collect data is nil")
}
}
func TestServer_Collect_ProviderError(t *testing.T) {
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, errors.New("provider failed")
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
}
var resp checker.ExternalCollectResponse
json.NewDecoder(rec.Body).Decode(&resp)
if resp.Error == "" {
t.Error("expected error in response, got empty")
}
}
func TestServer_Collect_BadBody(t *testing.T) {
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p)
req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid"))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("POST /collect bad body = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestServer_Definition(t *testing.T) {
def := &checker.CheckerDefinition{
ID: "test-checker",
Name: "Test Checker",
Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"},
},
}
p := &testProvider{key: "test", definition: def}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "GET", "/definition", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
}
var got checker.CheckerDefinition
json.NewDecoder(rec.Body).Decode(&got)
if got.ID != "test-checker" {
t.Errorf("definition ID = %q, want \"test-checker\"", got.ID)
}
if len(got.RulesInfo) != 1 {
t.Errorf("definition rules = %d, want 1", len(got.RulesInfo))
}
}
func TestServer_Evaluate(t *testing.T) {
def := &checker.CheckerDefinition{
ID: "test-checker",
Name: "Test Checker",
Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"},
&dummyRule{name: "rule2", desc: "second rule"},
},
}
p := &testProvider{key: "test", definition: def}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[checker.ObservationKey]json.RawMessage{
"test": json.RawMessage(`{"count":42}`),
},
Options: checker.CheckerOptions{},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 2 {
t.Fatalf("evaluate states = %d, want 2", len(resp.States))
}
if resp.States[0].RuleName != "rule1" {
t.Errorf("evaluate state[0].RuleName = %q, want \"rule1\"", resp.States[0].RuleName)
}
if resp.States[0].Code != "" {
t.Errorf("evaluate state[0].Code = %q, want empty (rule did not set one)", resp.States[0].Code)
}
}
func TestServer_Evaluate_DisabledRule(t *testing.T) {
def := &checker.CheckerDefinition{
ID: "test-checker",
Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first"},
&dummyRule{name: "rule2", desc: "second"},
},
}
p := &testProvider{key: "test", definition: def}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[checker.ObservationKey]json.RawMessage{
"test": json.RawMessage(`{}`),
},
EnabledRules: map[string]bool{"rule1": false},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 1 {
t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States))
}
if resp.States[0].RuleName != "rule2" {
t.Errorf("remaining state rule name = %q, want \"rule2\"", resp.States[0].RuleName)
}
}
func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
def := &checker.CheckerDefinition{
ID: "test-checker",
Rules: []checker.CheckRule{
&codedRule{name: "ruleA", code: "too_many_lookups"},
},
}
p := &testProvider{key: "test", definition: def}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[checker.ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 1 {
t.Fatalf("states = %d, want 1", len(resp.States))
}
if resp.States[0].RuleName != "ruleA" {
t.Errorf("state.RuleName = %q, want \"ruleA\"", resp.States[0].RuleName)
}
if resp.States[0].Code != "too_many_lookups" {
t.Errorf("state.Code = %q, want \"too_many_lookups\" (rule-set code must be preserved)", resp.States[0].Code)
}
}
func TestServer_Report_HTML(t *testing.T) {
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
htmlFn: func(raw json.RawMessage) (string, error) {
return "<p>hello</p>", nil
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
Key: "test",
Data: json.RawMessage(`{}`),
}, map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report html = %d, want %d", rec.Code, http.StatusOK)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q, want text/html", ct)
}
if body := rec.Body.String(); body != "<p>hello</p>" {
t.Errorf("body = %q, want \"<p>hello</p>\"", body)
}
}
func TestServer_Report_Metrics(t *testing.T) {
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
Key: "test",
Data: json.RawMessage(`{}`),
}, map[string]string{"Accept": "application/json"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK)
}
var metrics []checker.CheckMetric
json.NewDecoder(rec.Body).Decode(&metrics)
if len(metrics) != 1 {
t.Errorf("metrics count = %d, want 1", len(metrics))
}
}
// TestServer_Report_Related verifies the remote /report path wires
// ExternalReportRequest.Related through to the provider's ReportContext,
// the fix for the "remote checkers can't see related observations" gap.
func TestServer_Report_Related(t *testing.T) {
var gotRelated []checker.RelatedObservation
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
// Replace htmlFn with one that peeks at a related key. We can't do that
// directly through testProvider's htmlFn (which only sees raw), so
// bind to GetHTMLReport via an inline wrapper: use a per-test provider
// that captures the ReportContext before delegating to the template.
srv := New(&relatedPeekingProvider{
base: p,
target: &gotRelated,
})
defer srv.Close()
req := checker.ExternalReportRequest{
Key: "test",
Data: json.RawMessage(`{}`),
Related: map[checker.ObservationKey][]checker.RelatedObservation{
"tls_probes": {
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
},
},
}
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report = %d, want 200", rec.Code)
}
if len(gotRelated) != 1 {
t.Fatalf("provider saw %d related observations, want 1", len(gotRelated))
}
if gotRelated[0].CheckerID != "tls" || string(gotRelated[0].Data) != `{"ok":true}` {
t.Errorf("related mismatch: got %+v", gotRelated[0])
}
}
// relatedPeekingProvider forwards to a base testProvider but copies the
// Related("tls_probes") slice observed at GetHTMLReport time into target.
type relatedPeekingProvider struct {
base *testProvider
target *[]checker.RelatedObservation
}
func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return p.base.Collect(ctx, opts)
}
func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
*p.target = ctx.Related("tls_probes")
return "<p>ok</p>", nil
}
// statesPeekingProvider captures the ReportContext's States slice at
// GetHTMLReport / ExtractMetrics time.
type statesPeekingProvider struct {
base *testProvider
htmlSeen *[]checker.CheckState
metricSeen *[]checker.CheckState
}
func (p *statesPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
func (p *statesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return p.base.Collect(ctx, opts)
}
func (p *statesPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
func (p *statesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
if p.htmlSeen != nil {
*p.htmlSeen = ctx.States()
}
return "<p>ok</p>", nil
}
func (p *statesPeekingProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
if p.metricSeen != nil {
*p.metricSeen = ctx.States()
}
return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
}
// TestServer_Report_States_HTML verifies ExternalReportRequest.States is
// threaded into the ReportContext seen by the HTML reporter.
func TestServer_Report_States_HTML(t *testing.T) {
var seen []checker.CheckState
base := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen})
defer srv.Close()
states := []checker.CheckState{
{Status: checker.StatusCrit, Message: "broken", RuleName: "r1", Code: "bad", Subject: "host.example"},
}
req := checker.ExternalReportRequest{
Key: "test",
Data: json.RawMessage(`{}`),
States: states,
}
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report = %d, want 200", rec.Code)
}
if len(seen) != 1 || seen[0].RuleName != "r1" || seen[0].Code != "bad" || seen[0].Subject != "host.example" {
t.Errorf("reporter saw states = %+v, want single state {RuleName:r1, Code:bad, Subject:host.example}", seen)
}
}
// TestServer_Report_States_Metrics verifies the States passthrough on the
// metrics path as well.
func TestServer_Report_States_Metrics(t *testing.T) {
var seen []checker.CheckState
base := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
srv := New(&statesPeekingProvider{base: base, metricSeen: &seen})
defer srv.Close()
req := checker.ExternalReportRequest{
Key: "test",
Data: json.RawMessage(`{}`),
States: []checker.CheckState{{Status: checker.StatusWarn, RuleName: "r1"}},
}
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "application/json"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report = %d, want 200", rec.Code)
}
if len(seen) != 1 || seen[0].RuleName != "r1" {
t.Errorf("reporter saw states = %+v, want single state with RuleName=r1", seen)
}
}
// TestServer_Report_States_Absent verifies that omitting States in the
// request yields a nil States() slice on the reporter side (graceful
// degradation for hosts that don't thread evaluate→report yet).
func TestServer_Report_States_Absent(t *testing.T) {
seen := []checker.CheckState{{Status: checker.StatusOK}} // non-nil sentinel
base := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen})
defer srv.Close()
req := checker.ExternalReportRequest{Key: "test", Data: json.RawMessage(`{}`)}
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
if rec.Code != http.StatusOK {
t.Fatalf("POST /report = %d, want 200", rec.Code)
}
if seen != nil {
t.Errorf("States() = %+v, want nil when ExternalReportRequest.States is absent", seen)
}
}
func TestServer_Report_BadBody(t *testing.T) {
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
}
srv := newTestServer(p)
req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad"))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("POST /report bad body = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) {
// A provider that does NOT implement CheckerDefinitionProvider
p := &stubProvider{key: "basic"}
srv := New(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil)
// Should 404 or 405 since /evaluate is not registered
if rec.Code == http.StatusOK {
t.Error("POST /evaluate should not be available without CheckerDefinitionProvider")
}
}
// prereqRule implements RulePrecheck, failing when a named option is empty.
type prereqRule struct {
name string
optKey string
msg string
}
func (r *prereqRule) Name() string { return r.name }
func (r *prereqRule) Description() string { return "" }
func (r *prereqRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []checker.CheckState{{Status: checker.StatusOK}}
}
func (r *prereqRule) Precheck(ctx context.Context, opts checker.CheckerOptions) error {
if v, _ := opts[r.optKey].(string); v == "" {
return errors.New(r.msg)
}
return nil
}
// enablerProvider is a minimal ObservationProvider + CheckerDefinitionProvider
// that also implements CheckEnabler, returning whatever isEligibleFn yields.
type enablerProvider struct {
key checker.ObservationKey
definition *checker.CheckerDefinition
isEligibleFn func(ctx context.Context, opts checker.CheckerOptions) (bool, string, error)
}
func (p *enablerProvider) Key() checker.ObservationKey { return p.key }
func (p *enablerProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"result": "ok"}, nil
}
func (p *enablerProvider) Definition() *checker.CheckerDefinition { return p.definition }
func (p *enablerProvider) IsEligible(ctx context.Context, opts checker.CheckerOptions) (bool, string, error) {
return p.isEligibleFn(ctx, opts)
}
func TestServer_Precheck_Eligibility(t *testing.T) {
tests := []struct {
name string
fn func(ctx context.Context, opts checker.CheckerOptions) (bool, string, error)
wantNil bool // expect Eligible == nil
wantElig bool // value of *Eligible when not nil
wantReason string // expected EligibilityReason
}{
{
name: "eligible true",
fn: func(context.Context, checker.CheckerOptions) (bool, string, error) { return true, "", nil },
wantElig: true,
},
{
name: "eligible false with reason",
fn: func(context.Context, checker.CheckerOptions) (bool, string, error) { return false, "not a reverse zone", nil },
wantElig: false,
wantReason: "not a reverse zone",
},
{
name: "error fails open",
fn: func(context.Context, checker.CheckerOptions) (bool, string, error) { return false, "", errors.New("lookup timeout") },
wantNil: true,
wantReason: "lookup timeout",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &enablerProvider{
key: "test",
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
isEligibleFn: tc.fn,
}
srv := New(p)
defer srv.Close()
rec := doRequest(srv.Handler(), "POST", "/definition", checker.RulePrecheckRequest{Options: checker.CheckerOptions{}}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.RulePrecheckResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if tc.wantNil {
if resp.Eligible != nil {
t.Errorf("Eligible = %v, want nil", *resp.Eligible)
}
} else {
if resp.Eligible == nil {
t.Fatalf("Eligible = nil, want %v", tc.wantElig)
}
if *resp.Eligible != tc.wantElig {
t.Errorf("Eligible = %v, want %v", *resp.Eligible, tc.wantElig)
}
}
if resp.EligibilityReason != tc.wantReason {
t.Errorf("EligibilityReason = %q, want %q", resp.EligibilityReason, tc.wantReason)
}
})
}
}
// TestServer_Precheck_NoEnabler verifies that a provider not implementing
// CheckEnabler yields no eligibility fields (Eligible nil, reason empty).
func TestServer_Precheck_NoEnabler(t *testing.T) {
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p)
defer srv.Close()
rec := doRequest(srv.Handler(), "POST", "/definition", checker.RulePrecheckRequest{Options: checker.CheckerOptions{}}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition = %d, want %d", rec.Code, http.StatusOK)
}
if bytes.Contains(rec.Body.Bytes(), []byte("eligible")) {
t.Errorf("response leaked eligible field for non-enabler provider: %s", rec.Body.String())
}
var resp checker.RulePrecheckResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Eligible != nil {
t.Errorf("Eligible = %v, want nil for non-enabler provider", *resp.Eligible)
}
}
func TestServer_Precheck(t *testing.T) {
gated := &prereqRule{name: "gated", optKey: "api_key", msg: "missing API key"}
open := &dummyRule{name: "open", desc: "no prereq"}
p := &testProvider{
key: "test",
definition: &checker.CheckerDefinition{
ID: "test",
Rules: []checker.CheckRule{gated, open},
},
}
srv := newTestServer(p)
defer srv.Close()
handler := srv.Handler()
// GET /definition stays static — no precheck information surfaces here.
rec := doRequest(handler, "GET", "/definition", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
}
if bytes.Contains(rec.Body.Bytes(), []byte("precheck_failures")) {
t.Errorf("GET /definition leaked precheck_failures field: %s", rec.Body.String())
}
// POST /definition with empty opts: gated rule fails, open rule absent.
rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{Options: checker.CheckerOptions{}}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition (empty opts) = %d, want %d", rec.Code, http.StatusOK)
}
var resp checker.RulePrecheckResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode POST /definition: %v", err)
}
if resp.CheckerDefinition == nil {
t.Fatalf("POST /definition response missing embedded CheckerDefinition")
}
if resp.ID != "test" {
t.Errorf("response ID = %q, want %q", resp.ID, "test")
}
if len(resp.RulesInfo) != 2 {
t.Errorf("response RulesInfo len = %d, want 2", len(resp.RulesInfo))
}
if got := resp.PrecheckFailures["gated"]; got != "missing API key" {
t.Errorf("PrecheckFailures[gated] = %q, want %q", got, "missing API key")
}
if _, ok := resp.PrecheckFailures["open"]; ok {
t.Errorf("PrecheckFailures[open] should be absent (no RulePrecheck impl), got %q", resp.PrecheckFailures["open"])
}
if len(resp.PrecheckFailures) != 1 {
t.Errorf("PrecheckFailures = %v, want exactly 1 entry", resp.PrecheckFailures)
}
// POST /definition with sufficient opts: empty failure map.
rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{
Options: checker.CheckerOptions{"api_key": "secret"},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /definition (with opts) = %d, want %d", rec.Code, http.StatusOK)
}
resp = checker.RulePrecheckResponse{}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode POST /definition: %v", err)
}
if resp.CheckerDefinition == nil || resp.ID != "test" {
t.Errorf("response missing definition: %+v", resp)
}
if len(resp.PrecheckFailures) != 0 {
t.Errorf("PrecheckFailures = %v, want empty when opts satisfy prereqs", resp.PrecheckFailures)
}
}

View file

@ -21,7 +21,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
@ -42,6 +41,11 @@ const (
AutoFillZone = "zone"
AutoFillServiceType = "service_type"
AutoFillService = "service"
// AutoFillDiscoveryEntries receives DiscoveryEntry records published by
// other checkers on the same target. The host does not pre-filter by
// Type; consumers pick the contracts they understand and ignore the rest.
AutoFillDiscoveryEntries = "discovery_entries"
)
// CheckTarget identifies the resource a check applies to. Identifiers are
@ -66,19 +70,11 @@ func (t CheckTarget) Scope() CheckScopeType {
return CheckScopeUser
}
// String returns a stable string representation of the target.
// String returns a stable, unambiguous string representation of the target.
// All three fields are always present (even when empty) so that different
// targets never produce the same string.
func (t CheckTarget) String() string {
var parts []string
if t.UserId != "" {
parts = append(parts, t.UserId)
}
if t.DomainId != "" {
parts = append(parts, t.DomainId)
}
if t.ServiceId != "" {
parts = append(parts, t.ServiceId)
}
return strings.Join(parts, "/")
return t.UserId + "/" + t.DomainId + "/" + t.ServiceId
}
// CheckerAvailability declares on which scopes a checker can operate.
@ -186,12 +182,17 @@ func (s Status) String() string {
}
}
// CheckState is the result of evaluating a single rule.
// CheckState is the result of evaluating a single rule on a single subject.
// Subject is opaque to the SDK: producers and consumers agree on its shape
// (a hostname, a record key, a serial, …). Leave Subject empty for rules
// that produce a single, global result.
type CheckState struct {
Status Status `json:"status"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
Status Status `json:"status"`
Message string `json:"message"`
RuleName string `json:"rule,omitempty"`
Code string `json:"code,omitempty"`
Subject string `json:"subject,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
// CheckMetric represents a single metric produced by a check.
@ -219,6 +220,24 @@ type ObservationProvider interface {
Collect(ctx context.Context, opts CheckerOptions) (any, error)
}
// ObservationSharer is an optional interface an ObservationProvider can
// implement to declare that its observation depends only on a subset of the
// inputs and may therefore be shared across check targets.
//
// ShareKey returns a stable string derived purely from the inputs that affect
// the result (e.g. the sorted IP set + ping count). Two collections with the
// same ShareKey produce identical data; the host may serve one from the other.
// Return "" to opt out (the host falls back to per-target caching).
//
// ShareKey MUST be a pure function of opts: no network, no I/O. The host calls
// it before deciding whether to collect, possibly on the locally registered
// provider even when actual collection is delegated to a remote endpoint.
//
// Detect support with a type assertion: _, ok := provider.(ObservationSharer)
type ObservationSharer interface {
ShareKey(opts CheckerOptions) (string, error)
}
// CheckRuleInfo is the JSON-serializable description of a rule, for API/UI listing.
type CheckRuleInfo struct {
Name string `json:"name"`
@ -226,11 +245,20 @@ type CheckRuleInfo struct {
Options *CheckerOptionsDocumentation `json:"options,omitempty"`
}
// CheckRule evaluates observations and produces a CheckState.
// CheckRule evaluates observations and produces one or more CheckStates.
//
// Evaluate returns a slice so a rule iterating over multiple elements can
// emit one state per subject (each carrying CheckState.Subject) without
// squashing them into a single concatenated message.
//
// Evaluate must not return a nil or empty slice: callers expect at least
// one state per rule. When a rule finds nothing to evaluate, return a
// single CheckState with an appropriate status (typically StatusInfo or
// StatusOK) describing that fact.
type CheckRule interface {
Name() string
Description() string
Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) CheckState
Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState
}
// CheckRuleWithOptions is an optional interface that rules can implement
@ -240,10 +268,112 @@ type CheckRuleWithOptions interface {
Options() CheckerOptionsDocumentation
}
// RulePrecheck is an optional interface a CheckRule can implement to
// declare whether the current options are sufficient for the rule to
// run. Return nil if runnable, or an error describing the missing
// prerequisite (for example "missing API key"). The host calls this via
// POST /definition to surface unavailable rules in the UI; it is never
// invoked from Collect, so rules that need to short-circuit at run time
// must keep their own self-guard.
type RulePrecheck interface {
CheckRule
Precheck(ctx context.Context, opts CheckerOptions) error
}
// CheckEnabler is an optional interface an ObservationProvider can implement
// to declare, from the actual target data, whether running this checker is
// meaningful at all.
//
// It complements the two existing gates:
// - CheckerAvailability is a static, registration-time scope/service-type
// filter; it never sees the target's data.
// - RulePrecheck is a per-rule, options-only check ("missing API key").
//
// CheckEnabler is whole-checker and data-driven. IsEligible receives the same
// CheckerOptions as Collect, including the autofilled domain_name / zone /
// service payloads (read them with GetOption), and may perform light I/O
// (e.g. a DNSKEY lookup) to decide.
//
// Return (true, "", nil) to run the checker, or (false, reason, nil) with a
// short human-readable reason ("not a reverse zone", "DNSSEC not enabled")
// to skip it. Return a non-nil error only when eligibility could not be
// determined (transient I/O failure); the host treats that as "unknown" and
// fails open (shows the checker) rather than as a definitive skip.
//
// Detect support with a type assertion: _, ok := provider.(CheckEnabler)
type CheckEnabler interface {
IsEligible(ctx context.Context, opts CheckerOptions) (eligible bool, reason string, err error)
}
// RulePrecheckRequest is the body accepted by POST /definition.
type RulePrecheckRequest struct {
Options CheckerOptions `json:"options"`
}
// RulePrecheckResponse is the body returned by POST /definition. The
// embedded *CheckerDefinition mirrors GET /definition so a client can
// fetch the full definition and precheck results in one round-trip.
// Keys in PrecheckFailures are rule names; values are the precheck
// error messages. Rules that do not implement RulePrecheck, or whose
// Precheck returned nil for the given options, are absent from the map.
type RulePrecheckResponse struct {
*CheckerDefinition
PrecheckFailures map[string]string `json:"precheck_failures"`
// Eligible reports whether this checker is meaningful for the submitted
// target, as decided by the provider's CheckEnabler (if implemented). It
// is nil when the checker does not implement CheckEnabler, or when
// IsEligible could not determine eligibility (its error was non-nil). A
// non-nil false means the checker is definitively not applicable to this
// target; the host should hide it unless Eligible != nil && !*Eligible.
Eligible *bool `json:"eligible,omitempty"`
// EligibilityReason explains a false Eligible, or carries the lookup error
// message when eligibility could not be determined. Empty otherwise.
EligibilityReason string `json:"eligibility_reason,omitempty"`
}
// ObservationGetter provides access to observation data (used by CheckRule).
// Get unmarshals observation data into dest (like json.Unmarshal).
//
// GetRelated returns observations produced by other checkers on DiscoveryEntry
// records originally published by the current target. It is the core of
// cross-checker composition: a checker that published some entries via its
// DiscoveryPublisher can, during rule evaluation, fetch the latest
// observations that cover those entries and fold them into its own states.
//
// GetRelated returns an empty slice (not an error) when there is nothing
// to relate (no entries originally published, no downstream observation
// yet, no downstream checker registered for the entry type, …). Callers
// handle that as "no related data", typically skipping optional sections.
type ObservationGetter interface {
Get(ctx context.Context, key ObservationKey, dest any) error
GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error)
}
// RelatedObservation is a single observation, produced by some other checker,
// that covers a DiscoveryEntry originally published by the current target.
//
// Data carries the raw JSON payload; consumers parse it according to the
// producer's schema, which they are expected to know via external agreement
// (typically a shared contract package imported by both producer and
// consumer).
type RelatedObservation struct {
// CheckerID identifies the producer of this observation.
CheckerID string `json:"checkerId"`
// Key is the observation key the producer filled.
Key ObservationKey `json:"key"`
// Data is the raw JSON payload as persisted by the producer.
Data json.RawMessage `json:"data"`
// CollectedAt is when the producer ran its Collect.
CollectedAt time.Time `json:"collectedAt"`
// Ref matches DiscoveryEntry.Ref of the entry this observation covers.
// Opaque to the SDK; meaningful within the producer/consumer contract.
Ref string `json:"ref"`
}
// CheckAggregator combines multiple CheckStates into a single result.
@ -251,20 +381,79 @@ type CheckAggregator interface {
Aggregate(states []CheckState) CheckState
}
// ReportContext carries the primary observation payload, any observations
// produced by other checkers that cover the same discovery entries, and the
// CheckStates produced by this checker's rules for the same observation.
// Hosts build a ReportContext and hand it to reporter methods.
//
// Reporters use States() to render rule-driven sections (for example a
// "fix these first" list) without re-deriving severity or hints from the
// raw payload. Hosts that have not yet threaded rule output into the
// report pipeline return nil; reporters must treat a nil or empty slice
// as "not provided" and fall back to a data-only rendering. The same
// nil-tolerance applies to Related(key).
type ReportContext interface {
Data() json.RawMessage
Related(key ObservationKey) []RelatedObservation
States() []CheckState
}
// NewReportContext returns a ReportContext backed by a primary payload, a
// pre-resolved map of related observations by key, and the CheckStates
// produced by the checker's rules on this observation. The SDK's /report
// HTTP handler uses this to wrap ExternalReportRequest contents; hosts and
// tests can use it whenever they already have that material in memory.
//
// Passing a nil related map or a nil states slice is fine; Related(key)
// and States() will then return nil respectively. Use StaticReportContext
// as a shorthand when both are absent.
func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation, states []CheckState) ReportContext {
return fixedReportContext{data: data, related: related, states: states}
}
// StaticReportContext is a shorthand for NewReportContext(data, nil, nil):
// a ReportContext with a primary payload, no related observations, and no
// rule states. Intended for tests and ad-hoc callers that have no lineage
// or rule output to supply.
func StaticReportContext(data json.RawMessage) ReportContext {
return fixedReportContext{data: data}
}
type fixedReportContext struct {
data json.RawMessage
related map[ObservationKey][]RelatedObservation
states []CheckState
}
func (f fixedReportContext) Data() json.RawMessage { return f.data }
func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation {
if f.related == nil {
return nil
}
return f.related[key]
}
func (f fixedReportContext) States() []CheckState { return f.states }
// CheckerHTMLReporter is an optional interface that observation providers can
// implement to render their stored data as a full HTML document (for iframe embedding).
// Detect support with a type assertion: _, ok := provider.(CheckerHTMLReporter)
//
// The ReportContext carries the primary observation payload plus any
// downstream observations produced on DiscoveryEntry records this checker
// published. Implementations that do not need related observations can
// simply consume ctx.Data().
type CheckerHTMLReporter interface {
// GetHTMLReport generates an HTML document from the JSON-encoded observation data.
GetHTMLReport(raw json.RawMessage) (string, error)
GetHTMLReport(ctx ReportContext) (string, error)
}
// CheckerMetricsReporter is an optional interface that observation providers can
// implement to extract time-series metrics from their stored data.
// Detect support with a type assertion: _, ok := provider.(CheckerMetricsReporter)
//
// As with CheckerHTMLReporter, the ReportContext exposes related
// observations for cross-checker composition.
type CheckerMetricsReporter interface {
// ExtractMetrics returns metrics from JSON-encoded observation data.
ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]CheckMetric, error)
ExtractMetrics(ctx ReportContext, collectedAt time.Time) ([]CheckMetric, error)
}
// CheckerDefinitionProvider is an optional interface that observation providers can
@ -315,16 +504,68 @@ type OptionsValidator interface {
}
// ExternalCollectRequest is sent to POST /collect on a remote checker endpoint.
//
// EnabledRules lets the host inform the provider which rules will be evaluated
// downstream, so the provider can skip optional work (network calls, paid API
// hits, …) for data that would not surface in any state. nil means "run
// everything"; an explicit map with a rule name set to false means that rule
// is off. Providers access the value via EnabledRulesFromContext(ctx).
type ExternalCollectRequest struct {
Key ObservationKey `json:"key"`
Target CheckTarget `json:"target"`
Options CheckerOptions `json:"options"`
Key ObservationKey `json:"key"`
Target CheckTarget `json:"target"`
Options CheckerOptions `json:"options"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// ExternalCollectResponse is returned by POST /collect on a remote checker endpoint.
type ExternalCollectResponse struct {
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Entries []DiscoveryEntry `json:"entries,omitempty"`
Error string `json:"error,omitempty"`
}
// DiscoveryEntry is a single "thing worth probing" declared by a checker as a
// by-product of its collection, intended to be consumed by other checkers
// without having to re-parse raw observations.
//
// The SDK treats Payload as an opaque byte string: producer and consumer
// checkers agree on a schema through a separate contract (typically a small
// shared Go package imported by both). This keeps the SDK free of
// protocol-specific concepts; new entry families (TLS endpoint, HTTP probe,
// ACME challenge, DNSSEC key, …) can appear without touching it.
//
// Entries are ingested by happyDomain into a separate index. Each new
// collection from the same source atomically replaces the set of entries
// previously published for the same (producer, target) pair.
type DiscoveryEntry struct {
// Type names the contract Payload follows, e.g. "tls.endpoint" or
// "http.probe". Producers and consumers match on this string; the SDK
// does not interpret it. Stick to a reverse-DNS-ish convention so that
// independent contracts do not collide.
Type string `json:"type"`
// Ref is a stable per-entry identifier chosen by the producer. The host
// uses it to dedupe entries across repeated collections and to link
// related observations back to this entry (RelatedObservation.Ref). Two
// producers may reuse the same Ref space; the host namespaces them by
// (producer, target).
Ref string `json:"ref"`
// Payload is the entry-specific data, in the format defined by the
// contract named in Type. Opaque to the SDK.
Payload json.RawMessage `json:"payload"`
}
// DiscoveryPublisher is an optional interface an ObservationProvider can
// co-implement to declare DiscoveryEntry records derived from the value it
// just collected.
//
// The host invokes DiscoverEntries immediately after Collect, passing the
// native Go value returned by Collect (no JSON round-trip). Implementations
// should therefore type-assert data to their concrete collection type and
// marshal each contract payload themselves.
type DiscoveryPublisher interface {
DiscoverEntries(data any) ([]DiscoveryEntry, error)
}
// ExternalEvaluateRequest is sent to POST /evaluate on a remote checker endpoint.
@ -341,7 +582,52 @@ type ExternalEvaluateResponse struct {
}
// ExternalReportRequest is sent to POST /report on a remote checker endpoint.
//
// Related carries observations produced by other checkers on DiscoveryEntry
// records originally published by the target of this report, that is, the
// cross-checker lineage that ObservationGetter.GetRelated would expose in
// the in-process path. States carries the CheckStates the host produced by
// evaluating this checker's rules against the same observation, letting
// reporters render rule-driven sections (for example a "fix these first"
// list) without re-deriving severity or hints from Data.
//
// The host composes both fields before making the HTTP request. When both
// are absent, the remote checker receives a context equivalent to
// StaticReportContext (no related observations and no states); the
// reporter then falls back to a data-only rendering.
type ExternalReportRequest struct {
Key ObservationKey `json:"key"`
Data json.RawMessage `json:"data"`
Key ObservationKey `json:"key"`
Data json.RawMessage `json:"data"`
Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"`
States []CheckState `json:"states,omitempty"`
}
// HealthResponse is returned by GET /health on a remote checker endpoint.
// It carries lightweight runtime signals so a scheduler can pick the least
// busy worker among a set of equivalent checker instances.
//
// LoadAvg mirrors /proc/loadavg semantics: it is the 1, 5, 15-minute
// exponentially weighted moving average of the InFlight request count,
// sampled every 5 seconds. Divide by NumCPU to estimate saturation.
type HealthResponse struct {
// Status is a coarse liveness indicator. Currently always "ok";
// "degraded" is reserved for future use.
Status string `json:"status"`
// Uptime is the number of (fractional) seconds since the server started.
Uptime float64 `json:"uptime_seconds"`
// NumCPU is the value of runtime.NumCPU() on this worker.
NumCPU int `json:"num_cpu"`
// InFlight is the number of work requests (/collect, /evaluate, /report)
// currently being processed. /health and /definition are not counted.
InFlight int64 `json:"inflight"`
// TotalRequests is the cumulative number of work requests served since
// the server started. /health and /definition are not counted.
TotalRequests uint64 `json:"total_requests"`
// LoadAvg holds the 1, 5, 15-minute EWMAs of InFlight.
LoadAvg [3]float64 `json:"loadavg"`
}

214
checker/types_test.go Normal file
View file

@ -0,0 +1,214 @@
// Copyright 2020-2026 The happyDomain Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checker
import (
"context"
"encoding/json"
"reflect"
"testing"
)
// dummyRule is a minimal CheckRule used only by tests in this package.
type dummyRule struct {
name string
desc string
}
func (r *dummyRule) Name() string { return r.name }
func (r *dummyRule) Description() string { return r.desc }
func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState {
return []CheckState{{Status: StatusOK, Message: r.name + " passed"}}
}
func TestStatus_MarshalJSON(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusUnknown, `0`},
{StatusOK, `1`},
{StatusInfo, `2`},
{StatusWarn, `3`},
{StatusCrit, `4`},
{StatusError, `5`},
}
for _, tt := range tests {
got, err := json.Marshal(tt.status)
if err != nil {
t.Errorf("Marshal(%v) error: %v", tt.status, err)
continue
}
if string(got) != tt.want {
t.Errorf("Marshal(%v) = %s, want %s", tt.status, got, tt.want)
}
}
}
func TestStatus_UnmarshalJSON(t *testing.T) {
tests := []struct {
input string
want Status
}{
{`0`, StatusUnknown},
{`1`, StatusOK},
{`2`, StatusInfo},
{`3`, StatusWarn},
{`4`, StatusCrit},
{`5`, StatusError},
}
for _, tt := range tests {
var got Status
if err := json.Unmarshal([]byte(tt.input), &got); err != nil {
t.Errorf("Unmarshal(%s) error: %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("Unmarshal(%s) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestStatus_RoundTrip(t *testing.T) {
for _, s := range []Status{StatusOK, StatusInfo, StatusUnknown, StatusWarn, StatusCrit, StatusError} {
data, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal(%v) error: %v", s, err)
}
var got Status
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal(%s) error: %v", data, err)
}
if got != s {
t.Errorf("round-trip %v: got %v", s, got)
}
}
}
func TestStatus_String(t *testing.T) {
if got := StatusOK.String(); got != "OK" {
t.Errorf("StatusOK.String() = %q, want \"OK\"", got)
}
if got := Status(99).String(); got != "Status(99)" {
t.Errorf("Status(99).String() = %q, want \"Status(99)\"", got)
}
}
func TestCheckTarget_Scope(t *testing.T) {
tests := []struct {
target CheckTarget
want CheckScopeType
}{
{CheckTarget{}, CheckScopeUser},
{CheckTarget{UserId: "u1"}, CheckScopeUser},
{CheckTarget{DomainId: "d1"}, CheckScopeDomain},
{CheckTarget{DomainId: "d1", ServiceId: "s1"}, CheckScopeService},
{CheckTarget{ServiceId: "s1"}, CheckScopeService},
}
for _, tt := range tests {
if got := tt.target.Scope(); got != tt.want {
t.Errorf("%+v.Scope() = %v, want %v", tt.target, got, tt.want)
}
}
}
func TestCheckTarget_String(t *testing.T) {
tests := []struct {
target CheckTarget
want string
}{
{CheckTarget{}, "//"},
{CheckTarget{UserId: "u1"}, "u1//"},
{CheckTarget{UserId: "u1", DomainId: "d1"}, "u1/d1/"},
{CheckTarget{UserId: "u1", DomainId: "d1", ServiceId: "s1"}, "u1/d1/s1"},
// Ensure different targets with different empty fields don't collide.
{CheckTarget{DomainId: "d1"}, "/d1/"},
{CheckTarget{ServiceId: "s1"}, "//s1"},
}
for _, tt := range tests {
if got := tt.target.String(); got != tt.want {
t.Errorf("%+v.String() = %q, want %q", tt.target, got, tt.want)
}
}
}
func TestCheckerDefinition_BuildRulesInfo(t *testing.T) {
d := &CheckerDefinition{
Rules: []CheckRule{&dummyRule{name: "r1", desc: "desc1"}},
}
d.BuildRulesInfo()
if len(d.RulesInfo) != 1 {
t.Fatalf("BuildRulesInfo: got %d rules, want 1", len(d.RulesInfo))
}
if d.RulesInfo[0].Name != "r1" || d.RulesInfo[0].Description != "desc1" {
t.Errorf("BuildRulesInfo: got %+v, want {Name:r1, Description:desc1}", d.RulesInfo[0])
}
}
// Compile-time check that fixedReportContext implements ReportContext.
var _ ReportContext = fixedReportContext{}
func TestStaticReportContext_NoExtras(t *testing.T) {
ctx := StaticReportContext(json.RawMessage(`{"k":"v"}`))
if string(ctx.Data()) != `{"k":"v"}` {
t.Errorf("Data() = %s, want %s", ctx.Data(), `{"k":"v"}`)
}
if ctx.Related("any") != nil {
t.Error("Related(any) should be nil for StaticReportContext")
}
if ctx.States() != nil {
t.Error("States() should be nil for StaticReportContext")
}
}
func TestNewReportContext_NilStates(t *testing.T) {
ctx := NewReportContext(json.RawMessage(`{}`), nil, nil)
if ctx.States() != nil {
t.Errorf("States() = %v, want nil", ctx.States())
}
}
func TestNewReportContext_PassesStates(t *testing.T) {
states := []CheckState{
{Status: StatusWarn, Message: "heads up", RuleName: "r1"},
{Status: StatusCrit, Message: "fix me", RuleName: "r2", Subject: "host.example"},
}
ctx := NewReportContext(json.RawMessage(`{}`), nil, states)
got := ctx.States()
if !reflect.DeepEqual(got, states) {
t.Errorf("States() = %+v, want %+v", got, states)
}
}
func TestNewReportContext_PassesRelated(t *testing.T) {
rel := map[ObservationKey][]RelatedObservation{
"other.key": {{CheckerID: "other", Key: "other.key", Ref: "r1"}},
}
ctx := NewReportContext(json.RawMessage(`{}`), rel, nil)
if got := ctx.Related("other.key"); len(got) != 1 || got[0].CheckerID != "other" {
t.Errorf("Related(other.key) = %+v, want one entry with CheckerID=other", got)
}
if ctx.Related("missing") != nil {
t.Error("Related(missing) should be nil")
}
}
func TestRegisterChecker_EmptyIDRejected(t *testing.T) {
resetRegistries()
RegisterChecker(&CheckerDefinition{ID: "", Name: "bad"})
if len(GetCheckers()) != 0 {
t.Error("checker with empty ID should not be registered")
}
}