checkers: add domain model types and checker core registry

Introduce the foundational types for the checker system:
- CheckTarget, CheckPlan, Execution, CheckEvaluation model types
- CheckerDefinition, CheckerOptions, ObservationSnapshot types
- CheckRule, ObservationProvider, CheckAggregator interfaces
- CheckerEngine interface for orchestrating check pipelines
- Global checker and observation provider registries
- WorstStatusAggregator for combining rule results
- New error types, config option for max concurrency, and
  AutoFill constants for context-driven option resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-04 11:44:59 +07:00
commit eb347a4318
9 changed files with 651 additions and 2 deletions

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.StatusOK
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,125 @@
// 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 (
"context"
"fmt"
"sync"
"git.happydns.org/happyDomain/model"
)
// providerRegistry is the global registry for observation providers.
// Thread-safety: all writes happen during init() before any goroutines start.
// After initialization, the map is read-only and safe for concurrent access.
var providerRegistry = map[happydns.ObservationKey]happydns.ObservationProvider{}
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
providerRegistry[p.Key()] = p
}
// GetObservationProvider returns the provider for the given key, or nil.
func GetObservationProvider(key happydns.ObservationKey) happydns.ObservationProvider {
return providerRegistry[key]
}
// GetObservationProviders returns all registered observation providers.
func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationProvider {
return providerRegistry
}
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]any
errors map[happydns.ObservationKey]error
mu sync.RWMutex
}
// NewObservationContext creates a new ObservationContext for the given target and options.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]any),
errors: make(map[happydns.ObservationKey]error),
}
}
// Get returns the observation data for the given key, collecting it lazily if needed.
// Thread-safe: concurrent calls for the same key will only trigger one collection.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey) (any, error) {
// Fast path: check cache under read lock.
oc.mu.RLock()
if val, ok := oc.cache[key]; ok {
oc.mu.RUnlock()
return val, nil
}
if err, ok := oc.errors[key]; ok {
oc.mu.RUnlock()
return nil, 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 val, ok := oc.cache[key]; ok {
return val, nil
}
if err, ok := oc.errors[key]; ok {
return nil, err
}
provider := GetObservationProvider(key)
if provider == nil {
err := fmt.Errorf("no observation provider registered for key %q", key)
oc.errors[key] = err
return nil, err
}
val, err := provider.Collect(ctx, oc.target, oc.opts)
if err != nil {
oc.errors[key] = err
return nil, err
}
oc.cache[key] = val
return val, nil
}
// Data returns all cached observation data.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]any {
oc.mu.RLock()
defer oc.mu.RUnlock()
data := make(map[happydns.ObservationKey]any, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
return data
}

View file

