Compare commits

...

15 commits

Author SHA1 Message Date
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
13 changed files with 2576 additions and 624 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).

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
}

View file

@ -74,7 +74,7 @@ func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) {
}
// Second registration of the same definition pointer must NOT append a
// second "endpoint" AdminOpt the duplicate check has to fire before
// 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 {

View file

@ -1,232 +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"
"io"
"log"
"net/http"
"strings"
"time"
)
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
const maxRequestBodySize = 1 << 20
// 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.
//
// 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 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(io.LimitReader(r.Body, maxRequestBodySize)).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.StatusInternalServerError, ExternalCollectResponse{
Error: err.Error(),
})
return
}
raw, err := json.Marshal(data)
if err != nil {
writeJSON(w, http.StatusInternalServerError, 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(io.LimitReader(r.Body, maxRequestBodySize)).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(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.(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)
}
}

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

@ -0,0 +1,477 @@
// 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 /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)
}
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
}
data, err := s.provider.Collect(r.Context(), 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,669 @@
// 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")
}
}

View file

@ -1,319 +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 (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// --- test doubles ---
type testProvider struct {
key ObservationKey
collectFn func(ctx context.Context, opts CheckerOptions) (any, error)
definition *CheckerDefinition
htmlFn func(raw json.RawMessage) (string, error)
metricsFn func(raw json.RawMessage, t time.Time) ([]CheckMetric, error)
}
func (p *testProvider) Key() ObservationKey { return p.key }
func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
if p.collectFn != nil {
return p.collectFn(ctx, opts)
}
return map[string]string{"result": "ok"}, nil
}
func (p *testProvider) Definition() *CheckerDefinition { return p.definition }
func (p *testProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
if p.htmlFn != nil {
return p.htmlFn(raw)
}
return "<h1>report</h1>", nil
}
func (p *testProvider) ExtractMetrics(raw json.RawMessage, t time.Time) ([]CheckMetric, error) {
if p.metricsFn != nil {
return p.metricsFn(raw, t)
}
return []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 ObservationGetter, opts CheckerOptions) CheckState {
return CheckState{Status: StatusOK, Message: r.name + " passed"}
}
// --- helpers ---
func newTestServer(p *testProvider) *Server {
return NewServer(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: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
srv := newTestServer(p)
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 map[string]string
json.NewDecoder(rec.Body).Decode(&resp)
if resp["status"] != "ok" {
t.Errorf("GET /health status = %q, want \"ok\"", resp["status"])
}
}
func TestServer_Collect_Success(t *testing.T) {
p := &testProvider{
key: "test",
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
return map[string]int{"count": 42}, nil
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{
Key: "test",
Options: CheckerOptions{"a": "b"},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
}
var resp 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: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
return nil, errors.New("provider failed")
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
}
var resp 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: &CheckerDefinition{ID: "test", Rules: []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 := &CheckerDefinition{
ID: "test-checker",
Name: "Test Checker",
Rules: []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 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 := &CheckerDefinition{
ID: "test-checker",
Name: "Test Checker",
Rules: []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", ExternalEvaluateRequest{
Observations: map[ObservationKey]json.RawMessage{
"test": json.RawMessage(`{"count":42}`),
},
Options: CheckerOptions{},
}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
}
var resp 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].Code != "rule1" {
t.Errorf("evaluate state[0].Code = %q, want \"rule1\"", resp.States[0].Code)
}
}
func TestServer_Evaluate_DisabledRule(t *testing.T) {
def := &CheckerDefinition{
ID: "test-checker",
Rules: []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", ExternalEvaluateRequest{
Observations: map[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 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].Code != "rule2" {
t.Errorf("remaining state code = %q, want \"rule2\"", resp.States[0].Code)
}
}
func TestServer_Report_HTML(t *testing.T) {
p := &testProvider{
key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
htmlFn: func(raw json.RawMessage) (string, error) {
return "<p>hello</p>", nil
},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", 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: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
}
srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", 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 []CheckMetric
json.NewDecoder(rec.Body).Decode(&metrics)
if len(metrics) != 1 {
t.Errorf("metrics count = %d, want 1", len(metrics))
}
}
func TestServer_Report_BadBody(t *testing.T) {
p := &testProvider{
key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []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 := NewServer(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")
}
}

View file

@ -41,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
@ -146,21 +151,15 @@ type CheckerOptionsDocumentation struct {
}
// Status represents the result status of a check evaluation.
//
// Numeric ordering is severity ordering: lower = better, higher = worse.
// StatusUnknown is intentionally the zero value, so an uninitialized
// CheckState reads as "no signal yet" rather than as a healthy OK.
// "Good" statuses are negative so that aggregators can simply take the
// max() of a set of statuses to compute the worst one.
type Status int
const (
StatusOK Status = -2
StatusInfo Status = -1
StatusUnknown Status = 0 // zero value: not initialized / no signal yet
StatusWarn Status = 1
StatusCrit Status = 2
StatusError Status = 3
StatusUnknown Status = iota
StatusOK
StatusInfo
StatusWarn
StatusCrit
StatusError
)
// String returns the human-readable name of the status.
@ -183,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.
@ -223,11 +227,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
@ -239,8 +252,45 @@ type CheckRuleWithOptions interface {
// 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.
@ -248,20 +298,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
@ -320,8 +429,53 @@ type ExternalCollectRequest struct {
// 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.
@ -338,7 +492,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"`
}

View file

@ -15,21 +15,35 @@
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
}{
{StatusOK, `"OK"`},
{StatusInfo, `"INFO"`},
{StatusUnknown, `"UNKNOWN"`},
{StatusWarn, `"WARN"`},
{StatusCrit, `"CRIT"`},
{StatusError, `"ERROR"`},
{StatusUnknown, `0`},
{StatusOK, `1`},
{StatusInfo, `2`},
{StatusWarn, `3`},
{StatusCrit, `4`},
{StatusError, `5`},
}
for _, tt := range tests {
got, err := json.Marshal(tt.status)
@ -43,42 +57,17 @@ func TestStatus_MarshalJSON(t *testing.T) {
}
}
func TestStatus_UnmarshalJSON_String(t *testing.T) {
func TestStatus_UnmarshalJSON(t *testing.T) {
tests := []struct {
input string
want Status
}{
{`"OK"`, StatusOK},
{`"INFO"`, StatusInfo},
{`"UNKNOWN"`, StatusUnknown},
{`""`, StatusUnknown},
{`"WARN"`, StatusWarn},
{`"CRIT"`, StatusCrit},
{`"ERROR"`, 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_UnmarshalJSON_LegacyInt(t *testing.T) {
tests := []struct {
input string
want Status
}{
{`-2`, StatusOK},
{`-1`, StatusInfo},
{`0`, StatusUnknown},
{`1`, StatusWarn},
{`2`, StatusCrit},
{`3`, StatusError},
{`1`, StatusOK},
{`2`, StatusInfo},
{`3`, StatusWarn},
{`4`, StatusCrit},
{`5`, StatusError},
}
for _, tt := range tests {
var got Status
@ -92,14 +81,6 @@ func TestStatus_UnmarshalJSON_LegacyInt(t *testing.T) {
}
}
func TestStatus_UnmarshalJSON_UnknownString(t *testing.T) {
var s Status
err := json.Unmarshal([]byte(`"BOGUS"`), &s)
if err == nil {
t.Error("Unmarshal(\"BOGUS\") should return error, got nil")
}
}
func TestStatus_RoundTrip(t *testing.T) {
for _, s := range []Status{StatusOK, StatusInfo, StatusUnknown, StatusWarn, StatusCrit, StatusError} {
data, err := json.Marshal(s)
@ -176,6 +157,54 @@ func TestCheckerDefinition_BuildRulesInfo(t *testing.T) {
}
}
// 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"})