checkers: introduce checker subsystem foundation
Add the checker-sdk-go dependency and build the core checker infrastructure: - Domain model types: CheckTarget, CheckPlan, Execution, CheckEvaluation, CheckerDefinition, CheckerOptions, ObservationSnapshot, and associated interfaces - Observation collection engine with concurrent per-key gathering - Checker and observation provider registries (wrapping checker-sdk-go) - WorstStatusAggregator for combining rule evaluation results
This commit is contained in:
parent
a31f78cd48
commit
0511e6fdc8
9 changed files with 880 additions and 4 deletions
1
go.mod
1
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.0
|
|||
toolchain go1.26.1
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v0.2.0
|
||||
github.com/StackExchange/dnscontrol/v4 v4.34.0
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -8,6 +8,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
|
|||
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
|
||||
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc=
|
||||
git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
|
|
@ -386,10 +388,6 @@ github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1
|
|||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
|
||||
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q=
|
||||
github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=
|
||||
|
|
|
|||
48
internal/checker/aggregator.go
Normal file
48
internal/checker/aggregator.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// WorstStatusAggregator aggregates check states by taking the worst status.
|
||||
type WorstStatusAggregator struct{}
|
||||
|
||||
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
|
||||
worst := happydns.StatusUnknown
|
||||
var messages []string
|
||||
for _, s := range states {
|
||||
if s.Status > worst {
|
||||
worst = s.Status
|
||||
}
|
||||
if s.Message != "" {
|
||||
messages = append(messages, s.Message)
|
||||
}
|
||||
}
|
||||
return happydns.CheckState{
|
||||
Status: worst,
|
||||
Message: strings.Join(messages, "; "),
|
||||
}
|
||||
}
|
||||
282
internal/checker/observation.go
Normal file
282
internal/checker/observation.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// observation.go implements the observation subsystem, which is the data
|
||||
// collection layer for the checker framework. An observation represents a
|
||||
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
|
||||
// headers, TLS certificate details). Observations are identified by an
|
||||
// ObservationKey and collected on demand by registered ObservationProviders.
|
||||
//
|
||||
// The ObservationContext provides lazy-loading, cached, thread-safe access to
|
||||
// observations: the first checker that requests a given observation triggers
|
||||
// its collection, and subsequent checkers reuse the cached result. This
|
||||
// design decouples data collection from evaluation: checkers declare which
|
||||
// observations they need, and the context ensures each is collected at most
|
||||
// once per check run. Observations can also be persisted as snapshots and
|
||||
// reused across runs when freshness requirements allow.
|
||||
//
|
||||
// Observation providers may optionally implement reporting interfaces
|
||||
// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable
|
||||
// reports or extract time-series metrics from collected data.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// ObservationCacheLookup resolves a cached observation for a target+key.
|
||||
// Returns the raw data and collection time, or an error if not cached.
|
||||
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
|
||||
|
||||
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
|
||||
// Collected data is serialized to json.RawMessage immediately after collection.
|
||||
type ObservationContext struct {
|
||||
target happydns.CheckTarget
|
||||
opts happydns.CheckerOptions
|
||||
cache map[happydns.ObservationKey]json.RawMessage
|
||||
errors map[happydns.ObservationKey]error
|
||||
mu sync.RWMutex
|
||||
cacheLookup ObservationCacheLookup // nil = no DB cache
|
||||
freshness time.Duration // 0 = always collect
|
||||
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
|
||||
}
|
||||
|
||||
// NewObservationContext creates a new ObservationContext for the given target and options.
|
||||
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
|
||||
// Pass nil and 0 to disable DB-based caching.
|
||||
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
|
||||
return &ObservationContext{
|
||||
target: target,
|
||||
opts: opts,
|
||||
cache: make(map[happydns.ObservationKey]json.RawMessage),
|
||||
errors: make(map[happydns.ObservationKey]error),
|
||||
cacheLookup: cacheLookup,
|
||||
freshness: freshness,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProviderOverride registers a per-context provider that takes precedence
|
||||
// over the global registry for the given observation key. This is used to
|
||||
// substitute local providers with HTTP-backed ones when an endpoint is configured.
|
||||
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
|
||||
if oc.providerOverride == nil {
|
||||
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
|
||||
}
|
||||
oc.providerOverride[key] = p
|
||||
}
|
||||
|
||||
// getProvider returns the observation provider for the given key, checking
|
||||
// per-context overrides first, then falling back to the global registry.
|
||||
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
|
||||
if oc.providerOverride != nil {
|
||||
if p, ok := oc.providerOverride[key]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return sdk.FindObservationProvider(key)
|
||||
}
|
||||
|
||||
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
|
||||
// Thread-safe: concurrent calls for the same key will only trigger one collection.
|
||||
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
|
||||
// Fast path: check cache under read lock.
|
||||
oc.mu.RLock()
|
||||
if raw, ok := oc.cache[key]; ok {
|
||||
oc.mu.RUnlock()
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
if err, ok := oc.errors[key]; ok {
|
||||
oc.mu.RUnlock()
|
||||
return err
|
||||
}
|
||||
oc.mu.RUnlock()
|
||||
|
||||
// Slow path: acquire write lock and collect.
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock.
|
||||
if raw, ok := oc.cache[key]; ok {
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
if err, ok := oc.errors[key]; ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try DB cache before collecting fresh data.
|
||||
if oc.cacheLookup != nil && oc.freshness > 0 {
|
||||
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
|
||||
if time.Since(collectedAt) < oc.freshness {
|
||||
oc.cache[key] = raw
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider := oc.getProvider(key)
|
||||
if provider == nil {
|
||||
err := fmt.Errorf("no observation provider registered for key %q", key)
|
||||
oc.errors[key] = err
|
||||
return err
|
||||
}
|
||||
|
||||
val, err := provider.Collect(ctx, oc.opts)
|
||||
if err != nil {
|
||||
oc.errors[key] = err
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("observation %q: marshal failed: %w", key, err)
|
||||
oc.errors[key] = err
|
||||
return err
|
||||
}
|
||||
|
||||
oc.cache[key] = json.RawMessage(raw)
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
// Data returns all cached observation data as pre-serialized JSON.
|
||||
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
|
||||
oc.mu.RLock()
|
||||
defer oc.mu.RUnlock()
|
||||
|
||||
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
|
||||
for k, v := range oc.cache {
|
||||
data[k] = v
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Provider registration is startup-only (see comments on the registries in
|
||||
// internal/service/registry.go and internal/provider/registry.go), so the
|
||||
// "any provider implements X reporter" question has a fixed answer for the
|
||||
// process lifetime. We compute it once on first call and cache it.
|
||||
var (
|
||||
htmlReporterOnce sync.Once
|
||||
htmlReporterCached bool
|
||||
metricsReporterOnce sync.Once
|
||||
metricsReporterCached bool
|
||||
)
|
||||
|
||||
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
|
||||
func HasHTMLReporter() bool {
|
||||
htmlReporterOnce.Do(func() {
|
||||
for _, p := range sdk.GetObservationProviders() {
|
||||
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
|
||||
htmlReporterCached = true
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return htmlReporterCached
|
||||
}
|
||||
|
||||
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
|
||||
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
|
||||
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
return getHTMLReport(sdk.FindObservationProvider(key), key, raw)
|
||||
}
|
||||
|
||||
// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through
|
||||
// the ObservationContext, respecting per-context overrides.
|
||||
func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
return getHTMLReport(oc.getProvider(key), key, raw)
|
||||
}
|
||||
|
||||
func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
|
||||
if provider == nil {
|
||||
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
|
||||
}
|
||||
|
||||
hr, ok := provider.(happydns.CheckerHTMLReporter)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
html, err := hr.GetHTMLReport(raw)
|
||||
return html, true, err
|
||||
}
|
||||
|
||||
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
|
||||
func HasMetricsReporter() bool {
|
||||
metricsReporterOnce.Do(func() {
|
||||
for _, p := range sdk.GetObservationProviders() {
|
||||
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
|
||||
metricsReporterCached = true
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return metricsReporterCached
|
||||
}
|
||||
|
||||
// GetMetrics extracts metrics for the given observation key and raw JSON data.
|
||||
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
|
||||
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt)
|
||||
}
|
||||
|
||||
// GetMetricsCtx is like GetMetrics but resolves the provider through
|
||||
// the ObservationContext, respecting per-context overrides.
|
||||
func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
return getMetrics(oc.getProvider(key), key, raw, collectedAt)
|
||||
}
|
||||
|
||||
func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
|
||||
if provider == nil {
|
||||
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
|
||||
}
|
||||
|
||||
mr, ok := provider.(happydns.CheckerMetricsReporter)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
metrics, err := mr.ExtractMetrics(raw, collectedAt)
|
||||
return metrics, true, err
|
||||
}
|
||||
|
||||
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
|
||||
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
|
||||
var allMetrics []happydns.CheckMetric
|
||||
var errs []error
|
||||
for key, raw := range snap.Data {
|
||||
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("observation %q: %w", key, err))
|
||||
continue
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
allMetrics = append(allMetrics, metrics...)
|
||||
}
|
||||
return allMetrics, errors.Join(errs...)
|
||||
}
|
||||
180
internal/checker/observation_test.go
Normal file
180
internal/checker/observation_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// blockingProvider is an ObservationProvider whose Collect blocks on the
|
||||
// release channel until the test signals it. It records how many concurrent
|
||||
// Collect calls are in flight at any moment.
|
||||
type blockingProvider struct {
|
||||
key happydns.ObservationKey
|
||||
release chan struct{}
|
||||
calls int32
|
||||
maxCalls int32
|
||||
}
|
||||
|
||||
func (b *blockingProvider) Key() happydns.ObservationKey { return b.key }
|
||||
|
||||
func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
|
||||
atomic.AddInt32(&b.calls, 1)
|
||||
defer atomic.AddInt32(&b.calls, -1)
|
||||
for {
|
||||
current := atomic.LoadInt32(&b.calls)
|
||||
max := atomic.LoadInt32(&b.maxCalls)
|
||||
if current > max {
|
||||
if atomic.CompareAndSwapInt32(&b.maxCalls, max, current) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-b.release:
|
||||
return map[string]string{string(b.key): "ok"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls
|
||||
// for distinct observation keys can run their Collect concurrently, i.e.
|
||||
// the per-context lock is not held across provider.Collect.
|
||||
func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
|
||||
pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release}
|
||||
pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release}
|
||||
|
||||
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
|
||||
oc.SetProviderOverride(pa.key, pa)
|
||||
oc.SetProviderOverride(pb.key, pb)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i, key := range []happydns.ObservationKey{pa.key, pb.key} {
|
||||
wg.Add(1)
|
||||
go func(idx int, k happydns.ObservationKey) {
|
||||
defer wg.Done()
|
||||
var dst map[string]string
|
||||
results[idx] = oc.Get(ctx, k, &dst)
|
||||
}(i, key)
|
||||
}
|
||||
|
||||
// Wait until both providers are blocked inside Collect simultaneously.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 {
|
||||
t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b)
|
||||
}
|
||||
|
||||
// Release both Collects and wait for the Get calls to return.
|
||||
release <- struct{}{}
|
||||
release <- struct{}{}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range results {
|
||||
if err != nil {
|
||||
t.Errorf("Get %d returned error: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestObservationContext_DedupesSameKey verifies that concurrent Get calls
|
||||
// for the *same* key only invoke provider.Collect once.
|
||||
func TestObservationContext_DedupesSameKey(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
|
||||
var collectCount int32
|
||||
prov := &countingProvider{
|
||||
key: happydns.ObservationKey("test-dedup"),
|
||||
release: release,
|
||||
count: &collectCount,
|
||||
}
|
||||
|
||||
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
|
||||
oc.SetProviderOverride(prov.key, prov)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const N = 8
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var dst map[string]string
|
||||
if err := oc.Get(ctx, prov.key, &dst); err != nil {
|
||||
t.Errorf("Get error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for at least one collect to be in flight, then release it.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&collectCount); got != 1 {
|
||||
t.Errorf("expected exactly 1 Collect call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingProvider struct {
|
||||
key happydns.ObservationKey
|
||||
release chan struct{}
|
||||
count *int32
|
||||
}
|
||||
|
||||
func (c *countingProvider) Key() happydns.ObservationKey { return c.key }
|
||||
|
||||
func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
|
||||
atomic.AddInt32(c.count, 1)
|
||||
select {
|
||||
case <-c.release:
|
||||
return map[string]string{"k": "v"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
60
internal/checker/registry.go
Normal file
60
internal/checker/registry.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// The checker definition registry lives in the Apache-2.0 licensed
|
||||
// checker-sdk-go module, so external plugins can register themselves
|
||||
// without depending on AGPL code. These wrappers preserve the existing
|
||||
// happyDomain call sites.
|
||||
|
||||
// RegisterChecker registers a checker definition globally.
|
||||
func RegisterChecker(c *happydns.CheckerDefinition) {
|
||||
sdk.RegisterChecker(c)
|
||||
}
|
||||
|
||||
// RegisterExternalizableChecker registers a checker that supports being
|
||||
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
|
||||
// so the administrator can optionally configure a remote URL.
|
||||
// When the endpoint is left empty, the checker runs locally as usual.
|
||||
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
|
||||
sdk.RegisterExternalizableChecker(c)
|
||||
}
|
||||
|
||||
// RegisterObservationProvider registers an observation provider globally.
|
||||
func RegisterObservationProvider(p happydns.ObservationProvider) {
|
||||
sdk.RegisterObservationProvider(p)
|
||||
}
|
||||
|
||||
// GetCheckers returns all registered checker definitions.
|
||||
func GetCheckers() map[string]*happydns.CheckerDefinition {
|
||||
return sdk.GetCheckers()
|
||||
}
|
||||
|
||||
// FindChecker returns the checker definition with the given ID, or nil.
|
||||
func FindChecker(id string) *happydns.CheckerDefinition {
|
||||
return sdk.FindChecker(id)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ package config // import "git.happydns.org/happyDomain/config"
|
|||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
|
|
|
|||
287
model/checker.go
Normal file
287
model/checker.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// The types and helpers needed by external checker plugins live in the
|
||||
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
|
||||
// aliases so the rest of the happyDomain codebase keeps relying on this model.
|
||||
//
|
||||
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
|
||||
// defined in this file because they describe orchestration state that is
|
||||
// internal to the happyDomain server and never crosses the plugin boundary.
|
||||
|
||||
// --- Re-exports from checker-sdk-go ---
|
||||
|
||||
type CheckScopeType = sdk.CheckScopeType
|
||||
|
||||
const (
|
||||
CheckScopeAdmin = sdk.CheckScopeAdmin
|
||||
CheckScopeUser = sdk.CheckScopeUser
|
||||
CheckScopeDomain = sdk.CheckScopeDomain
|
||||
CheckScopeZone = sdk.CheckScopeZone
|
||||
CheckScopeService = sdk.CheckScopeService
|
||||
)
|
||||
|
||||
const (
|
||||
AutoFillDomainName = sdk.AutoFillDomainName
|
||||
AutoFillSubdomain = sdk.AutoFillSubdomain
|
||||
AutoFillZone = sdk.AutoFillZone
|
||||
AutoFillServiceType = sdk.AutoFillServiceType
|
||||
AutoFillService = sdk.AutoFillService
|
||||
)
|
||||
|
||||
type (
|
||||
CheckTarget = sdk.CheckTarget
|
||||
CheckerAvailability = sdk.CheckerAvailability
|
||||
CheckerOptions = sdk.CheckerOptions
|
||||
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
|
||||
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
|
||||
Status = sdk.Status
|
||||
CheckState = sdk.CheckState
|
||||
CheckMetric = sdk.CheckMetric
|
||||
ObservationKey = sdk.ObservationKey
|
||||
CheckIntervalSpec = sdk.CheckIntervalSpec
|
||||
ObservationProvider = sdk.ObservationProvider
|
||||
CheckRuleInfo = sdk.CheckRuleInfo
|
||||
CheckRule = sdk.CheckRule
|
||||
CheckRuleWithOptions = sdk.CheckRuleWithOptions
|
||||
ObservationGetter = sdk.ObservationGetter
|
||||
CheckAggregator = sdk.CheckAggregator
|
||||
CheckerHTMLReporter = sdk.CheckerHTMLReporter
|
||||
CheckerMetricsReporter = sdk.CheckerMetricsReporter
|
||||
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
|
||||
CheckerDefinition = sdk.CheckerDefinition
|
||||
OptionsValidator = sdk.OptionsValidator
|
||||
ExternalCollectRequest = sdk.ExternalCollectRequest
|
||||
ExternalCollectResponse = sdk.ExternalCollectResponse
|
||||
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
|
||||
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
|
||||
ExternalReportRequest = sdk.ExternalReportRequest
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOK = sdk.StatusOK
|
||||
StatusInfo = sdk.StatusInfo
|
||||
StatusUnknown = sdk.StatusUnknown
|
||||
StatusWarn = sdk.StatusWarn
|
||||
StatusCrit = sdk.StatusCrit
|
||||
StatusError = sdk.StatusError
|
||||
)
|
||||
|
||||
// --- Helpers for converting between target identifier strings and *Identifier ---
|
||||
|
||||
// TargetIdentifier parses a target identifier string into an *Identifier.
|
||||
// Returns nil if the string is empty or cannot be parsed.
|
||||
func TargetIdentifier(s string) *Identifier {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := NewIdentifierFromString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
// FormatIdentifier returns the string representation of id, or "" if nil.
|
||||
func FormatIdentifier(id *Identifier) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
|
||||
// --- Host-only types (orchestration state) ---
|
||||
|
||||
// CheckerRunRequest is the JSON body for manually triggering a checker.
|
||||
type CheckerRunRequest struct {
|
||||
Options CheckerOptions `json:"options,omitempty"`
|
||||
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
|
||||
}
|
||||
|
||||
// CheckerOptionsPositional stores options with their positional key components.
|
||||
type CheckerOptionsPositional struct {
|
||||
CheckName string `json:"checkName"`
|
||||
UserId *Identifier `json:"userId,omitempty"`
|
||||
DomainId *Identifier `json:"domainId,omitempty"`
|
||||
ServiceId *Identifier `json:"serviceId,omitempty"`
|
||||
|
||||
Options CheckerOptions `json:"options"`
|
||||
}
|
||||
|
||||
// CheckPlan is an optional user override for a checker on a specific target.
|
||||
type CheckPlan struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
|
||||
Enabled map[string]bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
|
||||
func (p *CheckPlan) IsFullyDisabled() bool {
|
||||
if len(p.Enabled) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range p.Enabled {
|
||||
if v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRuleEnabled returns whether a specific rule is enabled.
|
||||
// A nil or empty map means all rules are enabled. A missing key means enabled.
|
||||
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
|
||||
if len(p.Enabled) == 0 {
|
||||
return true
|
||||
}
|
||||
v, ok := p.Enabled[ruleName]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
|
||||
type CheckerStatus struct {
|
||||
*CheckerDefinition
|
||||
LatestExecution *Execution `json:"latestExecution,omitempty"`
|
||||
Plan *CheckPlan `json:"plan,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledRules map[string]bool `json:"enabledRules"`
|
||||
}
|
||||
|
||||
// CheckEvaluation is the result of running a checker on observed data.
|
||||
type CheckEvaluation struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
|
||||
CheckerID string `json:"checkerId" binding:"required"`
|
||||
Target CheckTarget `json:"target" binding:"required"`
|
||||
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
States []CheckState `json:"states" binding:"required" readonly:"true"`
|
||||
}
|
||||
|
||||
// ObservationSnapshot holds data collected during an execution.
|
||||
type ObservationSnapshot struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
|
||||
}
|
||||
|
||||
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
|
||||
type ObservationCacheEntry struct {
|
||||
SnapshotID Identifier `json:"snapshotId"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
// ExecutionStatus represents the lifecycle state of an execution.
|
||||
type ExecutionStatus int
|
||||
|
||||
const (
|
||||
ExecutionPending ExecutionStatus = iota
|
||||
ExecutionRunning
|
||||
ExecutionDone
|
||||
ExecutionFailed
|
||||
)
|
||||
|
||||
// TriggerType represents what initiated an execution.
|
||||
type TriggerType int
|
||||
|
||||
const (
|
||||
TriggerManual TriggerType = iota
|
||||
TriggerSchedule
|
||||
)
|
||||
|
||||
// TriggerInfo describes the trigger for an execution.
|
||||
type TriggerInfo struct {
|
||||
Type TriggerType `json:"type"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
|
||||
}
|
||||
|
||||
// Execution represents a single run of a checker pipeline.
|
||||
type Execution struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
|
||||
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
|
||||
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
|
||||
Error string `json:"error,omitempty" readonly:"true"`
|
||||
Result CheckState `json:"result" readonly:"true"`
|
||||
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
|
||||
}
|
||||
|
||||
// CheckerEngine orchestrates the full checker pipeline.
|
||||
type CheckerEngine interface {
|
||||
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
|
||||
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
|
||||
}
|
||||
|
||||
// CheckerOptionsKey builds the positional KV key for checker options.
|
||||
// Format: chckrcfg-{checkerName}|{userId}|{domainId}|{serviceId}
|
||||
func CheckerOptionsKey(checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) string {
|
||||
return fmt.Sprintf("chckrcfg-%s|%s|%s|%s", checkerName,
|
||||
FormatIdentifier(userId), FormatIdentifier(domainId), FormatIdentifier(serviceId))
|
||||
}
|
||||
|
||||
// ParseCheckerOptionsKey extracts the positional components from a KV key.
|
||||
func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) {
|
||||
trimmed := strings.TrimPrefix(key, "chckrcfg-")
|
||||
parts := strings.SplitN(trimmed, "|", 4)
|
||||
if len(parts) < 4 {
|
||||
return trimmed, nil, nil, nil
|
||||
}
|
||||
|
||||
checkerName = parts[0]
|
||||
if parts[1] != "" {
|
||||
if id, err := NewIdentifierFromString(parts[1]); err == nil {
|
||||
userId = &id
|
||||
}
|
||||
}
|
||||
if parts[2] != "" {
|
||||
if id, err := NewIdentifierFromString(parts[2]); err == nil {
|
||||
domainId = &id
|
||||
}
|
||||
}
|
||||
if parts[3] != "" {
|
||||
if id, err := NewIdentifierFromString(parts[3]); err == nil {
|
||||
serviceId = &id
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -106,6 +106,25 @@ type Field struct {
|
|||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// FieldFromCheckerOption converts a CheckerOptionDocumentation into a Field,
|
||||
// mapping the common subset of attributes. Keep this in sync when either
|
||||
// struct gains new fields.
|
||||
func FieldFromCheckerOption(opt CheckerOptionDocumentation) Field {
|
||||
return Field{
|
||||
Id: opt.Id,
|
||||
Type: opt.Type,
|
||||
Label: opt.Label,
|
||||
Placeholder: opt.Placeholder,
|
||||
Default: opt.Default,
|
||||
Choices: opt.Choices,
|
||||
Required: opt.Required,
|
||||
Secret: opt.Secret,
|
||||
Hide: opt.Hide,
|
||||
Textarea: opt.Textarea,
|
||||
Description: opt.Description,
|
||||
}
|
||||
}
|
||||
|
||||
type FormState struct {
|
||||
// Id for an already existing element.
|
||||
Id *Identifier `json:"_id,omitempty" swaggertype:"string"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue