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.
This commit is contained in:
nemunaire 2026-04-24 12:31:34 +07:00
commit 89cc3f112b
6 changed files with 276 additions and 240 deletions

View file

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package checker package server
import ( import (
"bytes" "bytes"
@ -24,29 +24,31 @@ import (
"maps" "maps"
"net/http" "net/http"
"time" "time"
"git.happydns.org/checker-sdk-go/checker"
) )
// CheckerInteractive is an optional interface that observation providers // Interactive is an optional interface that observation providers
// can implement to expose a human-facing web form usable standalone, // can implement to expose a human-facing web form usable standalone,
// outside of a happyDomain host. Detect support with a type assertion: // outside of a happyDomain host. Detect support with a type assertion:
// _, ok := provider.(CheckerInteractive). // _, ok := provider.(server.Interactive).
// //
// When the provider implements it, Server binds GET and POST on /check. // When the provider implements it, Server binds GET and POST on /check.
// GET renders an HTML form built from RenderForm(). POST calls ParseForm // GET renders an HTML form built from RenderForm(). POST calls ParseForm
// to obtain the CheckerOptions, then runs the standard pipeline // to obtain the checker.CheckerOptions, then runs the standard pipeline
// (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a // (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a
// consolidated result page. // consolidated result page.
// //
// Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed // Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed
// options from execution context, a CheckerInteractive implementation is // options from execution context, an Interactive implementation is
// responsible for resolving whatever it needs from the human inputs // responsible for resolving whatever it needs from the human inputs
// (typically via direct DNS queries) before Collect runs. // (typically via direct DNS queries) before Collect runs.
type CheckerInteractive interface { type Interactive interface {
// RenderForm returns the fields the human must fill in to bootstrap // RenderForm returns the fields the human must fill in to bootstrap
// a check. Typically a minimal set (domain name, nameserver to // a check. Typically a minimal set (domain name, nameserver to
// query, …) that ParseForm expands into the full CheckerOptions // query, …) that ParseForm expands into the full CheckerOptions
// that Collect expects. // that Collect expects.
RenderForm() []CheckerOptionField RenderForm() []checker.CheckerOptionField
// ParseForm reads the submitted form and returns the CheckerOptions // ParseForm reads the submitted form and returns the CheckerOptions
// ready to feed Collect. It is the checker's responsibility to do // ready to feed Collect. It is the checker's responsibility to do
@ -54,14 +56,29 @@ type CheckerInteractive interface {
// that would normally be auto-filled by happyDomain. Returning an // that would normally be auto-filled by happyDomain. Returning an
// error causes the SDK to re-render the form with the error // error causes the SDK to re-render the form with the error
// displayed. // displayed.
ParseForm(r *http.Request) (CheckerOptions, error) 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. // checkResult holds everything the result page needs to render.
type checkResult struct { type checkResult struct {
Title string Title string
States []CheckState States []checker.CheckState
Metrics []CheckMetric Metrics []checker.CheckMetric
ReportHTML string ReportHTML string
CollectErr string CollectErr string
ReportErr string ReportErr string
@ -70,7 +87,7 @@ type checkResult struct {
type checkFormPage struct { type checkFormPage struct {
Title string Title string
Fields []CheckerOptionField Fields []checker.CheckerOptionField
Error string Error string
} }
@ -110,7 +127,7 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
if s.definition != nil { if s.definition != nil {
obs := &mapObservationGetter{ obs := &mapObservationGetter{
data: map[ObservationKey]json.RawMessage{ data: map[checker.ObservationKey]json.RawMessage{
s.provider.Key(): raw, s.provider.Key(): raw,
}, },
related: related, related: related,
@ -118,9 +135,9 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
result.States = s.evaluateRules(r.Context(), obs, opts, nil) result.States = s.evaluateRules(r.Context(), obs, opts, nil)
} }
ctx := NewReportContext(raw, related) ctx := checker.NewReportContext(raw, related)
if reporter, ok := s.provider.(CheckerHTMLReporter); ok { if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok {
html, rerr := reporter.GetHTMLReport(ctx) html, rerr := reporter.GetHTMLReport(ctx)
if rerr != nil { if rerr != nil {
result.ReportErr = rerr.Error() result.ReportErr = rerr.Error()
@ -129,7 +146,7 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
} }
} }
if reporter, ok := s.provider.(CheckerMetricsReporter); ok { if reporter, ok := s.provider.(checker.CheckerMetricsReporter); ok {
metrics, merr := reporter.ExtractMetrics(ctx, time.Now()) metrics, merr := reporter.ExtractMetrics(ctx, time.Now())
if merr != nil { if merr != nil {
result.MetricsErr = merr.Error() result.MetricsErr = merr.Error()
@ -141,11 +158,11 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
s.renderCheckResult(w, result) s.renderCheckResult(w, result)
} }
// collectRelatedObservations runs sibling providers declared via // collectRelatedObservations runs sibling providers declared via Siblings
// InteractiveRelatedProviders and returns their results keyed by the // and returns their results keyed by the sibling's observation key.
// sibling's observation key. Sibling errors are logged and skipped. // Sibling errors are logged and skipped.
func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOptions, data any) map[ObservationKey][]RelatedObservation { func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation {
irp, ok := s.provider.(InteractiveRelatedProviders) irp, ok := s.provider.(Siblings)
if !ok { if !ok {
return nil return nil
} }
@ -154,8 +171,8 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt
return nil return nil
} }
var entries []DiscoveryEntry var entries []checker.DiscoveryEntry
if dp, ok := s.provider.(DiscoveryPublisher); ok { if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
e, err := dp.DiscoverEntries(data) e, err := dp.DiscoverEntries(data)
if err != nil { if err != nil {
log.Printf("interactive: DiscoverEntries failed: %v", err) log.Printf("interactive: DiscoverEntries failed: %v", err)
@ -164,11 +181,11 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt
} }
} }
related := make(map[ObservationKey][]RelatedObservation, len(siblings)) related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings))
for _, sp := range siblings { for _, sp := range siblings {
sOpts := cloneOptions(opts) sOpts := cloneOptions(opts)
siblingID := "" siblingID := ""
if dp, ok := sp.(CheckerDefinitionProvider); ok { if dp, ok := sp.(checker.CheckerDefinitionProvider); ok {
if def := dp.Definition(); def != nil { if def := dp.Definition(); def != nil {
siblingID = def.ID siblingID = def.ID
if len(entries) > 0 { if len(entries) > 0 {
@ -186,7 +203,7 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err) log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
continue continue
} }
related[sp.Key()] = append(related[sp.Key()], RelatedObservation{ related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{
CheckerID: siblingID, CheckerID: siblingID,
Key: sp.Key(), Key: sp.Key(),
Data: raw, Data: raw,
@ -196,16 +213,16 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt
return related return related
} }
func cloneOptions(opts CheckerOptions) CheckerOptions { func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions {
out := make(CheckerOptions, len(opts)) out := make(checker.CheckerOptions, len(opts))
maps.Copy(out, opts) maps.Copy(out, opts)
return out return out
} }
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes // fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
// entries into every option in def tagged AutoFill == AutoFillDiscoveryEntries. // entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries.
func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entries []DiscoveryEntry) { func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) {
scopes := [][]CheckerOptionDocumentation{ scopes := [][]checker.CheckerOptionDocumentation{
def.Options.AdminOpts, def.Options.AdminOpts,
def.Options.UserOpts, def.Options.UserOpts,
def.Options.DomainOpts, def.Options.DomainOpts,
@ -214,7 +231,7 @@ func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entri
} }
for _, scope := range scopes { for _, scope := range scopes {
for _, f := range scope { for _, f := range scope {
if f.AutoFill == AutoFillDiscoveryEntries { if f.AutoFill == checker.AutoFillDiscoveryEntries {
opts[f.Id] = entries opts[f.Id] = entries
} }
} }
@ -240,7 +257,7 @@ func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data
w.Write(buf.Bytes()) w.Write(buf.Bytes())
} }
func (s *Server) renderCheckForm(w http.ResponseWriter, fields []CheckerOptionField, errMsg string) { func (s *Server) renderCheckForm(w http.ResponseWriter, fields []checker.CheckerOptionField, errMsg string) {
status := http.StatusOK status := http.StatusOK
if errMsg != "" { if errMsg != "" {
status = http.StatusBadRequest status = http.StatusBadRequest
@ -256,17 +273,17 @@ func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) {
renderHTML(w, http.StatusOK, checkResultTemplate, result) renderHTML(w, http.StatusOK, checkResultTemplate, result)
} }
func statusClass(s Status) string { func statusClass(s checker.Status) string {
switch s { switch s {
case StatusOK: case checker.StatusOK:
return "ok" return "ok"
case StatusInfo: case checker.StatusInfo:
return "info" return "info"
case StatusWarn: case checker.StatusWarn:
return "warn" return "warn"
case StatusCrit: case checker.StatusCrit:
return "crit" return "crit"
case StatusError: case checker.StatusError:
return "error" return "error"
default: default:
return "unknown" return "unknown"
@ -298,7 +315,7 @@ func defaultBool(v any) bool {
var templateFuncs = template.FuncMap{ var templateFuncs = template.FuncMap{
"statusClass": statusClass, "statusClass": statusClass,
"statusString": Status.String, "statusString": checker.Status.String,
"defaultString": defaultString, "defaultString": defaultString,
"defaultBool": defaultBool, "defaultBool": defaultBool,
} }

View file

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package checker package server
import ( import (
"context" "context"
@ -22,28 +22,30 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"git.happydns.org/checker-sdk-go/checker"
) )
// interactiveProvider embeds testProvider and adds CheckerInteractive. // interactiveProvider embeds testProvider and adds Interactive.
type interactiveProvider struct { type interactiveProvider struct {
*testProvider *testProvider
fields []CheckerOptionField fields []checker.CheckerOptionField
parseFn func(r *http.Request) (CheckerOptions, error) parseFn func(r *http.Request) (checker.CheckerOptions, error)
parseErr error parseErr error
} }
func (p *interactiveProvider) RenderForm() []CheckerOptionField { func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField {
return p.fields return p.fields
} }
func (p *interactiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { func (p *interactiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
if p.parseErr != nil { if p.parseErr != nil {
return nil, p.parseErr return nil, p.parseErr
} }
if p.parseFn != nil { if p.parseFn != nil {
return p.parseFn(r) return p.parseFn(r)
} }
return CheckerOptions{"domain": r.FormValue("domain")}, nil return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
} }
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder { func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
@ -55,35 +57,35 @@ func postForm(handler http.Handler, path string, values url.Values) *httptest.Re
} }
// minimalProvider implements only ObservationProvider. // minimalProvider implements only ObservationProvider.
type minimalProvider struct{ key ObservationKey } type minimalProvider struct{ key checker.ObservationKey }
func (m *minimalProvider) Key() ObservationKey { return m.key } func (m *minimalProvider) Key() checker.ObservationKey { return m.key }
func (m *minimalProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, nil return nil, nil
} }
func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) {
p := &minimalProvider{key: "test"} p := &minimalProvider{key: "test"}
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("GET /check without CheckerInteractive = %d, want 404", rec.Code) t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code)
} }
} }
func TestCheck_Form_Renders(t *testing.T) { func TestCheck_Form_Renders(t *testing.T) {
p := &interactiveProvider{ p := &interactiveProvider{
testProvider: &testProvider{key: "test"}, testProvider: &testProvider{key: "test"},
fields: []CheckerOptionField{ fields: []checker.CheckerOptionField{
{Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"}, {Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"},
{Id: "verbose", Type: "bool", Label: "Verbose", Default: true}, {Id: "verbose", Type: "bool", Label: "Verbose", Default: true},
{Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"}, {Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"},
{Id: "hidden", Type: "string", Hide: true}, {Id: "hidden", Type: "string", Hide: true},
}, },
} }
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
@ -111,18 +113,18 @@ func TestCheck_Form_Renders(t *testing.T) {
} }
func TestCheck_Submit_Success(t *testing.T) { func TestCheck_Submit_Success(t *testing.T) {
definition := &CheckerDefinition{ definition := &checker.CheckerDefinition{
ID: "test", ID: "test",
Name: "Test Checker", Name: "Test Checker",
Rules: []CheckRule{ Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"}, &dummyRule{name: "rule1", desc: "first rule"},
}, },
} }
p := &interactiveProvider{ p := &interactiveProvider{
testProvider: &testProvider{key: "test", definition: definition}, testProvider: &testProvider{key: "test", definition: definition},
fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
} }
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
@ -150,10 +152,10 @@ func TestCheck_Submit_Success(t *testing.T) {
func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) { func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) {
p := &interactiveProvider{ p := &interactiveProvider{
testProvider: &testProvider{key: "test"}, testProvider: &testProvider{key: "test"},
fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
parseErr: errors.New("domain is required"), parseErr: errors.New("domain is required"),
} }
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}})
@ -173,13 +175,13 @@ func TestCheck_Submit_CollectError(t *testing.T) {
p := &interactiveProvider{ p := &interactiveProvider{
testProvider: &testProvider{ testProvider: &testProvider{
key: "test", key: "test",
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, errors.New("boom") return nil, errors.New("boom")
}, },
}, },
fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
} }
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
@ -196,16 +198,16 @@ func TestCheck_Submit_CollectError(t *testing.T) {
} }
func TestCheck_NoReporters(t *testing.T) { func TestCheck_NoReporters(t *testing.T) {
// Provider implements CheckerInteractive and has a definition (so // Provider implements Interactive and has a definition (so
// /evaluate-like logic runs) but no HTMLReporter / MetricsReporter. // /evaluate-like logic runs) but no HTMLReporter / MetricsReporter.
bare := &bareInteractiveProvider{ bare := &bareInteractiveProvider{
key: "test", key: "test",
def: &CheckerDefinition{ def: &checker.CheckerDefinition{
ID: "test", ID: "test",
Rules: []CheckRule{&dummyRule{name: "r", desc: "r"}}, Rules: []checker.CheckRule{&dummyRule{name: "r", desc: "r"}},
}, },
} }
srv := NewServer(bare) srv := New(bare)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
@ -225,89 +227,89 @@ func TestCheck_NoReporters(t *testing.T) {
} }
// bareInteractiveProvider implements only the required interfaces // bareInteractiveProvider implements only the required interfaces
// (ObservationProvider, CheckerDefinitionProvider, CheckerInteractive) // (ObservationProvider, CheckerDefinitionProvider, Interactive),
//, no reporters. // no reporters.
type bareInteractiveProvider struct { type bareInteractiveProvider struct {
key ObservationKey key checker.ObservationKey
def *CheckerDefinition def *checker.CheckerDefinition
} }
func (b *bareInteractiveProvider) Key() ObservationKey { return b.key } func (b *bareInteractiveProvider) Key() checker.ObservationKey { return b.key }
func (b *bareInteractiveProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (b *bareInteractiveProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"ok": "1"}, nil return map[string]string{"ok": "1"}, nil
} }
func (b *bareInteractiveProvider) Definition() *CheckerDefinition { return b.def } func (b *bareInteractiveProvider) Definition() *checker.CheckerDefinition { return b.def }
func (b *bareInteractiveProvider) RenderForm() []CheckerOptionField { func (b *bareInteractiveProvider) RenderForm() []checker.CheckerOptionField {
return []CheckerOptionField{{Id: "domain", Type: "string"}} return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
} }
func (b *bareInteractiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
return CheckerOptions{"domain": r.FormValue("domain")}, nil return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
} }
type siblingProvider struct { type siblingProvider struct {
key ObservationKey key checker.ObservationKey
id string id string
entriesOpt string entriesOpt string
gotOpts CheckerOptions gotOpts checker.CheckerOptions
payload any payload any
} }
func (s *siblingProvider) Key() ObservationKey { return s.key } func (s *siblingProvider) Key() checker.ObservationKey { return s.key }
func (s *siblingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (s *siblingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
s.gotOpts = opts s.gotOpts = opts
return s.payload, nil return s.payload, nil
} }
func (s *siblingProvider) Definition() *CheckerDefinition { func (s *siblingProvider) Definition() *checker.CheckerDefinition {
return &CheckerDefinition{ return &checker.CheckerDefinition{
ID: s.id, ID: s.id,
Options: CheckerOptionsDocumentation{ Options: checker.CheckerOptionsDocumentation{
RunOpts: []CheckerOptionDocumentation{ RunOpts: []checker.CheckerOptionDocumentation{
{Id: s.entriesOpt, Type: "array", AutoFill: AutoFillDiscoveryEntries}, {Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries},
}, },
}, },
} }
} }
type primaryWithSibling struct { type primaryWithSibling struct {
key ObservationKey key checker.ObservationKey
def *CheckerDefinition def *checker.CheckerDefinition
entries []DiscoveryEntry entries []checker.DiscoveryEntry
sibling ObservationProvider sibling checker.ObservationProvider
} }
func (p *primaryWithSibling) Key() ObservationKey { return p.key } func (p *primaryWithSibling) Key() checker.ObservationKey { return p.key }
func (p *primaryWithSibling) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (p *primaryWithSibling) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]string{"primary": "ok"}, nil return map[string]string{"primary": "ok"}, nil
} }
func (p *primaryWithSibling) Definition() *CheckerDefinition { return p.def } func (p *primaryWithSibling) Definition() *checker.CheckerDefinition { return p.def }
func (p *primaryWithSibling) RenderForm() []CheckerOptionField { func (p *primaryWithSibling) RenderForm() []checker.CheckerOptionField {
return []CheckerOptionField{{Id: "domain", Type: "string"}} return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
} }
func (p *primaryWithSibling) ParseForm(r *http.Request) (CheckerOptions, error) { func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
return CheckerOptions{"domain": r.FormValue("domain")}, nil return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
} }
func (p *primaryWithSibling) DiscoverEntries(data any) ([]DiscoveryEntry, error) { func (p *primaryWithSibling) DiscoverEntries(data any) ([]checker.DiscoveryEntry, error) {
return p.entries, nil return p.entries, nil
} }
func (p *primaryWithSibling) RelatedProviders() []ObservationProvider { func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider {
return []ObservationProvider{p.sibling} return []checker.ObservationProvider{p.sibling}
} }
type relatedAssertRule struct { type relatedAssertRule struct {
key ObservationKey key checker.ObservationKey
} }
func (r *relatedAssertRule) Name() string { return "related_assert" } func (r *relatedAssertRule) Name() string { return "related_assert" }
func (r *relatedAssertRule) Description() string { return "" } func (r *relatedAssertRule) Description() string { return "" }
func (r *relatedAssertRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { func (r *relatedAssertRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
related, err := obs.GetRelated(ctx, r.key) related, err := obs.GetRelated(ctx, r.key)
if err != nil { if err != nil {
return []CheckState{{Status: StatusError, Message: err.Error()}} return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}}
} }
if len(related) == 0 { if len(related) == 0 {
return []CheckState{{Status: StatusCrit, Message: "no related observation"}} return []checker.CheckState{{Status: checker.StatusCrit, Message: "no related observation"}}
} }
return []CheckState{{Status: StatusOK, Message: "saw related observation"}} return []checker.CheckState{{Status: checker.StatusOK, Message: "saw related observation"}}
} }
func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) { func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
@ -317,18 +319,18 @@ func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
entriesOpt: "endpoints", entriesOpt: "endpoints",
payload: map[string]string{"sibling": "ok"}, payload: map[string]string{"sibling": "ok"},
} }
entry := DiscoveryEntry{Type: "fake.v1", Ref: "r1"} entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"}
primary := &primaryWithSibling{ primary := &primaryWithSibling{
key: "primary_key", key: "primary_key",
def: &CheckerDefinition{ def: &checker.CheckerDefinition{
ID: "primary", ID: "primary",
Rules: []CheckRule{&relatedAssertRule{key: sibling.key}}, Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}},
}, },
entries: []DiscoveryEntry{entry}, entries: []checker.DiscoveryEntry{entry},
sibling: sibling, sibling: sibling,
} }
srv := NewServer(primary) srv := New(primary)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
@ -340,7 +342,7 @@ func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
t.Errorf("rule did not see related observation; body:\n%s", body) t.Errorf("rule did not see related observation; body:\n%s", body)
} }
got, ok := sibling.gotOpts[sibling.entriesOpt].([]DiscoveryEntry) got, ok := sibling.gotOpts[sibling.entriesOpt].([]checker.DiscoveryEntry)
if !ok { if !ok {
t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt]) t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt])
} }
@ -357,14 +359,14 @@ func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) {
p := &interactiveProvider{ p := &interactiveProvider{
testProvider: &testProvider{ testProvider: &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ definition: &checker.CheckerDefinition{
ID: "test", ID: "test",
Rules: []CheckRule{&relatedAssertRule{key: "other"}}, Rules: []checker.CheckRule{&relatedAssertRule{key: "other"}},
}, },
}, },
fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
} }
srv := NewServer(p) srv := New(p)
defer srv.Close() defer srv.Close()
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})

