Compare commits
No commits in common. "master" and "dev" have entirely different histories.
11 changed files with 169 additions and 1649 deletions
47
README.md
47
README.md
|
|
@ -30,53 +30,6 @@ go get git.happydns.org/checker-sdk-go/checker
|
||||||
See [checker-dummy](https://git.happydns.org/checker-dummy) for a
|
See [checker-dummy](https://git.happydns.org/checker-dummy) for a
|
||||||
fully working, documented template.
|
fully working, documented template.
|
||||||
|
|
||||||
## Extending the server
|
|
||||||
|
|
||||||
`checker.Server` exposes the standard SDK routes (`/health`, `/collect`,
|
|
||||||
and, depending on the provider's optional interfaces, `/definition`,
|
|
||||||
`/evaluate`, `/report`). Plugins that need to serve auxiliary endpoints
|
|
||||||
(debug pages, webhooks, custom UI assets, …) can register them on the
|
|
||||||
same mux:
|
|
||||||
|
|
||||||
```go
|
|
||||||
srv := checker.NewServer(provider)
|
|
||||||
|
|
||||||
srv.HandleFunc("GET /debug/state", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// …
|
|
||||||
})
|
|
||||||
|
|
||||||
// Opt a custom route into the in-flight / load-average signal
|
|
||||||
// reported on /health:
|
|
||||||
srv.Handle("POST /webhook", srv.TrackWork(myWebhookHandler))
|
|
||||||
|
|
||||||
log.Fatal(srv.ListenAndServe(":8080"))
|
|
||||||
```
|
|
||||||
|
|
||||||
Patterns that collide with built-in routes panic at registration:
|
|
||||||
pick non-overlapping paths. Custom handlers are not wrapped by the
|
|
||||||
load-tracking middleware unless you opt in via `TrackWork`.
|
|
||||||
|
|
||||||
## Standalone human UI (`/check`)
|
|
||||||
|
|
||||||
Providers that implement `CheckerInteractive` get a built-in human-facing
|
|
||||||
web form on `/check`, usable outside of happyDomain:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type CheckerInteractive interface {
|
|
||||||
RenderForm() []CheckerOptionField
|
|
||||||
ParseForm(r *http.Request) (CheckerOptions, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `GET /check` renders a form derived from `RenderForm()`.
|
|
||||||
- `POST /check` calls `ParseForm` to obtain `CheckerOptions`, runs the
|
|
||||||
standard `Collect` → `Evaluate` → `GetHTMLReport` / `ExtractMetrics`
|
|
||||||
pipeline, and returns a consolidated HTML page.
|
|
||||||
|
|
||||||
`ParseForm` is where the checker replaces what happyDomain would normally
|
|
||||||
auto-fill (zone records, service payload, …), typically by issuing its
|
|
||||||
own DNS queries from the human-supplied inputs.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||||
|
|
|
||||||
|
|
@ -1,37 +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 "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
|
|
||||||
}
|
|
||||||
|
|
@ -74,7 +74,7 @@ func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second registration of the same definition pointer must NOT append a
|
// 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.
|
// the append, otherwise we silently mutate the live definition.
|
||||||
RegisterExternalizableChecker(c)
|
RegisterExternalizableChecker(c)
|
||||||
if n := len(c.Options.AdminOpts); n != 1 {
|
if n := len(c.Options.AdminOpts); n != 1 {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,7 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
// Package server provides the HTTP server scaffolding used by standalone
|
package checker
|
||||||
// checkers. It is separated from the core checker package so that plugin
|
|
||||||
// and builtin builds, which never expose an HTTP endpoint, do not pay the
|
|
||||||
// cost of net/http, html/template, and their transitive dependencies.
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -26,16 +22,11 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
|
// maxRequestBodySize is the maximum allowed size for incoming request bodies (1 MB).
|
||||||
|
|
@ -46,10 +37,6 @@ const maxRequestBodySize = 1 << 20
|
||||||
// 5 seconds matches the Unix kernel's loadavg cadence.
|
// 5 seconds matches the Unix kernel's loadavg cadence.
|
||||||
const loadSampleInterval = 5 * time.Second
|
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
|
// EWMA smoothing factors for 1, 5, and 15-minute windows sampled every
|
||||||
// loadSampleInterval. Derived as 1 - exp(-interval/window) so that the
|
// loadSampleInterval. Derived as 1 - exp(-interval/window) so that the
|
||||||
// steady-state response to a constant InFlight of N converges to N.
|
// steady-state response to a constant InFlight of N converges to N.
|
||||||
|
|
@ -71,21 +58,19 @@ func updateLoadAvg(prev [3]float64, sample float64) [3]float64 {
|
||||||
|
|
||||||
// Server is a generic HTTP server for external checkers.
|
// Server is a generic HTTP server for external checkers.
|
||||||
// It always exposes /health and /collect. If the provider implements
|
// It always exposes /health and /collect. If the provider implements
|
||||||
// checker.CheckerDefinitionProvider, it also exposes /definition and /evaluate.
|
// CheckerDefinitionProvider, it also exposes /definition and /evaluate.
|
||||||
// If the provider implements checker.CheckerHTMLReporter or checker.CheckerMetricsReporter,
|
// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter,
|
||||||
// it also exposes /report. If the provider implements Interactive,
|
// it also exposes /report.
|
||||||
// it also exposes /check (a human-facing web form).
|
|
||||||
//
|
//
|
||||||
// Security: Server does not perform any authentication or authorization.
|
// Security: Server does not perform any authentication or authorization.
|
||||||
// It is intended to be run behind a reverse proxy or in a trusted network
|
// It is intended to be run behind a reverse proxy or in a trusted network
|
||||||
// where access control is handled externally (e.g. by the happyDomain server).
|
// where access control is handled externally (e.g. by the happyDomain server).
|
||||||
type Server struct {
|
type Server struct {
|
||||||
provider checker.ObservationProvider
|
provider ObservationProvider
|
||||||
definition *checker.CheckerDefinition
|
definition *CheckerDefinition
|
||||||
interactive Interactive
|
mux *http.ServeMux
|
||||||
mux *http.ServeMux
|
|
||||||
|
|
||||||
// startTime is captured in New and used to compute uptime.
|
// startTime is captured in NewServer and used to compute uptime.
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
|
|
||||||
// inFlight counts work requests (/collect, /evaluate, /report) currently
|
// inFlight counts work requests (/collect, /evaluate, /report) currently
|
||||||
|
|
@ -110,13 +95,13 @@ type Server struct {
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new checker HTTP server backed by the given provider.
|
// NewServer creates a new checker HTTP server backed by the given provider.
|
||||||
// Additional endpoints are registered based on optional interfaces the provider implements.
|
// Additional endpoints are registered based on optional interfaces the provider implements.
|
||||||
//
|
//
|
||||||
// New also starts a background goroutine that samples the in-flight
|
// NewServer also starts a background goroutine that samples the in-flight
|
||||||
// request count every loadSampleInterval to compute the load averages
|
// request count every loadSampleInterval to compute the load averages
|
||||||
// reported on /health. Call Close to stop it.
|
// reported on /health. Call Close to stop it.
|
||||||
func New(provider checker.ObservationProvider) *Server {
|
func NewServer(provider ObservationProvider) *Server {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s := &Server{
|
s := &Server{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
|
@ -126,27 +111,19 @@ func New(provider checker.ObservationProvider) *Server {
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
s.mux.Handle("POST /collect", s.TrackWork(http.HandlerFunc(s.handleCollect)))
|
s.mux.Handle("POST /collect", s.trackWork(http.HandlerFunc(s.handleCollect)))
|
||||||
|
|
||||||
if dp, ok := provider.(checker.CheckerDefinitionProvider); ok {
|
if dp, ok := provider.(CheckerDefinitionProvider); ok {
|
||||||
if def := dp.Definition(); def != nil {
|
s.definition = dp.Definition()
|
||||||
s.definition = def
|
s.definition.BuildRulesInfo()
|
||||||
s.definition.BuildRulesInfo()
|
s.mux.HandleFunc("GET /definition", s.handleDefinition)
|
||||||
s.mux.HandleFunc("GET /definition", s.handleDefinition)
|
s.mux.Handle("POST /evaluate", s.trackWork(http.HandlerFunc(s.handleEvaluate)))
|
||||||
s.mux.Handle("POST /evaluate", s.TrackWork(http.HandlerFunc(s.handleEvaluate)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := provider.(checker.CheckerHTMLReporter); ok {
|
if _, ok := provider.(CheckerHTMLReporter); ok {
|
||||||
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
|
s.mux.Handle("POST /report", s.trackWork(http.HandlerFunc(s.handleReport)))
|
||||||
} else if _, ok := provider.(checker.CheckerMetricsReporter); ok {
|
} else if _, ok := provider.(CheckerMetricsReporter); ok {
|
||||||
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
|
s.mux.Handle("POST /report", s.trackWork(http.HandlerFunc(s.handleReport)))
|
||||||
}
|
|
||||||
|
|
||||||
if ip, ok := provider.(Interactive); ok {
|
|
||||||
s.interactive = ip
|
|
||||||
s.mux.HandleFunc("GET /check", s.handleCheckForm)
|
|
||||||
s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go s.runSampler(ctx)
|
go s.runSampler(ctx)
|
||||||
|
|
@ -160,82 +137,19 @@ func (s *Server) Handler() http.Handler {
|
||||||
return requestLogger(s.mux)
|
return requestLogger(s.mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle registers an auxiliary handler on the server's mux. Must be called
|
// ListenAndServe starts the HTTP server on the given address.
|
||||||
// before ListenAndServe or Handler(). Custom handlers are not tracked by
|
|
||||||
// TrackWork; wrap them explicitly if you want them counted in /health load.
|
|
||||||
func (s *Server) Handle(pattern string, handler http.Handler) {
|
|
||||||
s.mux.Handle(pattern, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleFunc is the http.HandlerFunc-flavoured counterpart of Handle.
|
|
||||||
func (s *Server) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
|
||||||
s.mux.HandleFunc(pattern, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListenAndServe starts the HTTP server on the given address and blocks
|
|
||||||
// until the server stops.
|
|
||||||
//
|
//
|
||||||
// ListenAndServe installs a SIGINT/SIGTERM handler that triggers a graceful
|
// ListenAndServe does not stop the background load-average sampler on return;
|
||||||
// shutdown: new connections are refused and in-flight requests are given up
|
// call Close to stop it. This is not required for process-scoped usage but is
|
||||||
// to shutdownTimeout to complete. The background load-average sampler is
|
// recommended for tests and embedded lifecycles.
|
||||||
// stopped via Close before returning. Callers who need their own signal
|
|
||||||
// handling or shutdown semantics should use Handler() and run their own
|
|
||||||
// http.Server instead.
|
|
||||||
//
|
|
||||||
// If the consumer's flag.Parse() set the SDK-registered -healthcheck flag,
|
|
||||||
// ListenAndServe never starts the server: it probes /health on addr and calls
|
|
||||||
// os.Exit(0) on success or os.Exit(1) on failure. This is what lets a
|
|
||||||
// scratch-based Docker image use the binary itself as its HEALTHCHECK probe.
|
|
||||||
func (s *Server) ListenAndServe(addr string) error {
|
func (s *Server) ListenAndServe(addr string) error {
|
||||||
if *healthcheckMode {
|
|
||||||
if err := runHealthcheck(addr); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "healthcheck failed:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{Addr: addr, Handler: requestLogger(s.mux)}
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer signal.Stop(sigCh)
|
|
||||||
|
|
||||||
shutdownErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
sig, ok := <-sigCh
|
|
||||||
if !ok {
|
|
||||||
shutdownErr <- nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("checker received %s, shutting down (timeout %s)", sig, shutdownTimeout)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
||||||
defer cancel()
|
|
||||||
shutdownErr <- srv.Shutdown(ctx)
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("checker listening on %s", addr)
|
log.Printf("checker listening on %s", addr)
|
||||||
err := srv.ListenAndServe()
|
return http.ListenAndServe(addr, requestLogger(s.mux))
|
||||||
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
|
// 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
|
// 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 {
|
func (s *Server) Close() error {
|
||||||
s.closeOnce.Do(func() {
|
s.closeOnce.Do(func() {
|
||||||
s.cancelSampler()
|
s.cancelSampler()
|
||||||
|
|
@ -244,9 +158,10 @@ func (s *Server) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackWork wraps a handler with in-flight and total-request accounting,
|
// trackWork wraps a handler with in-flight and total-request accounting.
|
||||||
// opting custom routes into the load signal reported on /health.
|
// It is applied only to "work" endpoints (/collect, /evaluate, /report) so
|
||||||
func (s *Server) TrackWork(next http.Handler) http.Handler {
|
// that /health polling traffic does not pollute the load signal.
|
||||||
|
func (s *Server) trackWork(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.inFlight.Add(1)
|
s.inFlight.Add(1)
|
||||||
s.totalRequests.Add(1)
|
s.totalRequests.Add(1)
|
||||||
|
|
@ -302,7 +217,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
for i := range load {
|
for i := range load {
|
||||||
load[i] = math.Float64frombits(s.loadBits[i].Load())
|
load[i] = math.Float64frombits(s.loadBits[i].Load())
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, checker.HealthResponse{
|
writeJSON(w, http.StatusOK, HealthResponse{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Uptime: time.Since(s.startTime).Seconds(),
|
Uptime: time.Since(s.startTime).Seconds(),
|
||||||
NumCPU: runtime.NumCPU(),
|
NumCPU: runtime.NumCPU(),
|
||||||
|
|
@ -317,9 +232,9 @@ func (s *Server) handleDefinition(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||||
var req checker.ExternalCollectRequest
|
var req ExternalCollectRequest
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, checker.ExternalCollectResponse{
|
writeJSON(w, http.StatusBadRequest, ExternalCollectResponse{
|
||||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -327,7 +242,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
data, err := s.provider.Collect(r.Context(), req.Options)
|
data, err := s.provider.Collect(r.Context(), req.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
|
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -335,18 +250,18 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
raw, err := json.Marshal(data)
|
raw, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, checker.ExternalCollectResponse{
|
writeJSON(w, http.StatusInternalServerError, ExternalCollectResponse{
|
||||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := checker.ExternalCollectResponse{Data: json.RawMessage(raw)}
|
resp := ExternalCollectResponse{Data: json.RawMessage(raw)}
|
||||||
|
|
||||||
// Harvest discovery entries from the native Go value, before it goes
|
// Harvest discovery entries from the native Go value, before it goes
|
||||||
// out of scope. No re-parse; DiscoverEntries operates on the same
|
// out of scope. No re-parse; DiscoverEntries operates on the same
|
||||||
// object that was just marshaled above.
|
// object that was just marshaled above.
|
||||||
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
|
if dp, ok := s.provider.(DiscoveryPublisher); ok {
|
||||||
entries, derr := dp.DiscoverEntries(data)
|
entries, derr := dp.DiscoverEntries(data)
|
||||||
if derr != nil {
|
if derr != nil {
|
||||||
log.Printf("DiscoverEntries failed: %v", derr)
|
log.Printf("DiscoverEntries failed: %v", derr)
|
||||||
|
|
@ -358,47 +273,36 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluateRules runs all definition rules against obs/opts, skipping any rule
|
|
||||||
// whose name maps to false in enabledRules (nil means run all).
|
|
||||||
func (s *Server) evaluateRules(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions, enabledRules map[string]bool) []checker.CheckState {
|
|
||||||
var states []checker.CheckState
|
|
||||||
for _, rule := range s.definition.Rules {
|
|
||||||
if len(enabledRules) > 0 {
|
|
||||||
if enabled, ok := enabledRules[rule.Name()]; ok && !enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ruleStates := rule.Evaluate(ctx, obs, opts)
|
|
||||||
if len(ruleStates) == 0 {
|
|
||||||
ruleStates = []checker.CheckState{{
|
|
||||||
Status: checker.StatusUnknown,
|
|
||||||
Message: fmt.Sprintf("rule %q returned no state", rule.Name()),
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
for _, state := range ruleStates {
|
|
||||||
state.RuleName = rule.Name()
|
|
||||||
states = append(states, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return states
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
|
||||||
var req checker.ExternalEvaluateRequest
|
var req ExternalEvaluateRequest
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, checker.ExternalEvaluateResponse{
|
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{
|
||||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
obs := &mapObservationGetter{data: req.Observations}
|
obs := &mapObservationGetter{data: req.Observations}
|
||||||
states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules)
|
|
||||||
writeJSON(w, http.StatusOK, checker.ExternalEvaluateResponse{States: states})
|
var states []CheckState
|
||||||
|
for _, rule := range s.definition.Rules {
|
||||||
|
if len(req.EnabledRules) > 0 {
|
||||||
|
if enabled, ok := req.EnabledRules[rule.Name()]; ok && !enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state := rule.Evaluate(r.Context(), obs, req.Options)
|
||||||
|
if state.Code == "" {
|
||||||
|
state.Code = rule.Name()
|
||||||
|
}
|
||||||
|
states = append(states, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||||
var req checker.ExternalReportRequest
|
var req ExternalReportRequest
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||||
"error": fmt.Sprintf("invalid request body: %v", err),
|
"error": fmt.Sprintf("invalid request body: %v", err),
|
||||||
|
|
@ -409,13 +313,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get("Accept")
|
||||||
|
|
||||||
if strings.Contains(accept, "text/html") {
|
if strings.Contains(accept, "text/html") {
|
||||||
reporter, ok := s.provider.(checker.CheckerHTMLReporter)
|
reporter, ok := s.provider.(CheckerHTMLReporter)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
|
http.Error(w, "this checker does not support HTML reports", http.StatusNotImplemented)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := reporter.GetHTMLReport(checker.NewReportContext(req.Data, req.Related, req.States))
|
html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -427,13 +331,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: JSON metrics.
|
// Default: JSON metrics.
|
||||||
reporter, ok := s.provider.(checker.CheckerMetricsReporter)
|
reporter, ok := s.provider.(CheckerMetricsReporter)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
|
http.Error(w, "this checker does not support metrics reports", http.StatusNotImplemented)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics, err := reporter.ExtractMetrics(checker.NewReportContext(req.Data, req.Related, req.States), time.Now())
|
metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
"error": fmt.Sprintf("failed to extract metrics: %v", err),
|
"error": fmt.Sprintf("failed to extract metrics: %v", err),
|
||||||
|
|
@ -444,16 +348,12 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, metrics)
|
writeJSON(w, http.StatusOK, metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapObservationGetter implements checker.ObservationGetter backed by static maps.
|
// mapObservationGetter implements ObservationGetter backed by a static map.
|
||||||
// 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 {
|
type mapObservationGetter struct {
|
||||||
data map[checker.ObservationKey]json.RawMessage
|
data map[ObservationKey]json.RawMessage
|
||||||
related map[checker.ObservationKey][]checker.RelatedObservation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationKey, dest any) error {
|
func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error {
|
||||||
raw, ok := g.data[key]
|
raw, ok := g.data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("observation %q not available", key)
|
return fmt.Errorf("observation %q not available", key)
|
||||||
|
|
@ -461,13 +361,13 @@ func (g *mapObservationGetter) Get(ctx context.Context, key checker.ObservationK
|
||||||
return json.Unmarshal(raw, dest)
|
return json.Unmarshal(raw, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRelated returns the pre-resolved related observations for key, or nil
|
// GetRelated always returns nil in the remote /evaluate path: the host that
|
||||||
// when none were seeded. The remote /evaluate path leaves related nil
|
// invokes /evaluate does not (currently) carry cross-checker related data in
|
||||||
// because ExternalEvaluateRequest does not currently carry cross-checker
|
// ExternalEvaluateRequest. Consumers that need related observations must run
|
||||||
// lineage; the interactive /check path can seed it from sibling providers
|
// evaluation locally with a host-side ObservationContext that resolves
|
||||||
// declared via Siblings.
|
// lineage.
|
||||||
func (g *mapObservationGetter) GetRelated(ctx context.Context, key checker.ObservationKey) ([]checker.RelatedObservation, error) {
|
func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) {
|
||||||
return g.related[key], nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|
@ -1,81 +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 server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// healthcheckMode is registered on the default flag set so any consumer that
|
|
||||||
// calls flag.Parse() before ListenAndServe (the standard pattern in our
|
|
||||||
// checker mains) gets the behaviour for free. When set, ListenAndServe
|
|
||||||
// performs a short-lived HTTP probe against /health on the configured listen
|
|
||||||
// address and exits 0/1 instead of starting the server. This lets the same
|
|
||||||
// binary act as its own Docker HEALTHCHECK probe for scratch images, where
|
|
||||||
// no shell, curl or wget is available.
|
|
||||||
var healthcheckMode = flag.Bool(
|
|
||||||
"healthcheck",
|
|
||||||
false,
|
|
||||||
"probe /health on the server's listen address and exit 0 if healthy, 1 "+
|
|
||||||
"otherwise (intended as a Docker HEALTHCHECK for scratch-based images)",
|
|
||||||
)
|
|
||||||
|
|
||||||
// runHealthcheck performs a GET against http://<addr>/health with a short
|
|
||||||
// timeout. Returns nil on a 2xx response, an error otherwise. A bind address
|
|
||||||
// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface
|
|
||||||
// so the probe targets the local process.
|
|
||||||
func runHealthcheck(addr string) error {
|
|
||||||
host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid listen addr %q: %w", addr, err)
|
|
||||||
}
|
|
||||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
||||||
host = "127.0.0.1"
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 2 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode/100 != 2 {
|
|
||||||
return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHealthcheckAddr(a string) string {
|
|
||||||
if strings.HasPrefix(a, ":") {
|
|
||||||
return "127.0.0.1" + a
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(a, "[::]:") {
|
|
||||||
return "[::1]:" + strings.TrimPrefix(a, "[::]:")
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +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 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,453 +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 server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Interactive is an optional interface that observation providers
|
|
||||||
// can implement to expose a human-facing web form usable standalone,
|
|
||||||
// outside of a happyDomain host. Detect support with a type assertion:
|
|
||||||
// _, ok := provider.(server.Interactive).
|
|
||||||
//
|
|
||||||
// When the provider implements it, Server binds GET and POST on /check.
|
|
||||||
// GET renders an HTML form built from RenderForm(). POST calls ParseForm
|
|
||||||
// to obtain the checker.CheckerOptions, then runs the standard pipeline
|
|
||||||
// (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a
|
|
||||||
// consolidated result page.
|
|
||||||
//
|
|
||||||
// Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed
|
|
||||||
// options from execution context, an Interactive implementation is
|
|
||||||
// responsible for resolving whatever it needs from the human inputs
|
|
||||||
// (typically via direct DNS queries) before Collect runs.
|
|
||||||
type Interactive interface {
|
|
||||||
// RenderForm returns the fields the human must fill in to bootstrap
|
|
||||||
// a check. Typically a minimal set (domain name, nameserver to
|
|
||||||
// query, …) that ParseForm expands into the full CheckerOptions
|
|
||||||
// that Collect expects.
|
|
||||||
RenderForm() []checker.CheckerOptionField
|
|
||||||
|
|
||||||
// ParseForm reads the submitted form and returns the CheckerOptions
|
|
||||||
// ready to feed Collect. It is the checker's responsibility to do
|
|
||||||
// whatever lookups or resolutions are needed to populate fields
|
|
||||||
// that would normally be auto-filled by happyDomain. Returning an
|
|
||||||
// error causes the SDK to re-render the form with the error
|
|
||||||
// displayed.
|
|
||||||
ParseForm(r *http.Request) (checker.CheckerOptions, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Siblings is an optional interface an interactive ObservationProvider
|
|
||||||
// can co-implement to declare sibling providers whose Collect the SDK
|
|
||||||
// runs in-process during /check. Their results are exposed as
|
|
||||||
// RelatedObservations on ObservationGetter and ReportContext, mirroring
|
|
||||||
// the cross-checker lineage a happyDomain host resolves.
|
|
||||||
//
|
|
||||||
// For each sibling the SDK seeds options from the primary and, when the
|
|
||||||
// primary implements DiscoveryPublisher, writes its entries into any
|
|
||||||
// sibling option tagged AutoFill == checker.AutoFillDiscoveryEntries.
|
|
||||||
// Sibling errors are logged and skipped so the primary result still
|
|
||||||
// reaches the user.
|
|
||||||
type Siblings interface {
|
|
||||||
RelatedProviders() []checker.ObservationProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkResult holds everything the result page needs to render.
|
|
||||||
type checkResult struct {
|
|
||||||
Title string
|
|
||||||
States []checker.CheckState
|
|
||||||
Metrics []checker.CheckMetric
|
|
||||||
ReportHTML string
|
|
||||||
CollectErr string
|
|
||||||
ReportErr string
|
|
||||||
MetricsErr string
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkFormPage struct {
|
|
||||||
Title string
|
|
||||||
Fields []checker.CheckerOptionField
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCheckForm(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderCheckForm(w, s.interactive.RenderForm(), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fields := s.interactive.RenderForm()
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.renderCheckForm(w, fields, fmt.Sprintf("invalid form: %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opts, err := s.interactive.ParseForm(r)
|
|
||||||
if err != nil {
|
|
||||||
s.renderCheckForm(w, fields, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &checkResult{Title: s.checkPageTitle()}
|
|
||||||
|
|
||||||
data, err := s.provider.Collect(r.Context(), opts)
|
|
||||||
if err != nil {
|
|
||||||
result.CollectErr = err.Error()
|
|
||||||
s.renderCheckResult(w, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
result.CollectErr = fmt.Sprintf("failed to marshal collected data: %v", err)
|
|
||||||
s.renderCheckResult(w, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
related := s.collectRelatedObservations(r.Context(), opts, data)
|
|
||||||
|
|
||||||
if s.definition != nil {
|
|
||||||
obs := &mapObservationGetter{
|
|
||||||
data: map[checker.ObservationKey]json.RawMessage{
|
|
||||||
s.provider.Key(): raw,
|
|
||||||
},
|
|
||||||
related: related,
|
|
||||||
}
|
|
||||||
result.States = s.evaluateRules(r.Context(), obs, opts, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := checker.NewReportContext(raw, related, result.States)
|
|
||||||
|
|
||||||
if reporter, ok := s.provider.(checker.CheckerHTMLReporter); ok {
|
|
||||||
html, rerr := reporter.GetHTMLReport(ctx)
|
|
||||||
if rerr != nil {
|
|
||||||
result.ReportErr = rerr.Error()
|
|
||||||
} else {
|
|
||||||
result.ReportHTML = html
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if reporter, ok := s.provider.(checker.CheckerMetricsReporter); ok {
|
|
||||||
metrics, merr := reporter.ExtractMetrics(ctx, time.Now())
|
|
||||||
if merr != nil {
|
|
||||||
result.MetricsErr = merr.Error()
|
|
||||||
} else {
|
|
||||||
result.Metrics = metrics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.renderCheckResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectRelatedObservations runs sibling providers declared via Siblings
|
|
||||||
// and returns their results keyed by the sibling's observation key.
|
|
||||||
// Sibling errors are logged and skipped.
|
|
||||||
func (s *Server) collectRelatedObservations(ctx context.Context, opts checker.CheckerOptions, data any) map[checker.ObservationKey][]checker.RelatedObservation {
|
|
||||||
irp, ok := s.provider.(Siblings)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
siblings := irp.RelatedProviders()
|
|
||||||
if len(siblings) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []checker.DiscoveryEntry
|
|
||||||
if dp, ok := s.provider.(checker.DiscoveryPublisher); ok {
|
|
||||||
e, err := dp.DiscoverEntries(data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("interactive: DiscoverEntries failed: %v", err)
|
|
||||||
} else {
|
|
||||||
entries = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
related := make(map[checker.ObservationKey][]checker.RelatedObservation, len(siblings))
|
|
||||||
for _, sp := range siblings {
|
|
||||||
sOpts := cloneOptions(opts)
|
|
||||||
siblingID := ""
|
|
||||||
if dp, ok := sp.(checker.CheckerDefinitionProvider); ok {
|
|
||||||
if def := dp.Definition(); def != nil {
|
|
||||||
siblingID = def.ID
|
|
||||||
if len(entries) > 0 {
|
|
||||||
fillDiscoveryEntryOption(sOpts, def, entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sData, err := sp.Collect(ctx, sOpts)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(sData)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
related[sp.Key()] = append(related[sp.Key()], checker.RelatedObservation{
|
|
||||||
CheckerID: siblingID,
|
|
||||||
Key: sp.Key(),
|
|
||||||
Data: raw,
|
|
||||||
CollectedAt: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return related
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneOptions(opts checker.CheckerOptions) checker.CheckerOptions {
|
|
||||||
out := make(checker.CheckerOptions, len(opts))
|
|
||||||
maps.Copy(out, opts)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
|
|
||||||
// entries into every option in def tagged AutoFill == checker.AutoFillDiscoveryEntries.
|
|
||||||
func fillDiscoveryEntryOption(opts checker.CheckerOptions, def *checker.CheckerDefinition, entries []checker.DiscoveryEntry) {
|
|
||||||
scopes := [][]checker.CheckerOptionDocumentation{
|
|
||||||
def.Options.AdminOpts,
|
|
||||||
def.Options.UserOpts,
|
|
||||||
def.Options.DomainOpts,
|
|
||||||
def.Options.ServiceOpts,
|
|
||||||
def.Options.RunOpts,
|
|
||||||
}
|
|
||||||
for _, scope := range scopes {
|
|
||||||
for _, f := range scope {
|
|
||||||
if f.AutoFill == checker.AutoFillDiscoveryEntries {
|
|
||||||
opts[f.Id] = entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) checkPageTitle() string {
|
|
||||||
if s.definition != nil && s.definition.Name != "" {
|
|
||||||
return s.definition.Name
|
|
||||||
}
|
|
||||||
return "Checker"
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderHTML(w http.ResponseWriter, status int, tpl *template.Template, data any) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := tpl.Execute(&buf, data); err != nil {
|
|
||||||
log.Printf("render %s: %v", tpl.Name(), err)
|
|
||||||
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
w.Write(buf.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderCheckForm(w http.ResponseWriter, fields []checker.CheckerOptionField, errMsg string) {
|
|
||||||
status := http.StatusOK
|
|
||||||
if errMsg != "" {
|
|
||||||
status = http.StatusBadRequest
|
|
||||||
}
|
|
||||||
renderHTML(w, status, checkFormTemplate, checkFormPage{
|
|
||||||
Title: s.checkPageTitle(),
|
|
||||||
Fields: fields,
|
|
||||||
Error: errMsg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) renderCheckResult(w http.ResponseWriter, result *checkResult) {
|
|
||||||
renderHTML(w, http.StatusOK, checkResultTemplate, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusClass(s checker.Status) string {
|
|
||||||
switch s {
|
|
||||||
case checker.StatusOK:
|
|
||||||
return "ok"
|
|
||||||
case checker.StatusInfo:
|
|
||||||
return "info"
|
|
||||||
case checker.StatusWarn:
|
|
||||||
return "warn"
|
|
||||||
case checker.StatusCrit:
|
|
||||||
return "crit"
|
|
||||||
case checker.StatusError:
|
|
||||||
return "error"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultString avoids printing the literal "<nil>" for unset defaults.
|
|
||||||
func defaultString(v any) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch t := v.(type) {
|
|
||||||
case string:
|
|
||||||
return t
|
|
||||||
case bool:
|
|
||||||
if t {
|
|
||||||
return "true"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultBool(v any) bool {
|
|
||||||
b, _ := v.(bool)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateFuncs = template.FuncMap{
|
|
||||||
"statusClass": statusClass,
|
|
||||||
"statusString": checker.Status.String,
|
|
||||||
"defaultString": defaultString,
|
|
||||||
"defaultBool": defaultBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseCSS = `
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
|
|
||||||
h1, h2 { border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
|
|
||||||
form { display: grid; gap: 1rem; }
|
|
||||||
label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
|
|
||||||
.required::after { content: " *"; color: #c00; }
|
|
||||||
.desc { font-weight: normal; color: #666; font-size: 0.9rem; display: block; margin-top: 0.1rem; }
|
|
||||||
input[type=text], input[type=password], input[type=number], select, textarea {
|
|
||||||
width: 100%; padding: 0.5rem; border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; font: inherit;
|
|
||||||
}
|
|
||||||
textarea { min-height: 6rem; }
|
|
||||||
button { padding: 0.6rem 1.2rem; background: #0b63c5; color: #fff; border: 0; border-radius: 4px; font: inherit; cursor: pointer; }
|
|
||||||
button:hover { background: #084c98; }
|
|
||||||
.err { background: #fee; border: 1px solid #fbb; color: #900; padding: 0.6rem 0.8rem; border-radius: 4px; margin: 1rem 0; }
|
|
||||||
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0 1.5rem; }
|
|
||||||
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
||||||
th { background: #f7f7f7; }
|
|
||||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; color: #fff; }
|
|
||||||
.badge.ok { background: #2a9d3c; }
|
|
||||||
.badge.info { background: #3277cc; }
|
|
||||||
.badge.warn { background: #d08a00; }
|
|
||||||
.badge.crit { background: #c0392b; }
|
|
||||||
.badge.error { background: #7a1f1f; }
|
|
||||||
.badge.unknown { background: #777; }
|
|
||||||
iframe.report { width: 100%; min-height: 480px; border: 1px solid #ccc; border-radius: 4px; }
|
|
||||||
.actions { margin-top: 1.5rem; }
|
|
||||||
.actions a { color: #0b63c5; text-decoration: none; }
|
|
||||||
.actions a:hover { text-decoration: underline; }
|
|
||||||
`
|
|
||||||
|
|
||||||
var checkFormTemplate = template.Must(template.New("form").Funcs(templateFuncs).Parse(`<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>{{.Title}} – Check</title>
|
|
||||||
<style>` + baseCSS + `</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>{{.Title}}</h1>
|
|
||||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
|
||||||
<form method="POST" action="/check">
|
|
||||||
{{range .Fields}}{{if not .Hide}}
|
|
||||||
<div>
|
|
||||||
<label for="{{.Id}}" class="{{if .Required}}required{{end}}">{{if .Label}}{{.Label}}{{else}}{{.Id}}{{end}}
|
|
||||||
{{if .Description}}<span class="desc">{{.Description}}</span>{{end}}
|
|
||||||
</label>
|
|
||||||
{{if .Choices}}
|
|
||||||
<select id="{{.Id}}" name="{{.Id}}"{{if .Required}} required{{end}}>
|
|
||||||
{{$def := defaultString .Default}}
|
|
||||||
{{range .Choices}}<option value="{{.}}"{{if eq . $def}} selected{{end}}>{{.}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
{{else if eq .Type "bool"}}
|
|
||||||
<input type="checkbox" id="{{.Id}}" name="{{.Id}}" value="true"{{if defaultBool .Default}} checked{{end}}>
|
|
||||||
{{else if .Textarea}}
|
|
||||||
<textarea id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}"{{if .Required}} required{{end}}>{{defaultString .Default}}</textarea>
|
|
||||||
{{else if eq .Type "number"}}
|
|
||||||
<input type="number" step="any" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
|
|
||||||
{{else if eq .Type "uint"}}
|
|
||||||
<input type="number" min="0" step="1" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
|
|
||||||
{{else if .Secret}}
|
|
||||||
<input type="password" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}"{{if .Required}} required{{end}}>
|
|
||||||
{{else}}
|
|
||||||
<input type="text" id="{{.Id}}" name="{{.Id}}" placeholder="{{.Placeholder}}" value="{{defaultString .Default}}"{{if .Required}} required{{end}}>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}{{end}}
|
|
||||||
<div><button type="submit">Run check</button></div>
|
|
||||||
</form>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
var checkResultTemplate = template.Must(template.New("result").Funcs(templateFuncs).Parse(`<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>{{.Title}} – Result</title>
|
|
||||||
<style>` + baseCSS + `</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>{{.Title}}</h1>
|
|
||||||
|
|
||||||
{{if .CollectErr}}<div class="err"><strong>Collect failed:</strong> {{.CollectErr}}</div>{{end}}
|
|
||||||
|
|
||||||
{{if .States}}
|
|
||||||
<h2>Check states</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Status</th><th>Rule</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .States}}
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge {{statusClass .Status}}">{{statusString .Status}}</span></td>
|
|
||||||
<td>{{.RuleName}}</td>
|
|
||||||
<td>{{.Code}}</td>
|
|
||||||
<td>{{.Subject}}</td>
|
|
||||||
<td>{{.Message}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Metrics}}
|
|
||||||
<h2>Metrics</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Labels</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Metrics}}
|
|
||||||
<tr>
|
|
||||||
<td>{{.Name}}</td>
|
|
||||||
<td>{{.Value}}</td>
|
|
||||||
<td>{{.Unit}}</td>
|
|
||||||
<td>{{range $k, $v := .Labels}}{{$k}}={{$v}} {{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .MetricsErr}}<div class="err"><strong>Metrics error:</strong> {{.MetricsErr}}</div>{{end}}
|
|
||||||
|
|
||||||
{{if .ReportHTML}}
|
|
||||||
<h2>Report</h2>
|
|
||||||
<iframe class="report" sandbox srcdoc="{{.ReportHTML}}"></iframe>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .ReportErr}}<div class="err"><strong>Report error:</strong> {{.ReportErr}}</div>{{end}}
|
|
||||||
|
|
||||||
<div class="actions"><a href="/check">← Run another check</a></div>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
@ -1,439 +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 server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// interactiveProvider embeds testProvider and adds Interactive.
|
|
||||||
type interactiveProvider struct {
|
|
||||||
*testProvider
|
|
||||||
fields []checker.CheckerOptionField
|
|
||||||
parseFn func(r *http.Request) (checker.CheckerOptions, error)
|
|
||||||
parseErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *interactiveProvider) RenderForm() []checker.CheckerOptionField {
|
|
||||||
return p.fields
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *interactiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
|
||||||
if p.parseErr != nil {
|
|
||||||
return nil, p.parseErr
|
|
||||||
}
|
|
||||||
if p.parseFn != nil {
|
|
||||||
return p.parseFn(r)
|
|
||||||
}
|
|
||||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// minimalProvider implements only ObservationProvider.
|
|
||||||
type minimalProvider struct{ key checker.ObservationKey }
|
|
||||||
|
|
||||||
func (m *minimalProvider) Key() checker.ObservationKey { return m.key }
|
|
||||||
func (m *minimalProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_NotRegistered_WhenProviderLacksInterface(t *testing.T) {
|
|
||||||
p := &minimalProvider{key: "test"}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("GET /check without Interactive = %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Form_Renders(t *testing.T) {
|
|
||||||
p := &interactiveProvider{
|
|
||||||
testProvider: &testProvider{key: "test"},
|
|
||||||
fields: []checker.CheckerOptionField{
|
|
||||||
{Id: "domain", Type: "string", Label: "Domain name", Required: true, Placeholder: "example.com"},
|
|
||||||
{Id: "verbose", Type: "bool", Label: "Verbose", Default: true},
|
|
||||||
{Id: "flavor", Type: "string", Choices: []string{"a", "b"}, Default: "b"},
|
|
||||||
{Id: "hidden", Type: "string", Hide: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := doRequest(srv.Handler(), "GET", "/check", nil, nil)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`name="domain"`,
|
|
||||||
`placeholder="example.com"`,
|
|
||||||
`Domain name`,
|
|
||||||
`type="checkbox"`,
|
|
||||||
`name="verbose"`,
|
|
||||||
` checked`,
|
|
||||||
`<select id="flavor"`,
|
|
||||||
`<option value="b" selected>`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("form body missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(body, `name="hidden"`) {
|
|
||||||
t.Errorf("hidden field should not be rendered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Submit_Success(t *testing.T) {
|
|
||||||
definition := &checker.CheckerDefinition{
|
|
||||||
ID: "test",
|
|
||||||
Name: "Test Checker",
|
|
||||||
Rules: []checker.CheckRule{
|
|
||||||
&dummyRule{name: "rule1", desc: "first rule"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
p := &interactiveProvider{
|
|
||||||
testProvider: &testProvider{key: "test", definition: definition},
|
|
||||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`Test Checker`,
|
|
||||||
`Check states`,
|
|
||||||
`rule1`,
|
|
||||||
`rule1 passed`,
|
|
||||||
`badge ok`,
|
|
||||||
`Metrics`,
|
|
||||||
`m1`,
|
|
||||||
`Report`,
|
|
||||||
`<iframe`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("result body missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Submit_ParseError_RerendersForm(t *testing.T) {
|
|
||||||
p := &interactiveProvider{
|
|
||||||
testProvider: &testProvider{key: "test"},
|
|
||||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
|
||||||
parseErr: errors.New("domain is required"),
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {""}})
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("POST /check with bad input = %d, want 400", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "domain is required") {
|
|
||||||
t.Errorf("body missing error message, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `name="domain"`) {
|
|
||||||
t.Errorf("form not re-rendered on error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Submit_CollectError(t *testing.T) {
|
|
||||||
p := &interactiveProvider{
|
|
||||||
testProvider: &testProvider{
|
|
||||||
key: "test",
|
|
||||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return nil, errors.New("boom")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200 (collect failure still renders a page)", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "Collect failed") || !strings.Contains(body, "boom") {
|
|
||||||
t.Errorf("body missing Collect error, got: %s", body)
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "Check states") {
|
|
||||||
t.Errorf("states section should not render when Collect failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_NoReporters(t *testing.T) {
|
|
||||||
// Provider implements Interactive and has a definition (so
|
|
||||||
// /evaluate-like logic runs) but no HTMLReporter / MetricsReporter.
|
|
||||||
bare := &bareInteractiveProvider{
|
|
||||||
key: "test",
|
|
||||||
def: &checker.CheckerDefinition{
|
|
||||||
ID: "test",
|
|
||||||
Rules: []checker.CheckRule{&dummyRule{name: "r", desc: "r"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
srv := New(bare)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"x"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "Check states") {
|
|
||||||
t.Errorf("body missing states section")
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "<iframe") {
|
|
||||||
t.Errorf("body should not contain iframe when no HTML reporter")
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "<h2>Metrics</h2>") {
|
|
||||||
t.Errorf("body should not contain metrics section when no metrics reporter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bareInteractiveProvider implements only the required interfaces
|
|
||||||
// (ObservationProvider, CheckerDefinitionProvider, Interactive),
|
|
||||||
// no reporters.
|
|
||||||
type bareInteractiveProvider struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
def *checker.CheckerDefinition
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bareInteractiveProvider) Key() checker.ObservationKey { return b.key }
|
|
||||||
func (b *bareInteractiveProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return map[string]string{"ok": "1"}, nil
|
|
||||||
}
|
|
||||||
func (b *bareInteractiveProvider) Definition() *checker.CheckerDefinition { return b.def }
|
|
||||||
func (b *bareInteractiveProvider) RenderForm() []checker.CheckerOptionField {
|
|
||||||
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
|
|
||||||
}
|
|
||||||
func (b *bareInteractiveProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
|
||||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type siblingProvider struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
id string
|
|
||||||
entriesOpt string
|
|
||||||
gotOpts checker.CheckerOptions
|
|
||||||
payload any
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *siblingProvider) Key() checker.ObservationKey { return s.key }
|
|
||||||
func (s *siblingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
s.gotOpts = opts
|
|
||||||
return s.payload, nil
|
|
||||||
}
|
|
||||||
func (s *siblingProvider) Definition() *checker.CheckerDefinition {
|
|
||||||
return &checker.CheckerDefinition{
|
|
||||||
ID: s.id,
|
|
||||||
Options: checker.CheckerOptionsDocumentation{
|
|
||||||
RunOpts: []checker.CheckerOptionDocumentation{
|
|
||||||
{Id: s.entriesOpt, Type: "array", AutoFill: checker.AutoFillDiscoveryEntries},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type primaryWithSibling struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
def *checker.CheckerDefinition
|
|
||||||
entries []checker.DiscoveryEntry
|
|
||||||
sibling checker.ObservationProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *primaryWithSibling) Key() checker.ObservationKey { return p.key }
|
|
||||||
func (p *primaryWithSibling) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return map[string]string{"primary": "ok"}, nil
|
|
||||||
}
|
|
||||||
func (p *primaryWithSibling) Definition() *checker.CheckerDefinition { return p.def }
|
|
||||||
func (p *primaryWithSibling) RenderForm() []checker.CheckerOptionField {
|
|
||||||
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
|
|
||||||
}
|
|
||||||
func (p *primaryWithSibling) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
|
||||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
|
||||||
}
|
|
||||||
func (p *primaryWithSibling) DiscoverEntries(data any) ([]checker.DiscoveryEntry, error) {
|
|
||||||
return p.entries, nil
|
|
||||||
}
|
|
||||||
func (p *primaryWithSibling) RelatedProviders() []checker.ObservationProvider {
|
|
||||||
return []checker.ObservationProvider{p.sibling}
|
|
||||||
}
|
|
||||||
|
|
||||||
type relatedAssertRule struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *relatedAssertRule) Name() string { return "related_assert" }
|
|
||||||
func (r *relatedAssertRule) Description() string { return "" }
|
|
||||||
func (r *relatedAssertRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
|
||||||
related, err := obs.GetRelated(ctx, r.key)
|
|
||||||
if err != nil {
|
|
||||||
return []checker.CheckState{{Status: checker.StatusError, Message: err.Error()}}
|
|
||||||
}
|
|
||||||
if len(related) == 0 {
|
|
||||||
return []checker.CheckState{{Status: checker.StatusCrit, Message: "no related observation"}}
|
|
||||||
}
|
|
||||||
return []checker.CheckState{{Status: checker.StatusOK, Message: "saw related observation"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Submit_RunsSiblingAndExposesRelated(t *testing.T) {
|
|
||||||
sibling := &siblingProvider{
|
|
||||||
key: "sibling_key",
|
|
||||||
id: "sibling",
|
|
||||||
entriesOpt: "endpoints",
|
|
||||||
payload: map[string]string{"sibling": "ok"},
|
|
||||||
}
|
|
||||||
entry := checker.DiscoveryEntry{Type: "fake.v1", Ref: "r1"}
|
|
||||||
primary := &primaryWithSibling{
|
|
||||||
key: "primary_key",
|
|
||||||
def: &checker.CheckerDefinition{
|
|
||||||
ID: "primary",
|
|
||||||
Rules: []checker.CheckRule{&relatedAssertRule{key: sibling.key}},
|
|
||||||
},
|
|
||||||
entries: []checker.DiscoveryEntry{entry},
|
|
||||||
sibling: sibling,
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := New(primary)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "saw related observation") {
|
|
||||||
t.Errorf("rule did not see related observation; body:\n%s", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, ok := sibling.gotOpts[sibling.entriesOpt].([]checker.DiscoveryEntry)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt])
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].Ref != entry.Ref {
|
|
||||||
t.Errorf("sibling saw entries %v, want [%v]", got, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v, _ := sibling.gotOpts["domain"].(string); v != "example.com" {
|
|
||||||
t.Errorf("sibling did not receive primary domain opt, got %q", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// interactiveStatesPeekingProvider implements Interactive + HTMLReporter
|
|
||||||
// and captures the ReportContext.States() seen at GetHTMLReport time.
|
|
||||||
type interactiveStatesPeekingProvider struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
def *checker.CheckerDefinition
|
|
||||||
seen *[]checker.CheckState
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *interactiveStatesPeekingProvider) Key() checker.ObservationKey { return p.key }
|
|
||||||
func (p *interactiveStatesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return map[string]string{"ok": "1"}, nil
|
|
||||||
}
|
|
||||||
func (p *interactiveStatesPeekingProvider) Definition() *checker.CheckerDefinition { return p.def }
|
|
||||||
func (p *interactiveStatesPeekingProvider) RenderForm() []checker.CheckerOptionField {
|
|
||||||
return []checker.CheckerOptionField{{Id: "domain", Type: "string"}}
|
|
||||||
}
|
|
||||||
func (p *interactiveStatesPeekingProvider) ParseForm(r *http.Request) (checker.CheckerOptions, error) {
|
|
||||||
return checker.CheckerOptions{"domain": r.FormValue("domain")}, nil
|
|
||||||
}
|
|
||||||
func (p *interactiveStatesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
|
|
||||||
if p.seen != nil {
|
|
||||||
*p.seen = ctx.States()
|
|
||||||
}
|
|
||||||
return "<p>ok</p>", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCheck_Submit_ThreadsStatesIntoReport verifies that CheckStates
|
|
||||||
// produced by evaluateRules during POST /check are threaded into the
|
|
||||||
// ReportContext handed to GetHTMLReport. Without this wiring, the /check
|
|
||||||
// UI can show states in its own section but the embedded report would
|
|
||||||
// have to re-derive severity/hints from Data.
|
|
||||||
func TestCheck_Submit_ThreadsStatesIntoReport(t *testing.T) {
|
|
||||||
var seen []checker.CheckState
|
|
||||||
p := &interactiveStatesPeekingProvider{
|
|
||||||
key: "test",
|
|
||||||
def: &checker.CheckerDefinition{
|
|
||||||
ID: "test",
|
|
||||||
Rules: []checker.CheckRule{&dummyRule{name: "rule1", desc: "first"}},
|
|
||||||
},
|
|
||||||
seen: &seen,
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if len(seen) != 1 {
|
|
||||||
t.Fatalf("reporter saw %d states, want 1", len(seen))
|
|
||||||
}
|
|
||||||
if seen[0].RuleName != "rule1" {
|
|
||||||
t.Errorf("state RuleName = %q, want %q", seen[0].RuleName, "rule1")
|
|
||||||
}
|
|
||||||
if seen[0].Status != checker.StatusOK {
|
|
||||||
t.Errorf("state Status = %v, want %v", seen[0].Status, checker.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) {
|
|
||||||
p := &interactiveProvider{
|
|
||||||
testProvider: &testProvider{
|
|
||||||
key: "test",
|
|
||||||
definition: &checker.CheckerDefinition{
|
|
||||||
ID: "test",
|
|
||||||
Rules: []checker.CheckRule{&relatedAssertRule{key: "other"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: []checker.CheckerOptionField{{Id: "domain", Type: "string"}},
|
|
||||||
}
|
|
||||||
srv := New(p)
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /check = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "no related observation") {
|
|
||||||
t.Errorf("rule should have seen no related observation; body:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
package server
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -24,39 +24,37 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/checker-sdk-go/checker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- test doubles ---
|
// --- test doubles ---
|
||||||
|
|
||||||
type testProvider struct {
|
type testProvider struct {
|
||||||
key checker.ObservationKey
|
key ObservationKey
|
||||||
collectFn func(ctx context.Context, opts checker.CheckerOptions) (any, error)
|
collectFn func(ctx context.Context, opts CheckerOptions) (any, error)
|
||||||
definition *checker.CheckerDefinition
|
definition *CheckerDefinition
|
||||||
htmlFn func(raw json.RawMessage) (string, error)
|
htmlFn func(raw json.RawMessage) (string, error)
|
||||||
metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error)
|
metricsFn func(raw json.RawMessage, t time.Time) ([]CheckMetric, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *testProvider) Key() checker.ObservationKey { return p.key }
|
func (p *testProvider) Key() ObservationKey { return p.key }
|
||||||
func (p *testProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||||
if p.collectFn != nil {
|
if p.collectFn != nil {
|
||||||
return p.collectFn(ctx, opts)
|
return p.collectFn(ctx, opts)
|
||||||
}
|
}
|
||||||
return map[string]string{"result": "ok"}, nil
|
return map[string]string{"result": "ok"}, nil
|
||||||
}
|
}
|
||||||
func (p *testProvider) Definition() *checker.CheckerDefinition { return p.definition }
|
func (p *testProvider) Definition() *CheckerDefinition { return p.definition }
|
||||||
func (p *testProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
|
func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) {
|
||||||
if p.htmlFn != nil {
|
if p.htmlFn != nil {
|
||||||
return p.htmlFn(ctx.Data())
|
return p.htmlFn(ctx.Data())
|
||||||
}
|
}
|
||||||
return "<h1>report</h1>", nil
|
return "<h1>report</h1>", nil
|
||||||
}
|
}
|
||||||
func (p *testProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
|
func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) {
|
||||||
if p.metricsFn != nil {
|
if p.metricsFn != nil {
|
||||||
return p.metricsFn(ctx.Data(), t)
|
return p.metricsFn(ctx.Data(), t)
|
||||||
}
|
}
|
||||||
return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dummyRule is a minimal CheckRule for testing evaluate.
|
// dummyRule is a minimal CheckRule for testing evaluate.
|
||||||
|
|
@ -67,37 +65,14 @@ type dummyRule struct {
|
||||||
|
|
||||||
func (r *dummyRule) Name() string { return r.name }
|
func (r *dummyRule) Name() string { return r.name }
|
||||||
func (r *dummyRule) Description() string { return r.desc }
|
func (r *dummyRule) Description() string { return r.desc }
|
||||||
func (r *dummyRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) CheckState {
|
||||||
return []checker.CheckState{{Status: checker.StatusOK, Message: r.name + " passed"}}
|
return CheckState{Status: StatusOK, Message: r.name + " passed"}
|
||||||
}
|
|
||||||
|
|
||||||
// codedRule emits a CheckState with a pre-set Code, to verify the server
|
|
||||||
// stamps RuleName without clobbering rule-provided codes.
|
|
||||||
type codedRule struct {
|
|
||||||
name, code string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *codedRule) Name() string { return r.name }
|
|
||||||
func (r *codedRule) Description() string { return "" }
|
|
||||||
func (r *codedRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState {
|
|
||||||
return []checker.CheckState{{Status: checker.StatusWarn, Code: r.code, Message: "coded finding"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stubProvider is a minimal ObservationProvider that does not implement
|
|
||||||
// CheckerDefinitionProvider, used to verify conditional endpoint registration.
|
|
||||||
type stubProvider struct {
|
|
||||||
key checker.ObservationKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stubProvider) Key() checker.ObservationKey { return s.key }
|
|
||||||
func (s stubProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func newTestServer(p *testProvider) *Server {
|
func newTestServer(p *testProvider) *Server {
|
||||||
return New(p)
|
return NewServer(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
func doRequest(handler http.Handler, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
|
@ -120,14 +95,14 @@ func doRequest(handler http.Handler, method, path string, body any, headers map[
|
||||||
// --- tests ---
|
// --- tests ---
|
||||||
|
|
||||||
func TestServer_Health(t *testing.T) {
|
func TestServer_Health(t *testing.T) {
|
||||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
rec := doRequest(srv.Handler(), "GET", "/health", nil, nil)
|
rec := doRequest(srv.Handler(), "GET", "/health", nil, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("GET /health = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var resp checker.HealthResponse
|
var resp HealthResponse
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
t.Fatalf("decode /health: %v", err)
|
t.Fatalf("decode /health: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -156,8 +131,8 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
||||||
var collectEntered sync.WaitGroup
|
var collectEntered sync.WaitGroup
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||||
collectEntered.Done()
|
collectEntered.Done()
|
||||||
<-release
|
<-release
|
||||||
return map[string]string{"ok": "1"}, nil
|
return map[string]string{"ok": "1"}, nil
|
||||||
|
|
@ -174,7 +149,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer clientsDone.Done()
|
defer clientsDone.Done()
|
||||||
doRequest(handler, "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
|
doRequest(handler, "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +158,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
||||||
|
|
||||||
// Record /health mid-flight. Also hammer it to verify /health polls
|
// Record /health mid-flight. Also hammer it to verify /health polls
|
||||||
// do not inflate InFlight or TotalRequests.
|
// do not inflate InFlight or TotalRequests.
|
||||||
var mid checker.HealthResponse
|
var mid HealthResponse
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
rec := doRequest(handler, "GET", "/health", nil, nil)
|
rec := doRequest(handler, "GET", "/health", nil, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -205,7 +180,7 @@ func TestServer_Health_TracksInFlight(t *testing.T) {
|
||||||
clientsDone.Wait()
|
clientsDone.Wait()
|
||||||
|
|
||||||
rec := doRequest(handler, "GET", "/health", nil, nil)
|
rec := doRequest(handler, "GET", "/health", nil, nil)
|
||||||
var after checker.HealthResponse
|
var after HealthResponse
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&after); err != nil {
|
if err := json.NewDecoder(rec.Body).Decode(&after); err != nil {
|
||||||
t.Fatalf("decode /health: %v", err)
|
t.Fatalf("decode /health: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +225,7 @@ func TestUpdateLoadAvg(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Close_Idempotent(t *testing.T) {
|
func TestServer_Close_Idempotent(t *testing.T) {
|
||||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
done := make(chan error, 2)
|
done := make(chan error, 2)
|
||||||
go func() { done <- srv.Close() }()
|
go func() { done <- srv.Close() }()
|
||||||
|
|
@ -270,20 +245,20 @@ func TestServer_Close_Idempotent(t *testing.T) {
|
||||||
func TestServer_Collect_Success(t *testing.T) {
|
func TestServer_Collect_Success(t *testing.T) {
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||||
return map[string]int{"count": 42}, nil
|
return map[string]int{"count": 42}, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{
|
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{
|
||||||
Key: "test",
|
Key: "test",
|
||||||
Options: checker.CheckerOptions{"a": "b"},
|
Options: CheckerOptions{"a": "b"},
|
||||||
}, nil)
|
}, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var resp checker.ExternalCollectResponse
|
var resp ExternalCollectResponse
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
t.Errorf("POST /collect error = %q, want empty", resp.Error)
|
t.Errorf("POST /collect error = %q, want empty", resp.Error)
|
||||||
|
|
@ -296,17 +271,17 @@ func TestServer_Collect_Success(t *testing.T) {
|
||||||
func TestServer_Collect_ProviderError(t *testing.T) {
|
func TestServer_Collect_ProviderError(t *testing.T) {
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}},
|
||||||
collectFn: func(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
collectFn: func(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||||
return nil, errors.New("provider failed")
|
return nil, errors.New("provider failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
rec := doRequest(srv.Handler(), "POST", "/collect", checker.ExternalCollectRequest{Key: "test"}, nil)
|
rec := doRequest(srv.Handler(), "POST", "/collect", ExternalCollectRequest{Key: "test"}, nil)
|
||||||
if rec.Code != http.StatusInternalServerError {
|
if rec.Code != http.StatusInternalServerError {
|
||||||
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
|
t.Fatalf("POST /collect = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
var resp checker.ExternalCollectResponse
|
var resp ExternalCollectResponse
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
if resp.Error == "" {
|
if resp.Error == "" {
|
||||||
t.Error("expected error in response, got empty")
|
t.Error("expected error in response, got empty")
|
||||||
|
|
@ -314,7 +289,7 @@ func TestServer_Collect_ProviderError(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Collect_BadBody(t *testing.T) {
|
func TestServer_Collect_BadBody(t *testing.T) {
|
||||||
p := &testProvider{key: "test", definition: &checker.CheckerDefinition{ID: "test", Rules: []checker.CheckRule{}}}
|
p := &testProvider{key: "test", definition: &CheckerDefinition{ID: "test", Rules: []CheckRule{}}}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid"))
|
req := httptest.NewRequest("POST", "/collect", bytes.NewBufferString("{invalid"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
@ -325,10 +300,10 @@ func TestServer_Collect_BadBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Definition(t *testing.T) {
|
func TestServer_Definition(t *testing.T) {
|
||||||
def := &checker.CheckerDefinition{
|
def := &CheckerDefinition{
|
||||||
ID: "test-checker",
|
ID: "test-checker",
|
||||||
Name: "Test Checker",
|
Name: "Test Checker",
|
||||||
Rules: []checker.CheckRule{
|
Rules: []CheckRule{
|
||||||
&dummyRule{name: "rule1", desc: "first rule"},
|
&dummyRule{name: "rule1", desc: "first rule"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -338,7 +313,7 @@ func TestServer_Definition(t *testing.T) {
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var got checker.CheckerDefinition
|
var got CheckerDefinition
|
||||||
json.NewDecoder(rec.Body).Decode(&got)
|
json.NewDecoder(rec.Body).Decode(&got)
|
||||||
if got.ID != "test-checker" {
|
if got.ID != "test-checker" {
|
||||||
t.Errorf("definition ID = %q, want \"test-checker\"", got.ID)
|
t.Errorf("definition ID = %q, want \"test-checker\"", got.ID)
|
||||||
|
|
@ -349,10 +324,10 @@ func TestServer_Definition(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Evaluate(t *testing.T) {
|
func TestServer_Evaluate(t *testing.T) {
|
||||||
def := &checker.CheckerDefinition{
|
def := &CheckerDefinition{
|
||||||
ID: "test-checker",
|
ID: "test-checker",
|
||||||
Name: "Test Checker",
|
Name: "Test Checker",
|
||||||
Rules: []checker.CheckRule{
|
Rules: []CheckRule{
|
||||||
&dummyRule{name: "rule1", desc: "first rule"},
|
&dummyRule{name: "rule1", desc: "first rule"},
|
||||||
&dummyRule{name: "rule2", desc: "second rule"},
|
&dummyRule{name: "rule2", desc: "second rule"},
|
||||||
},
|
},
|
||||||
|
|
@ -360,32 +335,29 @@ func TestServer_Evaluate(t *testing.T) {
|
||||||
p := &testProvider{key: "test", definition: def}
|
p := &testProvider{key: "test", definition: def}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{
|
||||||
Observations: map[checker.ObservationKey]json.RawMessage{
|
Observations: map[ObservationKey]json.RawMessage{
|
||||||
"test": json.RawMessage(`{"count":42}`),
|
"test": json.RawMessage(`{"count":42}`),
|
||||||
},
|
},
|
||||||
Options: checker.CheckerOptions{},
|
Options: CheckerOptions{},
|
||||||
}, nil)
|
}, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var resp checker.ExternalEvaluateResponse
|
var resp ExternalEvaluateResponse
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
if len(resp.States) != 2 {
|
if len(resp.States) != 2 {
|
||||||
t.Fatalf("evaluate states = %d, want 2", len(resp.States))
|
t.Fatalf("evaluate states = %d, want 2", len(resp.States))
|
||||||
}
|
}
|
||||||
if resp.States[0].RuleName != "rule1" {
|
if resp.States[0].Code != "rule1" {
|
||||||
t.Errorf("evaluate state[0].RuleName = %q, want \"rule1\"", resp.States[0].RuleName)
|
t.Errorf("evaluate state[0].Code = %q, want \"rule1\"", resp.States[0].Code)
|
||||||
}
|
|
||||||
if resp.States[0].Code != "" {
|
|
||||||
t.Errorf("evaluate state[0].Code = %q, want empty (rule did not set one)", resp.States[0].Code)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
||||||
def := &checker.CheckerDefinition{
|
def := &CheckerDefinition{
|
||||||
ID: "test-checker",
|
ID: "test-checker",
|
||||||
Rules: []checker.CheckRule{
|
Rules: []CheckRule{
|
||||||
&dummyRule{name: "rule1", desc: "first"},
|
&dummyRule{name: "rule1", desc: "first"},
|
||||||
&dummyRule{name: "rule2", desc: "second"},
|
&dummyRule{name: "rule2", desc: "second"},
|
||||||
},
|
},
|
||||||
|
|
@ -393,8 +365,8 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
||||||
p := &testProvider{key: "test", definition: def}
|
p := &testProvider{key: "test", definition: def}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
rec := doRequest(srv.Handler(), "POST", "/evaluate", ExternalEvaluateRequest{
|
||||||
Observations: map[checker.ObservationKey]json.RawMessage{
|
Observations: map[ObservationKey]json.RawMessage{
|
||||||
"test": json.RawMessage(`{}`),
|
"test": json.RawMessage(`{}`),
|
||||||
},
|
},
|
||||||
EnabledRules: map[string]bool{"rule1": false},
|
EnabledRules: map[string]bool{"rule1": false},
|
||||||
|
|
@ -402,55 +374,26 @@ func TestServer_Evaluate_DisabledRule(t *testing.T) {
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var resp checker.ExternalEvaluateResponse
|
var resp ExternalEvaluateResponse
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
json.NewDecoder(rec.Body).Decode(&resp)
|
||||||
if len(resp.States) != 1 {
|
if len(resp.States) != 1 {
|
||||||
t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States))
|
t.Fatalf("evaluate with disabled rule: states = %d, want 1", len(resp.States))
|
||||||
}
|
}
|
||||||
if resp.States[0].RuleName != "rule2" {
|
if resp.States[0].Code != "rule2" {
|
||||||
t.Errorf("remaining state rule name = %q, want \"rule2\"", resp.States[0].RuleName)
|
t.Errorf("remaining state code = %q, want \"rule2\"", resp.States[0].Code)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Evaluate_RulePreservesCode(t *testing.T) {
|
|
||||||
def := &checker.CheckerDefinition{
|
|
||||||
ID: "test-checker",
|
|
||||||
Rules: []checker.CheckRule{
|
|
||||||
&codedRule{name: "ruleA", code: "too_many_lookups"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
p := &testProvider{key: "test", definition: def}
|
|
||||||
srv := newTestServer(p)
|
|
||||||
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", checker.ExternalEvaluateRequest{
|
|
||||||
Observations: map[checker.ObservationKey]json.RawMessage{"test": json.RawMessage(`{}`)},
|
|
||||||
}, nil)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /evaluate = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
var resp checker.ExternalEvaluateResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
|
||||||
if len(resp.States) != 1 {
|
|
||||||
t.Fatalf("states = %d, want 1", len(resp.States))
|
|
||||||
}
|
|
||||||
if resp.States[0].RuleName != "ruleA" {
|
|
||||||
t.Errorf("state.RuleName = %q, want \"ruleA\"", resp.States[0].RuleName)
|
|
||||||
}
|
|
||||||
if resp.States[0].Code != "too_many_lookups" {
|
|
||||||
t.Errorf("state.Code = %q, want \"too_many_lookups\" (rule-set code must be preserved)", resp.States[0].Code)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Report_HTML(t *testing.T) {
|
func TestServer_Report_HTML(t *testing.T) {
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||||
htmlFn: func(raw json.RawMessage) (string, error) {
|
htmlFn: func(raw json.RawMessage) (string, error) {
|
||||||
return "<p>hello</p>", nil
|
return "<p>hello</p>", nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
|
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{
|
||||||
Key: "test",
|
Key: "test",
|
||||||
Data: json.RawMessage(`{}`),
|
Data: json.RawMessage(`{}`),
|
||||||
}, map[string]string{"Accept": "text/html"})
|
}, map[string]string{"Accept": "text/html"})
|
||||||
|
|
@ -467,18 +410,18 @@ func TestServer_Report_HTML(t *testing.T) {
|
||||||
|
|
||||||
func TestServer_Report_Metrics(t *testing.T) {
|
func TestServer_Report_Metrics(t *testing.T) {
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||||
}
|
}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{
|
rec := doRequest(srv.Handler(), "POST", "/report", ExternalReportRequest{
|
||||||
Key: "test",
|
Key: "test",
|
||||||
Data: json.RawMessage(`{}`),
|
Data: json.RawMessage(`{}`),
|
||||||
}, map[string]string{"Accept": "application/json"})
|
}, map[string]string{"Accept": "application/json"})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
var metrics []checker.CheckMetric
|
var metrics []CheckMetric
|
||||||
json.NewDecoder(rec.Body).Decode(&metrics)
|
json.NewDecoder(rec.Body).Decode(&metrics)
|
||||||
if len(metrics) != 1 {
|
if len(metrics) != 1 {
|
||||||
t.Errorf("metrics count = %d, want 1", len(metrics))
|
t.Errorf("metrics count = %d, want 1", len(metrics))
|
||||||
|
|
@ -489,25 +432,25 @@ func TestServer_Report_Metrics(t *testing.T) {
|
||||||
// ExternalReportRequest.Related through to the provider's ReportContext,
|
// ExternalReportRequest.Related through to the provider's ReportContext,
|
||||||
// the fix for the "remote checkers can't see related observations" gap.
|
// the fix for the "remote checkers can't see related observations" gap.
|
||||||
func TestServer_Report_Related(t *testing.T) {
|
func TestServer_Report_Related(t *testing.T) {
|
||||||
var gotRelated []checker.RelatedObservation
|
var gotRelated []RelatedObservation
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||||
}
|
}
|
||||||
// Replace htmlFn with one that peeks at a related key. We can't do that
|
// Replace htmlFn with one that peeks at a related key. We can't do that
|
||||||
// directly through testProvider's htmlFn (which only sees raw), so
|
// directly through testProvider's htmlFn (which only sees raw), so
|
||||||
// bind to GetHTMLReport via an inline wrapper: use a per-test provider
|
// bind to GetHTMLReport via an inline wrapper: use a per-test provider
|
||||||
// that captures the ReportContext before delegating to the template.
|
// that captures the ReportContext before delegating to the template.
|
||||||
srv := New(&relatedPeekingProvider{
|
srv := NewServer(&relatedPeekingProvider{
|
||||||
base: p,
|
base: p,
|
||||||
target: &gotRelated,
|
target: &gotRelated,
|
||||||
})
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
req := checker.ExternalReportRequest{
|
req := ExternalReportRequest{
|
||||||
Key: "test",
|
Key: "test",
|
||||||
Data: json.RawMessage(`{}`),
|
Data: json.RawMessage(`{}`),
|
||||||
Related: map[checker.ObservationKey][]checker.RelatedObservation{
|
Related: map[ObservationKey][]RelatedObservation{
|
||||||
"tls_probes": {
|
"tls_probes": {
|
||||||
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
|
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
|
||||||
},
|
},
|
||||||
|
|
@ -529,124 +472,23 @@ func TestServer_Report_Related(t *testing.T) {
|
||||||
// Related("tls_probes") slice observed at GetHTMLReport time into target.
|
// Related("tls_probes") slice observed at GetHTMLReport time into target.
|
||||||
type relatedPeekingProvider struct {
|
type relatedPeekingProvider struct {
|
||||||
base *testProvider
|
base *testProvider
|
||||||
target *[]checker.RelatedObservation
|
target *[]RelatedObservation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
|
func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() }
|
||||||
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||||
return p.base.Collect(ctx, opts)
|
return p.base.Collect(ctx, opts)
|
||||||
}
|
}
|
||||||
func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
|
func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition }
|
||||||
func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
|
func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) {
|
||||||
*p.target = ctx.Related("tls_probes")
|
*p.target = ctx.Related("tls_probes")
|
||||||
return "<p>ok</p>", nil
|
return "<p>ok</p>", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// statesPeekingProvider captures the ReportContext's States slice at
|
|
||||||
// GetHTMLReport / ExtractMetrics time.
|
|
||||||
type statesPeekingProvider struct {
|
|
||||||
base *testProvider
|
|
||||||
htmlSeen *[]checker.CheckState
|
|
||||||
metricSeen *[]checker.CheckState
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *statesPeekingProvider) Key() checker.ObservationKey { return p.base.Key() }
|
|
||||||
func (p *statesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) {
|
|
||||||
return p.base.Collect(ctx, opts)
|
|
||||||
}
|
|
||||||
func (p *statesPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition }
|
|
||||||
func (p *statesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) {
|
|
||||||
if p.htmlSeen != nil {
|
|
||||||
*p.htmlSeen = ctx.States()
|
|
||||||
}
|
|
||||||
return "<p>ok</p>", nil
|
|
||||||
}
|
|
||||||
func (p *statesPeekingProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) {
|
|
||||||
if p.metricSeen != nil {
|
|
||||||
*p.metricSeen = ctx.States()
|
|
||||||
}
|
|
||||||
return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServer_Report_States_HTML verifies ExternalReportRequest.States is
|
|
||||||
// threaded into the ReportContext seen by the HTML reporter.
|
|
||||||
func TestServer_Report_States_HTML(t *testing.T) {
|
|
||||||
var seen []checker.CheckState
|
|
||||||
base := &testProvider{
|
|
||||||
key: "test",
|
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
|
||||||
}
|
|
||||||
srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
states := []checker.CheckState{
|
|
||||||
{Status: checker.StatusCrit, Message: "broken", RuleName: "r1", Code: "bad", Subject: "host.example"},
|
|
||||||
}
|
|
||||||
req := checker.ExternalReportRequest{
|
|
||||||
Key: "test",
|
|
||||||
Data: json.RawMessage(`{}`),
|
|
||||||
States: states,
|
|
||||||
}
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /report = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if len(seen) != 1 || seen[0].RuleName != "r1" || seen[0].Code != "bad" || seen[0].Subject != "host.example" {
|
|
||||||
t.Errorf("reporter saw states = %+v, want single state {RuleName:r1, Code:bad, Subject:host.example}", seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServer_Report_States_Metrics verifies the States passthrough on the
|
|
||||||
// metrics path as well.
|
|
||||||
func TestServer_Report_States_Metrics(t *testing.T) {
|
|
||||||
var seen []checker.CheckState
|
|
||||||
base := &testProvider{
|
|
||||||
key: "test",
|
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
|
||||||
}
|
|
||||||
srv := New(&statesPeekingProvider{base: base, metricSeen: &seen})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req := checker.ExternalReportRequest{
|
|
||||||
Key: "test",
|
|
||||||
Data: json.RawMessage(`{}`),
|
|
||||||
States: []checker.CheckState{{Status: checker.StatusWarn, RuleName: "r1"}},
|
|
||||||
}
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "application/json"})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /report = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if len(seen) != 1 || seen[0].RuleName != "r1" {
|
|
||||||
t.Errorf("reporter saw states = %+v, want single state with RuleName=r1", seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestServer_Report_States_Absent verifies that omitting States in the
|
|
||||||
// request yields a nil States() slice on the reporter side (graceful
|
|
||||||
// degradation for hosts that don't thread evaluate→report yet).
|
|
||||||
func TestServer_Report_States_Absent(t *testing.T) {
|
|
||||||
seen := []checker.CheckState{{Status: checker.StatusOK}} // non-nil sentinel
|
|
||||||
base := &testProvider{
|
|
||||||
key: "test",
|
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
|
||||||
}
|
|
||||||
srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen})
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
req := checker.ExternalReportRequest{Key: "test", Data: json.RawMessage(`{}`)}
|
|
||||||
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /report = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
if seen != nil {
|
|
||||||
t.Errorf("States() = %+v, want nil when ExternalReportRequest.States is absent", seen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_Report_BadBody(t *testing.T) {
|
func TestServer_Report_BadBody(t *testing.T) {
|
||||||
p := &testProvider{
|
p := &testProvider{
|
||||||
key: "test",
|
key: "test",
|
||||||
definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}},
|
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||||
}
|
}
|
||||||
srv := newTestServer(p)
|
srv := newTestServer(p)
|
||||||
req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad"))
|
req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad"))
|
||||||
|
|
@ -660,7 +502,7 @@ func TestServer_Report_BadBody(t *testing.T) {
|
||||||
func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) {
|
func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) {
|
||||||
// A provider that does NOT implement CheckerDefinitionProvider
|
// A provider that does NOT implement CheckerDefinitionProvider
|
||||||
p := &stubProvider{key: "basic"}
|
p := &stubProvider{key: "basic"}
|
||||||
srv := New(p)
|
srv := NewServer(p)
|
||||||
rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil)
|
rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil)
|
||||||
// Should 404 or 405 since /evaluate is not registered
|
// Should 404 or 405 since /evaluate is not registered
|
||||||
if rec.Code == http.StatusOK {
|
if rec.Code == http.StatusOK {
|
||||||
|
|
@ -182,17 +182,12 @@ func (s Status) String() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckState is the result of evaluating a single rule on a single subject.
|
// CheckState is the result of evaluating a single rule.
|
||||||
// Subject is opaque to the SDK: producers and consumers agree on its shape
|
|
||||||
// (a hostname, a record key, a serial, …). Leave Subject empty for rules
|
|
||||||
// that produce a single, global result.
|
|
||||||
type CheckState struct {
|
type CheckState struct {
|
||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
RuleName string `json:"rule,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
Code string `json:"code,omitempty"`
|
Meta map[string]any `json:"meta,omitempty"`
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
Meta map[string]any `json:"meta,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMetric represents a single metric produced by a check.
|
// CheckMetric represents a single metric produced by a check.
|
||||||
|
|
@ -227,20 +222,11 @@ type CheckRuleInfo struct {
|
||||||
Options *CheckerOptionsDocumentation `json:"options,omitempty"`
|
Options *CheckerOptionsDocumentation `json:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckRule evaluates observations and produces one or more CheckStates.
|
// CheckRule evaluates observations and produces a CheckState.
|
||||||
//
|
|
||||||
// Evaluate returns a slice so a rule iterating over multiple elements can
|
|
||||||
// emit one state per subject (each carrying CheckState.Subject) without
|
|
||||||
// squashing them into a single concatenated message.
|
|
||||||
//
|
|
||||||
// Evaluate must not return a nil or empty slice: callers expect at least
|
|
||||||
// one state per rule. When a rule finds nothing to evaluate, return a
|
|
||||||
// single CheckState with an appropriate status (typically StatusInfo or
|
|
||||||
// StatusOK) describing that fact.
|
|
||||||
type CheckRule interface {
|
type CheckRule interface {
|
||||||
Name() string
|
Name() string
|
||||||
Description() string
|
Description() string
|
||||||
Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState
|
Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) CheckState
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckRuleWithOptions is an optional interface that rules can implement
|
// CheckRuleWithOptions is an optional interface that rules can implement
|
||||||
|
|
@ -298,40 +284,32 @@ type CheckAggregator interface {
|
||||||
Aggregate(states []CheckState) CheckState
|
Aggregate(states []CheckState) CheckState
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportContext carries the primary observation payload, any observations
|
// ReportContext carries both the primary observation payload and any
|
||||||
// produced by other checkers that cover the same discovery entries, and the
|
// observations produced by other checkers that cover the same discovery
|
||||||
// CheckStates produced by this checker's rules for the same observation.
|
// entries. Hosts build a ReportContext and hand it to reporter methods.
|
||||||
// Hosts build a ReportContext and hand it to reporter methods.
|
|
||||||
//
|
//
|
||||||
// Reporters use States() to render rule-driven sections (for example a
|
// The method set is deliberately tiny: a single primary payload (Data) and
|
||||||
// "fix these first" list) without re-deriving severity or hints from the
|
// a query for related observations by key (Related). Hosts return nil from
|
||||||
// raw payload. Hosts that have not yet threaded rule output into the
|
// Related when there is nothing to relate; reporters must tolerate that.
|
||||||
// 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 {
|
type ReportContext interface {
|
||||||
Data() json.RawMessage
|
Data() json.RawMessage
|
||||||
Related(key ObservationKey) []RelatedObservation
|
Related(key ObservationKey) []RelatedObservation
|
||||||
States() []CheckState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReportContext returns a ReportContext backed by a primary payload, a
|
// NewReportContext returns a ReportContext backed by a primary payload and
|
||||||
// pre-resolved map of related observations by key, and the CheckStates
|
// a pre-resolved map of related observations by key. The SDK's /report HTTP
|
||||||
// produced by the checker's rules on this observation. The SDK's /report
|
// handler uses this to wrap ExternalReportRequest contents; hosts and tests
|
||||||
// HTTP handler uses this to wrap ExternalReportRequest contents; hosts and
|
// can use it whenever they already have the related observations in memory.
|
||||||
// tests can use it whenever they already have that material in memory.
|
|
||||||
//
|
//
|
||||||
// Passing a nil related map or a nil states slice is fine; Related(key)
|
// Passing a nil or empty related map is fine; Related(key) will then return
|
||||||
// and States() will then return nil respectively. Use StaticReportContext
|
// nil, just like StaticReportContext.
|
||||||
// as a shorthand when both are absent.
|
func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation) ReportContext {
|
||||||
func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation, states []CheckState) ReportContext {
|
return fixedReportContext{data: data, related: related}
|
||||||
return fixedReportContext{data: data, related: related, states: states}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticReportContext is a shorthand for NewReportContext(data, nil, nil):
|
// StaticReportContext is a shorthand for NewReportContext(data, nil): a
|
||||||
// a ReportContext with a primary payload, no related observations, and no
|
// ReportContext with a primary payload and no related observations.
|
||||||
// rule states. Intended for tests and ad-hoc callers that have no lineage
|
// Intended for tests and ad-hoc callers that have no lineage to supply.
|
||||||
// or rule output to supply.
|
|
||||||
func StaticReportContext(data json.RawMessage) ReportContext {
|
func StaticReportContext(data json.RawMessage) ReportContext {
|
||||||
return fixedReportContext{data: data}
|
return fixedReportContext{data: data}
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +317,6 @@ func StaticReportContext(data json.RawMessage) ReportContext {
|
||||||
type fixedReportContext struct {
|
type fixedReportContext struct {
|
||||||
data json.RawMessage
|
data json.RawMessage
|
||||||
related map[ObservationKey][]RelatedObservation
|
related map[ObservationKey][]RelatedObservation
|
||||||
states []CheckState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fixedReportContext) Data() json.RawMessage { return f.data }
|
func (f fixedReportContext) Data() json.RawMessage { return f.data }
|
||||||
|
|
@ -349,7 +326,6 @@ func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation {
|
||||||
}
|
}
|
||||||
return f.related[key]
|
return f.related[key]
|
||||||
}
|
}
|
||||||
func (f fixedReportContext) States() []CheckState { return f.states }
|
|
||||||
|
|
||||||
// CheckerHTMLReporter is an optional interface that observation providers can
|
// CheckerHTMLReporter is an optional interface that observation providers can
|
||||||
// implement to render their stored data as a full HTML document (for iframe embedding).
|
// implement to render their stored data as a full HTML document (for iframe embedding).
|
||||||
|
|
@ -496,20 +472,13 @@ type ExternalEvaluateResponse struct {
|
||||||
// Related carries observations produced by other checkers on DiscoveryEntry
|
// Related carries observations produced by other checkers on DiscoveryEntry
|
||||||
// records originally published by the target of this report, that is, the
|
// records originally published by the target of this report, that is, the
|
||||||
// cross-checker lineage that ObservationGetter.GetRelated would expose in
|
// cross-checker lineage that ObservationGetter.GetRelated would expose in
|
||||||
// the in-process path. States carries the CheckStates the host produced by
|
// the in-process path. The host composes it before making the HTTP request;
|
||||||
// evaluating this checker's rules against the same observation, letting
|
// when absent, the remote checker receives a context that reports no
|
||||||
// reporters render rule-driven sections (for example a "fix these first"
|
// related observations (equivalent to StaticReportContext).
|
||||||
// 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 {
|
type ExternalReportRequest struct {
|
||||||
Key ObservationKey `json:"key"`
|
Key ObservationKey `json:"key"`
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"`
|
Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"`
|
||||||
States []CheckState `json:"states,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthResponse is returned by GET /health on a remote checker endpoint.
|
// HealthResponse is returned by GET /health on a remote checker endpoint.
|
||||||
|
|
|
||||||
|
|
@ -15,24 +15,10 @@
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dummyRule is a minimal CheckRule used only by tests in this package.
|
|
||||||
type dummyRule struct {
|
|
||||||
name string
|
|
||||||
desc string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *dummyRule) Name() string { return r.name }
|
|
||||||
func (r *dummyRule) Description() string { return r.desc }
|
|
||||||
func (r *dummyRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState {
|
|
||||||
return []CheckState{{Status: StatusOK, Message: r.name + " passed"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatus_MarshalJSON(t *testing.T) {
|
func TestStatus_MarshalJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
status Status
|
status Status
|
||||||
|
|
@ -157,54 +143,6 @@ 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) {
|
func TestRegisterChecker_EmptyIDRejected(t *testing.T) {
|
||||||
resetRegistries()
|
resetRegistries()
|
||||||
RegisterChecker(&CheckerDefinition{ID: "", Name: "bad"})
|
RegisterChecker(&CheckerDefinition{ID: "", Name: "bad"})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue