diff --git a/checker/server.go b/checker/server.go index 8b2eb31..d9b7f61 100644 --- a/checker/server.go +++ b/checker/server.go @@ -256,21 +256,9 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { return } - 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) + writeJSON(w, http.StatusOK, ExternalCollectResponse{ + Data: json.RawMessage(raw), + }) } func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { @@ -319,7 +307,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related)) + html, err := reporter.GetHTMLReport(req.Data) if err != nil { http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError) return @@ -337,7 +325,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { return } - metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now()) + metrics, err := reporter.ExtractMetrics(req.Data, time.Now()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": fmt.Sprintf("failed to extract metrics: %v", err), @@ -361,15 +349,6 @@ 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 6a228b6..a20ee61 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(ctx ReportContext) (string, error) { +func (p *testProvider) GetHTMLReport(raw json.RawMessage) (string, error) { if p.htmlFn != nil { - return p.htmlFn(ctx.Data()) + return p.htmlFn(raw) } return "

report

", nil } -func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) { +func (p *testProvider) ExtractMetrics(raw json.RawMessage, t time.Time) ([]CheckMetric, error) { if p.metricsFn != nil { - return p.metricsFn(ctx.Data(), t) + return p.metricsFn(raw, t) } return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil } @@ -428,63 +428,6 @@ func TestServer_Report_Metrics(t *testing.T) { } } -// TestServer_Report_Related verifies the remote /report path wires -// ExternalReportRequest.Related through to the provider's ReportContext, -// the fix for the "remote checkers can't see related observations" gap. -func TestServer_Report_Related(t *testing.T) { - var gotRelated []RelatedObservation - p := &testProvider{ - key: "test", - definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}}, - } - // Replace htmlFn with one that peeks at a related key. We can't do that - // directly through testProvider's htmlFn (which only sees raw), so - // bind to GetHTMLReport via an inline wrapper: use a per-test provider - // that captures the ReportContext before delegating to the template. - srv := NewServer(&relatedPeekingProvider{ - base: p, - target: &gotRelated, - }) - defer srv.Close() - - req := ExternalReportRequest{ - Key: "test", - Data: json.RawMessage(`{}`), - Related: map[ObservationKey][]RelatedObservation{ - "tls_probes": { - {CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"}, - }, - }, - } - rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) - if rec.Code != http.StatusOK { - t.Fatalf("POST /report = %d, want 200", rec.Code) - } - if len(gotRelated) != 1 { - t.Fatalf("provider saw %d related observations, want 1", len(gotRelated)) - } - if gotRelated[0].CheckerID != "tls" || string(gotRelated[0].Data) != `{"ok":true}` { - t.Errorf("related mismatch: got %+v", gotRelated[0]) - } -} - -// relatedPeekingProvider forwards to a base testProvider but copies the -// Related("tls_probes") slice observed at GetHTMLReport time into target. -type relatedPeekingProvider struct { - base *testProvider - target *[]RelatedObservation -} - -func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() } -func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) { - return p.base.Collect(ctx, opts) -} -func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition } -func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) { - *p.target = ctx.Related("tls_probes") - 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 9099a2f..08f7431 100644 --- a/checker/types.go +++ b/checker/types.go @@ -41,11 +41,6 @@ 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 @@ -238,45 +233,8 @@ 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. @@ -284,69 +242,20 @@ 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(ctx ReportContext) (string, error) + // GetHTMLReport generates an HTML document from the JSON-encoded observation data. + GetHTMLReport(raw json.RawMessage) (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(ctx ReportContext, collectedAt time.Time) ([]CheckMetric, error) + // ExtractMetrics returns metrics from JSON-encoded observation data. + ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]CheckMetric, error) } // CheckerDefinitionProvider is an optional interface that observation providers can @@ -405,53 +314,8 @@ type ExternalCollectRequest struct { // ExternalCollectResponse is returned by POST /collect on a remote checker endpoint. type ExternalCollectResponse struct { - 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) + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` } // ExternalEvaluateRequest is sent to POST /evaluate on a remote checker endpoint. @@ -468,17 +332,9 @@ 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"` - Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"` + Key ObservationKey `json:"key"` + Data json.RawMessage `json:"data"` } // HealthResponse is returned by GET /health on a remote checker endpoint.