diff --git a/checker/interactive.go b/checker/interactive.go index bb7158e..7e8b190 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -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 diff --git a/checker/interactive_test.go b/checker/interactive_test.go index d85948b..dc93b93 100644 --- a/checker/interactive_test.go +++ b/checker/interactive_test.go @@ -243,3 +243,136 @@ func (b *bareInteractiveProvider) RenderForm() []CheckerOptionField { func (b *bareInteractiveProvider) ParseForm(r *http.Request) (CheckerOptions, error) { return CheckerOptions{"domain": r.FormValue("domain")}, nil } + +type siblingProvider struct { + key ObservationKey + id string + entriesOpt string + gotOpts CheckerOptions + payload any +} + +func (s *siblingProvider) Key() ObservationKey { return s.key } +func (s *siblingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { + s.gotOpts = opts + return s.payload, nil +} +func (s *siblingProvider) Definition() *CheckerDefinition { + return &CheckerDefinition{ + ID: s.id, + Options: CheckerOptionsDocumentation{ + RunOpts: []CheckerOptionDocumentation{ + {Id: s.entriesOpt, Type: "array", AutoFill: AutoFillDiscoveryEntries}, + }, + }, + } +} + +type primaryWithSibling struct { + key ObservationKey + def *CheckerDefinition + entries []DiscoveryEntry + sibling ObservationProvider +} + +func (p *primaryWithSibling) Key() ObservationKey { return p.key } +func (p *primaryWithSibling) Collect(ctx context.Context, opts CheckerOptions) (any, error) { + return map[string]string{"primary": "ok"}, nil +} +func (p *primaryWithSibling) Definition() *CheckerDefinition { return p.def } +func (p *primaryWithSibling) RenderForm() []CheckerOptionField { + return []CheckerOptionField{{Id: "domain", Type: "string"}} +} +func (p *primaryWithSibling) ParseForm(r *http.Request) (CheckerOptions, error) { + return CheckerOptions{"domain": r.FormValue("domain")}, nil +} +func (p *primaryWithSibling) DiscoverEntries(data any) ([]DiscoveryEntry, error) { + return p.entries, nil +} +func (p *primaryWithSibling) RelatedProviders() []ObservationProvider { + return []ObservationProvider{p.sibling} +} + +type relatedAssertRule struct { + key ObservationKey +} + +func (r *relatedAssertRule) Name() string { return "related_assert" } +func (r *relatedAssertRule) Description() string { return "" } +func (r *relatedAssertRule) Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) []CheckState { + related, err := obs.GetRelated(ctx, r.key) + if err != nil { + return []CheckState{{Status: StatusError, Message: err.Error()}} + } + if len(related) == 0 { + return []CheckState{{Status: StatusCrit, Message: "no related observation"}} + } + return []CheckState{{Status: 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 := DiscoveryEntry{Type: "fake.v1", Ref: "r1"} + primary := &primaryWithSibling{ + key: "primary_key", + def: &CheckerDefinition{ + ID: "primary", + Rules: []CheckRule{&relatedAssertRule{key: sibling.key}}, + }, + entries: []DiscoveryEntry{entry}, + sibling: sibling, + } + + srv := NewServer(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].([]DiscoveryEntry) + if !ok { + t.Fatalf("sibling opts missing %q or wrong type: %#v", sibling.entriesOpt, sibling.gotOpts[sibling.entriesOpt]) + } + if len(got) != 1 || got[0].Ref != entry.Ref { + t.Errorf("sibling saw entries %v, want [%v]", got, entry) + } + + if v, _ := sibling.gotOpts["domain"].(string); v != "example.com" { + t.Errorf("sibling did not receive primary domain opt, got %q", v) + } +} + +func TestCheck_Submit_NoSibling_LeavesRelatedEmpty(t *testing.T) { + p := &interactiveProvider{ + testProvider: &testProvider{ + key: "test", + definition: &CheckerDefinition{ + ID: "test", + Rules: []CheckRule{&relatedAssertRule{key: "other"}}, + }, + }, + fields: []CheckerOptionField{{Id: "domain", Type: "string"}}, + } + srv := NewServer(p) + defer srv.Close() + + rec := postForm(srv.Handler(), "/check", url.Values{"domain": {"example.com"}}) + if rec.Code != http.StatusOK { + t.Fatalf("POST /check = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "no related observation") { + t.Errorf("rule should have seen no related observation; body:\n%s", body) + } +} diff --git a/checker/server.go b/checker/server.go index bfec550..4478baf 100644 --- a/checker/server.go +++ b/checker/server.go @@ -380,9 +380,13 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, metrics) } -// mapObservationGetter implements ObservationGetter backed by a static map. +// mapObservationGetter implements ObservationGetter backed by static maps. +// Both fields are optional: Get reads from data, GetRelated reads from +// related. Leaving related nil preserves the pre-existing "no lineage" +// behavior used by the remote /evaluate path. type mapObservationGetter struct { - data map[ObservationKey]json.RawMessage + data map[ObservationKey]json.RawMessage + related map[ObservationKey][]RelatedObservation } func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest any) error { @@ -393,13 +397,13 @@ func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest return json.Unmarshal(raw, dest) } -// GetRelated always returns nil in the remote /evaluate path: the host that -// invokes /evaluate does not (currently) carry cross-checker related data in -// ExternalEvaluateRequest. Consumers that need related observations must run -// evaluation locally with a host-side ObservationContext that resolves -// lineage. +// GetRelated returns the pre-resolved related observations for key, or nil +// when none were seeded. The remote /evaluate path leaves related nil +// because ExternalEvaluateRequest does not currently carry cross-checker +// lineage; the interactive /check path can seed it from sibling providers +// declared via InteractiveRelatedProviders. func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { - return nil, nil + return g.related[key], nil } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/checker/types.go b/checker/types.go index c218dbb..1e1ad0e 100644 --- a/checker/types.go +++ b/checker/types.go @@ -372,6 +372,21 @@ type CheckerDefinitionProvider interface { Definition() *CheckerDefinition } +// InteractiveRelatedProviders 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 == AutoFillDiscoveryEntries. Sibling +// errors are logged and skipped so the primary result still reaches the +// user. +type InteractiveRelatedProviders interface { + RelatedProviders() []ObservationProvider +} + // CheckerDefinition is the complete definition of a checker, registered via init(). type CheckerDefinition struct { ID string `json:"id"`