From 356e6cd8dbfa8b024b55cd910548488d3f0b92ff Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 10:01:57 +0700 Subject: [PATCH 1/7] Fix comments --- README.md | 4 ++-- checker/interactive_test.go | 2 +- checker/registry_test.go | 2 +- checker/server.go | 2 +- checker/server_test.go | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) 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 index 0a48512..d85948b 100644 --- a/checker/interactive_test.go +++ b/checker/interactive_test.go @@ -226,7 +226,7 @@ func TestCheck_NoReporters(t *testing.T) { // bareInteractiveProvider implements only the required interfaces // (ObservationProvider, CheckerDefinitionProvider, CheckerInteractive) -// — no reporters. +//, no reporters. type bareInteractiveProvider struct { key ObservationKey def *CheckerDefinition diff --git a/checker/registry_test.go b/checker/registry_test.go index e4170b2..ca7450a 100644 --- a/checker/registry_test.go +++ b/checker/registry_test.go @@ -74,7 +74,7 @@ func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) { } // Second registration of the same definition pointer must NOT append a - // second "endpoint" AdminOpt — the duplicate check has to fire before + // second "endpoint" AdminOpt, the duplicate check has to fire before // the append, otherwise we silently mutate the live definition. RegisterExternalizableChecker(c) if n := len(c.Options.AdminOpts); n != 1 { diff --git a/checker/server.go b/checker/server.go index 1c25e31..bfec550 100644 --- a/checker/server.go +++ b/checker/server.go @@ -171,7 +171,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() diff --git a/checker/server_test.go b/checker/server_test.go index 7b07e13..af62c94 100644 --- a/checker/server_test.go +++ b/checker/server_test.go @@ -430,7 +430,7 @@ func TestServer_Evaluate_RulePreservesCode(t *testing.T) { func TestServer_Report_HTML(t *testing.T) { p := &testProvider{ - key: "test", + key: "test", definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, htmlFn: func(raw json.RawMessage) (string, error) { return "

hello

", nil @@ -454,7 +454,7 @@ func TestServer_Report_HTML(t *testing.T) { func TestServer_Report_Metrics(t *testing.T) { p := &testProvider{ - key: "test", + key: "test", definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, } srv := newTestServer(p) @@ -478,7 +478,7 @@ func TestServer_Report_Metrics(t *testing.T) { func TestServer_Report_Related(t *testing.T) { var gotRelated []RelatedObservation p := &testProvider{ - key: "test", + key: "test", definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, } // Replace htmlFn with one that peeks at a related key. We can't do that From c244ca48c6c6dda0dcf5bf78b298f34f5d027d8d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 11:19:59 +0700 Subject: [PATCH 2/7] interactive: let standalone providers compose sibling observations Add an opt-in InteractiveRelatedProviders interface: a checker served by /check can declare sibling ObservationProviders the SDK will run in-process after the primary Collect. The SDK auto-fills any sibling option tagged AutoFill==AutoFillDiscoveryEntries from the primary's DiscoverEntries output, mirroring the host's AutoFill wiring so siblings that work in-host work here too. Results are exposed through ObservationGetter.GetRelated and ReportContext.Related, unblocking cross-checker rules (e.g. DANE reading checker-tls probes) on the standalone /check flow. --- checker/interactive.go | 95 ++++++++++++++++++++++++-- checker/interactive_test.go | 133 ++++++++++++++++++++++++++++++++++++ checker/server.go | 20 +++--- checker/types.go | 15 ++++ 4 files changed, 251 insertions(+), 12 deletions(-) diff --git a/checker/interactive.go b/checker/interactive.go index bb7158e..7e8b190 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -16,10 +16,12 @@ package checker import ( "bytes" + "context" "encoding/json" "fmt" "html/template" "log" + "maps" "net/http" "time" ) @@ -104,14 +106,19 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { return } + related := s.collectRelatedObservations(r.Context(), opts, data) + if s.definition != nil { - obs := &mapObservationGetter{data: map[ObservationKey]json.RawMessage{ - s.provider.Key(): raw, - }} + obs := &mapObservationGetter{ + data: map[ObservationKey]json.RawMessage{ + s.provider.Key(): raw, + }, + related: related, + } result.States = s.evaluateRules(r.Context(), obs, opts, nil) } - ctx := NewReportContext(raw, nil) + ctx := NewReportContext(raw, related) if reporter, ok := s.provider.(CheckerHTMLReporter); ok { html, rerr := reporter.GetHTMLReport(ctx) @@ -134,6 +141,86 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { s.renderCheckResult(w, result) } +// collectRelatedObservations runs sibling providers declared via +// InteractiveRelatedProviders and returns their results keyed by the +// sibling's observation key. Sibling errors are logged and skipped. +func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOptions, data any) map[ObservationKey][]RelatedObservation { + irp, ok := s.provider.(InteractiveRelatedProviders) + if !ok { + return nil + } + siblings := irp.RelatedProviders() + if len(siblings) == 0 { + return nil + } + + var entries []DiscoveryEntry + if dp, ok := s.provider.(DiscoveryPublisher); ok { + e, err := dp.DiscoverEntries(data) + if err != nil { + log.Printf("interactive: DiscoverEntries failed: %v", err) + } else { + entries = e + } + } + + related := make(map[ObservationKey][]RelatedObservation, len(siblings)) + for _, sp := range siblings { + sOpts := cloneOptions(opts) + siblingID := "" + if dp, ok := sp.(CheckerDefinitionProvider); ok { + if def := dp.Definition(); def != nil { + siblingID = def.ID + if len(entries) > 0 { + fillDiscoveryEntryOption(sOpts, def, entries) + } + } + } + sData, err := sp.Collect(ctx, sOpts) + if err != nil { + log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err) + continue + } + raw, err := json.Marshal(sData) + if err != nil { + log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err) + continue + } + related[sp.Key()] = append(related[sp.Key()], RelatedObservation{ + CheckerID: siblingID, + Key: sp.Key(), + Data: raw, + CollectedAt: time.Now(), + }) + } + return related +} + +func cloneOptions(opts CheckerOptions) CheckerOptions { + out := make(CheckerOptions, len(opts)) + maps.Copy(out, opts) + return out +} + +// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes +// entries into every option in def tagged AutoFill == AutoFillDiscoveryEntries. +func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entries []DiscoveryEntry) { + scopes := [][]CheckerOptionDocumentation{ + def.Options.AdminOpts, + def.Options.UserOpts, + def.Options.DomainOpts, + def.Options.ServiceOpts, + def.Options.RunOpts, + } + for _, scope := range scopes { + for _, f := range scope { + if f.AutoFill == AutoFillDiscoveryEntries { + opts[f.Id] = entries + } + } + } +} + func (s *Server) checkPageTitle() string { if s.definition != nil && s.definition.Name != "" { return s.definition.Name diff --git a/checker/interactive_test.go b/checker/interactive_test.go index d85948b..dc93b93 100644 --- a/checker/interactive_test.go +++ b/checker/interactive_test.go @@ -243,3 +243,136 @@ func (b *bareInteractiveProvider) RenderForm() []CheckerOptionField { func (b *bareInteractiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { return CheckerOptions{"domain": r.FormValue("domain")}, nil } + +type siblingProvider struct { + key ObservationKey + id string + entriesOpt string + gotOpts CheckerOptions + payload any +} + +func (s *siblingProvider) Key() ObservationKey { return s.key } +func (s *siblingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { + s.gotOpts = opts + return s.payload, nil +} +func (s *siblingProvider) Definition() *CheckerDefinition { + return &CheckerDefinition{ + ID: s.id, + Options: CheckerOptionsDocumentation{ + RunOpts: []CheckerOptionDocumentation{ + {Id: s.entriesOpt, Type: "array", AutoFill: AutoFillDiscoveryEntries}, + }, + }, + } +} + +type primaryWithSibling struct { + key ObservationKey + def *CheckerDefinition + entries []DiscoveryEntry + sibling ObservationProvider +} + +func (p *primaryWithSibling) Key() ObservationKey { return p.key } +func (p *primaryWithSibling) Collect(ctx context.Context, opts CheckerOptions) (any, error) { + return map[string]string{"primary": "ok"}, nil +} +func (p *primaryWithSibling) Definition() *CheckerDefinition { return p.def } +func (p *primaryWithSibling) RenderForm() []CheckerOptionField { + return []CheckerOptionField{{Id: "domain", Type: "string"}} +} +func (p *primaryWithSibling) ParseForm(r *http.Request) (CheckerOptions, error) { + return CheckerOptions{"domain": r.FormValue("domain")}, nil +} +func (p *primaryWithSibling) DiscoverEntries(data any) ([]DiscoveryEntry, error) { + return p.entries, nil +} +func (p *primaryWithSibling) RelatedProviders() []ObservationProvider { + return []ObservationProvider{p.sibling} +} + +type relatedAssertRule struct { + key ObservationKey +} + +func (r *relatedAssertRule) Name() string { return "related_assert" } +func (r *relatedAssertRule) Description() string { return "" } +func (r *relatedAssertRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { + related, err := obs.GetRelated(ctx, r.key) + if err != nil { + return []CheckState{{Status: StatusError, Message: err.Error()}} + } + if len(related) == 0 { + return []CheckState{{Status: StatusCrit, Message: "no related observation"}} + } + return []CheckState{{Status: 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 := DiscoveryEntry{Type: "fake.v1", Ref: "r1"} + primary := &primaryWithSibling{ + key: "primary_key", + def: &CheckerDefinition{ + ID: "primary", + Rules: []CheckRule{&relatedAssertRule{key: sibling.key}}, + }, + entries: []DiscoveryEntry{entry}, + sibling: sibling, + } + + srv := NewServer(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].([]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: &CheckerDefinition{ + ID: "test", + Rules: []CheckRule{&relatedAssertRule{key: "other"}}, + }, + }, + fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, + } + srv := NewServer(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.go index bfec550..4478baf 100644 --- a/checker/server.go +++ b/checker/server.go @@ -380,9 +380,13 @@ 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 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[ObservationKey]json.RawMessage + related map[ObservationKey][]RelatedObservation } func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error { @@ -393,13 +397,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. +// 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 InteractiveRelatedProviders. func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { - return nil, nil + return g.related[key], nil } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/checker/types.go b/checker/types.go index c218dbb..1e1ad0e 100644 --- a/checker/types.go +++ b/checker/types.go @@ -372,6 +372,21 @@ type CheckerDefinitionProvider interface { 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(). type CheckerDefinition struct { ID string `json:"id"` From 89cc3f112bf40b9108025963a901b7f92dc1eabc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 12:31:34 +0700 Subject: [PATCH 3/7] 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. --- checker/{ => server}/interactive.go | 95 ++++++++------ checker/{ => server}/interactive_test.go | 154 ++++++++++++----------- checker/{ => server}/server.go | 86 +++++++------ checker/{ => server}/server_test.go | 153 +++++++++++----------- checker/types.go | 15 --- checker/types_test.go | 13 ++ 6 files changed, 276 insertions(+), 240 deletions(-) rename checker/{ => server}/interactive.go (80%) rename checker/{ => server}/interactive_test.go (62%) rename checker/{ => server}/server.go (80%) rename checker/{ => server}/server_test.go (72%) diff --git a/checker/interactive.go b/checker/server/interactive.go similarity index 80% rename from checker/interactive.go rename to checker/server/interactive.go index 7e8b190..2ac133c 100644 --- a/checker/interactive.go +++ b/checker/server/interactive.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,29 +24,31 @@ import ( "maps" "net/http" "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, // 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. // 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 // consolidated result page. // // 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 // (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 // a check. Typically a minimal set (domain name, nameserver to // query, …) that ParseForm expands into the full CheckerOptions // that Collect expects. - RenderForm() []CheckerOptionField + RenderForm() []checker.CheckerOptionField // ParseForm reads the submitted form and returns the CheckerOptions // 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 // error causes the SDK to re-render the form with the error // 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. type checkResult struct { Title string - States []CheckState - Metrics []CheckMetric + States []checker.CheckState + Metrics []checker.CheckMetric ReportHTML string CollectErr string ReportErr string @@ -70,7 +87,7 @@ type checkResult struct { type checkFormPage struct { Title string - Fields []CheckerOptionField + Fields []checker.CheckerOptionField Error string } @@ -110,7 +127,7 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { if s.definition != nil { obs := &mapObservationGetter{ - data: map[ObservationKey]json.RawMessage{ + data: map[checker.ObservationKey]json.RawMessage{ s.provider.Key(): raw, }, 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) } - 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) if rerr != nil { 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()) if merr != nil { result.MetricsErr = merr.Error() @@ -141,11 +158,11 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { s.renderCheckResult(w, result) } -// collectRelatedObservations runs sibling providers declared via -// InteractiveRelatedProviders and returns their results keyed by the -// sibling's observation key. Sibling errors are logged and skipped. -func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOptions, data any) map[ObservationKey][]RelatedObservation { - irp, ok := s.provider.(InteractiveRelatedProviders) +// collectRelatedObservations runs sibling providers declared via Siblings +// and returns their results keyed by the sibling's observation key. +// Sibling errors are logged and skipped. +func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation { + irp, ok := s.provider.(Siblings) if !ok { return nil } @@ -154,8 +171,8 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt return nil } - var entries []DiscoveryEntry - if dp, ok := s.provider.(DiscoveryPublisher); ok { + var entries []checker.DiscoveryEntry + if dp, ok := s.provider.(checker.DiscoveryPublisher); ok { e, err := dp.DiscoverEntries(data) if err != nil { log.Printf("interactive: DiscoverEntries failed: %v", err) @@ -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 { sOpts := cloneOptions(opts) siblingID := "" - if dp, ok := sp.(CheckerDefinitionProvider); ok { + if dp, ok := sp.(checker.CheckerDefinitionProvider); ok { if def := dp.Definition(); def != nil { siblingID = def.ID 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) continue } - related[sp.Key()] = append(related[sp.Key()], RelatedObservation{ + related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{ CheckerID: siblingID, Key: sp.Key(), Data: raw, @@ -196,16 +213,16 @@ func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOpt return related } -func cloneOptions(opts CheckerOptions) CheckerOptions { - out := make(CheckerOptions, len(opts)) +func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions { + out := make(checker.CheckerOptions, len(opts)) maps.Copy(out, opts) return out } // fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes -// entries into every option in def tagged AutoFill == AutoFillDiscoveryEntries. -func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entries []DiscoveryEntry) { - scopes := [][]CheckerOptionDocumentation{ +// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries. +func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) { + scopes := [][]checker.CheckerOptionDocumentation{ def.Options.AdminOpts, def.Options.UserOpts, def.Options.DomainOpts, @@ -214,7 +231,7 @@ func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entri } for _, scope := range scopes { for _, f := range scope { - if f.AutoFill == AutoFillDiscoveryEntries { + if f.AutoFill == checker.AutoFillDiscoveryEntries { opts[f.Id] = entries } } @@ -240,7 +257,7 @@ func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data 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 if errMsg != "" { status = http.StatusBadRequest @@ -256,17 +273,17 @@ func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) { renderHTML(w, http.StatusOK, checkResultTemplate, result) } -func statusClass(s Status) string { +func statusClass(s checker.Status) string { switch s { - case StatusOK: + case checker.StatusOK: return "ok" - case StatusInfo: + case checker.StatusInfo: return "info" - case StatusWarn: + case checker.StatusWarn: return "warn" - case StatusCrit: + case checker.StatusCrit: return "crit" - case StatusError: + case checker.StatusError: return "error" default: return "unknown" @@ -298,7 +315,7 @@ func defaultBool(v any) bool { var templateFuncs = template.FuncMap{ "statusClass": statusClass, - "statusString": Status.String, + "statusString": checker.Status.String, "defaultString": defaultString, "defaultBool": defaultBool, } diff --git a/checker/interactive_test.go b/checker/server/interactive_test.go similarity index 62% rename from checker/interactive_test.go rename to checker/server/interactive_test.go index dc93b93..bc1d338 100644 --- a/checker/interactive_test.go +++ b/checker/server/interactive_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 ( "context" @@ -22,28 +22,30 @@ import ( "net/url" "strings" "testing" + + "git.happydns.org/checker-sdk-go/checker" ) -// interactiveProvider embeds testProvider and adds CheckerInteractive. +// interactiveProvider embeds testProvider and adds Interactive. type interactiveProvider struct { *testProvider - fields []CheckerOptionField - parseFn func(r *http.Request) (CheckerOptions, error) + fields []checker.CheckerOptionField + parseFn func(r *http.Request) (checker.CheckerOptions, error) parseErr error } -func (p *interactiveProvider) RenderForm() []CheckerOptionField { +func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField { 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 { return nil, p.parseErr } if p.parseFn != nil { 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 { @@ -55,35 +57,35 @@ func postForm(handler http.Handler, path string, values url.Values) *httptest.Re } // 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) Collect(ctx context.Context, opts CheckerOptions) (any, error) { +func (m *minimalProvider) Key() checker.ObservationKey { return m.key } +func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { return nil, nil } func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) { p := &minimalProvider{key: "test"} - srv := NewServer(p) + srv := New(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) + t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code) } } func TestCheck_Form_Renders(t *testing.T) { p := &interactiveProvider{ testProvider: &testProvider{key: "test"}, - fields: []CheckerOptionField{ + fields: []checker.CheckerOptionField{ {Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"}, {Id: "verbose", Type: "bool", Label: "Verbose", Default: true}, {Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"}, {Id: "hidden", Type: "string", Hide: true}, }, } - srv := NewServer(p) + srv := New(p) defer srv.Close() 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) { - definition := &CheckerDefinition{ + definition := &checker.CheckerDefinition{ ID: "test", Name: "Test Checker", - Rules: []CheckRule{ + Rules: []checker.CheckRule{ &dummyRule{name: "rule1", desc: "first rule"}, }, } p := &interactiveProvider{ 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() 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) { p := &interactiveProvider{ testProvider: &testProvider{key: "test"}, - fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, + fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}}, parseErr: errors.New("domain is required"), } - srv := NewServer(p) + srv := New(p) defer srv.Close() rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}}) @@ -173,13 +175,13 @@ func TestCheck_Submit_CollectError(t *testing.T) { p := &interactiveProvider{ testProvider: &testProvider{ 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") }, }, - fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, + fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}}, } - srv := NewServer(p) + srv := New(p) defer srv.Close() 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) { - // Provider implements CheckerInteractive and has a definition (so + // Provider implements Interactive and has a definition (so // /evaluate-like logic runs) but no HTMLReporter / MetricsReporter. bare := &bareInteractiveProvider{ key: "test", - def: &CheckerDefinition{ + def: &checker.CheckerDefinition{ 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() 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 -// (ObservationProvider, CheckerDefinitionProvider, CheckerInteractive) -//, no reporters. +// (ObservationProvider, CheckerDefinitionProvider, Interactive), +// no reporters. type bareInteractiveProvider struct { - key ObservationKey - def *CheckerDefinition + key checker.ObservationKey + def *checker.CheckerDefinition } -func (b *bareInteractiveProvider) Key() ObservationKey { return b.key } -func (b *bareInteractiveProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { +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() *CheckerDefinition { return b.def } -func (b *bareInteractiveProvider) RenderForm() []CheckerOptionField { - return []CheckerOptionField{{Id: "domain", Type: "string"}} +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) (CheckerOptions, error) { - return CheckerOptions{"domain": r.FormValue("domain")}, nil +func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) { + return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil } type siblingProvider struct { - key ObservationKey + key checker.ObservationKey id string entriesOpt string - gotOpts CheckerOptions + gotOpts checker.CheckerOptions payload any } -func (s *siblingProvider) Key() ObservationKey { return s.key } -func (s *siblingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { +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() *CheckerDefinition { - return &CheckerDefinition{ +func (s *siblingProvider) Definition() *checker.CheckerDefinition { + return &checker.CheckerDefinition{ ID: s.id, - Options: CheckerOptionsDocumentation{ - RunOpts: []CheckerOptionDocumentation{ - {Id: s.entriesOpt, Type: "array", AutoFill: AutoFillDiscoveryEntries}, + Options: checker.CheckerOptionsDocumentation{ + RunOpts: []checker.CheckerOptionDocumentation{ + {Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries}, }, }, } } type primaryWithSibling struct { - key ObservationKey - def *CheckerDefinition - entries []DiscoveryEntry - sibling ObservationProvider + key checker.ObservationKey + def *checker.CheckerDefinition + entries []checker.DiscoveryEntry + sibling checker.ObservationProvider } -func (p *primaryWithSibling) Key() ObservationKey { return p.key } -func (p *primaryWithSibling) Collect(ctx context.Context, opts CheckerOptions) (any, error) { +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() *CheckerDefinition { return p.def } -func (p *primaryWithSibling) RenderForm() []CheckerOptionField { - return []CheckerOptionField{{Id: "domain", Type: "string"}} +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) (CheckerOptions, error) { - return CheckerOptions{"domain": r.FormValue("domain")}, nil +func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) { + 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 } -func (p *primaryWithSibling) RelatedProviders() []ObservationProvider { - return []ObservationProvider{p.sibling} +func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider { + return []checker.ObservationProvider{p.sibling} } type relatedAssertRule struct { - key ObservationKey + 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 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) if err != nil { - return []CheckState{{Status: StatusError, Message: err.Error()}} + return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}} } 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) { @@ -317,18 +319,18 @@ func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) { entriesOpt: "endpoints", payload: map[string]string{"sibling": "ok"}, } - entry := DiscoveryEntry{Type: "fake.v1", Ref: "r1"} + entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"} primary := &primaryWithSibling{ key: "primary_key", - def: &CheckerDefinition{ + def: &checker.CheckerDefinition{ ID: "primary", - Rules: []CheckRule{&relatedAssertRule{key: sibling.key}}, + Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}}, }, - entries: []DiscoveryEntry{entry}, + entries: []checker.DiscoveryEntry{entry}, sibling: sibling, } - srv := NewServer(primary) + srv := New(primary) defer srv.Close() 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) } - got, ok := sibling.gotOpts[sibling.entriesOpt].([]DiscoveryEntry) + 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]) } @@ -357,14 +359,14 @@ func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) { p := &interactiveProvider{ testProvider: &testProvider{ key: "test", - definition: &CheckerDefinition{ + definition: &checker.CheckerDefinition{ 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() rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) diff --git a/checker/server.go b/checker/server/server.go similarity index 80% rename from checker/server.go rename to checker/server/server.go index 4478baf..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))) @@ -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,16 +386,16 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { 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 // 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 - related map[ObservationKey][]RelatedObservation + 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) @@ -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 // because ExternalEvaluateRequest does not currently carry cross-checker // lineage; the interactive /check path can seed it from sibling providers -// declared via InteractiveRelatedProviders. -func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { +// declared via Siblings. +func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) { return g.related[key], nil } 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 af62c94..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)) @@ -431,13 +444,13 @@ 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{}}, + 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"}) @@ -455,17 +468,17 @@ 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{}}, + 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{}}, + 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.go b/checker/types.go index 1e1ad0e..c218dbb 100644 --- a/checker/types.go +++ b/checker/types.go @@ -372,21 +372,6 @@ type CheckerDefinitionProvider interface { 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(). type CheckerDefinition struct { ID string `json:"id"` 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 From 3382f62f5735706ee07535fd4198d36eaa5b754d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Apr 2026 16:58:16 +0700 Subject: [PATCH 4/7] checker: thread rule states into ReportContext Reporters can now read rule output via ctx.States() instead of re-deriving severity/hints from the raw payload, keeping the rules screen and the HTML report aligned on a single source of truth. --- checker/server/interactive.go | 7 +- checker/server/interactive_test.go | 59 +++++++++++++++++ checker/server/server.go | 4 +- checker/server/server_test.go | 101 +++++++++++++++++++++++++++++ checker/types.go | 57 ++++++++++------ checker/types_test.go | 49 ++++++++++++++ 6 files changed, 252 insertions(+), 25 deletions(-) diff --git a/checker/server/interactive.go b/checker/server/interactive.go index 2ac133c..27cc260 100644 --- a/checker/server/interactive.go +++ b/checker/server/interactive.go @@ -96,14 +96,15 @@ func (s *Server) handleCheckForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { + fields := s.interactive.RenderForm() if err := r.ParseForm(); err != nil { - s.renderCheckForm(w, s.interactive.RenderForm(), fmt.Sprintf("invalid form: %v", err)) + s.renderCheckForm(w, fields, fmt.Sprintf("invalid form: %v", err)) return } opts, err := s.interactive.ParseForm(r) if err != nil { - s.renderCheckForm(w, s.interactive.RenderForm(), err.Error()) + s.renderCheckForm(w, fields, err.Error()) return } @@ -135,7 +136,7 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) { result.States = s.evaluateRules(r.Context(), obs, opts, nil) } - ctx := checker.NewReportContext(raw, related) + ctx := checker.NewReportContext(raw, related, result.States) if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok { html, rerr := reporter.GetHTMLReport(ctx) diff --git a/checker/server/interactive_test.go b/checker/server/interactive_test.go index bc1d338..4ce7ade 100644 --- a/checker/server/interactive_test.go +++ b/checker/server/interactive_test.go @@ -355,6 +355,65 @@ func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) { } } +// interactiveStatesPeekingProvider implements Interactive + HTMLReporter +// and captures the ReportContext.States() seen at GetHTMLReport time. +type interactiveStatesPeekingProvider struct { + key checker.ObservationKey + def *checker.CheckerDefinition + seen *[]checker.CheckState +} + +func (p *interactiveStatesPeekingProvider) Key() checker.ObservationKey { return p.key } +func (p *interactiveStatesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { + return map[string]string{"ok": "1"}, nil +} +func (p *interactiveStatesPeekingProvider) Definition() *checker.CheckerDefinition { return p.def } +func (p *interactiveStatesPeekingProvider) RenderForm() []checker.CheckerOptionField { + return []checker.CheckerOptionField{{Id: "domain", Type: "string"}} +} +func (p *interactiveStatesPeekingProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) { + return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil +} +func (p *interactiveStatesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { + if p.seen != nil { + *p.seen = ctx.States() + } + return "

ok

", nil +} + +// TestCheck_Submit_ThreadsStatesIntoReport verifies that CheckStates +// produced by evaluateRules during POST /check are threaded into the +// ReportContext handed to GetHTMLReport. Without this wiring, the /check +// UI can show states in its own section but the embedded report would +// have to re-derive severity/hints from Data. +func TestCheck_Submit_ThreadsStatesIntoReport(t *testing.T) { + var seen []checker.CheckState + p := &interactiveStatesPeekingProvider{ + key: "test", + def: &checker.CheckerDefinition{ + ID: "test", + Rules: []checker.CheckRule{&dummyRule{name: "rule1", desc: "first"}}, + }, + seen: &seen, + } + srv := New(p) + defer srv.Close() + + rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) + if rec.Code != http.StatusOK { + t.Fatalf("POST /check = %d, want 200", rec.Code) + } + if len(seen) != 1 { + t.Fatalf("reporter saw %d states, want 1", len(seen)) + } + if seen[0].RuleName != "rule1" { + t.Errorf("state RuleName = %q, want %q", seen[0].RuleName, "rule1") + } + if seen[0].Status != checker.StatusOK { + t.Errorf("state Status = %v, want %v", seen[0].Status, checker.StatusOK) + } +} + func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) { p := &interactiveProvider{ testProvider: &testProvider{ diff --git a/checker/server/server.go b/checker/server/server.go index d875dc2..26f50ee 100644 --- a/checker/server/server.go +++ b/checker/server/server.go @@ -357,7 +357,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related)) + html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related, req.States)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) return @@ -375,7 +375,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related), time.Now()) + metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related, req.States), time.Now()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to extract metrics: %v", err), diff --git a/checker/server/server_test.go b/checker/server/server_test.go index 03aa58f..9e0adc4 100644 --- a/checker/server/server_test.go +++ b/checker/server/server_test.go @@ -542,6 +542,107 @@ func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (strin return "

ok

", nil } +// statesPeekingProvider captures the ReportContext's States slice at +// GetHTMLReport / ExtractMetrics time. +type statesPeekingProvider struct { + base *testProvider + htmlSeen *[]checker.CheckState + metricSeen *[]checker.CheckState +} + +func (p *statesPeekingProvider) Key() checker.ObservationKey { return p.base.Key() } +func (p *statesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { + return p.base.Collect(ctx, opts) +} +func (p *statesPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition } +func (p *statesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { + if p.htmlSeen != nil { + *p.htmlSeen = ctx.States() + } + return "

ok

", nil +} +func (p *statesPeekingProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) { + if p.metricSeen != nil { + *p.metricSeen = ctx.States() + } + return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil +} + +// TestServer_Report_States_HTML verifies ExternalReportRequest.States is +// threaded into the ReportContext seen by the HTML reporter. +func TestServer_Report_States_HTML(t *testing.T) { + var seen []checker.CheckState + base := &testProvider{ + key: "test", + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + } + srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) + defer srv.Close() + + states := []checker.CheckState{ + {Status: checker.StatusCrit, Message: "broken", RuleName: "r1", Code: "bad", Subject: "host.example"}, + } + req := checker.ExternalReportRequest{ + Key: "test", + Data: json.RawMessage(`{}`), + States: states, + } + rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) + if rec.Code != http.StatusOK { + t.Fatalf("POST /report = %d, want 200", rec.Code) + } + if len(seen) != 1 || seen[0].RuleName != "r1" || seen[0].Code != "bad" || seen[0].Subject != "host.example" { + t.Errorf("reporter saw states = %+v, want single state {RuleName:r1, Code:bad, Subject:host.example}", seen) + } +} + +// TestServer_Report_States_Metrics verifies the States passthrough on the +// metrics path as well. +func TestServer_Report_States_Metrics(t *testing.T) { + var seen []checker.CheckState + base := &testProvider{ + key: "test", + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + } + srv := New(&statesPeekingProvider{base: base, metricSeen: &seen}) + defer srv.Close() + + req := checker.ExternalReportRequest{ + Key: "test", + Data: json.RawMessage(`{}`), + States: []checker.CheckState{{Status: checker.StatusWarn, RuleName: "r1"}}, + } + rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "application/json"}) + if rec.Code != http.StatusOK { + t.Fatalf("POST /report = %d, want 200", rec.Code) + } + if len(seen) != 1 || seen[0].RuleName != "r1" { + t.Errorf("reporter saw states = %+v, want single state with RuleName=r1", seen) + } +} + +// TestServer_Report_States_Absent verifies that omitting States in the +// request yields a nil States() slice on the reporter side (graceful +// degradation for hosts that don't thread evaluate→report yet). +func TestServer_Report_States_Absent(t *testing.T) { + seen := []checker.CheckState{{Status: checker.StatusOK}} // non-nil sentinel + base := &testProvider{ + key: "test", + definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, + } + srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) + defer srv.Close() + + req := checker.ExternalReportRequest{Key: "test", Data: json.RawMessage(`{}`)} + rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) + if rec.Code != http.StatusOK { + t.Fatalf("POST /report = %d, want 200", rec.Code) + } + if seen != nil { + t.Errorf("States() = %+v, want nil when ExternalReportRequest.States is absent", seen) + } +} + func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", diff --git a/checker/types.go b/checker/types.go index c218dbb..7256766 100644 --- a/checker/types.go +++ b/checker/types.go @@ -298,32 +298,40 @@ type CheckAggregator interface { Aggregate(states []CheckState) CheckState } -// ReportContext carries both the primary observation payload and any -// observations produced by other checkers that cover the same discovery -// entries. Hosts build a ReportContext and hand it to reporter methods. +// ReportContext carries the primary observation payload, any observations +// produced by other checkers that cover the same discovery entries, and the +// CheckStates produced by this checker's rules for the same observation. +// Hosts build a ReportContext and hand it to reporter methods. // -// The method set is deliberately tiny: a single primary payload (Data) and -// a query for related observations by key (Related). Hosts return nil from -// Related when there is nothing to relate; reporters must tolerate that. +// Reporters use States() to render rule-driven sections (for example a +// "fix these first" list) without re-deriving severity or hints from the +// raw payload. Hosts that have not yet threaded rule output into the +// report pipeline return nil; reporters must treat a nil or empty slice +// as "not provided" and fall back to a data-only rendering. The same +// nil-tolerance applies to Related(key). type ReportContext interface { Data() json.RawMessage Related(key ObservationKey) []RelatedObservation + States() []CheckState } -// NewReportContext returns a ReportContext backed by a primary payload and -// a pre-resolved map of related observations by key. The SDK's /report HTTP -// handler uses this to wrap ExternalReportRequest contents; hosts and tests -// can use it whenever they already have the related observations in memory. +// NewReportContext returns a ReportContext backed by a primary payload, a +// pre-resolved map of related observations by key, and the CheckStates +// produced by the checker's rules on this observation. The SDK's /report +// HTTP handler uses this to wrap ExternalReportRequest contents; hosts and +// tests can use it whenever they already have that material in memory. // -// Passing a nil or empty related map is fine; Related(key) will then return -// nil, just like StaticReportContext. -func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation) ReportContext { - return fixedReportContext{data: data, related: related} +// Passing a nil related map or a nil states slice is fine; Related(key) +// and States() will then return nil respectively. Use StaticReportContext +// as a shorthand when both are absent. +func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation, states []CheckState) ReportContext { + return fixedReportContext{data: data, related: related, states: states} } -// StaticReportContext is a shorthand for NewReportContext(data, nil): a -// ReportContext with a primary payload and no related observations. -// Intended for tests and ad-hoc callers that have no lineage to supply. +// StaticReportContext is a shorthand for NewReportContext(data, nil, nil): +// a ReportContext with a primary payload, no related observations, and no +// rule states. Intended for tests and ad-hoc callers that have no lineage +// or rule output to supply. func StaticReportContext(data json.RawMessage) ReportContext { return fixedReportContext{data: data} } @@ -331,6 +339,7 @@ func StaticReportContext(data json.RawMessage) ReportContext { type fixedReportContext struct { data json.RawMessage related map[ObservationKey][]RelatedObservation + states []CheckState } func (f fixedReportContext) Data() json.RawMessage { return f.data } @@ -340,6 +349,7 @@ func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation { } return f.related[key] } +func (f fixedReportContext) States() []CheckState { return f.states } // CheckerHTMLReporter is an optional interface that observation providers can // implement to render their stored data as a full HTML document (for iframe embedding). @@ -486,13 +496,20 @@ type ExternalEvaluateResponse struct { // Related carries observations produced by other checkers on DiscoveryEntry // records originally published by the target of this report, that is, the // cross-checker lineage that ObservationGetter.GetRelated would expose in -// the in-process path. The host composes it before making the HTTP request; -// when absent, the remote checker receives a context that reports no -// related observations (equivalent to StaticReportContext). +// the in-process path. States carries the CheckStates the host produced by +// evaluating this checker's rules against the same observation, letting +// reporters render rule-driven sections (for example a "fix these first" +// list) without re-deriving severity or hints from Data. +// +// The host composes both fields before making the HTTP request. When both +// are absent, the remote checker receives a context equivalent to +// StaticReportContext (no related observations and no states); the +// reporter then falls back to a data-only rendering. type ExternalReportRequest struct { Key ObservationKey `json:"key"` Data json.RawMessage `json:"data"` Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"` + States []CheckState `json:"states,omitempty"` } // HealthResponse is returned by GET /health on a remote checker endpoint. diff --git a/checker/types_test.go b/checker/types_test.go index 8ba89f2..87022ee 100644 --- a/checker/types_test.go +++ b/checker/types_test.go @@ -17,6 +17,7 @@ package checker import ( "context" "encoding/json" + "reflect" "testing" ) @@ -156,6 +157,54 @@ func TestCheckerDefinition_BuildRulesInfo(t *testing.T) { } } +// Compile-time check that fixedReportContext implements ReportContext. +var _ ReportContext = fixedReportContext{} + +func TestStaticReportContext_NoExtras(t *testing.T) { + ctx := StaticReportContext(json.RawMessage(`{"k":"v"}`)) + if string(ctx.Data()) != `{"k":"v"}` { + t.Errorf("Data() = %s, want %s", ctx.Data(), `{"k":"v"}`) + } + if ctx.Related("any") != nil { + t.Error("Related(any) should be nil for StaticReportContext") + } + if ctx.States() != nil { + t.Error("States() should be nil for StaticReportContext") + } +} + +func TestNewReportContext_NilStates(t *testing.T) { + ctx := NewReportContext(json.RawMessage(`{}`), nil, nil) + if ctx.States() != nil { + t.Errorf("States() = %v, want nil", ctx.States()) + } +} + +func TestNewReportContext_PassesStates(t *testing.T) { + states := []CheckState{ + {Status: StatusWarn, Message: "heads up", RuleName: "r1"}, + {Status: StatusCrit, Message: "fix me", RuleName: "r2", Subject: "host.example"}, + } + ctx := NewReportContext(json.RawMessage(`{}`), nil, states) + got := ctx.States() + if !reflect.DeepEqual(got, states) { + t.Errorf("States() = %+v, want %+v", got, states) + } +} + +func TestNewReportContext_PassesRelated(t *testing.T) { + rel := map[ObservationKey][]RelatedObservation{ + "other.key": {{CheckerID: "other", Key: "other.key", Ref: "r1"}}, + } + ctx := NewReportContext(json.RawMessage(`{}`), rel, nil) + if got := ctx.Related("other.key"); len(got) != 1 || got[0].CheckerID != "other" { + t.Errorf("Related(other.key) = %+v, want one entry with CheckerID=other", got) + } + if ctx.Related("missing") != nil { + t.Error("Related(missing) should be nil") + } +} + func TestRegisterChecker_EmptyIDRejected(t *testing.T) { resetRegistries() RegisterChecker(&CheckerDefinition{ID: "", Name: "bad"}) From 660fda9c3aa96c25dbdcfda7433e7e3ebbf9fcb5 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 10:52:30 +0700 Subject: [PATCH 5/7] server: add -healthcheck flag for scratch-image Docker probes Registered on the default FlagSet; ListenAndServe intercepts it and exits 0/1 after probing /health on the -listen address. Lets checker-* Dockerfiles add HEALTHCHECK without any main.go change, even though the runtime image is scratch (no shell, no curl, no wget). --- checker/server/healthcheck.go | 81 ++++++++++++++++++++++++++++++ checker/server/healthcheck_test.go | 72 ++++++++++++++++++++++++++ checker/server/server.go | 13 +++++ 3 files changed, 166 insertions(+) create mode 100644 checker/server/healthcheck.go create mode 100644 checker/server/healthcheck_test.go diff --git a/checker/server/healthcheck.go b/checker/server/healthcheck.go new file mode 100644 index 0000000..bb79d22 --- /dev/null +++ b/checker/server/healthcheck.go @@ -0,0 +1,81 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// healthcheckMode is registered on the default flag set so any consumer that +// calls flag.Parse() before ListenAndServe (the standard pattern in our +// checker mains) gets the behaviour for free. When set, ListenAndServe +// performs a short-lived HTTP probe against /health on the configured listen +// address and exits 0/1 instead of starting the server. This lets the same +// binary act as its own Docker HEALTHCHECK probe for scratch images, where +// no shell, curl or wget is available. +var healthcheckMode = flag.Bool( + "healthcheck", + false, + "probe /health on the server's listen address and exit 0 if healthy, 1 "+ + "otherwise (intended as a Docker HEALTHCHECK for scratch-based images)", +) + +// runHealthcheck performs a GET against http:///health with a short +// timeout. Returns nil on a 2xx response, an error otherwise. A bind address +// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface +// so the probe targets the local process. +func runHealthcheck(addr string) error { + host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr)) + if err != nil { + return fmt.Errorf("invalid listen addr %q: %w", addr, err) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode) + } + return nil +} + +func normalizeHealthcheckAddr(a string) string { + if strings.HasPrefix(a, ":") { + return "127.0.0.1" + a + } + if strings.HasPrefix(a, "[::]:") { + return "[::1]:" + strings.TrimPrefix(a, "[::]:") + } + return a +} diff --git a/checker/server/healthcheck_test.go b/checker/server/healthcheck_test.go new file mode 100644 index 0000000..daa4bc5 --- /dev/null +++ b/checker/server/healthcheck_test.go @@ -0,0 +1,72 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRunHealthcheck_OK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err != nil { + t.Fatalf("runHealthcheck(%s) returned error: %v", addr, err) + } +} + +func TestRunHealthcheck_NonOK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err == nil { + t.Fatalf("runHealthcheck against 503 returned nil; want error") + } +} + +func TestRunHealthcheck_Unreachable(t *testing.T) { + // Reserved-for-documentation port on loopback that nothing should bind. + if err := runHealthcheck("127.0.0.1:1"); err == nil { + t.Fatalf("runHealthcheck against unreachable port returned nil; want error") + } +} + +func TestNormalizeHealthcheckAddr(t *testing.T) { + cases := map[string]string{ + ":8080": "127.0.0.1:8080", + "127.0.0.1:8080": "127.0.0.1:8080", + "0.0.0.0:8080": "0.0.0.0:8080", + "[::1]:8080": "[::1]:8080", + "[::]:8080": "[::1]:8080", + } + for in, want := range cases { + if got := normalizeHealthcheckAddr(in); got != want { + t.Errorf("normalizeHealthcheckAddr(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/checker/server/server.go b/checker/server/server.go index 26f50ee..caae01f 100644 --- a/checker/server/server.go +++ b/checker/server/server.go @@ -26,6 +26,7 @@ import ( "log" "math" "net/http" + "os" "runtime" "strings" "sync" @@ -170,7 +171,19 @@ func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *h // ListenAndServe does not stop the background load-average sampler on return; // call Close to stop it. This is not required for process-scoped usage but is // recommended for tests and embedded lifecycles. +// +// If the consumer's flag.Parse() set the SDK-registered -healthcheck flag, +// ListenAndServe never starts the server: it probes /health on addr and calls +// os.Exit(0) on success or os.Exit(1) on failure. This is what lets a +// scratch-based Docker image use the binary itself as its HEALTHCHECK probe. func (s *Server) ListenAndServe(addr string) error { + if *healthcheckMode { + if err := runHealthcheck(addr); err != nil { + fmt.Fprintln(os.Stderr, "healthcheck failed:", err) + os.Exit(1) + } + os.Exit(0) + } log.Printf("checker listening on %s", addr) return http.ListenAndServe(addr, requestLogger(s.mux)) } From 8f8dc3ca57b221806a705b70b77af9422d89dc40 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 27 Apr 2026 00:23:13 +0700 Subject: [PATCH 6/7] server: graceful shutdown on SIGINT/SIGTERM in ListenAndServe Drains in-flight requests within a 10s timeout and stops the load-average sampler before returning. Callers needing custom signal handling can still opt out via Handler() and run their own http.Server. --- checker/server/server.go | 55 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/checker/server/server.go b/checker/server/server.go index caae01f..763a600 100644 --- a/checker/server/server.go +++ b/checker/server/server.go @@ -27,10 +27,12 @@ import ( "math" "net/http" "os" + "os/signal" "runtime" "strings" "sync" "sync/atomic" + "syscall" "time" "git.happydns.org/checker-sdk-go/checker" @@ -44,6 +46,10 @@ const maxRequestBodySize = 1 << 20 // 5 seconds matches the Unix kernel's loadavg cadence. const loadSampleInterval = 5 * time.Second +// shutdownTimeout bounds how long ListenAndServe waits for in-flight +// requests to drain after receiving SIGINT or SIGTERM. +const shutdownTimeout = 10 * time.Second + // EWMA smoothing factors for 1, 5, and 15-minute windows sampled every // loadSampleInterval. Derived as 1 - exp(-interval/window) so that the // steady-state response to a constant InFlight of N converges to N. @@ -166,11 +172,15 @@ func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *h s.mux.HandleFunc(pattern, handler) } -// ListenAndServe starts the HTTP server on the given address. +// ListenAndServe starts the HTTP server on the given address and blocks +// until the server stops. // -// ListenAndServe does not stop the background load-average sampler on return; -// call Close to stop it. This is not required for process-scoped usage but is -// recommended for tests and embedded lifecycles. +// ListenAndServe installs a SIGINT/SIGTERM handler that triggers a graceful +// shutdown: new connections are refused and in-flight requests are given up +// to shutdownTimeout to complete. The background load-average sampler is +// stopped via Close before returning. Callers who need their own signal +// handling or shutdown semantics should use Handler() and run their own +// http.Server instead. // // If the consumer's flag.Parse() set the SDK-registered -healthcheck flag, // ListenAndServe never starts the server: it probes /health on addr and calls @@ -184,8 +194,43 @@ func (s *Server) ListenAndServe(addr string) error { } os.Exit(0) } + + srv := &http.Server{Addr: addr, Handler: requestLogger(s.mux)} + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + shutdownErr := make(chan error, 1) + go func() { + sig, ok := <-sigCh + if !ok { + shutdownErr <- nil + return + } + log.Printf("checker received %s, shutting down (timeout %s)", sig, shutdownTimeout) + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + shutdownErr <- srv.Shutdown(ctx) + }() + log.Printf("checker listening on %s", addr) - return http.ListenAndServe(addr, requestLogger(s.mux)) + err := srv.ListenAndServe() + signal.Stop(sigCh) + close(sigCh) + + if err == http.ErrServerClosed { + if sErr := <-shutdownErr; sErr != nil { + err = sErr + } else { + err = nil + } + } + + if cErr := s.Close(); cErr != nil && err == nil { + err = cErr + } + return err } // Close stops the background load-average sampler goroutine. It is safe to From c1de9aca1cab48e7fd97a02e6ee2f58e907441f6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 17:35:13 +0700 Subject: [PATCH 7/7] checker: add JoinRelative helper for service-relative owner names --- checker/names.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 checker/names.go diff --git a/checker/names.go b/checker/names.go new file mode 100644 index 0000000..61df52e --- /dev/null +++ b/checker/names.go @@ -0,0 +1,37 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import "strings" + +// JoinRelative treats name as relative to origin, as happyDomain encodes +// service-embedded record owners and subdomains. An empty or "@" name +// resolves to the origin itself; an empty origin returns the trimmed name +// unchanged. A name already suffixed by origin is returned as-is so that +// absolute encodings round-trip safely. Trailing dots are stripped. +func JoinRelative(name, origin string) string { + origin = strings.TrimSuffix(origin, ".") + name = strings.TrimSuffix(name, ".") + if origin == "" { + return name + } + if name == "" || name == "@" { + return origin + } + if name == origin || strings.HasSuffix(name, "."+origin) { + return name + } + return name + "." + origin +}