Compare commits
17 commits
39b5af099b
...
c6534e607d
| Author | SHA1 | Date | |
|---|---|---|---|
| c6534e607d | |||
| 46901f23d0 | |||
| b2de9241fb | |||
| edabb1a301 | |||
| 9350b71b48 | |||
| e67ee23805 | |||
| dcf2711cb1 | |||
| d0d849db72 | |||
| 410e955e50 | |||
| 0d8aecc952 | |||
| 51ceb796e4 | |||
| 9f53e0fa80 | |||
| cfdef2abb0 | |||
| e728a43d5b | |||
| 45a68b4091 | |||
| eb347a4318 | |||
| 13a58d18d3 |
23 changed files with 661 additions and 170 deletions
|
|
@ -36,6 +36,9 @@ import (
|
|||
"git.happydns.org/happyDomain/services/abstract"
|
||||
)
|
||||
|
||||
// ObservationKeyPing is the observation key for ICMP ping data.
|
||||
const ObservationKeyPing happydns.ObservationKey = "ping"
|
||||
|
||||
// PingData holds the collected ping results for all targets.
|
||||
type PingData struct {
|
||||
Targets []PingTargetResult `json:"targets"`
|
||||
|
|
@ -57,7 +60,7 @@ type PingTargetResult struct {
|
|||
type pingProvider struct{}
|
||||
|
||||
func (p *pingProvider) Key() happydns.ObservationKey {
|
||||
return "ping"
|
||||
return ObservationKeyPing
|
||||
}
|
||||
|
||||
// ipsFromService extracts IP addresses from an auto-filled abstract.Server service.
|
||||
|
|
@ -247,24 +250,15 @@ func (r *pingRule) ValidateOptions(opts happydns.CheckerOptions) error {
|
|||
}
|
||||
|
||||
func (r *pingRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
raw, err := obs.Get(ctx, "ping")
|
||||
if err != nil {
|
||||
var data PingData
|
||||
if err := obs.Get(ctx, ObservationKeyPing, &data); err != nil {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: fmt.Sprintf("Failed to collect ping data: %v", err),
|
||||
Message: fmt.Sprintf("Failed to get ping data: %v", err),
|
||||
Code: "ping_error",
|
||||
}
|
||||
}
|
||||
|
||||
data, ok := raw.(*PingData)
|
||||
if !ok {
|
||||
return happydns.CheckState{
|
||||
Status: happydns.StatusError,
|
||||
Message: "Invalid ping data format",
|
||||
Code: "ping_format_error",
|
||||
}
|
||||
}
|
||||
|
||||
warningRTT := checker.GetFloatOption(opts, "warningRTT", 100)
|
||||
criticalRTT := checker.GetFloatOption(opts, "criticalRTT", 500)
|
||||
warningPacketLoss := checker.GetFloatOption(opts, "warningPacketLoss", 10)
|
||||
|
|
@ -340,10 +334,11 @@ func init() {
|
|||
},
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "privileged",
|
||||
Type: "boolean",
|
||||
Label: "Use privileged ICMP (requires CAP_NET_RAW or root)",
|
||||
Default: false,
|
||||
Id: "privileged",
|
||||
Type: "boolean",
|
||||
Label: "Use privileged ICMP (requires CAP_NET_RAW or root)",
|
||||
Default: false,
|
||||
NoOverride: true,
|
||||
},
|
||||
},
|
||||
ServiceOpts: []happydns.CheckerOptionDocumentation{
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -23,6 +23,7 @@ require (
|
|||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oracle/nosql-go-sdk v1.4.7
|
||||
github.com/ovh/go-ovh v1.9.0
|
||||
github.com/prometheus-community/pro-bing v0.8.0
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
|
|
@ -76,7 +77,6 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 // indirect
|
||||
|
|
@ -180,7 +180,6 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -107,8 +107,6 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
|||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
|
||||
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -235,7 +234,7 @@ func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, val)
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
|
||||
}
|
||||
|
||||
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
|
||||
|
|
@ -380,13 +379,7 @@ func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw))
|
||||
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -263,8 +263,9 @@ func (app *App) initUsecases() {
|
|||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
|
||||
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,37 +51,48 @@ func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationP
|
|||
return providerRegistry
|
||||
}
|
||||
|
||||
// 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]any
|
||||
errors map[happydns.ObservationKey]error
|
||||
mu sync.RWMutex
|
||||
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
|
||||
}
|
||||
|
||||
// NewObservationContext creates a new ObservationContext for the given target and options.
|
||||
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions) *ObservationContext {
|
||||
// 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]any),
|
||||
errors: make(map[happydns.ObservationKey]error),
|
||||
target: target,
|
||||
opts: opts,
|
||||
cache: make(map[happydns.ObservationKey]json.RawMessage),
|
||||
errors: make(map[happydns.ObservationKey]error),
|
||||
cacheLookup: cacheLookup,
|
||||
freshness: freshness,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the observation data for the given key, collecting it lazily if needed.
|
||||
// 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) (any, error) {
|
||||
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
|
||||
// Fast path: check cache under read lock.
|
||||
oc.mu.RLock()
|
||||
if val, ok := oc.cache[key]; ok {
|
||||
if raw, ok := oc.cache[key]; ok {
|
||||
oc.mu.RUnlock()
|
||||
return val, nil
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
if err, ok := oc.errors[key]; ok {
|
||||
oc.mu.RUnlock()
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
oc.mu.RUnlock()
|
||||
|
||||
|
|
@ -90,36 +101,53 @@ func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationK
|
|||
defer oc.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock.
|
||||
if val, ok := oc.cache[key]; ok {
|
||||
return val, nil
|
||||
if raw, ok := oc.cache[key]; ok {
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
if err, ok := oc.errors[key]; ok {
|
||||
return nil, err
|
||||
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 := GetObservationProvider(key)
|
||||
if provider == nil {
|
||||
err := fmt.Errorf("no observation provider registered for key %q", key)
|
||||
oc.errors[key] = err
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
val, err := provider.Collect(ctx, oc.target, oc.opts)
|
||||
if err != nil {
|
||||
oc.errors[key] = err
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
oc.cache[key] = val
|
||||
return val, nil
|
||||
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.
|
||||
func (oc *ObservationContext) Data() map[happydns.ObservationKey]any {
|
||||
// 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]any, len(oc.cache))
|
||||
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
|
||||
for k, v := range oc.cache {
|
||||
data[k] = v
|
||||
}
|
||||
|
|
@ -181,12 +209,8 @@ func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt ti
|
|||
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
|
||||
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
|
||||
var allMetrics []happydns.CheckMetric
|
||||
for key, val := range snap.Data {
|
||||
raw, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
metrics, supported, err := GetMetrics(key, json.RawMessage(raw), snap.CollectedAt)
|
||||
for key, raw := range snap.Data {
|
||||
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
|
||||
if err != nil || !supported {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -579,6 +579,32 @@ func (s *InMemoryStorage) ClearSnapshots() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// --- ObservationCacheStorage ---
|
||||
|
||||
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
|
||||
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
|
||||
}
|
||||
|
||||
func (s *InMemoryStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, ok := s.data[obsCacheKey(target, key)]
|
||||
if !ok {
|
||||
return nil, happydns.ErrNotFound
|
||||
}
|
||||
|
||||
entry := &happydns.ObservationCacheEntry{}
|
||||
if err := s.DecodeData(data, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *InMemoryStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
|
||||
return s.Put(obsCacheKey(target, key), entry)
|
||||
}
|
||||
|
||||
// --- SchedulerStateStorage ---
|
||||
|
||||
func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type Storage interface {
|
|||
checker.CheckerOptionsStorage
|
||||
checker.CheckEvaluationStorage
|
||||
checker.ExecutionStorage
|
||||
checker.ObservationCacheStorage
|
||||
checker.ObservationSnapshotStorage
|
||||
checker.SchedulerStateStorage
|
||||
domain.DomainStorage
|
||||
|
|
|
|||
45
internal/storage/kvtpl/observation_cache.go
Normal file
45
internal/storage/kvtpl/observation_cache.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// 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 database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
|
||||
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
|
||||
entry := &happydns.ObservationCacheEntry{}
|
||||
err := s.db.Get(obsCacheKey(target, key), entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
|
||||
return s.db.Put(obsCacheKey(target, key), entry)
|
||||
}
|
||||
|
|
@ -361,10 +361,10 @@ func (u *CheckStatusUsecase) GetSnapshotByExecution(execID happydns.Identifier,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
val, ok := snap.Data[obsKey]
|
||||
raw, ok := snap.Data[obsKey]
|
||||
if !ok {
|
||||
return nil, happydns.ErrSnapshotNotFound
|
||||
}
|
||||
|
||||
return json.Marshal(val)
|
||||
return raw, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ package checker
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
|
@ -37,6 +38,7 @@ type checkerEngine struct {
|
|||
evalStore CheckEvaluationStorage
|
||||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
cacheStore ObservationCacheStorage
|
||||
}
|
||||
|
||||
// NewCheckerEngine creates a new CheckerEngine implementation.
|
||||
|
|
@ -45,12 +47,14 @@ func NewCheckerEngine(
|
|||
evalStore CheckEvaluationStorage,
|
||||
execStore ExecutionStorage,
|
||||
snapStore ObservationSnapshotStorage,
|
||||
cacheStore ObservationCacheStorage,
|
||||
) happydns.CheckerEngine {
|
||||
return &checkerEngine{
|
||||
optionsUC: optionsUC,
|
||||
evalStore: evalStore,
|
||||
execStore: execStore,
|
||||
snapStore: snapStore,
|
||||
optionsUC: optionsUC,
|
||||
evalStore: evalStore,
|
||||
execStore: execStore,
|
||||
snapStore: snapStore,
|
||||
cacheStore: cacheStore,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,8 +145,35 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
|
|||
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
|
||||
}
|
||||
|
||||
// Build observation cache lookup for cross-checker reuse.
|
||||
var cacheLookup checkerPkg.ObservationCacheLookup
|
||||
if e.cacheStore != nil {
|
||||
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
|
||||
entry, err := e.cacheStore.GetCachedObservation(target, key)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
raw, ok := snap.Data[key]
|
||||
if !ok {
|
||||
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
|
||||
}
|
||||
return raw, entry.CollectedAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
var freshness time.Duration
|
||||
if plan != nil && plan.Interval != nil {
|
||||
freshness = *plan.Interval
|
||||
} else if def.Interval != nil {
|
||||
freshness = def.Interval.Default
|
||||
}
|
||||
|
||||
// Create observation context for lazy data collection.
|
||||
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts)
|
||||
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
|
||||
|
||||
// Evaluate all rules, skipping disabled ones.
|
||||
states := make([]happydns.CheckState, 0, len(def.Rules))
|
||||
|
|
@ -174,6 +205,16 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
|
|||
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Update observation cache pointers for cross-checker reuse.
|
||||
if e.cacheStore != nil {
|
||||
for key := range snap.Data {
|
||||
_ = e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
|
||||
SnapshotID: snap.Id,
|
||||
CollectedAt: snap.CollectedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Persist evaluation.
|
||||
eval := &happydns.CheckEvaluation{
|
||||
PlanID: planID,
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ func (r *testCheckRule) Name() string { return r.name }
|
|||
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
|
||||
|
||||
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
|
||||
_, err := obs.Get(ctx, "test_obs")
|
||||
if err != nil {
|
||||
var data map[string]any
|
||||
if err := obs.Get(ctx, "test_obs", &data); err != nil {
|
||||
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
|
||||
}
|
||||
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
|
||||
|
|
@ -79,7 +79,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
|
|||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
|
@ -143,7 +143,7 @@ func TestCheckerEngine_RunWarn(t *testing.T) {
|
|||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
|
@ -188,7 +188,7 @@ func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
|
|||
})
|
||||
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
|
@ -278,7 +278,7 @@ func TestCheckerEngine_RunNotFound(t *testing.T) {
|
|||
t.Fatalf("NewInMemoryStorage() returned error: %v", err)
|
||||
}
|
||||
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
|
||||
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: &uid}
|
||||
|
|
|
|||
|
|
@ -126,10 +126,24 @@ func (u *CheckerOptionsUsecase) GetCheckerOptions(
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which fields are NoOverride.
|
||||
var noOverrideIds map[string]bool
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideIds = getNoOverrideFieldIds(def)
|
||||
}
|
||||
|
||||
merged := make(happydns.CheckerOptions)
|
||||
// positionals are returned in order of increasing specificity.
|
||||
for _, p := range positionals {
|
||||
maps.Copy(merged, p.Options)
|
||||
for k, v := range p.Options {
|
||||
// If the key is NoOverride and already set by a less specific scope, skip it.
|
||||
if noOverrideIds[k] {
|
||||
if _, exists := merged[k]; exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
|
@ -153,17 +167,26 @@ func (u *CheckerOptionsUsecase) SetCheckerOptions(
|
|||
serviceId *happydns.Identifier,
|
||||
opts happydns.CheckerOptions,
|
||||
) error {
|
||||
// Determine which field IDs are auto-filled for this checker.
|
||||
// Determine which field IDs are auto-filled or NoOverride for this checker.
|
||||
var autoFillIds map[string]string
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
autoFillIds = getAutoFillFieldIds(def)
|
||||
noOverrideScopes = getNoOverrideFieldScopes(def)
|
||||
}
|
||||
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
filtered := make(happydns.CheckerOptions, len(opts))
|
||||
for k, v := range opts {
|
||||
if !isEmptyValue(v) && autoFillIds[k] == "" {
|
||||
filtered[k] = v
|
||||
if isEmptyValue(v) || autoFillIds[k] != "" {
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth: strip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
filtered[k] = v
|
||||
}
|
||||
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, filtered)
|
||||
}
|
||||
|
|
@ -181,7 +204,19 @@ func (u *CheckerOptionsUsecase) AddCheckerOptions(
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine NoOverride scopes for defense-in-depth stripping.
|
||||
var noOverrideScopes map[string]happydns.CheckScopeType
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideScopes = getNoOverrideFieldScopes(def)
|
||||
}
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
|
||||
for k, v := range newOpts {
|
||||
// Defense-in-depth: skip NoOverride fields at scopes below their definition.
|
||||
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
|
||||
continue
|
||||
}
|
||||
if isEmptyValue(v) {
|
||||
delete(existing, k)
|
||||
} else {
|
||||
|
|
@ -331,6 +366,17 @@ func (u *CheckerOptionsUsecase) SetCheckerOption(
|
|||
optName string,
|
||||
value any,
|
||||
) error {
|
||||
// Defense-in-depth: reject NoOverride fields at scopes below their definition.
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
noOverrideScopes := getNoOverrideFieldScopes(def)
|
||||
if defScope, ok := noOverrideScopes[optName]; ok {
|
||||
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
|
||||
if currentScope > defScope {
|
||||
return fmt.Errorf("option %q cannot be overridden at this scope level", optName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -374,6 +420,72 @@ func getAutoFillFieldIds(def *happydns.CheckerDefinition) map[string]string {
|
|||
return result
|
||||
}
|
||||
|
||||
// collectNoOverrideFromDoc scans all option groups in a CheckerOptionsDocumentation
|
||||
// and adds any fields with NoOverride set to the result set.
|
||||
func collectNoOverrideFromDoc(doc happydns.CheckerOptionsDocumentation, result map[string]bool) {
|
||||
for _, group := range [][]happydns.Field{
|
||||
doc.AdminOpts,
|
||||
doc.UserOpts,
|
||||
doc.DomainOpts,
|
||||
doc.ServiceOpts,
|
||||
doc.RunOpts,
|
||||
} {
|
||||
for _, f := range group {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getNoOverrideFieldIds returns the set of field IDs that have NoOverride set
|
||||
// for the given checker definition across all option groups and rules.
|
||||
func getNoOverrideFieldIds(def *happydns.CheckerDefinition) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
collectNoOverrideFromDoc(def.Options, result)
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
collectNoOverrideFromDoc(rwo.Options(), result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getNoOverrideFieldScopes returns a map from field ID to the scope at which
|
||||
// the NoOverride field is defined. Used for defense-in-depth stripping.
|
||||
func getNoOverrideFieldScopes(def *happydns.CheckerDefinition) map[string]happydns.CheckScopeType {
|
||||
result := make(map[string]happydns.CheckScopeType)
|
||||
scanGroups := func(doc happydns.CheckerOptionsDocumentation) {
|
||||
for _, f := range doc.AdminOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeAdmin
|
||||
}
|
||||
}
|
||||
for _, f := range doc.UserOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeUser
|
||||
}
|
||||
}
|
||||
for _, f := range doc.DomainOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeDomain
|
||||
}
|
||||
}
|
||||
for _, f := range doc.ServiceOpts {
|
||||
if f.NoOverride {
|
||||
result[f.Id] = happydns.CheckScopeService
|
||||
}
|
||||
}
|
||||
}
|
||||
scanGroups(def.Options)
|
||||
for _, rule := range def.Rules {
|
||||
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
|
||||
scanGroups(rwo.Options())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildAutoFillContext loads domain/zone data from storage and builds a map
|
||||
// of auto-fill key to resolved value.
|
||||
func (u *CheckerOptionsUsecase) buildAutoFillContext(
|
||||
|
|
@ -484,6 +596,15 @@ func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
|
|||
|
||||
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
|
||||
|
||||
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
|
||||
if def := checkerPkg.FindChecker(checkerName); def != nil {
|
||||
for id := range getNoOverrideFieldIds(def) {
|
||||
if v, ok := storedOpts[id]; ok {
|
||||
merged[id] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target := happydns.CheckTarget{
|
||||
UserId: userId,
|
||||
DomainId: domainId,
|
||||
|
|
|
|||
|
|
@ -1440,3 +1440,215 @@ func TestValidateOptions_SkipsAutoFillFields(t *testing.T) {
|
|||
t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NoOverride tests ---
|
||||
|
||||
func TestGetCheckerOptions_NoOverridePreservesAdminValue(t *testing.T) {
|
||||
registerTestChecker("no_override_merge", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Set at admin scope.
|
||||
store.UpdateCheckerConfiguration("no_override_merge", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
// Attempt to override at user scope (should be ignored during merge).
|
||||
store.UpdateCheckerConfiguration("no_override_merge", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": false,
|
||||
"threshold": float64(42),
|
||||
})
|
||||
|
||||
merged, err := uc.GetCheckerOptions("no_override_merge", uid, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true (admin value preserved), got %v", merged["locked"])
|
||||
}
|
||||
if merged["threshold"] != float64(42) {
|
||||
t.Errorf("expected threshold=42 (user value applied), got %v", merged["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckerOptions_NoOverrideAllowsSameScope(t *testing.T) {
|
||||
registerTestChecker("no_override_same_scope", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
// Only admin scope sets the value — no conflict.
|
||||
store.UpdateCheckerConfiguration("no_override_same_scope", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
|
||||
merged, err := uc.GetCheckerOptions("no_override_same_scope", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true, got %v", merged["locked"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMergedCheckerOptionsWithAutoFill_NoOverrideBlocksRunOpts(t *testing.T) {
|
||||
registerTestChecker("no_override_runopt", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Admin sets locked=true.
|
||||
store.UpdateCheckerConfiguration("no_override_runopt", nil, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
})
|
||||
// User sets threshold.
|
||||
store.UpdateCheckerConfiguration("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
|
||||
"threshold": float64(10),
|
||||
})
|
||||
|
||||
// RunOpts tries to override locked.
|
||||
merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if merged["locked"] != true {
|
||||
t.Errorf("expected locked=true (NoOverride should block runOpts), got %v", merged["locked"])
|
||||
}
|
||||
if merged["threshold"] != float64(10) {
|
||||
t.Errorf("expected threshold=10, got %v", merged["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_set", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Try to set locked at user scope — should be silently stripped.
|
||||
err := uc.SetCheckerOptions("no_override_set", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
"threshold": float64(99),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check what was actually stored.
|
||||
stored := store.data[posKey("no_override_set", uid, nil, nil)]
|
||||
if _, ok := stored["locked"]; ok {
|
||||
t.Error("expected locked to be stripped from user-scope storage")
|
||||
}
|
||||
if stored["threshold"] != float64(99) {
|
||||
t.Errorf("expected threshold=99 to be stored, got %v", stored["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_add", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "threshold", Type: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Pre-populate user scope with threshold.
|
||||
store.UpdateCheckerConfiguration("no_override_add", uid, nil, nil, happydns.CheckerOptions{
|
||||
"threshold": float64(50),
|
||||
})
|
||||
|
||||
// Try to add locked at user scope — should be silently skipped.
|
||||
result, err := uc.AddCheckerOptions("no_override_add", uid, nil, nil, happydns.CheckerOptions{
|
||||
"locked": true,
|
||||
"threshold": float64(75),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := result["locked"]; ok {
|
||||
t.Error("expected locked to be skipped in AddCheckerOptions result")
|
||||
}
|
||||
if result["threshold"] != float64(75) {
|
||||
t.Errorf("expected threshold=75, got %v", result["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCheckerOption_RejectsNoOverrideAtLowerScope(t *testing.T) {
|
||||
registerTestChecker("no_override_set_single", &happydns.CheckerDefinition{
|
||||
Options: happydns.CheckerOptionsDocumentation{
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{Id: "locked", Type: "boolean", NoOverride: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store := newOptionsStore()
|
||||
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
|
||||
|
||||
uid := idPtr()
|
||||
|
||||
// Setting at admin scope should work.
|
||||
err := uc.SetCheckerOption("no_override_set_single", nil, nil, nil, "locked", true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected SetCheckerOption at admin scope to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
// Setting at user scope should fail.
|
||||
err = uc.SetCheckerOption("no_override_set_single", uid, nil, nil, "locked", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when setting NoOverride field at lower scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot be overridden") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
"context"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -47,7 +48,7 @@ type SchedulerJob struct {
|
|||
PlanID *happydns.Identifier `json:"planID" swaggertype:"string"`
|
||||
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
index int `json:"index"` // heap index
|
||||
index int // heap index
|
||||
}
|
||||
|
||||
// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun.
|
||||
|
|
@ -98,6 +99,7 @@ type Scheduler struct {
|
|||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
|
|
@ -107,7 +109,7 @@ type Scheduler struct {
|
|||
}
|
||||
|
||||
// NewScheduler creates a new Scheduler.
|
||||
func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, stateStore SchedulerStateStorage) *Scheduler {
|
||||
func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, zoneStore ZoneGetter, stateStore SchedulerStateStorage) *Scheduler {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
|
|
@ -115,6 +117,7 @@ func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore C
|
|||
engine: engine,
|
||||
planStore: planStore,
|
||||
domainStore: domainStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
maxConcurrency: maxConcurrency,
|
||||
}
|
||||
|
|
@ -319,30 +322,47 @@ func (s *Scheduler) buildQueue() {
|
|||
}
|
||||
}
|
||||
|
||||
// Collect checkers by scope for efficient iteration.
|
||||
var domainCheckers, serviceCheckers []struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
domainCheckers = append(domainCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
if def.Availability.ApplyToService {
|
||||
serviceCheckers = append(serviceCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discovery: enumerate all domains and schedule applicable checkers.
|
||||
domains := s.loadAllDomains()
|
||||
for _, domain := range domains {
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
target := happydns.CheckTarget{UserId: &uid, DomainId: &did}
|
||||
domainTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did}
|
||||
|
||||
for checkerID, def := range checkers {
|
||||
if !def.Availability.ApplyToDomain {
|
||||
continue
|
||||
}
|
||||
key := checkerID + "|" + target.String()
|
||||
for _, c := range domainCheckers {
|
||||
key := c.id + "|" + domainTarget.String()
|
||||
if disabledSet[key] {
|
||||
continue
|
||||
}
|
||||
plan := planMap[key]
|
||||
|
||||
interval := s.effectiveInterval(def, plan)
|
||||
offset := computeOffset(checkerID, target.String(), interval)
|
||||
interval := s.effectiveInterval(c.def, plan)
|
||||
offset := computeOffset(c.id, domainTarget.String(), interval)
|
||||
nextRun := computeNextRun(interval, offset, lastRun)
|
||||
|
||||
job := &SchedulerJob{
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
CheckerID: c.id,
|
||||
Target: domainTarget,
|
||||
Interval: interval,
|
||||
NextRun: nextRun,
|
||||
}
|
||||
|
|
@ -351,6 +371,41 @@ func (s *Scheduler) buildQueue() {
|
|||
}
|
||||
heap.Push(&s.queue, job)
|
||||
}
|
||||
|
||||
// Service-level discovery: load the latest zone and match services.
|
||||
if len(serviceCheckers) > 0 {
|
||||
services := s.loadDomainServices(domain)
|
||||
for _, svc := range services {
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did, ServiceId: &sid, ServiceType: svc.Type}
|
||||
|
||||
for _, c := range serviceCheckers {
|
||||
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
key := c.id + "|" + svcTarget.String()
|
||||
if disabledSet[key] {
|
||||
continue
|
||||
}
|
||||
plan := planMap[key]
|
||||
|
||||
interval := s.effectiveInterval(c.def, plan)
|
||||
offset := computeOffset(c.id, svcTarget.String(), interval)
|
||||
nextRun := computeNextRun(interval, offset, lastRun)
|
||||
|
||||
job := &SchedulerJob{
|
||||
CheckerID: c.id,
|
||||
Target: svcTarget,
|
||||
Interval: interval,
|
||||
NextRun: nextRun,
|
||||
}
|
||||
if plan != nil {
|
||||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +442,25 @@ func (s *Scheduler) loadAllDomains() []*happydns.Domain {
|
|||
return domains
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
|
||||
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
latestZoneID := domain.ZoneHistory[len(domain.ZoneHistory)-1]
|
||||
zone, err := s.zoneStore.GetZone(latestZoneID)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", latestZoneID, domain.DomainName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var services []*happydns.ServiceMessage
|
||||
for _, svcs := range zone.Services {
|
||||
services = append(services, svcs...)
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
func (s *Scheduler) effectiveInterval(def *happydns.CheckerDefinition, plan *happydns.CheckPlan) time.Duration {
|
||||
interval := defaultInterval
|
||||
if def.Interval != nil {
|
||||
|
|
@ -423,9 +497,7 @@ func (s *Scheduler) spreadOverdueJobs() {
|
|||
}
|
||||
|
||||
window := time.Duration(len(overdue)) * minSpacing
|
||||
if window > maxCatchUpWindow {
|
||||
window = maxCatchUpWindow
|
||||
}
|
||||
window = min(window, maxCatchUpWindow)
|
||||
|
||||
for i, job := range overdue {
|
||||
delay := window * time.Duration(i) / time.Duration(len(overdue))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ type DomainLister interface {
|
|||
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
|
||||
}
|
||||
|
||||
// ZoneGetter is the minimal interface needed by the scheduler to load zones for service discovery.
|
||||
type ZoneGetter interface {
|
||||
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
|
||||
}
|
||||
|
||||
// CheckAutoFillStorage provides access to domain, zone and user data
|
||||
// needed to resolve auto-fill field values at execution time.
|
||||
type CheckAutoFillStorage interface {
|
||||
|
|
@ -107,3 +112,10 @@ type ObservationSnapshotStorage interface {
|
|||
DeleteSnapshot(snapID happydns.Identifier) error
|
||||
ClearSnapshots() error
|
||||
}
|
||||
|
||||
// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
|
||||
// to the snapshot that holds the most recent data.
|
||||
type ObservationCacheStorage interface {
|
||||
GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
|
||||
PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,7 +241,13 @@ 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"`
|
||||
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -311,8 +317,9 @@ type CheckRuleWithOptions interface {
|
|||
}
|
||||
|
||||
// ObservationGetter provides access to observation data (used by CheckRule).
|
||||
// Get unmarshals observation data into dest (like json.Unmarshal).
|
||||
type ObservationGetter interface {
|
||||
Get(ctx context.Context, key ObservationKey) (any, error)
|
||||
Get(ctx context.Context, key ObservationKey, dest any) error
|
||||
}
|
||||
|
||||
// CheckAggregator combines multiple CheckStates into a single result.
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ type Field struct {
|
|||
// 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"`
|
||||
|
||||
// NoOverride indicates that once this field is set at a given scope,
|
||||
// more specific scopes cannot override its value.
|
||||
NoOverride bool `json:"noOverride,omitempty"`
|
||||
}
|
||||
|
||||
type FormState struct {
|
||||
|
|
|
|||
|
|
@ -75,11 +75,13 @@
|
|||
onclean,
|
||||
}: Props = $props();
|
||||
|
||||
// Filter out auto-fill fields from editable groups (they are system-provided).
|
||||
// Filter out auto-fill and noOverride fields from editable groups.
|
||||
// Auto-fill fields are system-provided; noOverride fields can only be
|
||||
// changed at the scope where they are defined (typically admin).
|
||||
let filteredEditableGroups = $derived(
|
||||
editableGroups.map((g) => ({
|
||||
...g,
|
||||
opts: g.opts.filter((opt) => !opt.autoFill),
|
||||
opts: g.opts.filter((opt) => !opt.autoFill && !opt.noOverride),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
const ids = new Set<string>();
|
||||
if (!resolvedStatus) return ids;
|
||||
const addOpts = (opts: HappydnsCheckerOptionDocumentation[] | undefined) =>
|
||||
opts?.forEach((o) => o.id && ids.add(o.id));
|
||||
opts?.forEach((o) => o.id && !o.noOverride && ids.add(o.id));
|
||||
addOpts(resolvedStatus.options?.runOpts);
|
||||
addOpts(resolvedStatus.options?.adminOpts);
|
||||
addOpts(resolvedStatus.options?.userOpts);
|
||||
|
|
@ -204,7 +204,7 @@
|
|||
{@const runOpts = [
|
||||
...(status.options?.runOpts || []),
|
||||
...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
|
||||
]}
|
||||
].filter((o: any) => !o.noOverride)}
|
||||
{@const otherOpts = [
|
||||
...(status.options?.adminOpts || []),
|
||||
...(status.options?.userOpts || []),
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
...(r?.options?.userOpts || []),
|
||||
...(r?.options?.domainOpts || []),
|
||||
]),
|
||||
].filter((o: any) => o.id)}
|
||||
].filter((o: any) => o.id && !o.noOverride)}
|
||||
<Form
|
||||
id="run-check-modal"
|
||||
onsubmit={(e: Event) => {
|
||||
|
|
|
|||
|
|
@ -506,73 +506,6 @@
|
|||
"no-group": "Divers",
|
||||
"title": "Vos groupes"
|
||||
},
|
||||
"checkers": {
|
||||
"children-checkers": {
|
||||
"title": "Vérifications de zones et services",
|
||||
"description": "Ces vérifications s'appliquent aux zones ou aux services individuels. Vous pouvez les configurer ici, mais les résultats n'apparaîtront que lors de l'exécution au niveau approprié."
|
||||
},
|
||||
"executions": {
|
||||
"loading": "Chargement des résultats...",
|
||||
"no-results": "Aucun résultat. Cliquez sur « Exécuter maintenant » pour lancer la vérification.",
|
||||
"title": "Exécutions ({{count}})",
|
||||
"run-check-now": "Exécuter maintenant",
|
||||
"back-to-checks": "Retour aux vérifications",
|
||||
"delete-all": "Tout supprimer",
|
||||
"delete-confirm": "Voulez-vous vraiment supprimer ce résultat ?",
|
||||
"delete-all-confirm": "Voulez-vous vraiment supprimer TOUS les résultats de cette vérification ? Cette action est irréversible.",
|
||||
"deleted-all": "Tous les résultats ont été supprimés.",
|
||||
"delete-failed": "Échec de la suppression",
|
||||
"delete-all-failed": "Échec de la suppression des résultats",
|
||||
"configure": "Configurer",
|
||||
"domain-level": "Au niveau du domaine",
|
||||
"error-loading": "Erreur lors du chargement : {{error}}",
|
||||
"error-deleting": "Erreur lors de la suppression : {{error}}",
|
||||
"table": {
|
||||
"executed-at": "Date d'exécution",
|
||||
"status": "Statut",
|
||||
"message": "Message",
|
||||
"duration": "Durée",
|
||||
"type": "Type",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"type": {
|
||||
"scheduled": "Planifié",
|
||||
"manual": "Manuel"
|
||||
},
|
||||
"pending": {
|
||||
"queued": "En attente",
|
||||
"queued-description": "En attente d'exécution…",
|
||||
"running": "En cours",
|
||||
"running-description": "Vérification en cours…"
|
||||
},
|
||||
"view": "Voir"
|
||||
},
|
||||
"run-check": {
|
||||
"title": "Exécuter la vérification",
|
||||
"loading-options": "Chargement des options du vérificateur...",
|
||||
"select-rule": "Règle à vérifier",
|
||||
"configure-info": "Configurez les options du vérificateur ci-dessous. Les valeurs pré-remplies proviennent des paramètres au niveau du domaine.",
|
||||
"no-options": "Ce vérificateur n'a pas d'options configurables. Cliquez sur « Exécuter la vérification » pour lancer avec les paramètres par défaut.",
|
||||
"no-run-options": "Ce vérificateur n'a pas d'options d'exécution. Vous pouvez toujours écraser les paramètres avancés ci-dessous.",
|
||||
"error-loading-options": "Erreur lors du chargement des options du vérificateur : {{error}}",
|
||||
"run-button": "Exécuter la vérification",
|
||||
"triggered-success": "Vérification déclenchée avec succès ! ID d'exécution : {{id}}",
|
||||
"trigger-failed": "Échec du déclenchement de la vérification : {{error}}",
|
||||
"advanced-options": "Options avancées",
|
||||
"rules": "Règles"
|
||||
},
|
||||
"result": {
|
||||
"view-metrics": "Métriques",
|
||||
"view-html": "Rapport HTML",
|
||||
"view-json": "JSON brut",
|
||||
"download-html": "Télécharger HTML",
|
||||
"download-json": "Télécharger JSON",
|
||||
"full-report": "Rapport complet",
|
||||
"metric-name": "Métrique",
|
||||
"metric-value": "Valeur",
|
||||
"metric-unit": "Unité"
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
"viewer": "Visualiseur de zone",
|
||||
"viewer-subtitle": "Vos services en un coup d'œil",
|
||||
|
|
|
|||
|
|
@ -110,5 +110,10 @@ export const domains_by_groups = derived(domains, ($domains: Array<HappydnsDomai
|
|||
});
|
||||
|
||||
export function domainLink(dnid: string): string {
|
||||
return get(domains_idx)[get(domains_idx)[dnid].domain] ? get(domains_idx)[dnid].domain : dnid;
|
||||
const idx = get(domains_idx);
|
||||
const entry = idx[dnid];
|
||||
if (entry && idx[entry.domain]) {
|
||||
return entry.domain;
|
||||
}
|
||||
return dnid;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export function collectAllOptionDocs(
|
|||
...(r.options?.userOpts || []),
|
||||
...(r.options?.domainOpts || []),
|
||||
]),
|
||||
];
|
||||
].filter((o) => !o.noOverride);
|
||||
}
|
||||
|
||||
export function downloadBlob(content: string, filename: string, mime: string) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue