diff --git a/README.md b/README.md index ed5f3f6..49767f7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,53 @@ go get git.happydns.org/checker-sdk-go/checker See [checker-dummy](https://git.happydns.org/checker-dummy) for a 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 Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/checker/names.go b/checker/names.go new file mode 100644 index 0000000..61df52e --- /dev/null +++ b/checker/names.go @@ -0,0 +1,37 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checker + +import "strings" + +// JoinRelative treats name as relative to origin, as happyDomain encodes +// service-embedded record owners and subdomains. An empty or "@" name +// resolves to the origin itself; an empty origin returns the trimmed name +// unchanged. A name already suffixed by origin is returned as-is so that +// absolute encodings round-trip safely. Trailing dots are stripped. +func JoinRelative(name, origin string) string { + origin = strings.TrimSuffix(origin, ".") + name = strings.TrimSuffix(name, ".") + if origin == "" { + return name + } + if name == "" || name == "@" { + return origin + } + if name == origin || strings.HasSuffix(name, "."+origin) { + return name + } + return name + "." + origin +} diff --git a/checker/registry_test.go b/checker/registry_test.go index e4170b2..ca7450a 100644 --- a/checker/registry_test.go +++ b/checker/registry_test.go @@ -74,7 +74,7 @@ func TestRegisterExternalizableChecker_AppendsEndpointOnce(t *testing.T) { } // Second registration of the same definition pointer must NOT append a - // second "endpoint" AdminOpt — the duplicate check has to fire before + // second "endpoint" AdminOpt, the duplicate check has to fire before // the append, otherwise we silently mutate the live definition. RegisterExternalizableChecker(c) if n := len(c.Options.AdminOpts); n != 1 { diff --git a/checker/server/healthcheck.go b/checker/server/healthcheck.go new file mode 100644 index 0000000..bb79d22 --- /dev/null +++ b/checker/server/healthcheck.go @@ -0,0 +1,81 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// healthcheckMode is registered on the default flag set so any consumer that +// calls flag.Parse() before ListenAndServe (the standard pattern in our +// checker mains) gets the behaviour for free. When set, ListenAndServe +// performs a short-lived HTTP probe against /health on the configured listen +// address and exits 0/1 instead of starting the server. This lets the same +// binary act as its own Docker HEALTHCHECK probe for scratch images, where +// no shell, curl or wget is available. +var healthcheckMode = flag.Bool( + "healthcheck", + false, + "probe /health on the server's listen address and exit 0 if healthy, 1 "+ + "otherwise (intended as a Docker HEALTHCHECK for scratch-based images)", +) + +// runHealthcheck performs a GET against http:///health with a short +// timeout. Returns nil on a 2xx response, an error otherwise. A bind address +// like ":8080" or "0.0.0.0:8080" is rewritten to dial the loopback interface +// so the probe targets the local process. +func runHealthcheck(addr string) error { + host, port, err := net.SplitHostPort(normalizeHealthcheckAddr(addr)) + if err != nil { + return fmt.Errorf("invalid listen addr %q: %w", addr, err) + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, port)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode) + } + return nil +} + +func normalizeHealthcheckAddr(a string) string { + if strings.HasPrefix(a, ":") { + return "127.0.0.1" + a + } + if strings.HasPrefix(a, "[::]:") { + return "[::1]:" + strings.TrimPrefix(a, "[::]:") + } + return a +} diff --git a/checker/server/healthcheck_test.go b/checker/server/healthcheck_test.go new file mode 100644 index 0000000..daa4bc5 --- /dev/null +++ b/checker/server/healthcheck_test.go @@ -0,0 +1,72 @@ +// Copyright 2020-2026 The happyDomain Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRunHealthcheck_OK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err != nil { + t.Fatalf("runHealthcheck(%s) returned error: %v", addr, err) + } +} + +func TestRunHealthcheck_NonOK(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + addr := strings.TrimPrefix(srv.URL, "http://") + if err := runHealthcheck(addr); err == nil { + t.Fatalf("runHealthcheck against 503 returned nil; want error") + } +} + +func TestRunHealthcheck_Unreachable(t *testing.T) { + // Reserved-for-documentation port on loopback that nothing should bind. + if err := runHealthcheck("127.0.0.1:1"); err == nil { + t.Fatalf("runHealthcheck against unreachable port returned nil; want error") + } +} + +func TestNormalizeHealthcheckAddr(t *testing.T) { + cases := map[string]string{ + ":8080": "127.0.0.1:8080", + "127.0.0.1:8080": "127.0.0.1:8080", + "0.0.0.0:8080": "0.0.0.0:8080", + "[::1]:8080": "[::1]:8080", + "[::]:8080": "[::1]:8080", + } + for in, want := range cases { + if got := normalizeHealthcheckAddr(in); got != want { + t.Errorf("normalizeHealthcheckAddr(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/checker/server/interactive.go b/checker/server/interactive.go new file mode 100644 index 0000000..27cc260 --- /dev/null +++ b/checker/server/interactive.go @@ -0,0 +1,453 @@ +// 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 "" 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(` + + + +{{.Title}} – Check + + + +

{{.Title}}

+{{if .Error}}
{{.Error}}
{{end}} +
+{{range .Fields}}{{if not .Hide}} +
+ + {{if .Choices}} + + {{else if eq .Type "bool"}} + + {{else if .Textarea}} + + {{else if eq .Type "number"}} + + {{else if eq .Type "uint"}} + + {{else if .Secret}} + + {{else}} + + {{end}} +
+{{end}}{{end}} +
+
+ +`)) + +var checkResultTemplate = template.Must(template.New("result").Funcs(templateFuncs).Parse(` + + + +{{.Title}} – Result + + + +

{{.Title}}

+ +{{if .CollectErr}}
Collect failed: {{.CollectErr}}
{{end}} + +{{if .States}} +

Check states

+ + + + {{range .States}} + + + + + + + + {{end}} + +
StatusRuleCodeSubjectMessage
{{statusString .Status}}{{.RuleName}}{{.Code}}{{.Subject}}{{.Message}}
+{{end}} + +{{if .Metrics}} +

Metrics

+ + + + {{range .Metrics}} + + + + + + + {{end}} + +
NameValueUnitLabels
{{.Name}}{{.Value}}{{.Unit}}{{range $k, $v := .Labels}}{{$k}}={{$v}} {{end}}
+{{end}} + +{{if .MetricsErr}}
Metrics error: {{.MetricsErr}}
{{end}} + +{{if .ReportHTML}} +

Report

+ +{{end}} + +{{if .ReportErr}}
Report error: {{.ReportErr}}
{{end}} + + + +`)) diff --git a/checker/server/interactive_test.go b/checker/server/interactive_test.go new file mode 100644 index 0000000..4ce7ade --- /dev/null +++ b/checker/server/interactive_test.go @@ -0,0 +1,439 @@ +// 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`, + `