diff --git a/README.md b/README.md index e224fdf..49767f7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ srv.Handle("POST /webhook", srv.TrackWork(myWebhookHandler)) log.Fatal(srv.ListenAndServe(":8080")) ``` -Patterns that collide with built-in routes panic at registration — +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`. @@ -74,7 +74,7 @@ type CheckerInteractive interface { 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 +auto-fill (zone records, service payload, …), typically by issuing its own DNS queries from the human-supplied inputs. ## License diff --git a/checker/interactive_test.go b/checker/interactive_test.go deleted file mode 100644 index 0a48512..0000000 --- a/checker/interactive_test.go +++ /dev/null @@ -1,245 +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" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -// interactiveProvider embeds testProvider and adds CheckerInteractive. -type interactiveProvider struct { - *testProvider - fields []CheckerOptionField - parseFn func(r *http.Request) (CheckerOptions, error) - parseErr error -} - -func (p *interactiveProvider) RenderForm() []CheckerOptionField { - return p.fields -} - -func (p *interactiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { - if p.parseErr != nil { - return nil, p.parseErr - } - if p.parseFn != nil { - return p.parseFn(r) - } - return 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 ObservationKey } - -func (m *minimalProvider) Key() ObservationKey { return m.key } -func (m *minimalProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { - return nil, nil -} - -func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { - p := &minimalProvider{key: "test"} - srv := NewServer(p) - defer srv.Close() - - rec := doRequest(srv.Handler(), "GET", "/check", nil, nil) - if rec.Code != http.StatusNotFound { - t.Fatalf("GET /check without CheckerInteractive = %d, want 404", rec.Code) - } -} - -func TestCheck_Form_Renders(t *testing.T) { - p := &interactiveProvider{ - testProvider: &testProvider{key: "test"}, - fields: []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 := NewServer(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`, - ``, + } { + 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`, + `Metrics") { + 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) + } +} + +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) + } +} diff --git a/checker/server.go b/checker/server/server.go similarity index 76% rename from checker/server.go rename to checker/server/server.go index 1c25e31..d875dc2 100644 --- a/checker/server.go +++ b/checker/server/server.go @@ -12,7 +12,11 @@ // See the License for the specific language governing permissions and // 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 ( "context" @@ -27,6 +31,8 @@ import ( "sync" "sync/atomic" "time" + + "git.happydns.org/checker-sdk-go/checker" ) // 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. // 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. If the provider implements CheckerInteractive, +// 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 ObservationProvider - definition *CheckerDefinition - interactive CheckerInteractive + provider checker.ObservationProvider + definition *checker.CheckerDefinition + interactive Interactive 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 // inFlight counts work requests (/collect, /evaluate, /report) currently @@ -97,13 +103,13 @@ type Server struct { 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. // -// 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 // 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()) s := &Server{ provider: provider, @@ -115,7 +121,7 @@ func NewServer(provider ObservationProvider) *Server { s.mux.HandleFunc("GET /health", s.handleHealth) 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 { s.definition = def 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))) - } else if _, ok := provider.(CheckerMetricsReporter); ok { + } else if _, ok := provider.(checker.CheckerMetricsReporter); ok { 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.mux.HandleFunc("GET /check", s.handleCheckForm) s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit))) @@ -171,7 +177,7 @@ func (s *Server) ListenAndServe(addr string) error { // 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. +// any underlying http.Server, callers own that lifecycle. func (s *Server) Close() error { s.closeOnce.Do(func() { s.cancelSampler() @@ -238,7 +244,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { for i := range load { load[i] = math.Float64frombits(s.loadBits[i].Load()) } - writeJSON(w, http.StatusOK, HealthResponse{ + writeJSON(w, http.StatusOK, checker.HealthResponse{ Status: "ok", Uptime: time.Since(s.startTime).Seconds(), 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) { - var req ExternalCollectRequest + var req checker.ExternalCollectRequest 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), }) return @@ -263,7 +269,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { data, err := s.provider.Collect(r.Context(), req.Options) if err != nil { - writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ + writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{ Error: err.Error(), }) return @@ -271,18 +277,18 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { raw, err := json.Marshal(data) if err != nil { - writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{ + writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{ Error: fmt.Sprintf("failed to marshal result: %v", err), }) 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 // out of scope. No re-parse; DiscoverEntries operates on the same // 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) if derr != nil { 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 // 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 { - var states []CheckState +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 { @@ -306,8 +312,8 @@ func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts } ruleStates := rule.Evaluate(ctx, obs, opts) if len(ruleStates) == 0 { - ruleStates = []CheckState{{ - Status: StatusUnknown, + ruleStates = []checker.CheckState{{ + Status: checker.StatusUnknown, 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) { - var req ExternalEvaluateRequest + var req checker.ExternalEvaluateRequest 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), }) return @@ -330,11 +336,11 @@ func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { obs := &mapObservationGetter{data: req.Observations} 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) { - var req ExternalReportRequest + 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), @@ -345,13 +351,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept") if strings.Contains(accept, "text/html") { - reporter, ok := s.provider.(CheckerHTMLReporter) + 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(NewReportContext(req.Data, req.Related)) + html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) return @@ -363,13 +369,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { } // Default: JSON metrics. - reporter, ok := s.provider.(CheckerMetricsReporter) + 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(NewReportContext(req.Data, req.Related), time.Now()) + metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related), time.Now()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to extract metrics: %v", err), @@ -380,12 +386,16 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, metrics) } -// mapObservationGetter implements ObservationGetter backed by a static map. +// 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[ObservationKey]json.RawMessage + data map[checker.ObservationKey]json.RawMessage + 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] if !ok { return fmt.Errorf("observation %q not available", key) @@ -393,13 +403,13 @@ func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest return json.Unmarshal(raw, dest) } -// GetRelated always returns nil in the remote /evaluate path: the host that -// invokes /evaluate does not (currently) carry cross-checker related data in -// ExternalEvaluateRequest. Consumers that need related observations must run -// evaluation locally with a host-side ObservationContext that resolves -// lineage. -func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { - return nil, nil +// 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) { diff --git a/checker/server_test.go b/checker/server/server_test.go similarity index 72% rename from checker/server_test.go rename to checker/server/server_test.go index 7b07e13..03aa58f 100644 --- a/checker/server_test.go +++ b/checker/server/server_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package checker +package server import ( "bytes" @@ -24,37 +24,39 @@ import ( "sync" "testing" "time" + + "git.happydns.org/checker-sdk-go/checker" ) // --- test doubles --- type testProvider struct { - key ObservationKey - collectFn func(ctx context.Context, opts CheckerOptions) (any, error) - definition *CheckerDefinition + 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) ([]CheckMetric, error) + metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error) } -func (p *testProvider) Key() ObservationKey { return p.key } -func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, 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() *CheckerDefinition { return p.definition } -func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) { +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 "

report

", 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 { 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. @@ -65,8 +67,8 @@ type dummyRule struct { 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 (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 @@ -77,14 +79,25 @@ type codedRule struct { func (r *codedRule) Name() string { return r.name } func (r *codedRule) Description() string { return "" } -func (r *codedRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { - return []CheckState{{Status: StatusWarn, Code: r.code, Message: "coded finding"}} +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 NewServer(p) + return New(p) } 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 --- 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) 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 HealthResponse + var resp checker.HealthResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode /health: %v", err) } @@ -143,8 +156,8 @@ func TestServer_Health_TracksInFlight(t *testing.T) { var collectEntered sync.WaitGroup p := &testProvider{ key: "test", - definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, - collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { + 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 @@ -161,7 +174,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) { for i := 0; i < n; i++ { go func() { 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 // do not inflate InFlight or TotalRequests. - var mid HealthResponse + var mid checker.HealthResponse for i := 0; i < 5; i++ { rec := doRequest(handler, "GET", "/health", nil, nil) if rec.Code != http.StatusOK { @@ -192,7 +205,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) { clientsDone.Wait() rec := doRequest(handler, "GET", "/health", nil, nil) - var after HealthResponse + var after checker.HealthResponse if err := json.NewDecoder(rec.Body).Decode(&after); err != nil { t.Fatalf("decode /health: %v", err) } @@ -237,7 +250,7 @@ func TestUpdateLoadAvg(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) done := make(chan error, 2) go func() { done <- srv.Close() }() @@ -257,20 +270,20 @@ func TestServer_Close_Idempotent(t *testing.T) { 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) { + 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", ExternalCollectRequest{ + rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{ Key: "test", - Options: CheckerOptions{"a": "b"}, + Options: checker.CheckerOptions{"a": "b"}, }, nil) if 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) if 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) { p := &testProvider{ key: "test", - definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}, - collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) { + 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", ExternalCollectRequest{Key: "test"}, nil) + 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 ExternalCollectResponse + var resp checker.ExternalCollectResponse json.NewDecoder(rec.Body).Decode(&resp) if resp.Error == "" { 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) { - 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) req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid")) rec := httptest.NewRecorder() @@ -312,10 +325,10 @@ func TestServer_Collect_BadBody(t *testing.T) { } func TestServer_Definition(t *testing.T) { - def := &CheckerDefinition{ + def := &checker.CheckerDefinition{ ID: "test-checker", Name: "Test Checker", - Rules: []CheckRule{ + Rules: []checker.CheckRule{ &dummyRule{name: "rule1", desc: "first rule"}, }, } @@ -325,7 +338,7 @@ func TestServer_Definition(t *testing.T) { if 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) if got.ID != "test-checker" { 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) { - def := &CheckerDefinition{ + def := &checker.CheckerDefinition{ ID: "test-checker", Name: "Test Checker", - Rules: []CheckRule{ + Rules: []checker.CheckRule{ &dummyRule{name: "rule1", desc: "first rule"}, &dummyRule{name: "rule2", desc: "second rule"}, }, @@ -347,16 +360,16 @@ func TestServer_Evaluate(t *testing.T) { p := &testProvider{key: "test", definition: def} srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ - Observations: map[ObservationKey]json.RawMessage{ + rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{ + Observations: map[checker.ObservationKey]json.RawMessage{ "test": json.RawMessage(`{"count":42}`), }, - Options: CheckerOptions{}, + Options: checker.CheckerOptions{}, }, nil) if 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) if len(resp.States) != 2 { 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) { - def := &CheckerDefinition{ + def := &checker.CheckerDefinition{ ID: "test-checker", - Rules: []CheckRule{ + Rules: []checker.CheckRule{ &dummyRule{name: "rule1", desc: "first"}, &dummyRule{name: "rule2", desc: "second"}, }, @@ -380,8 +393,8 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) { p := &testProvider{key: "test", definition: def} srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{ - Observations: map[ObservationKey]json.RawMessage{ + rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{ + Observations: map[checker.ObservationKey]json.RawMessage{ "test": json.RawMessage(`{}`), }, EnabledRules: map[string]bool{"rule1": false}, @@ -389,7 +402,7 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) { if 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) if len(resp.States) != 1 { 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) { - def := &CheckerDefinition{ + def := &checker.CheckerDefinition{ ID: "test-checker", - Rules: []CheckRule{ + 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", ExternalEvaluateRequest{ - Observations: map[ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)}, + 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 ExternalEvaluateResponse + var resp checker.ExternalEvaluateResponse json.NewDecoder(rec.Body).Decode(&resp) if len(resp.States) != 1 { t.Fatalf("states = %d, want 1", len(resp.States)) @@ -430,14 +443,14 @@ func TestServer_Evaluate_RulePreservesCode(t *testing.T) { func TestServer_Report_HTML(t *testing.T) { p := &testProvider{ - key: "test", - definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, + key: "test", + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, htmlFn: func(raw json.RawMessage) (string, error) { return "

hello

", nil }, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ + rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), }, map[string]string{"Accept": "text/html"}) @@ -454,18 +467,18 @@ func TestServer_Report_HTML(t *testing.T) { func TestServer_Report_Metrics(t *testing.T) { p := &testProvider{ - key: "test", - definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, + key: "test", + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := newTestServer(p) - rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{ + 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 []CheckMetric + var metrics []checker.CheckMetric json.NewDecoder(rec.Body).Decode(&metrics) if len(metrics) != 1 { 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, // the fix for the "remote checkers can't see related observations" gap. func TestServer_Report_Related(t *testing.T) { - var gotRelated []RelatedObservation + var gotRelated []checker.RelatedObservation p := &testProvider{ - key: "test", - definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, + 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 := NewServer(&relatedPeekingProvider{ + srv := New(&relatedPeekingProvider{ base: p, target: &gotRelated, }) defer srv.Close() - req := ExternalReportRequest{ + req := checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), - Related: map[ObservationKey][]RelatedObservation{ + Related: map[checker.ObservationKey][]checker.RelatedObservation{ "tls_probes": { {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. type relatedPeekingProvider struct { base *testProvider - target *[]RelatedObservation + target *[]checker.RelatedObservation } -func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() } -func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { +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() *CheckerDefinition { return p.base.definition } -func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) { +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 "

ok

", nil } @@ -532,7 +545,7 @@ func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", - definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := newTestServer(p) 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) { // A provider that does NOT implement CheckerDefinitionProvider p := &stubProvider{key: "basic"} - srv := NewServer(p) + 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 { diff --git a/checker/types_test.go b/checker/types_test.go index 74b57d0..8ba89f2 100644 --- a/checker/types_test.go +++ b/checker/types_test.go @@ -15,10 +15,23 @@ package checker import ( + "context" "encoding/json" "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