Compare commits
3 commits
c9ee6655ca
...
89cc3f112b
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cc3f112b | |||
| c244ca48c6 | |||
| 356e6cd8db |
8 changed files with 666 additions and 391 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
// Copyright 2020-2026 The happyDomain Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// interactiveProvider embeds testProvider and adds CheckerInteractive.
|
||||
type interactiveProvider struct {
|
||||
*testProvider
|
||||
fields []CheckerOptionField
|
||||
parseFn func(r *http.Request) (CheckerOptions, error)
|
||||
parseErr error
|
||||
}
|
||||
|
||||
func (p *interactiveProvider) RenderForm() []CheckerOptionField {
|
||||
return p.fields
|
||||
}
|
||||
|
||||
func (p *interactiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) {
|
||||
if p.parseErr != nil {
|
||||
return nil, p.parseErr
|
||||
}
|
||||
if p.parseFn != nil {
|
||||
return p.parseFn(r)
|
||||
}
|
||||
return CheckerOptions{"domain": r.FormValue("domain")}, nil
|
||||
}
|
||||
|
||||
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// minimalProvider implements only ObservationProvider.
|
||||
type minimalProvider struct{ key ObservationKey }
|
||||
|
||||
func (m *minimalProvider) Key() ObservationKey { return m.key }
|
||||
func (m *minimalProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) {
|
||||
p := &minimalProvider{key: "test"}
|
||||
srv := NewServer(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /check without CheckerInteractive = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Form_Renders(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test"},
|
||||
fields: []CheckerOptionField{
|
||||
{Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"},
|
||||
{Id: "verbose", Type: "bool", Label: "Verbose", Default: true},
|
||||
{Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"},
|
||||
{Id: "hidden", Type: "string", Hide: true},
|
||||
},
|
||||
}
|
||||
srv := NewServer(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`name="domain"`,
|
||||
`placeholder="example.com"`,
|
||||
`Domain name`,
|
||||
`type="checkbox"`,
|
||||
`name="verbose"`,
|
||||
` checked`,
|
||||
`<select id="flavor"`,
|
||||
`<option value="b" selected>`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("form body missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `name="hidden"`) {
|
||||
t.Errorf("hidden field should not be rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_Success(t *testing.T) {
|
||||
definition := &CheckerDefinition{
|
||||
ID: "test",
|
||||
Name: "Test Checker",
|
||||
Rules: []CheckRule{
|
||||
&dummyRule{name: "rule1", desc: "first rule"},
|
||||
},
|
||||
}
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test", definition: definition},
|
||||
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()
|
||||
for _, want := range []string{
|
||||
`Test Checker`,
|
||||
`Check states`,
|
||||
`rule1`,
|
||||
`rule1 passed`,
|
||||
`badge ok`,
|
||||
`Metrics`,
|
||||
`m1`,
|
||||
`Report`,
|
||||
`<iframe`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("result body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test"},
|
||||
fields: []CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
parseErr: errors.New("domain is required"),
|
||||
}
|
||||
srv := NewServer(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("POST /check with bad input = %d, want 400", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "domain is required") {
|
||||
t.Errorf("body missing error message, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `name="domain"`) {
|
||||
t.Errorf("form not re-rendered on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_CollectError(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{
|
||||
key: "test",
|
||||
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
},
|
||||
fields: []CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
}
|
||||
srv := NewServer(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200 (collect failure still renders a page)", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Collect failed") || !strings.Contains(body, "boom") {
|
||||
t.Errorf("body missing Collect error, got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "Check states") {
|
||||
t.Errorf("states section should not render when Collect failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_NoReporters(t *testing.T) {
|
||||
// Provider implements CheckerInteractive and has a definition (so
|
||||
// /evaluate-like logic runs) but no HTMLReporter / MetricsReporter.
|
||||
bare := &bareInteractiveProvider{
|
||||
key: "test",
|
||||
def: &CheckerDefinition{
|
||||
ID: "test",
|
||||
Rules: []CheckRule{&dummyRule{name: "r", desc: "r"}},
|
||||
},
|
||||
}
|
||||
srv := NewServer(bare)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Check states") {
|
||||
t.Errorf("body missing states section")
|
||||
}
|
||||
if strings.Contains(body, "<iframe") {
|
||||
t.Errorf("body should not contain iframe when no HTML reporter")
|
||||
}
|
||||
if strings.Contains(body, "<h2>Metrics</h2>") {
|
||||
t.Errorf("body should not contain metrics section when no metrics reporter")
|
||||
}
|
||||
}
|
||||
|
||||
// bareInteractiveProvider implements only the required interfaces
|
||||
// (ObservationProvider, CheckerDefinitionProvider, CheckerInteractive)
|
||||
// — no reporters.
|
||||
type bareInteractiveProvider struct {
|
||||
key ObservationKey
|
||||
def *CheckerDefinition
|
||||
}
|
||||
|
||||
func (b *bareInteractiveProvider) Key() ObservationKey { return b.key }
|
||||
func (b *bareInteractiveProvider) Collect(ctx context.Context, opts 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) ParseForm(r *http.Request) (CheckerOptions, error) {
|
||||
return CheckerOptions{"domain": r.FormValue("domain")}, nil
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -12,39 +12,43 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package checker
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"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
|
||||
|
|
@ -52,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
|
||||
|
|
@ -68,7 +87,7 @@ type checkResult struct {
|
|||
|
||||
type checkFormPage struct {
|
||||
Title string
|
||||
Fields []CheckerOptionField
|
||||
Fields []checker.CheckerOptionField
|
||||
Error string
|
||||
}
|
||||
|
||||
|
|
@ -104,16 +123,21 @@ 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[checker.ObservationKey]json.RawMessage{
|
||||
s.provider.Key(): raw,
|
||||
},
|
||||
related: related,
|
||||
}
|
||||
result.States = s.evaluateRules(r.Context(), obs, opts, nil)
|
||||
}
|
||||
|
||||
ctx := NewReportContext(raw, nil)
|
||||
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()
|
||||
|
|
@ -122,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()
|
||||
|
|
@ -134,6 +158,86 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
|
|||
s.renderCheckResult(w, result)
|
||||
}
|
||||
|
||||
// collectRelatedObservations runs sibling providers declared via Siblings
|
||||
// and returns their results keyed by the sibling's observation key.
|
||||
// Sibling errors are logged and skipped.
|
||||
func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation {
|
||||
irp, ok := s.provider.(Siblings)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
siblings := irp.RelatedProviders()
|
||||
if len(siblings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []checker.DiscoveryEntry
|
||||
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
|
||||
e, err := dp.DiscoverEntries(data)
|
||||
if err != nil {
|
||||
log.Printf("interactive: DiscoverEntries failed: %v", err)
|
||||
} else {
|
||||
entries = e
|
||||
}
|
||||
}
|
||||
|
||||
related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings))
|
||||
for _, sp := range siblings {
|
||||
sOpts := cloneOptions(opts)
|
||||
siblingID := ""
|
||||
if dp, ok := sp.(checker.CheckerDefinitionProvider); ok {
|
||||
if def := dp.Definition(); def != nil {
|
||||
siblingID = def.ID
|
||||
if len(entries) > 0 {
|
||||
fillDiscoveryEntryOption(sOpts, def, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
sData, err := sp.Collect(ctx, sOpts)
|
||||
if err != nil {
|
||||
log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err)
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(sData)
|
||||
if err != nil {
|
||||
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
|
||||
continue
|
||||
}
|
||||
related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{
|
||||
CheckerID: siblingID,
|
||||
Key: sp.Key(),
|
||||
Data: raw,
|
||||
CollectedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return related
|
||||
}
|
||||
|
||||
func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions {
|
||||
out := make(checker.CheckerOptions, len(opts))
|
||||
maps.Copy(out, opts)
|
||||
return out
|
||||
}
|
||||
|
||||
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
|
||||
// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries.
|
||||
func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) {
|
||||
scopes := [][]checker.CheckerOptionDocumentation{
|
||||
def.Options.AdminOpts,
|
||||
def.Options.UserOpts,
|
||||
def.Options.DomainOpts,
|
||||
def.Options.ServiceOpts,
|
||||
def.Options.RunOpts,
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, f := range scope {
|
||||
if f.AutoFill == checker.AutoFillDiscoveryEntries {
|
||||
opts[f.Id] = entries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) checkPageTitle() string {
|
||||
if s.definition != nil && s.definition.Name != "" {
|
||||
return s.definition.Name
|
||||
|
|
@ -153,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
|
||||
|
|
@ -169,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"
|
||||
|
|
@ -211,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,
|
||||
}
|
||||
380
checker/server/interactive_test.go
Normal file
380
checker/server/interactive_test.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
// Copyright 2020-2026 The happyDomain Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// interactiveProvider embeds testProvider and adds Interactive.
|
||||
type interactiveProvider struct {
|
||||
*testProvider
|
||||
fields []checker.CheckerOptionField
|
||||
parseFn func(r *http.Request) (checker.CheckerOptions, error)
|
||||
parseErr error
|
||||
}
|
||||
|
||||
func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField {
|
||||
return p.fields
|
||||
}
|
||||
|
||||
func (p *interactiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
||||
if p.parseErr != nil {
|
||||
return nil, p.parseErr
|
||||
}
|
||||
if p.parseFn != nil {
|
||||
return p.parseFn(r)
|
||||
}
|
||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
||||
}
|
||||
|
||||
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// minimalProvider implements only ObservationProvider.
|
||||
type minimalProvider struct{ key checker.ObservationKey }
|
||||
|
||||
func (m *minimalProvider) Key() checker.ObservationKey { return m.key }
|
||||
func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) {
|
||||
p := &minimalProvider{key: "test"}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Form_Renders(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test"},
|
||||
fields: []checker.CheckerOptionField{
|
||||
{Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"},
|
||||
{Id: "verbose", Type: "bool", Label: "Verbose", Default: true},
|
||||
{Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"},
|
||||
{Id: "hidden", Type: "string", Hide: true},
|
||||
},
|
||||
}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`name="domain"`,
|
||||
`placeholder="example.com"`,
|
||||
`Domain name`,
|
||||
`type="checkbox"`,
|
||||
`name="verbose"`,
|
||||
` checked`,
|
||||
`<select id="flavor"`,
|
||||
`<option value="b" selected>`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("form body missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `name="hidden"`) {
|
||||
t.Errorf("hidden field should not be rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_Success(t *testing.T) {
|
||||
definition := &checker.CheckerDefinition{
|
||||
ID: "test",
|
||||
Name: "Test Checker",
|
||||
Rules: []checker.CheckRule{
|
||||
&dummyRule{name: "rule1", desc: "first rule"},
|
||||
},
|
||||
}
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test", definition: definition},
|
||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`Test Checker`,
|
||||
`Check states`,
|
||||
`rule1`,
|
||||
`rule1 passed`,
|
||||
`badge ok`,
|
||||
`Metrics`,
|
||||
`m1`,
|
||||
`Report`,
|
||||
`<iframe`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("result body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{key: "test"},
|
||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
parseErr: errors.New("domain is required"),
|
||||
}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("POST /check with bad input = %d, want 400", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "domain is required") {
|
||||
t.Errorf("body missing error message, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `name="domain"`) {
|
||||
t.Errorf("form not re-rendered on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_CollectError(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{
|
||||
key: "test",
|
||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
},
|
||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200 (collect failure still renders a page)", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Collect failed") || !strings.Contains(body, "boom") {
|
||||
t.Errorf("body missing Collect error, got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "Check states") {
|
||||
t.Errorf("states section should not render when Collect failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_NoReporters(t *testing.T) {
|
||||
// Provider implements Interactive and has a definition (so
|
||||
// /evaluate-like logic runs) but no HTMLReporter / MetricsReporter.
|
||||
bare := &bareInteractiveProvider{
|
||||
key: "test",
|
||||
def: &checker.CheckerDefinition{
|
||||
ID: "test",
|
||||
Rules: []checker.CheckRule{&dummyRule{name: "r", desc: "r"}},
|
||||
},
|
||||
}
|
||||
srv := New(bare)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Check states") {
|
||||
t.Errorf("body missing states section")
|
||||
}
|
||||
if strings.Contains(body, "<iframe") {
|
||||
t.Errorf("body should not contain iframe when no HTML reporter")
|
||||
}
|
||||
if strings.Contains(body, "<h2>Metrics</h2>") {
|
||||
t.Errorf("body should not contain metrics section when no metrics reporter")
|
||||
}
|
||||
}
|
||||
|
||||
// bareInteractiveProvider implements only the required interfaces
|
||||
// (ObservationProvider, CheckerDefinitionProvider, Interactive),
|
||||
// no reporters.
|
||||
type bareInteractiveProvider struct {
|
||||
key checker.ObservationKey
|
||||
def *checker.CheckerDefinition
|
||||
}
|
||||
|
||||
func (b *bareInteractiveProvider) Key() checker.ObservationKey { return b.key }
|
||||
func (b *bareInteractiveProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return map[string]string{"ok": "1"}, nil
|
||||
}
|
||||
func (b *bareInteractiveProvider) Definition() *checker.CheckerDefinition { return b.def }
|
||||
func (b *bareInteractiveProvider) RenderForm() []checker.CheckerOptionField {
|
||||
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
|
||||
}
|
||||
func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
||||
}
|
||||
|
||||
type siblingProvider struct {
|
||||
key checker.ObservationKey
|
||||
id string
|
||||
entriesOpt string
|
||||
gotOpts checker.CheckerOptions
|
||||
payload any
|
||||
}
|
||||
|
||||
func (s *siblingProvider) Key() checker.ObservationKey { return s.key }
|
||||
func (s *siblingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
s.gotOpts = opts
|
||||
return s.payload, nil
|
||||
}
|
||||
func (s *siblingProvider) Definition() *checker.CheckerDefinition {
|
||||
return &checker.CheckerDefinition{
|
||||
ID: s.id,
|
||||
Options: checker.CheckerOptionsDocumentation{
|
||||
RunOpts: []checker.CheckerOptionDocumentation{
|
||||
{Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type primaryWithSibling struct {
|
||||
key checker.ObservationKey
|
||||
def *checker.CheckerDefinition
|
||||
entries []checker.DiscoveryEntry
|
||||
sibling checker.ObservationProvider
|
||||
}
|
||||
|
||||
func (p *primaryWithSibling) Key() checker.ObservationKey { return p.key }
|
||||
func (p *primaryWithSibling) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return map[string]string{"primary": "ok"}, nil
|
||||
}
|
||||
func (p *primaryWithSibling) Definition() *checker.CheckerDefinition { return p.def }
|
||||
func (p *primaryWithSibling) RenderForm() []checker.CheckerOptionField {
|
||||
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
|
||||
}
|
||||
func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
||||
}
|
||||
func (p *primaryWithSibling) DiscoverEntries(data any) ([]checker.DiscoveryEntry, error) {
|
||||
return p.entries, nil
|
||||
}
|
||||
func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider {
|
||||
return []checker.ObservationProvider{p.sibling}
|
||||
}
|
||||
|
||||
type relatedAssertRule struct {
|
||||
key checker.ObservationKey
|
||||
}
|
||||
|
||||
func (r *relatedAssertRule) Name() string { return "related_assert" }
|
||||
func (r *relatedAssertRule) Description() string { return "" }
|
||||
func (r *relatedAssertRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
||||
related, err := obs.GetRelated(ctx, r.key)
|
||||
if err != nil {
|
||||
return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}}
|
||||
}
|
||||
if len(related) == 0 {
|
||||
return []checker.CheckState{{Status: checker.StatusCrit, Message: "no related observation"}}
|
||||
}
|
||||
return []checker.CheckState{{Status: checker.StatusOK, Message: "saw related observation"}}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
|
||||
sibling := &siblingProvider{
|
||||
key: "sibling_key",
|
||||
id: "sibling",
|
||||
entriesOpt: "endpoints",
|
||||
payload: map[string]string{"sibling": "ok"},
|
||||
}
|
||||
entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"}
|
||||
primary := &primaryWithSibling{
|
||||
key: "primary_key",
|
||||
def: &checker.CheckerDefinition{
|
||||
ID: "primary",
|
||||
Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}},
|
||||
},
|
||||
entries: []checker.DiscoveryEntry{entry},
|
||||
sibling: sibling,
|
||||
}
|
||||
|
||||
srv := New(primary)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "saw related observation") {
|
||||
t.Errorf("rule did not see related observation; body:\n%s", body)
|
||||
}
|
||||
|
||||
got, ok := sibling.gotOpts[sibling.entriesOpt].([]checker.DiscoveryEntry)
|
||||
if !ok {
|
||||
t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt])
|
||||
}
|
||||
if len(got) != 1 || got[0].Ref != entry.Ref {
|
||||
t.Errorf("sibling saw entries %v, want [%v]", got, entry)
|
||||
}
|
||||
|
||||
if v, _ := sibling.gotOpts["domain"].(string); v != "example.com" {
|
||||
t.Errorf("sibling did not receive primary domain opt, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) {
|
||||
p := &interactiveProvider{
|
||||
testProvider: &testProvider{
|
||||
key: "test",
|
||||
definition: &checker.CheckerDefinition{
|
||||
ID: "test",
|
||||
Rules: []checker.CheckRule{&relatedAssertRule{key: "other"}},
|
||||
},
|
||||
},
|
||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
||||
}
|
||||
srv := New(p)
|
||||
defer srv.Close()
|
||||
|
||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "no related observation") {
|
||||
t.Errorf("rule should have seen no related observation; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,11 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package checker
|
||||
// Package server provides the HTTP server scaffolding used by standalone
|
||||
// checkers. It is separated from the core checker package so that plugin
|
||||
// and builtin builds, which never expose an HTTP endpoint, do not pay the
|
||||
// cost of net/http, html/template, and their transitive dependencies.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -27,6 +31,8 @@ import (
|
|||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
|
||||
|
|
@ -58,21 +64,21 @@ func updateLoadAvg(prev [3]float64, sample float64) [3]float64 {
|
|||
|
||||
// Server is a generic HTTP server for external checkers.
|
||||
// It always exposes /health and /collect. If the provider implements
|
||||
// CheckerDefinitionProvider, it also exposes /definition and /evaluate.
|
||||
// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter,
|
||||
// it also exposes /report. If the provider implements CheckerInteractive,
|
||||
// checker.CheckerDefinitionProvider, it also exposes /definition and /evaluate.
|
||||
// If the provider implements checker.CheckerHTMLReporter or checker.CheckerMetricsReporter,
|
||||
// it also exposes /report. If the provider implements Interactive,
|
||||
// it also exposes /check (a human-facing web form).
|
||||
//
|
||||
// Security: Server does not perform any authentication or authorization.
|
||||
// It is intended to be run behind a reverse proxy or in a trusted network
|
||||
// where access control is handled externally (e.g. by the happyDomain server).
|
||||
type Server struct {
|
||||
provider ObservationProvider
|
||||
definition *CheckerDefinition
|
||||
interactive CheckerInteractive
|
||||
provider checker.ObservationProvider
|
||||
definition *checker.CheckerDefinition
|
||||
interactive Interactive
|
||||
mux *http.ServeMux
|
||||
|
||||
// startTime is captured in NewServer and used to compute uptime.
|
||||
// startTime is captured in New and used to compute uptime.
|
||||
startTime time.Time
|
||||
|
||||
// inFlight counts work requests (/collect, /evaluate, /report) currently
|
||||
|
|
@ -97,13 +103,13 @@ type Server struct {
|
|||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewServer creates a new checker HTTP server backed by the given provider.
|
||||
// New creates a new checker HTTP server backed by the given provider.
|
||||
// Additional endpoints are registered based on optional interfaces the provider implements.
|
||||
//
|
||||
// NewServer also starts a background goroutine that samples the in-flight
|
||||
// New also starts a background goroutine that samples the in-flight
|
||||
// request count every loadSampleInterval to compute the load averages
|
||||
// reported on /health. Call Close to stop it.
|
||||
func NewServer(provider ObservationProvider) *Server {
|
||||
func New(provider checker.ObservationProvider) *Server {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{
|
||||
provider: provider,
|
||||
|
|
@ -115,7 +121,7 @@ func NewServer(provider ObservationProvider) *Server {
|
|||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect)))
|
||||
|
||||
if dp, ok := provider.(CheckerDefinitionProvider); ok {
|
||||
if dp, ok := provider.(checker.CheckerDefinitionProvider); ok {
|
||||
if def := dp.Definition(); def != nil {
|
||||
s.definition = def
|
||||
s.definition.BuildRulesInfo()
|
||||
|
|
@ -124,13 +130,13 @@ func NewServer(provider ObservationProvider) *Server {
|
|||
}
|
||||
}
|
||||
|
||||
if _, ok := provider.(CheckerHTMLReporter); ok {
|
||||
if _, ok := provider.(checker.CheckerHTMLReporter); ok {
|
||||
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
|
||||
} else if _, ok := provider.(CheckerMetricsReporter); ok {
|
||||
} else if _, ok := provider.(checker.CheckerMetricsReporter); ok {
|
||||
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
|
||||
}
|
||||
|
||||
if ip, ok := provider.(CheckerInteractive); ok {
|
||||
if ip, ok := provider.(Interactive); ok {
|
||||
s.interactive = ip
|
||||
s.mux.HandleFunc("GET /check", s.handleCheckForm)
|
||||
s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit)))
|
||||
|
|
@ -171,7 +177,7 @@ func (s *Server) ListenAndServe(addr string) error {
|
|||
|
||||
// Close stops the background load-average sampler goroutine. It is safe to
|
||||
// call multiple times; subsequent calls are no-ops. Close does not shut down
|
||||
// any underlying http.Server — callers own that lifecycle.
|
||||
// any underlying http.Server, callers own that lifecycle.
|
||||
func (s *Server) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.cancelSampler()
|
||||
|
|
@ -238,7 +244,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||
for i := range load {
|
||||
load[i] = math.Float64frombits(s.loadBits[i].Load())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, HealthResponse{
|
||||
writeJSON(w, http.StatusOK, checker.HealthResponse{
|
||||
Status: "ok",
|
||||
Uptime: time.Since(s.startTime).Seconds(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
|
|
@ -253,9 +259,9 @@ func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||
var req ExternalCollectRequest
|
||||
var req checker.ExternalCollectRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{
|
||||
writeJSON(w, http.StatusBadRequest, checker.ExternalCollectResponse{
|
||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||
})
|
||||
return
|
||||
|
|
@ -263,7 +269,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
data, err := s.provider.Collect(r.Context(), req.Options)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{
|
||||
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
|
|
@ -271,18 +277,18 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{
|
||||
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
|
||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp := ExternalCollectResponse{Data: json.RawMessage(raw)}
|
||||
resp := checker.ExternalCollectResponse{Data: json.RawMessage(raw)}
|
||||
|
||||
// Harvest discovery entries from the native Go value, before it goes
|
||||
// out of scope. No re-parse; DiscoverEntries operates on the same
|
||||
// object that was just marshaled above.
|
||||
if dp, ok := s.provider.(DiscoveryPublisher); ok {
|
||||
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
|
||||
entries, derr := dp.DiscoverEntries(data)
|
||||
if derr != nil {
|
||||
log.Printf("DiscoverEntries failed: %v", derr)
|
||||
|
|
@ -296,8 +302,8 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// evaluateRules runs all definition rules against obs/opts, skipping any rule
|
||||
// whose name maps to false in enabledRules (nil means run all).
|
||||
func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts CheckerOptions, enabledRules map[string]bool) []CheckState {
|
||||
var states []CheckState
|
||||
func (s *Server) evaluateRules(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions, enabledRules map[string]bool) []checker.CheckState {
|
||||
var states []checker.CheckState
|
||||
for _, rule := range s.definition.Rules {
|
||||
if len(enabledRules) > 0 {
|
||||
if enabled, ok := enabledRules[rule.Name()]; ok && !enabled {
|
||||
|
|
@ -306,8 +312,8 @@ func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts
|
|||
}
|
||||
ruleStates := rule.Evaluate(ctx, obs, opts)
|
||||
if len(ruleStates) == 0 {
|
||||
ruleStates = []CheckState{{
|
||||
Status: StatusUnknown,
|
||||
ruleStates = []checker.CheckState{{
|
||||
Status: checker.StatusUnknown,
|
||||
Message: fmt.Sprintf("rule %q returned no state", rule.Name()),
|
||||
}}
|
||||
}
|
||||
|
|
@ -320,9 +326,9 @@ func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts
|
|||
}
|
||||
|
||||
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
|
||||
var req ExternalEvaluateRequest
|
||||
var req checker.ExternalEvaluateRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{
|
||||
writeJSON(w, http.StatusBadRequest, checker.ExternalEvaluateResponse{
|
||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||
})
|
||||
return
|
||||
|
|
@ -330,11 +336,11 @@ func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
obs := &mapObservationGetter{data: req.Observations}
|
||||
states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules)
|
||||
writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states})
|
||||
writeJSON(w, http.StatusOK, checker.ExternalEvaluateResponse{States: states})
|
||||
}
|
||||
|
||||
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
var req ExternalReportRequest
|
||||
var req checker.ExternalReportRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": fmt.Sprintf("invalid request body: %v", err),
|
||||
|
|
@ -345,13 +351,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||
accept := r.Header.Get("Accept")
|
||||
|
||||
if strings.Contains(accept, "text/html") {
|
||||
reporter, ok := s.provider.(CheckerHTMLReporter)
|
||||
reporter, ok := s.provider.(checker.CheckerHTMLReporter)
|
||||
if !ok {
|
||||
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related))
|
||||
html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -363,13 +369,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Default: JSON metrics.
|
||||
reporter, ok := s.provider.(CheckerMetricsReporter)
|
||||
reporter, ok := s.provider.(checker.CheckerMetricsReporter)
|
||||
if !ok {
|
||||
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now())
|
||||
metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related), time.Now())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("failed to extract metrics: %v", err),
|
||||
|
|
@ -380,12 +386,16 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
// mapObservationGetter implements ObservationGetter backed by a static map.
|
||||
// mapObservationGetter implements checker.ObservationGetter backed by static maps.
|
||||
// Both fields are optional: Get reads from data, GetRelated reads from
|
||||
// related. Leaving related nil preserves the pre-existing "no lineage"
|
||||
// behavior used by the remote /evaluate path.
|
||||
type mapObservationGetter struct {
|
||||
data map[ObservationKey]json.RawMessage
|
||||
data map[checker.ObservationKey]json.RawMessage
|
||||
related map[checker.ObservationKey][]checker.RelatedObservation
|
||||
}
|
||||
|
||||
func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error {
|
||||
func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationKey, dest any) error {
|
||||
raw, ok := g.data[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("observation %q not available", key)
|
||||
|
|
@ -393,13 +403,13 @@ func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest
|
|||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
// GetRelated always returns nil in the remote /evaluate path: the host that
|
||||
// invokes /evaluate does not (currently) carry cross-checker related data in
|
||||
// ExternalEvaluateRequest. Consumers that need related observations must run
|
||||
// evaluation locally with a host-side ObservationContext that resolves
|
||||
// lineage.
|
||||
func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) {
|
||||
return nil, nil
|
||||
// GetRelated returns the pre-resolved related observations for key, or nil
|
||||
// when none were seeded. The remote /evaluate path leaves related nil
|
||||
// because ExternalEvaluateRequest does not currently carry cross-checker
|
||||
// lineage; the interactive /check path can seed it from sibling providers
|
||||
// declared via Siblings.
|
||||
func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) {
|
||||
return g.related[key], nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
|
@ -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 "<h1>report</h1>", nil
|
||||
}
|
||||
func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) {
|
||||
func (p *testProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
|
||||
if p.metricsFn != nil {
|
||||
return p.metricsFn(ctx.Data(), t)
|
||||
}
|
||||
return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
||||
return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
||||
}
|
||||
|
||||
// dummyRule is a minimal CheckRule for testing evaluate.
|
||||
|
|
@ -65,8 +67,8 @@ type dummyRule struct {
|
|||
|
||||
func (r *dummyRule) Name() string { return r.name }
|
||||
func (r *dummyRule) Description() string { return r.desc }
|
||||
func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState {
|
||||
return []CheckState{{Status: StatusOK, Message: r.name + " passed"}}
|
||||
func (r *dummyRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
||||
return []checker.CheckState{{Status: checker.StatusOK, Message: r.name + " passed"}}
|
||||
}
|
||||
|
||||
// codedRule emits a CheckState with a pre-set Code, to verify the server
|
||||
|
|
@ -77,14 +79,25 @@ type codedRule struct {
|
|||
|
||||
func (r *codedRule) Name() string { return r.name }
|
||||
func (r *codedRule) Description() string { return "" }
|
||||
func (r *codedRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState {
|
||||
return []CheckState{{Status: StatusWarn, Code: r.code, Message: "coded finding"}}
|
||||
func (r *codedRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
||||
return []checker.CheckState{{Status: checker.StatusWarn, Code: r.code, Message: "coded finding"}}
|
||||
}
|
||||
|
||||
// stubProvider is a minimal ObservationProvider that does not implement
|
||||
// CheckerDefinitionProvider, used to verify conditional endpoint registration.
|
||||
type stubProvider struct {
|
||||
key checker.ObservationKey
|
||||
}
|
||||
|
||||
func (s stubProvider) Key() checker.ObservationKey { return s.key }
|
||||
func (s stubProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newTestServer(p *testProvider) *Server {
|
||||
return NewServer(p)
|
||||
return New(p)
|
||||
}
|
||||
|
||||
func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
|
|
@ -107,14 +120,14 @@ func doRequest(handler http.Handler, method, path string, body any, headers map[
|
|||
// --- tests ---
|
||||
|
||||
func TestServer_Health(t *testing.T) {
|
||||
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
||||
srv := newTestServer(p)
|
||||
defer srv.Close()
|
||||
rec := doRequest(srv.Handler(), "GET", "/health", nil, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var resp HealthResponse
|
||||
var resp checker.HealthResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode /health: %v", err)
|
||||
}
|
||||
|
|
@ -143,8 +156,8 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
|||
var collectEntered sync.WaitGroup
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
collectEntered.Done()
|
||||
<-release
|
||||
return map[string]string{"ok": "1"}, nil
|
||||
|
|
@ -161,7 +174,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
|||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer clientsDone.Done()
|
||||
doRequest(handler, "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil)
|
||||
doRequest(handler, "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +183,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
|||
|
||||
// Record /health mid-flight. Also hammer it to verify /health polls
|
||||
// do not inflate InFlight or TotalRequests.
|
||||
var mid HealthResponse
|
||||
var mid checker.HealthResponse
|
||||
for i := 0; i < 5; i++ {
|
||||
rec := doRequest(handler, "GET", "/health", nil, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
|
|
@ -192,7 +205,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
|||
clientsDone.Wait()
|
||||
|
||||
rec := doRequest(handler, "GET", "/health", nil, nil)
|
||||
var after HealthResponse
|
||||
var after checker.HealthResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&after); err != nil {
|
||||
t.Fatalf("decode /health: %v", err)
|
||||
}
|
||||
|
|
@ -237,7 +250,7 @@ func TestUpdateLoadAvg(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Close_Idempotent(t *testing.T) {
|
||||
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
||||
srv := newTestServer(p)
|
||||
done := make(chan error, 2)
|
||||
go func() { done <- srv.Close() }()
|
||||
|
|
@ -257,20 +270,20 @@ func TestServer_Close_Idempotent(t *testing.T) {
|
|||
func TestServer_Collect_Success(t *testing.T) {
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return map[string]int{"count": 42}, nil
|
||||
},
|
||||
}
|
||||
srv := newTestServer(p)
|
||||
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{
|
||||
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{
|
||||
Key: "test",
|
||||
Options: CheckerOptions{"a": "b"},
|
||||
Options: checker.CheckerOptions{"a": "b"},
|
||||
}, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var resp ExternalCollectResponse
|
||||
var resp checker.ExternalCollectResponse
|
||||
json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if resp.Error != "" {
|
||||
t.Errorf("POST /collect error = %q, want empty", resp.Error)
|
||||
|
|
@ -283,17 +296,17 @@ func TestServer_Collect_Success(t *testing.T) {
|
|||
func TestServer_Collect_ProviderError(t *testing.T) {
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return nil, errors.New("provider failed")
|
||||
},
|
||||
}
|
||||
srv := newTestServer(p)
|
||||
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil)
|
||||
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
var resp ExternalCollectResponse
|
||||
var resp checker.ExternalCollectResponse
|
||||
json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if resp.Error == "" {
|
||||
t.Error("expected error in response, got empty")
|
||||
|
|
@ -301,7 +314,7 @@ func TestServer_Collect_ProviderError(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Collect_BadBody(t *testing.T) {
|
||||
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
||||
srv := newTestServer(p)
|
||||
req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -312,10 +325,10 @@ func TestServer_Collect_BadBody(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Definition(t *testing.T) {
|
||||
def := &CheckerDefinition{
|
||||
def := &checker.CheckerDefinition{
|
||||
ID: "test-checker",
|
||||
Name: "Test Checker",
|
||||
Rules: []CheckRule{
|
||||
Rules: []checker.CheckRule{
|
||||
&dummyRule{name: "rule1", desc: "first rule"},
|
||||
},
|
||||
}
|
||||
|
|
@ -325,7 +338,7 @@ func TestServer_Definition(t *testing.T) {
|
|||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var got CheckerDefinition
|
||||
var got checker.CheckerDefinition
|
||||
json.NewDecoder(rec.Body).Decode(&got)
|
||||
if got.ID != "test-checker" {
|
||||
t.Errorf("definition ID = %q, want \"test-checker\"", got.ID)
|
||||
|
|
@ -336,10 +349,10 @@ func TestServer_Definition(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Evaluate(t *testing.T) {
|
||||
def := &CheckerDefinition{
|
||||
def := &checker.CheckerDefinition{
|
||||
ID: "test-checker",
|
||||
Name: "Test Checker",
|
||||
Rules: []CheckRule{
|
||||
Rules: []checker.CheckRule{
|
||||
&dummyRule{name: "rule1", desc: "first rule"},
|
||||
&dummyRule{name: "rule2", desc: "second rule"},
|
||||
},
|
||||
|
|
@ -347,16 +360,16 @@ func TestServer_Evaluate(t *testing.T) {
|
|||
p := &testProvider{key: "test", definition: def}
|
||||
srv := newTestServer(p)
|
||||
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{
|
||||
Observations: map[ObservationKey]json.RawMessage{
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
||||
Observations: map[checker.ObservationKey]json.RawMessage{
|
||||
"test": json.RawMessage(`{"count":42}`),
|
||||
},
|
||||
Options: CheckerOptions{},
|
||||
Options: checker.CheckerOptions{},
|
||||
}, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var resp ExternalEvaluateResponse
|
||||
var resp checker.ExternalEvaluateResponse
|
||||
json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if len(resp.States) != 2 {
|
||||
t.Fatalf("evaluate states = %d, want 2", len(resp.States))
|
||||
|
|
@ -370,9 +383,9 @@ func TestServer_Evaluate(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
||||
def := &CheckerDefinition{
|
||||
def := &checker.CheckerDefinition{
|
||||
ID: "test-checker",
|
||||
Rules: []CheckRule{
|
||||
Rules: []checker.CheckRule{
|
||||
&dummyRule{name: "rule1", desc: "first"},
|
||||
&dummyRule{name: "rule2", desc: "second"},
|
||||
},
|
||||
|
|
@ -380,8 +393,8 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
|||
p := &testProvider{key: "test", definition: def}
|
||||
srv := newTestServer(p)
|
||||
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{
|
||||
Observations: map[ObservationKey]json.RawMessage{
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
||||
Observations: map[checker.ObservationKey]json.RawMessage{
|
||||
"test": json.RawMessage(`{}`),
|
||||
},
|
||||
EnabledRules: map[string]bool{"rule1": false},
|
||||
|
|
@ -389,7 +402,7 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
|||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var resp ExternalEvaluateResponse
|
||||
var resp checker.ExternalEvaluateResponse
|
||||
json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if len(resp.States) != 1 {
|
||||
t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States))
|
||||
|
|
@ -400,22 +413,22 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
|
||||
def := &CheckerDefinition{
|
||||
def := &checker.CheckerDefinition{
|
||||
ID: "test-checker",
|
||||
Rules: []CheckRule{
|
||||
Rules: []checker.CheckRule{
|
||||
&codedRule{name: "ruleA", code: "too_many_lookups"},
|
||||
},
|
||||
}
|
||||
p := &testProvider{key: "test", definition: def}
|
||||
srv := newTestServer(p)
|
||||
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{
|
||||
Observations: map[ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)},
|
||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
||||
Observations: map[checker.ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)},
|
||||
}, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var resp ExternalEvaluateResponse
|
||||
var resp checker.ExternalEvaluateResponse
|
||||
json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if len(resp.States) != 1 {
|
||||
t.Fatalf("states = %d, want 1", len(resp.States))
|
||||
|
|
@ -430,14 +443,14 @@ func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
|
|||
|
||||
func TestServer_Report_HTML(t *testing.T) {
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||
key: "test",
|
||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
||||
htmlFn: func(raw json.RawMessage) (string, error) {
|
||||
return "<p>hello</p>", nil
|
||||
},
|
||||
}
|
||||
srv := newTestServer(p)
|
||||
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{
|
||||
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
|
||||
Key: "test",
|
||||
Data: json.RawMessage(`{}`),
|
||||
}, map[string]string{"Accept": "text/html"})
|
||||
|
|
@ -454,18 +467,18 @@ func TestServer_Report_HTML(t *testing.T) {
|
|||
|
||||
func TestServer_Report_Metrics(t *testing.T) {
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||
key: "test",
|
||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
||||
}
|
||||
srv := newTestServer(p)
|
||||
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{
|
||||
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
|
||||
Key: "test",
|
||||
Data: json.RawMessage(`{}`),
|
||||
}, map[string]string{"Accept": "application/json"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
var metrics []CheckMetric
|
||||
var metrics []checker.CheckMetric
|
||||
json.NewDecoder(rec.Body).Decode(&metrics)
|
||||
if len(metrics) != 1 {
|
||||
t.Errorf("metrics count = %d, want 1", len(metrics))
|
||||
|
|
@ -476,25 +489,25 @@ func TestServer_Report_Metrics(t *testing.T) {
|
|||
// ExternalReportRequest.Related through to the provider's ReportContext,
|
||||
// the fix for the "remote checkers can't see related observations" gap.
|
||||
func TestServer_Report_Related(t *testing.T) {
|
||||
var gotRelated []RelatedObservation
|
||||
var gotRelated []checker.RelatedObservation
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||
key: "test",
|
||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
||||
}
|
||||
// Replace htmlFn with one that peeks at a related key. We can't do that
|
||||
// directly through testProvider's htmlFn (which only sees raw), so
|
||||
// bind to GetHTMLReport via an inline wrapper: use a per-test provider
|
||||
// that captures the ReportContext before delegating to the template.
|
||||
srv := NewServer(&relatedPeekingProvider{
|
||||
srv := New(&relatedPeekingProvider{
|
||||
base: p,
|
||||
target: &gotRelated,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
req := ExternalReportRequest{
|
||||
req := checker.ExternalReportRequest{
|
||||
Key: "test",
|
||||
Data: json.RawMessage(`{}`),
|
||||
Related: map[ObservationKey][]RelatedObservation{
|
||||
Related: map[checker.ObservationKey][]checker.RelatedObservation{
|
||||
"tls_probes": {
|
||||
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
|
||||
},
|
||||
|
|
@ -516,15 +529,15 @@ func TestServer_Report_Related(t *testing.T) {
|
|||
// Related("tls_probes") slice observed at GetHTMLReport time into target.
|
||||
type relatedPeekingProvider struct {
|
||||
base *testProvider
|
||||
target *[]RelatedObservation
|
||||
target *[]checker.RelatedObservation
|
||||
}
|
||||
|
||||
func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() }
|
||||
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
|
||||
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
||||
return p.base.Collect(ctx, opts)
|
||||
}
|
||||
func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition }
|
||||
func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) {
|
||||
func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
|
||||
func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
|
||||
*p.target = ctx.Related("tls_probes")
|
||||
return "<p>ok</p>", 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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue