server: split HTTP scaffolding into its own subpackage
Move server.go and interactive.go (and their tests) from the root checker/ package into checker/server/. Plugin and builtin consumers of the SDK now import only checker/ and no longer drag net/http, html/template, or the form-rendering code into their artifacts: on checker-dane.so this drops the binary by ~1.2 MB and removes 170 html/template symbols along with the net/http contribution that came from the SDK itself. Breaking for standalone consumers (main.go): NewServer(p) -> server.New(p) CheckerInteractive -> server.Interactive InteractiveRelatedProviders -> server.Siblings Providers that only satisfy the interactive interfaces structurally (method set match, no explicit type reference) need no source change; only main.go has to switch its import path and the constructor name.
This commit is contained in:
parent
c244ca48c6
commit
89cc3f112b
6 changed files with 276 additions and 240 deletions
|
|
@ -1,435 +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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckerInteractive is an optional interface that observation providers
|
||||
// can implement to expose a human-facing web form usable standalone,
|
||||
// outside of a happyDomain host. Detect support with a type assertion:
|
||||
// _, ok := provider.(CheckerInteractive).
|
||||
//
|
||||
// When the provider implements it, Server binds GET and POST on /check.
|
||||
// GET renders an HTML form built from RenderForm(). POST calls ParseForm
|
||||
// to obtain the CheckerOptions, then runs the standard pipeline
|
||||
// (Collect, Evaluate, GetHTMLReport, ExtractMetrics) and renders a
|
||||
// consolidated result page.
|
||||
//
|
||||
// Unlike /evaluate, which relies on happyDomain to fill AutoFill-backed
|
||||
// options from execution context, a CheckerInteractive implementation is
|
||||
// responsible for resolving whatever it needs from the human inputs
|
||||
// (typically via direct DNS queries) before Collect runs.
|
||||
type CheckerInteractive interface {
|
||||
// RenderForm returns the fields the human must fill in to bootstrap
|
||||
// a check. Typically a minimal set (domain name, nameserver to
|
||||
// query, …) that ParseForm expands into the full CheckerOptions
|
||||
// that Collect expects.
|
||||
RenderForm() []CheckerOptionField
|
||||
|
||||
// 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) (CheckerOptions, error)
|
||||
}
|
||||
|
||||
// checkResult holds everything the result page needs to render.
|
||||
type checkResult struct {
|
||||
Title string
|
||||
States []CheckState
|
||||
Metrics []CheckMetric
|
||||
ReportHTML string
|
||||
CollectErr string
|
||||
ReportErr string
|
||||
MetricsErr string
|
||||
}
|
||||
|
||||
type checkFormPage struct {
|
||||
Title string
|
||||
Fields []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) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderCheckForm(w, s.interactive.RenderForm(), fmt.Sprintf("invalid form: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
opts, err := s.interactive.ParseForm(r)
|
||||
if err != nil {
|
||||
s.renderCheckForm(w, s.interactive.RenderForm(), 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[ObservationKey]json.RawMessage{
|
||||
s.provider.Key(): raw,
|
||||
},
|
||||
related: related,
|
||||
}
|
||||
result.States = s.evaluateRules(r.Context(), obs, opts, nil)
|
||||
}
|
||||
|
||||
ctx := NewReportContext(raw, related)
|
||||
|
||||
if reporter, ok := s.provider.(CheckerHTMLReporter); ok {
|
||||
html, rerr := reporter.GetHTMLReport(ctx)
|
||||
if rerr != nil {
|
||||
result.ReportErr = rerr.Error()
|
||||
} else {
|
||||
result.ReportHTML = html
|
||||
}
|
||||
}
|
||||
|
||||
if reporter, ok := s.provider.(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
|
||||
// InteractiveRelatedProviders and returns their results keyed by the
|
||||
// sibling's observation key. Sibling errors are logged and skipped.
|
||||
func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOptions, data any) map[ObservationKey][]RelatedObservation {
|
||||
irp, ok := s.provider.(InteractiveRelatedProviders)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
siblings := irp.RelatedProviders()
|
||||
if len(siblings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []DiscoveryEntry
|
||||
if dp, ok := s.provider.(DiscoveryPublisher); ok {
|
||||
e, err := dp.DiscoverEntries(data)
|
||||
if err != nil {
|
||||
log.Printf("interactive: DiscoverEntries failed: %v", err)
|
||||
} else {
|
||||
entries = e
|
||||
}
|
||||
}
|
||||
|
||||
related := make(map[ObservationKey][]RelatedObservation, len(siblings))
|
||||
for _, sp := range siblings {
|
||||
sOpts := cloneOptions(opts)
|
||||
siblingID := ""
|
||||
if dp, ok := sp.(CheckerDefinitionProvider); ok {
|
||||
if def := dp.Definition(); def != nil {
|
||||
siblingID = def.ID
|
||||
if len(entries) > 0 {
|
||||
fillDiscoveryEntryOption(sOpts, def, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
sData, err := sp.Collect(ctx, sOpts)
|
||||
if err != nil {
|
||||
log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err)
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(sData)
|
||||
if err != nil {
|
||||
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
|
||||
continue
|
||||
}
|
||||
related[sp.Key()] = append(related[sp.Key()], RelatedObservation{
|
||||
CheckerID: siblingID,
|
||||
Key: sp.Key(),
|
||||
Data: raw,
|
||||
CollectedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return related
|
||||
}
|
||||
|
||||
func cloneOptions(opts CheckerOptions) CheckerOptions {
|
||||
out := make(CheckerOptions, len(opts))
|
||||
maps.Copy(out, opts)
|
||||
return out
|
||||
}
|
||||
|
||||
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
|
||||
// entries into every option in def tagged AutoFill == AutoFillDiscoveryEntries.
|
||||
func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entries []DiscoveryEntry) {
|
||||
scopes := [][]CheckerOptionDocumentation{
|
||||
def.Options.AdminOpts,
|
||||
def.Options.UserOpts,
|
||||
def.Options.DomainOpts,
|
||||
def.Options.ServiceOpts,
|
||||
def.Options.RunOpts,
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, f := range scope {
|
||||
if f.AutoFill == AutoFillDiscoveryEntries {
|
||||
opts[f.Id] = entries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) checkPageTitle() string {
|
||||
if s.definition != nil && s.definition.Name != "" {
|
||||
return s.definition.Name
|
||||
}
|
||||
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 []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 Status) string {
|
||||
switch s {
|
||||
case StatusOK:
|
||||
return "ok"
|
||||
case StatusInfo:
|
||||
return "info"
|
||||
case StatusWarn:
|
||||
return "warn"
|
||||
case StatusCrit:
|
||||
return "crit"
|
||||
case 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": 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>`))
|
||||
Loading…
Add table
Add a link
Reference in a new issue