interactive: let standalone providers compose sibling observations

Add an opt-in InteractiveRelatedProviders interface: a checker served
by /check can declare sibling ObservationProviders the SDK will run
in-process after the primary Collect. The SDK auto-fills any sibling
option tagged AutoFill==AutoFillDiscoveryEntries from the primary's
DiscoverEntries output, mirroring the host's AutoFill wiring so
siblings that work in-host work here too. Results are exposed through
ObservationGetter.GetRelated and ReportContext.Related, unblocking
cross-checker rules (e.g. DANE reading checker-tls probes) on the
standalone /check flow.
This commit is contained in:
nemunaire 2026-04-24 11:19:59 +07:00
commit c244ca48c6
4 changed files with 251 additions and 12 deletions

View file

@ -16,10 +16,12 @@ package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"maps"
"net/http"
"time"
)
@ -104,14 +106,19 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
return
}
related := s.collectRelatedObservations(r.Context(), opts, data)
if s.definition != nil {
obs := &mapObservationGetter{data: map[ObservationKey]json.RawMessage{
s.provider.Key(): raw,
}}
obs := &mapObservationGetter{
data: map[ObservationKey]json.RawMessage{
s.provider.Key(): raw,
},
related: related,
}
result.States = s.evaluateRules(r.Context(), obs, opts, nil)
}
ctx := NewReportContext(raw, nil)
ctx := NewReportContext(raw, related)
if reporter, ok := s.provider.(CheckerHTMLReporter); ok {
html, rerr := reporter.GetHTMLReport(ctx)
@ -134,6 +141,86 @@ func (s *Server) handleCheckSubmit(w http.ResponseWriter, r *http.Request) {
s.renderCheckResult(w, result)
}
// collectRelatedObservations runs sibling providers declared via
// InteractiveRelatedProviders and returns their results keyed by the
// sibling's observation key. Sibling errors are logged and skipped.
func (s *Server) collectRelatedObservations(ctx context.Context, opts CheckerOptions, data any) map[ObservationKey][]RelatedObservation {
irp, ok := s.provider.(InteractiveRelatedProviders)
if !ok {
return nil
}
siblings := irp.RelatedProviders()
if len(siblings) == 0 {
return nil
}
var entries []DiscoveryEntry
if dp, ok := s.provider.(DiscoveryPublisher); ok {
e, err := dp.DiscoverEntries(data)
if err != nil {
log.Printf("interactive: DiscoverEntries failed: %v", err)
} else {
entries = e
}
}
related := make(map[ObservationKey][]RelatedObservation, len(siblings))
for _, sp := range siblings {
sOpts := cloneOptions(opts)
siblingID := ""
if dp, ok := sp.(CheckerDefinitionProvider); ok {
if def := dp.Definition(); def != nil {
siblingID = def.ID
if len(entries) > 0 {
fillDiscoveryEntryOption(sOpts, def, entries)
}
}
}
sData, err := sp.Collect(ctx, sOpts)
if err != nil {
log.Printf("interactive: sibling %q Collect failed: %v", sp.Key(), err)
continue
}
raw, err := json.Marshal(sData)
if err != nil {
log.Printf("interactive: sibling %q marshal failed: %v", sp.Key(), err)
continue
}
related[sp.Key()] = append(related[sp.Key()], RelatedObservation{
CheckerID: siblingID,
Key: sp.Key(),
Data: raw,
CollectedAt: time.Now(),
})
}
return related
}
func cloneOptions(opts CheckerOptions) CheckerOptions {
out := make(CheckerOptions, len(opts))
maps.Copy(out, opts)
return out
}
// fillDiscoveryEntryOption mirrors the host's AutoFill wiring: it writes
// entries into every option in def tagged AutoFill == AutoFillDiscoveryEntries.
func fillDiscoveryEntryOption(opts CheckerOptions, def *CheckerDefinition, entries []DiscoveryEntry) {
scopes := [][]CheckerOptionDocumentation{
def.Options.AdminOpts,
def.Options.UserOpts,
def.Options.DomainOpts,
def.Options.ServiceOpts,
def.Options.RunOpts,
}
for _, scope := range scopes {
for _, f := range scope {
if f.AutoFill == AutoFillDiscoveryEntries {
opts[f.Id] = entries
}
}
}
}
func (s *Server) checkPageTitle() string {
if s.definition != nil && s.definition.Name != "" {
return s.definition.Name