diff --git a/checker/server.go b/checker/server.go index d9b7f61..8b2eb31 100644 --- a/checker/server.go +++ b/checker/server.go @@ -256,9 +256,21 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, ExternalCollectResponse{ - Data: json.RawMessage(raw), - }) + resp := ExternalCollectResponse{Data: json.RawMessage(raw)} + + // Harvest discovery entries from the native Go value, before it goes + // out of scope. No re-parse; DiscoverEntries operates on the same + // object that was just marshaled above. + if dp, ok := s.provider.(DiscoveryPublisher); ok { + entries, derr := dp.DiscoverEntries(data) + if derr != nil { + log.Printf("DiscoverEntries failed: %v", derr) + } else { + resp.Entries = entries + } + } + + writeJSON(w, http.StatusOK, resp) } func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { @@ -307,7 +319,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - html, err := reporter.GetHTMLReport(req.Data) + html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related)) if err != nil { http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) return @@ -325,7 +337,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - metrics, err := reporter.ExtractMetrics(req.Data, time.Now()) + metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to extract metrics: %v", err), @@ -349,6 +361,15 @@ 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. +func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) { + return nil, nil +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/checker/server_test.go b/checker/server_test.go index a20ee61..6a228b6 100644 --- a/checker/server_test.go +++ b/checker/server_test.go @@ -44,15 +44,15 @@ func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, e return map[string]string{"result": "ok"}, nil } func (p *testProvider) Definition() *CheckerDefinition { return p.definition } -func (p *testProvider) GetHTMLReport(raw json.RawMessage) (string, error) { +func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) { if p.htmlFn != nil { - return p.htmlFn(raw) + return p.htmlFn(ctx.Data()) } return "
ok
", nil +} + func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", diff --git a/checker/types.go b/checker/types.go index 08f7431..9099a2f 100644 --- a/checker/types.go +++ b/checker/types.go @@ -41,6 +41,11 @@ const ( AutoFillZone = "zone" AutoFillServiceType = "service_type" AutoFillService = "service" + + // AutoFillDiscoveryEntries receives DiscoveryEntry records published by + // other checkers on the same target. The host does not pre-filter by + // Type; consumers pick the contracts they understand and ignore the rest. + AutoFillDiscoveryEntries = "discovery_entries" ) // CheckTarget identifies the resource a check applies to. Identifiers are @@ -233,8 +238,45 @@ type CheckRuleWithOptions interface { // ObservationGetter provides access to observation data (used by CheckRule). // Get unmarshals observation data into dest (like json.Unmarshal). +// +// GetRelated returns observations produced by other checkers on DiscoveryEntry +// records originally published by the current target. It is the core of +// cross-checker composition: a checker that published some entries via its +// DiscoveryPublisher can, during rule evaluation, fetch the latest +// observations that cover those entries and fold them into its own states. +// +// GetRelated returns an empty slice (not an error) when there is nothing +// to relate (no entries originally published, no downstream observation +// yet, no downstream checker registered for the entry type, …). Callers +// handle that as "no related data", typically skipping optional sections. type ObservationGetter interface { Get(ctx context.Context, key ObservationKey, dest any) error + GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) +} + +// RelatedObservation is a single observation, produced by some other checker, +// that covers a DiscoveryEntry originally published by the current target. +// +// Data carries the raw JSON payload; consumers parse it according to the +// producer's schema, which they are expected to know via external agreement +// (typically a shared contract package imported by both producer and +// consumer). +type RelatedObservation struct { + // CheckerID identifies the producer of this observation. + CheckerID string `json:"checkerId"` + + // Key is the observation key the producer filled. + Key ObservationKey `json:"key"` + + // Data is the raw JSON payload as persisted by the producer. + Data json.RawMessage `json:"data"` + + // CollectedAt is when the producer ran its Collect. + CollectedAt time.Time `json:"collectedAt"` + + // Ref matches DiscoveryEntry.Ref of the entry this observation covers. + // Opaque to the SDK; meaningful within the producer/consumer contract. + Ref string `json:"ref"` } // CheckAggregator combines multiple CheckStates into a single result. @@ -242,20 +284,69 @@ type CheckAggregator interface { Aggregate(states []CheckState) CheckState } +// ReportContext carries both the primary observation payload and any +// observations produced by other checkers that cover the same discovery +// entries. Hosts build a ReportContext and hand it to reporter methods. +// +// The method set is deliberately tiny: a single primary payload (Data) and +// a query for related observations by key (Related). Hosts return nil from +// Related when there is nothing to relate; reporters must tolerate that. +type ReportContext interface { + Data() json.RawMessage + Related(key ObservationKey) []RelatedObservation +} + +// NewReportContext returns a ReportContext backed by a primary payload and +// a pre-resolved map of related observations by key. The SDK's /report HTTP +// handler uses this to wrap ExternalReportRequest contents; hosts and tests +// can use it whenever they already have the related observations in memory. +// +// Passing a nil or empty related map is fine; Related(key) will then return +// nil, just like StaticReportContext. +func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation) ReportContext { + return fixedReportContext{data: data, related: related} +} + +// StaticReportContext is a shorthand for NewReportContext(data, nil): a +// ReportContext with a primary payload and no related observations. +// Intended for tests and ad-hoc callers that have no lineage to supply. +func StaticReportContext(data json.RawMessage) ReportContext { + return fixedReportContext{data: data} +} + +type fixedReportContext struct { + data json.RawMessage + related map[ObservationKey][]RelatedObservation +} + +func (f fixedReportContext) Data() json.RawMessage { return f.data } +func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation { + if f.related == nil { + return nil + } + return f.related[key] +} + // CheckerHTMLReporter is an optional interface that observation providers can // implement to render their stored data as a full HTML document (for iframe embedding). // Detect support with a type assertion: _, ok := provider.(CheckerHTMLReporter) +// +// The ReportContext carries the primary observation payload plus any +// downstream observations produced on DiscoveryEntry records this checker +// published. Implementations that do not need related observations can +// simply consume ctx.Data(). type CheckerHTMLReporter interface { - // GetHTMLReport generates an HTML document from the JSON-encoded observation data. - GetHTMLReport(raw json.RawMessage) (string, error) + GetHTMLReport(ctx ReportContext) (string, error) } // CheckerMetricsReporter is an optional interface that observation providers can // implement to extract time-series metrics from their stored data. // Detect support with a type assertion: _, ok := provider.(CheckerMetricsReporter) +// +// As with CheckerHTMLReporter, the ReportContext exposes related +// observations for cross-checker composition. type CheckerMetricsReporter interface { - // ExtractMetrics returns metrics from JSON-encoded observation data. - ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]CheckMetric, error) + ExtractMetrics(ctx ReportContext, collectedAt time.Time) ([]CheckMetric, error) } // CheckerDefinitionProvider is an optional interface that observation providers can @@ -314,8 +405,53 @@ type ExternalCollectRequest struct { // ExternalCollectResponse is returned by POST /collect on a remote checker endpoint. type ExternalCollectResponse struct { - Data json.RawMessage `json:"data,omitempty"` - Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Entries []DiscoveryEntry `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +// DiscoveryEntry is a single "thing worth probing" declared by a checker as a +// by-product of its collection, intended to be consumed by other checkers +// without having to re-parse raw observations. +// +// The SDK treats Payload as an opaque byte string: producer and consumer +// checkers agree on a schema through a separate contract (typically a small +// shared Go package imported by both). This keeps the SDK free of +// protocol-specific concepts; new entry families (TLS endpoint, HTTP probe, +// ACME challenge, DNSSEC key, …) can appear without touching it. +// +// Entries are ingested by happyDomain into a separate index. Each new +// collection from the same source atomically replaces the set of entries +// previously published for the same (producer, target) pair. +type DiscoveryEntry struct { + // Type names the contract Payload follows, e.g. "tls.endpoint" or + // "http.probe". Producers and consumers match on this string; the SDK + // does not interpret it. Stick to a reverse-DNS-ish convention so that + // independent contracts do not collide. + Type string `json:"type"` + + // Ref is a stable per-entry identifier chosen by the producer. The host + // uses it to dedupe entries across repeated collections and to link + // related observations back to this entry (RelatedObservation.Ref). Two + // producers may reuse the same Ref space; the host namespaces them by + // (producer, target). + Ref string `json:"ref"` + + // Payload is the entry-specific data, in the format defined by the + // contract named in Type. Opaque to the SDK. + Payload json.RawMessage `json:"payload"` +} + +// DiscoveryPublisher is an optional interface an ObservationProvider can +// co-implement to declare DiscoveryEntry records derived from the value it +// just collected. +// +// The host invokes DiscoverEntries immediately after Collect, passing the +// native Go value returned by Collect (no JSON round-trip). Implementations +// should therefore type-assert data to their concrete collection type and +// marshal each contract payload themselves. +type DiscoveryPublisher interface { + DiscoverEntries(data any) ([]DiscoveryEntry, error) } // ExternalEvaluateRequest is sent to POST /evaluate on a remote checker endpoint. @@ -332,9 +468,17 @@ type ExternalEvaluateResponse struct { } // ExternalReportRequest is sent to POST /report on a remote checker endpoint. +// +// Related carries observations produced by other checkers on DiscoveryEntry +// records originally published by the target of this report, that is, the +// cross-checker lineage that ObservationGetter.GetRelated would expose in +// the in-process path. The host composes it before making the HTTP request; +// when absent, the remote checker receives a context that reports no +// related observations (equivalent to StaticReportContext). type ExternalReportRequest struct { - Key ObservationKey `json:"key"` - Data json.RawMessage `json:"data"` + Key ObservationKey `json:"key"` + Data json.RawMessage `json:"data"` + Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"` } // HealthResponse is returned by GET /health on a remote checker endpoint.