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:
nemunaire 2026-04-04 11:44:59 +07:00
commit 0511e6fdc8
9 changed files with 880 additions and 4 deletions

1
go.mod
View file

@ -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
View file

@ -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=

View 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, "; "),
}
}

View 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...)
}

View 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()
}
}

View 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)
}

View file

@ -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
View 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
}

View file

@ -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"`