@ -0,0 +1,50 @@
// 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 (
"log"
"git.happydns.org/happyDomain/model"
)
// checkerRegistry is the global registry for checker definitions.
// Thread-safety: all writes happen during init() before any goroutines start.
// After initialization, the map is read-only and safe for concurrent access.
var checkerRegistry = map[string]*happydns.CheckerDefinition{}
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
log.Println("Registering new checker:", c.ID)
c.BuildRulesInfo()
checkerRegistry[c.ID] = c
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return checkerRegistry
}
// FindChecker returns the checker definition with the given ID, or nil.
func FindChecker(id string) *happydns.CheckerDefinition {
return checkerRegistry[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"
@ -45,6 +46,7 @@ func declareFlags(o *happydns.Options) {
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")

403
model/checker.go Normal file
View file

@ -0,0 +1,403 @@
// 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 happydns
import (
"context"
"fmt"
"strings"
"time"
)
// CheckScopeType represents the scope level of a check target.
type CheckScopeType int
const (
CheckScopeAdmin CheckScopeType = 0
CheckScopeUser CheckScopeType = iota
CheckScopeDomain
CheckScopeZone
CheckScopeService
)
const (
AutoFillDomainName = "domain_name"
AutoFillSubdomain = "subdomain"
AutoFillZone = "zone"
AutoFillServiceType = "service_type"
AutoFillService = "service"
)
// CheckTarget identifies the resource a check applies to.
type CheckTarget struct {
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
}
// Scope returns the most specific scope level of this target.
func (t CheckTarget) Scope() CheckScopeType {
if t.ServiceId != nil {
return CheckScopeService
}
if t.DomainId != nil {
return CheckScopeDomain
}
return CheckScopeUser
}
// String returns a stable string representation of the target.
func (t CheckTarget) String() string {
var parts []string
if t.UserId != nil {
parts = append(parts, t.UserId.String())
}
if t.DomainId != nil {
parts = append(parts, t.DomainId.String())
}
if t.ServiceId != nil {
parts = append(parts, t.ServiceId.String())
}
return strings.Join(parts, "/")
}
// CheckerAvailability declares on which scopes a checker can operate.
type CheckerAvailability struct {
ApplyToDomain bool `json:"applyToDomain,omitempty"`
ApplyToZone bool `json:"applyToZone,omitempty"`
ApplyToService bool `json:"applyToService,omitempty"`
LimitToProviders []string `json:"limitToProviders,omitempty"`
LimitToServices []string `json:"limitToServices,omitempty"`
}
// CheckerOptions holds the runtime options for a checker execution.
type CheckerOptions map[string]any
// 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"`
}
// CheckerOptionDocumentation describes a single checker option.
type CheckerOptionDocumentation = Field
// CheckerOptionsDocumentation describes all options a checker accepts, organized by level.
type CheckerOptionsDocumentation struct {
AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"`
UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"`
DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"`
ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"`
RunOpts []CheckerOptionDocumentation `json:"runOpts,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"`
}
// Status represents the result status of a check evaluation.
type Status int
const (
StatusUnknown Status = iota
StatusOK
StatusInfo
StatusWarn
StatusCrit
StatusError
)
// String returns the human-readable name of the status.
func (s Status) String() string {
switch s {
case StatusUnknown:
return "UNKNOWN"
case StatusOK:
return "OK"
case StatusInfo:
return "INFO"
case StatusWarn:
return "WARN"
case StatusCrit:
return "CRIT"
case StatusError:
return "ERROR"
default:
return fmt.Sprintf("Status(%d)", int(s))
}
}
// CheckState is the result of evaluating a single rule.
type CheckState struct {
Status Status `json:"status"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
}
// CheckMetric represents a single metric produced by a check.
type CheckMetric struct {
Name string `json:"name" binding:"required"`
Value float64 `json:"value" binding:"required"`
Labels map[string]string `json:"labels,omitempty"`
Timestamp time.Time `json:"timestamp" binding:"required" format:"date-time"`
}
// ObservationKey identifies a type of observation data.
type ObservationKey = string
// CheckIntervalSpec defines scheduling bounds for a checker.
type CheckIntervalSpec struct {
Min time.Duration `json:"min" swaggertype:"integer"`
Max time.Duration `json:"max" swaggertype:"integer"`
Default time.Duration `json:"default" swaggertype:"integer"`
}
// 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]any `json:"data" binding:"required" readonly:"true"`
}
// 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"`
}
// ObservationProvider collects a specific type of data for a target.
type ObservationProvider interface {
Key() ObservationKey
Collect(ctx context.Context, target CheckTarget, opts CheckerOptions) (any, error)
}
// CheckRuleInfo is the JSON-serializable description of a rule, for API/UI listing.
type CheckRuleInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Options *CheckerOptionsDocumentation `json:"options,omitempty"`
}
// CheckRule evaluates observations and produces a CheckState.
type CheckRule interface {
Name() string
Description() string
Evaluate(ctx context.Context, obs ObservationGetter, opts CheckerOptions) CheckState
}
// CheckRuleWithOptions is an optional interface that rules can implement
// to declare their own options documentation for API/UI grouping.
type CheckRuleWithOptions interface {
CheckRule
Options() CheckerOptionsDocumentation
}
// ObservationGetter provides access to observation data (used by CheckRule).
type ObservationGetter interface {
Get(ctx context.Context, key ObservationKey) (any, error)
}
// CheckAggregator combines multiple CheckStates into a single result.
type CheckAggregator interface {
Aggregate(states []CheckState) CheckState
}
// CheckerDefinition is the complete definition of a checker, registered via init().
type CheckerDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
RulesInfo []CheckRuleInfo `json:"rules"`
Rules []CheckRule `json:"-"`
Aggregator CheckAggregator `json:"-"`
Interval *CheckIntervalSpec `json:"interval,omitempty"`
}
// BuildRulesInfo populates RulesInfo from the Rules slice.
func (d *CheckerDefinition) BuildRulesInfo() {
d.RulesInfo = make([]CheckRuleInfo, len(d.Rules))
for i, rule := range d.Rules {
info := CheckRuleInfo{
Name: rule.Name(),
Description: rule.Description(),
}
if rwo, ok := rule.(CheckRuleWithOptions); ok {
opts := rwo.Options()
info.Options = &opts
}
d.RulesInfo[i] = info
}
}
// OptionsValidator is an optional interface that checkers (or their rules/providers)
// can implement to perform domain-specific validation of checker options.
type OptionsValidator interface {
ValidateOptions(opts CheckerOptions) error
}
// 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 {
uid := ""
if userId != nil {
uid = userId.String()
}
did := ""
if domainId != nil {
did = domainId.String()
}
sid := ""
if serviceId != nil {
sid = serviceId.String()
}
return fmt.Sprintf("chckrcfg-%s/%s/%s/%s", checkerName, uid, did, sid)
}
// 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

@ -93,6 +93,10 @@ type Options struct {
OIDCClients []OIDCSettings
// CheckerMaxConcurrency is the maximum number of checker jobs that can
// run simultaneously. Defaults to runtime.NumCPU().
CheckerMaxConcurrency int
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string

View file

@ -34,8 +34,13 @@ var (
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrZoneNotFound = errors.New("zone not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -104,6 +104,10 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates that this field is automatically filled by the system
// based on execution context (e.g. domain name, zone, service type).
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -154,6 +154,14 @@ type ZoneServices struct {
Services []*Service `json:"services"`
}
// ZoneWithServicesCheckStatus wraps a Zone with the worst check status for each service.
type ZoneWithServicesCheckStatus struct {
*Zone
// ServicesCheckStatus holds the worst check status for each service,
// keyed by service identifier string. Nil/absent if no results exist yet.
ServicesCheckStatus map[string]*Status `json:"services_check_status,omitempty"`
}
type ZoneUsecase interface {
AddRecord(*Zone, string, Record) error
CreateZone(*Zone) error