View file

@ -12,7 +12,11 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package checker // 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 ( import (
"context" "context"
@ -27,6 +31,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"git.happydns.org/checker-sdk-go/checker"
) )
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB). // maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
@ -58,21 +64,21 @@ func updateLoadAvg(prev [3]float64, sample float64) [3]float64 {
// Server is a generic HTTP server for external checkers. // Server is a generic HTTP server for external checkers.
// It always exposes /health and /collect. If the provider implements // It always exposes /health and /collect. If the provider implements
// CheckerDefinitionProvider, it also exposes /definition and /evaluate. // checker.CheckerDefinitionProvider, it also exposes /definition and /evaluate.
// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter, // If the provider implements checker.CheckerHTMLReporter or checker.CheckerMetricsReporter,
// it also exposes /report. If the provider implements CheckerInteractive, // it also exposes /report. If the provider implements Interactive,
// it also exposes /check (a human-facing web form). // it also exposes /check (a human-facing web form).
// //
// Security: Server does not perform any authentication or authorization. // Security: Server does not perform any authentication or authorization.
// It is intended to be run behind a reverse proxy or in a trusted network // 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). // where access control is handled externally (e.g. by the happyDomain server).
type Server struct { type Server struct {
provider ObservationProvider provider checker.ObservationProvider
definition *CheckerDefinition definition *checker.CheckerDefinition
interactive CheckerInteractive interactive Interactive
mux *http.ServeMux mux *http.ServeMux
// startTime is captured in NewServer and used to compute uptime. // startTime is captured in New and used to compute uptime.
startTime time.Time startTime time.Time
// inFlight counts work requests (/collect, /evaluate, /report) currently // inFlight counts work requests (/collect, /evaluate, /report) currently
@ -97,13 +103,13 @@ type Server struct {
closeOnce sync.Once closeOnce sync.Once
} }
// NewServer creates a new checker HTTP server backed by the given provider. // New creates a new checker HTTP server backed by the given provider.
// Additional endpoints are registered based on optional interfaces the provider implements. // Additional endpoints are registered based on optional interfaces the provider implements.
// //
// NewServer also starts a background goroutine that samples the in-flight // New also starts a background goroutine that samples the in-flight
// request count every loadSampleInterval to compute the load averages // request count every loadSampleInterval to compute the load averages
// reported on /health. Call Close to stop it. // reported on /health. Call Close to stop it.
func NewServer(provider ObservationProvider) *Server { func New(provider checker.ObservationProvider) *Server {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
s := &Server{ s := &Server{
provider: provider, provider: provider,
@ -115,7 +121,7 @@ func NewServer(provider ObservationProvider) *Server {
s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect))) s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect)))
if dp, ok := provider.(CheckerDefinitionProvider); ok { if dp, ok := provider.(checker.CheckerDefinitionProvider); ok {
if def := dp.Definition(); def != nil { if def := dp.Definition(); def != nil {
s.definition = def s.definition = def
s.definition.BuildRulesInfo() s.definition.BuildRulesInfo()
@ -124,13 +130,13 @@ func NewServer(provider ObservationProvider) *Server {
} }
} }
if _, ok := provider.(CheckerHTMLReporter); ok { if _, ok := provider.(checker.CheckerHTMLReporter); ok {
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport))) s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
} else if _, ok := provider.(CheckerMetricsReporter); ok { } else if _, ok := provider.(checker.CheckerMetricsReporter); ok {
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport))) s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
} }
if ip, ok := provider.(CheckerInteractive); ok { if ip, ok := provider.(Interactive); ok {
s.interactive = ip s.interactive = ip
s.mux.HandleFunc("GET /check", s.handleCheckForm) s.mux.HandleFunc("GET /check", s.handleCheckForm)
s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit))) s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit)))
@ -238,7 +244,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
for i := range load { for i := range load {
load[i] = math.Float64frombits(s.loadBits[i].Load()) load[i] = math.Float64frombits(s.loadBits[i].Load())
} }
writeJSON(w, http.StatusOK, HealthResponse{ writeJSON(w, http.StatusOK, checker.HealthResponse{
Status: "ok", Status: "ok",
Uptime: time.Since(s.startTime).Seconds(), Uptime: time.Since(s.startTime).Seconds(),
NumCPU: runtime.NumCPU(), NumCPU: runtime.NumCPU(),
@ -253,9 +259,9 @@ func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
var req ExternalCollectRequest var req checker.ExternalCollectRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{ writeJSON(w, http.StatusBadRequest, checker.ExternalCollectResponse{
Error: fmt.Sprintf("invalid request body: %v", err), Error: fmt.Sprintf("invalid request body: %v", err),
}) })
return return
@ -263,7 +269,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
data, err := s.provider.Collect(r.Context(), req.Options) data, err := s.provider.Collect(r.Context(), req.Options)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
Error: err.Error(), Error: err.Error(),
}) })
return return
@ -271,18 +277,18 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
raw, err := json.Marshal(data) raw, err := json.Marshal(data)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
Error: fmt.Sprintf("failed to marshal result: %v", err), Error: fmt.Sprintf("failed to marshal result: %v", err),
}) })
return return
} }
resp := ExternalCollectResponse{Data: json.RawMessage(raw)} resp := checker.ExternalCollectResponse{Data: json.RawMessage(raw)}
// Harvest discovery entries from the native Go value, before it goes // Harvest discovery entries from the native Go value, before it goes
// out of scope. No re-parse; DiscoverEntries operates on the same // out of scope. No re-parse; DiscoverEntries operates on the same
// object that was just marshaled above. // object that was just marshaled above.
if dp, ok := s.provider.(DiscoveryPublisher); ok { if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
entries, derr := dp.DiscoverEntries(data) entries, derr := dp.DiscoverEntries(data)
if derr != nil { if derr != nil {
log.Printf("DiscoverEntries failed: %v", derr) log.Printf("DiscoverEntries failed: %v", derr)
@ -296,8 +302,8 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
// evaluateRules runs all definition rules against obs/opts, skipping any rule // evaluateRules runs all definition rules against obs/opts, skipping any rule
// whose name maps to false in enabledRules (nil means run all). // whose name maps to false in enabledRules (nil means run all).
func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts CheckerOptions, enabledRules map[string]bool) []CheckState { func (s *Server) evaluateRules(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions, enabledRules map[string]bool) []checker.CheckState {
var states []CheckState var states []checker.CheckState
for _, rule := range s.definition.Rules { for _, rule := range s.definition.Rules {
if len(enabledRules) > 0 { if len(enabledRules) > 0 {
if enabled, ok := enabledRules[rule.Name()]; ok && !enabled { if enabled, ok := enabledRules[rule.Name()]; ok && !enabled {
@ -306,8 +312,8 @@ func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts
} }
ruleStates := rule.Evaluate(ctx, obs, opts) ruleStates := rule.Evaluate(ctx, obs, opts)
if len(ruleStates) == 0 { if len(ruleStates) == 0 {
ruleStates = []CheckState{{ ruleStates = []checker.CheckState{{
Status: StatusUnknown, Status: checker.StatusUnknown,
Message: fmt.Sprintf("rule %q returned no state", rule.Name()), Message: fmt.Sprintf("rule %q returned no state", rule.Name()),
}} }}
} }
@ -320,9 +326,9 @@ func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts
} }
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
var req ExternalEvaluateRequest var req checker.ExternalEvaluateRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{ writeJSON(w, http.StatusBadRequest, checker.ExternalEvaluateResponse{
Error: fmt.Sprintf("invalid request body: %v", err), Error: fmt.Sprintf("invalid request body: %v", err),
}) })
return return
@ -330,11 +336,11 @@ func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
obs := &mapObservationGetter{data: req.Observations} obs := &mapObservationGetter{data: req.Observations}
states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules) states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules)
writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states}) writeJSON(w, http.StatusOK, checker.ExternalEvaluateResponse{States: states})
} }
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
var req ExternalReportRequest var req checker.ExternalReportRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("invalid request body: %v", err), "error": fmt.Sprintf("invalid request body: %v", err),
@ -345,13 +351,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept") accept := r.Header.Get("Accept")
if strings.Contains(accept, "text/html") { if strings.Contains(accept, "text/html") {
reporter, ok := s.provider.(CheckerHTMLReporter) reporter, ok := s.provider.(checker.CheckerHTMLReporter)
if !ok { if !ok {
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented) http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
return return
} }
html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related)) html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related))
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
return return
@ -363,13 +369,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
} }
// Default: JSON metrics. // Default: JSON metrics.
reporter, ok := s.provider.(CheckerMetricsReporter) reporter, ok := s.provider.(checker.CheckerMetricsReporter)
if !ok { if !ok {
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented) http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
return return
} }
metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now()) metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related), time.Now())
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to extract metrics: %v", err), "error": fmt.Sprintf("failed to extract metrics: %v", err),
@ -380,16 +386,16 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, metrics) writeJSON(w, http.StatusOK, metrics)
} }
// mapObservationGetter implements ObservationGetter backed by static maps. // mapObservationGetter implements checker.ObservationGetter backed by static maps.
// Both fields are optional: Get reads from data, GetRelated reads from // Both fields are optional: Get reads from data, GetRelated reads from
// related. Leaving related nil preserves the pre-existing "no lineage" // related. Leaving related nil preserves the pre-existing "no lineage"
// behavior used by the remote /evaluate path. // behavior used by the remote /evaluate path.
type mapObservationGetter struct { type mapObservationGetter struct {
data map[ObservationKey]json.RawMessage data map[checker.ObservationKey]json.RawMessage
related map[ObservationKey][]RelatedObservation related map[checker.ObservationKey][]checker.RelatedObservation
} }
func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error { func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationKey, dest any) error {
raw, ok := g.data[key] raw, ok := g.data[key]
if !ok { if !ok {
return fmt.Errorf("observation %q not available", key) return fmt.Errorf("observation %q not available", key)
@ -401,8 +407,8 @@ func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest
// when none were seeded. The remote /evaluate path leaves related nil // when none were seeded. The remote /evaluate path leaves related nil
// because ExternalEvaluateRequest does not currently carry cross-checker // because ExternalEvaluateRequest does not currently carry cross-checker
// lineage; the interactive /check path can seed it from sibling providers // lineage; the interactive /check path can seed it from sibling providers
// declared via InteractiveRelatedProviders. // declared via Siblings.
func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) {
return g.related[key], nil return g.related[key], nil
} }

View file

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package checker package server
import ( import (
"bytes" "bytes"
@ -24,37 +24,39 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"git.happydns.org/checker-sdk-go/checker"
) )
// --- test doubles --- // --- test doubles ---
type testProvider struct { type testProvider struct {
key ObservationKey key checker.ObservationKey
collectFn func(ctx context.Context, opts CheckerOptions) (any, error) collectFn func(ctx context.Context, opts checker.CheckerOptions) (any, error)
definition *CheckerDefinition definition *checker.CheckerDefinition
htmlFn func(raw json.RawMessage) (string, error) htmlFn func(raw json.RawMessage) (string, error)
metricsFn func(raw json.RawMessage, t time.Time) ([]CheckMetric, error) metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error)
} }
func (p *testProvider) Key() ObservationKey { return p.key } func (p *testProvider) Key() checker.ObservationKey { return p.key }
func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (p *testProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
if p.collectFn != nil { if p.collectFn != nil {
return p.collectFn(ctx, opts) return p.collectFn(ctx, opts)
} }
return map[string]string{"result": "ok"}, nil return map[string]string{"result": "ok"}, nil
} }
func (p *testProvider) Definition() *CheckerDefinition { return p.definition } func (p *testProvider) Definition() *checker.CheckerDefinition { return p.definition }
func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) { func (p *testProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
if p.htmlFn != nil { if p.htmlFn != nil {
return p.htmlFn(ctx.Data()) return p.htmlFn(ctx.Data())
} }
return "<h1>report</h1>", nil return "<h1>report</h1>", nil
} }
func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) { func (p *testProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
if p.metricsFn != nil { if p.metricsFn != nil {
return p.metricsFn(ctx.Data(), t) return p.metricsFn(ctx.Data(), t)
} }
return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
} }
// dummyRule is a minimal CheckRule for testing evaluate. // dummyRule is a minimal CheckRule for testing evaluate.
@ -65,8 +67,8 @@ type dummyRule struct {
func (r *dummyRule) Name() string { return r.name } func (r *dummyRule) Name() string { return r.name }
func (r *dummyRule) Description() string { return r.desc } func (r *dummyRule) Description() string { return r.desc }
func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { func (r *dummyRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []CheckState{{Status: StatusOK, Message: r.name + " passed"}} return []checker.CheckState{{Status: checker.StatusOK, Message: r.name + " passed"}}
} }
// codedRule emits a CheckState with a pre-set Code, to verify the server // codedRule emits a CheckState with a pre-set Code, to verify the server
@ -77,14 +79,25 @@ type codedRule struct {
func (r *codedRule) Name() string { return r.name } func (r *codedRule) Name() string { return r.name }
func (r *codedRule) Description() string { return "" } func (r *codedRule) Description() string { return "" }
func (r *codedRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { func (r *codedRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
return []CheckState{{Status: StatusWarn, Code: r.code, Message: "coded finding"}} 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 --- // --- helpers ---
func newTestServer(p *testProvider) *Server { func newTestServer(p *testProvider) *Server {
return NewServer(p) return New(p)
} }
func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder { func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
@ -107,14 +120,14 @@ func doRequest(handler http.Handler, method, path string, body any, headers map[
// --- tests --- // --- tests ---
func TestServer_Health(t *testing.T) { func TestServer_Health(t *testing.T) {
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p) srv := newTestServer(p)
defer srv.Close() defer srv.Close()
rec := doRequest(srv.Handler(), "GET", "/health", nil, nil) rec := doRequest(srv.Handler(), "GET", "/health", nil, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
} }
var resp HealthResponse var resp checker.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode /health: %v", err) t.Fatalf("decode /health: %v", err)
} }
@ -143,8 +156,8 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
var collectEntered sync.WaitGroup var collectEntered sync.WaitGroup
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
collectEntered.Done() collectEntered.Done()
<-release <-release
return map[string]string{"ok": "1"}, nil return map[string]string{"ok": "1"}, nil
@ -161,7 +174,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
go func() { go func() {
defer clientsDone.Done() defer clientsDone.Done()
doRequest(handler, "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil) doRequest(handler, "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
}() }()
} }
@ -170,7 +183,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
// Record /health mid-flight. Also hammer it to verify /health polls // Record /health mid-flight. Also hammer it to verify /health polls
// do not inflate InFlight or TotalRequests. // do not inflate InFlight or TotalRequests.
var mid HealthResponse var mid checker.HealthResponse
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
rec := doRequest(handler, "GET", "/health", nil, nil) rec := doRequest(handler, "GET", "/health", nil, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@ -192,7 +205,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
clientsDone.Wait() clientsDone.Wait()
rec := doRequest(handler, "GET", "/health", nil, nil) rec := doRequest(handler, "GET", "/health", nil, nil)
var after HealthResponse var after checker.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&after); err != nil { if err := json.NewDecoder(rec.Body).Decode(&after); err != nil {
t.Fatalf("decode /health: %v", err) t.Fatalf("decode /health: %v", err)
} }
@ -237,7 +250,7 @@ func TestUpdateLoadAvg(t *testing.T) {
} }
func TestServer_Close_Idempotent(t *testing.T) { func TestServer_Close_Idempotent(t *testing.T) {
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p) srv := newTestServer(p)
done := make(chan error, 2) done := make(chan error, 2)
go func() { done <- srv.Close() }() go func() { done <- srv.Close() }()
@ -257,20 +270,20 @@ func TestServer_Close_Idempotent(t *testing.T) {
func TestServer_Collect_Success(t *testing.T) { func TestServer_Collect_Success(t *testing.T) {
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return map[string]int{"count": 42}, nil return map[string]int{"count": 42}, nil
}, },
} }
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{ rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{
Key: "test", Key: "test",
Options: CheckerOptions{"a": "b"}, Options: checker.CheckerOptions{"a": "b"},
}, nil) }, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
} }
var resp ExternalCollectResponse var resp checker.ExternalCollectResponse
json.NewDecoder(rec.Body).Decode(&resp) json.NewDecoder(rec.Body).Decode(&resp)
if resp.Error != "" { if resp.Error != "" {
t.Errorf("POST /collect error = %q, want empty", resp.Error) t.Errorf("POST /collect error = %q, want empty", resp.Error)
@ -283,17 +296,17 @@ func TestServer_Collect_Success(t *testing.T) {
func TestServer_Collect_ProviderError(t *testing.T) { func TestServer_Collect_ProviderError(t *testing.T) {
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return nil, errors.New("provider failed") return nil, errors.New("provider failed")
}, },
} }
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil) rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError) t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
} }
var resp ExternalCollectResponse var resp checker.ExternalCollectResponse
json.NewDecoder(rec.Body).Decode(&resp) json.NewDecoder(rec.Body).Decode(&resp)
if resp.Error == "" { if resp.Error == "" {
t.Error("expected error in response, got empty") t.Error("expected error in response, got empty")
@ -301,7 +314,7 @@ func TestServer_Collect_ProviderError(t *testing.T) {
} }
func TestServer_Collect_BadBody(t *testing.T) { func TestServer_Collect_BadBody(t *testing.T) {
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}} p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
srv := newTestServer(p) srv := newTestServer(p)
req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid")) req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid"))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -312,10 +325,10 @@ func TestServer_Collect_BadBody(t *testing.T) {
} }
func TestServer_Definition(t *testing.T) { func TestServer_Definition(t *testing.T) {
def := &CheckerDefinition{ def := &checker.CheckerDefinition{
ID: "test-checker", ID: "test-checker",
Name: "Test Checker", Name: "Test Checker",
Rules: []CheckRule{ Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"}, &dummyRule{name: "rule1", desc: "first rule"},
}, },
} }
@ -325,7 +338,7 @@ func TestServer_Definition(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
} }
var got CheckerDefinition var got checker.CheckerDefinition
json.NewDecoder(rec.Body).Decode(&got) json.NewDecoder(rec.Body).Decode(&got)
if got.ID != "test-checker" { if got.ID != "test-checker" {
t.Errorf("definition ID = %q, want \"test-checker\"", got.ID) t.Errorf("definition ID = %q, want \"test-checker\"", got.ID)
@ -336,10 +349,10 @@ func TestServer_Definition(t *testing.T) {
} }
func TestServer_Evaluate(t *testing.T) { func TestServer_Evaluate(t *testing.T) {
def := &CheckerDefinition{ def := &checker.CheckerDefinition{
ID: "test-checker", ID: "test-checker",
Name: "Test Checker", Name: "Test Checker",
Rules: []CheckRule{ Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first rule"}, &dummyRule{name: "rule1", desc: "first rule"},
&dummyRule{name: "rule2", desc: "second rule"}, &dummyRule{name: "rule2", desc: "second rule"},
}, },
@ -347,16 +360,16 @@ func TestServer_Evaluate(t *testing.T) {
p := &testProvider{key: "test", definition: def} p := &testProvider{key: "test", definition: def}
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[ObservationKey]json.RawMessage{ Observations: map[checker.ObservationKey]json.RawMessage{
"test": json.RawMessage(`{"count":42}`), "test": json.RawMessage(`{"count":42}`),
}, },
Options: CheckerOptions{}, Options: checker.CheckerOptions{},
}, nil) }, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
} }
var resp ExternalEvaluateResponse var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp) json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 2 { if len(resp.States) != 2 {
t.Fatalf("evaluate states = %d, want 2", len(resp.States)) t.Fatalf("evaluate states = %d, want 2", len(resp.States))
@ -370,9 +383,9 @@ func TestServer_Evaluate(t *testing.T) {
} }
func TestServer_Evaluate_DisabledRule(t *testing.T) { func TestServer_Evaluate_DisabledRule(t *testing.T) {
def := &CheckerDefinition{ def := &checker.CheckerDefinition{
ID: "test-checker", ID: "test-checker",
Rules: []CheckRule{ Rules: []checker.CheckRule{
&dummyRule{name: "rule1", desc: "first"}, &dummyRule{name: "rule1", desc: "first"},
&dummyRule{name: "rule2", desc: "second"}, &dummyRule{name: "rule2", desc: "second"},
}, },
@ -380,8 +393,8 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
p := &testProvider{key: "test", definition: def} p := &testProvider{key: "test", definition: def}
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[ObservationKey]json.RawMessage{ Observations: map[checker.ObservationKey]json.RawMessage{
"test": json.RawMessage(`{}`), "test": json.RawMessage(`{}`),
}, },
EnabledRules: map[string]bool{"rule1": false}, EnabledRules: map[string]bool{"rule1": false},
@ -389,7 +402,7 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
} }
var resp ExternalEvaluateResponse var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp) json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 1 { if len(resp.States) != 1 {
t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States)) t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States))
@ -400,22 +413,22 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
} }
func TestServer_Evaluate_RulePreservesCode(t *testing.T) { func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
def := &CheckerDefinition{ def := &checker.CheckerDefinition{
ID: "test-checker", ID: "test-checker",
Rules: []CheckRule{ Rules: []checker.CheckRule{
&codedRule{name: "ruleA", code: "too_many_lookups"}, &codedRule{name: "ruleA", code: "too_many_lookups"},
}, },
} }
p := &testProvider{key: "test", definition: def} p := &testProvider{key: "test", definition: def}
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
Observations: map[ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)}, Observations: map[checker.ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)},
}, nil) }, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
} }
var resp ExternalEvaluateResponse var resp checker.ExternalEvaluateResponse
json.NewDecoder(rec.Body).Decode(&resp) json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.States) != 1 { if len(resp.States) != 1 {
t.Fatalf("states = %d, want 1", len(resp.States)) t.Fatalf("states = %d, want 1", len(resp.States))
@ -431,13 +444,13 @@ func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
func TestServer_Report_HTML(t *testing.T) { func TestServer_Report_HTML(t *testing.T) {
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
htmlFn: func(raw json.RawMessage) (string, error) { htmlFn: func(raw json.RawMessage) (string, error) {
return "<p>hello</p>", nil return "<p>hello</p>", nil
}, },
} }
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
Key: "test", Key: "test",
Data: json.RawMessage(`{}`), Data: json.RawMessage(`{}`),
}, map[string]string{"Accept": "text/html"}) }, map[string]string{"Accept": "text/html"})
@ -455,17 +468,17 @@ func TestServer_Report_HTML(t *testing.T) {
func TestServer_Report_Metrics(t *testing.T) { func TestServer_Report_Metrics(t *testing.T) {
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
} }
srv := newTestServer(p) srv := newTestServer(p)
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
Key: "test", Key: "test",
Data: json.RawMessage(`{}`), Data: json.RawMessage(`{}`),
}, map[string]string{"Accept": "application/json"}) }, map[string]string{"Accept": "application/json"})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK)
} }
var metrics []CheckMetric var metrics []checker.CheckMetric
json.NewDecoder(rec.Body).Decode(&metrics) json.NewDecoder(rec.Body).Decode(&metrics)
if len(metrics) != 1 { if len(metrics) != 1 {
t.Errorf("metrics count = %d, want 1", len(metrics)) t.Errorf("metrics count = %d, want 1", len(metrics))
@ -476,25 +489,25 @@ func TestServer_Report_Metrics(t *testing.T) {
// ExternalReportRequest.Related through to the provider's ReportContext, // ExternalReportRequest.Related through to the provider's ReportContext,
// the fix for the "remote checkers can't see related observations" gap. // the fix for the "remote checkers can't see related observations" gap.
func TestServer_Report_Related(t *testing.T) { func TestServer_Report_Related(t *testing.T) {
var gotRelated []RelatedObservation var gotRelated []checker.RelatedObservation
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
} }
// Replace htmlFn with one that peeks at a related key. We can't do that // 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 // directly through testProvider's htmlFn (which only sees raw), so
// bind to GetHTMLReport via an inline wrapper: use a per-test provider // bind to GetHTMLReport via an inline wrapper: use a per-test provider
// that captures the ReportContext before delegating to the template. // that captures the ReportContext before delegating to the template.
srv := NewServer(&relatedPeekingProvider{ srv := New(&relatedPeekingProvider{
base: p, base: p,
target: &gotRelated, target: &gotRelated,
}) })
defer srv.Close() defer srv.Close()
req := ExternalReportRequest{ req := checker.ExternalReportRequest{
Key: "test", Key: "test",
Data: json.RawMessage(`{}`), Data: json.RawMessage(`{}`),
Related: map[ObservationKey][]RelatedObservation{ Related: map[checker.ObservationKey][]checker.RelatedObservation{
"tls_probes": { "tls_probes": {
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"}, {CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
}, },
@ -516,15 +529,15 @@ func TestServer_Report_Related(t *testing.T) {
// Related("tls_probes") slice observed at GetHTMLReport time into target. // Related("tls_probes") slice observed at GetHTMLReport time into target.
type relatedPeekingProvider struct { type relatedPeekingProvider struct {
base *testProvider base *testProvider
target *[]RelatedObservation target *[]checker.RelatedObservation
} }
func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() } func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
return p.base.Collect(ctx, opts) return p.base.Collect(ctx, opts)
} }
func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition } func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) { func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
*p.target = ctx.Related("tls_probes") *p.target = ctx.Related("tls_probes")
return "<p>ok</p>", nil return "<p>ok</p>", nil
} }
@ -532,7 +545,7 @@ func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error
func TestServer_Report_BadBody(t *testing.T) { func TestServer_Report_BadBody(t *testing.T) {
p := &testProvider{ p := &testProvider{
key: "test", key: "test",
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
} }
srv := newTestServer(p) srv := newTestServer(p)
req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad")) req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad"))
@ -546,7 +559,7 @@ func TestServer_Report_BadBody(t *testing.T) {
func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) { func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) {
// A provider that does NOT implement CheckerDefinitionProvider // A provider that does NOT implement CheckerDefinitionProvider
p := &stubProvider{key: "basic"} p := &stubProvider{key: "basic"}
srv := NewServer(p) srv := New(p)
rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil) rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil)
// Should 404 or 405 since /evaluate is not registered // Should 404 or 405 since /evaluate is not registered
if rec.Code == http.StatusOK { if rec.Code == http.StatusOK {

View file

@ -372,21 +372,6 @@ type CheckerDefinitionProvider interface {
Definition() *CheckerDefinition Definition() *CheckerDefinition
} }
// InteractiveRelatedProviders 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 == AutoFillDiscoveryEntries. Sibling
// errors are logged and skipped so the primary result still reaches the
// user.
type InteractiveRelatedProviders interface {
RelatedProviders() []ObservationProvider
}
// CheckerDefinition is the complete definition of a checker, registered via init(). // CheckerDefinition is the complete definition of a checker, registered via init().
type CheckerDefinition struct { type CheckerDefinition struct {
ID string `json:"id"` ID string `json:"id"`

View file

@ -15,10 +15,23 @@
package checker package checker
import ( import (
"context"
"encoding/json" "encoding/json"
"testing" "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) { func TestStatus_MarshalJSON(t *testing.T) {
tests := []struct { tests := []struct {
status Status status Status