Compare commits
4 commits
c6534e607d
...
d5372e0a21
| Author | SHA1 | Date | |
|---|---|---|---|
| d5372e0a21 | |||
| a39418b4f3 | |||
| 88663ed4cc | |||
| 50badc5811 |
24 changed files with 842 additions and 88 deletions
32
checkers/ping.go
Normal file
32
checkers/ping.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// 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 checkers
|
||||
|
||||
import (
|
||||
ping "git.happydns.org/happyDomain/checker-ping/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(ping.Provider())
|
||||
checker.RegisterExternalizableChecker(ping.Definition())
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.0
|
|||
toolchain go1.26.1
|
||||
|
||||
require (
|
||||
git.happydns.org/happyDomain/checker-ping v0.0.0-00010101000000-000000000000
|
||||
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
|
||||
|
|
@ -179,6 +180,7 @@ 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
|
|
@ -471,6 +471,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
|
|
|
|||
|
|
@ -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,9 +263,14 @@ 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.store)
|
||||
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
|
||||
|
||||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
|
|||
|
|
@ -51,37 +51,70 @@ 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
|
||||
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 GetObservationProvider(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) (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 +123,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
|
||||
}
|
||||
|
||||
provider := GetObservationProvider(key)
|
||||
// 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 nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
val, err := provider.Collect(ctx, oc.target, oc.opts)
|
||||
val, err := provider.Collect(ctx, 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 +231,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,36 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// GetOption extracts a typed value from checker options, handling both
|
||||
// native Go types (in-process providers) and map[string]any values
|
||||
// (from JSON round-tripping through HTTP providers). Returns the zero
|
||||
// value and false if the key is missing or the value cannot be converted.
|
||||
func GetOption[T any](options happydns.CheckerOptions, key string) (T, bool) {
|
||||
v, ok := options[key]
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Direct type assertion (in-process path).
|
||||
if t, ok := v.(T); ok {
|
||||
return t, true
|
||||
}
|
||||
|
||||
// JSON round-trip for values deserialized as map[string]any over HTTP.
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
var t T
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// GetFloatOption extracts a float64 from checker options, handling both
|
||||
// native float64 values and json.Number. Returns defaultVal if the key
|
||||
// is missing or the value cannot be converted.
|
||||
|
|
|
|||
105
internal/checker/provider_http.go
Normal file
105
internal/checker/provider_http.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// HTTPObservationProvider is an ObservationProvider that delegates data
|
||||
// collection to a remote HTTP endpoint via POST /collect.
|
||||
type HTTPObservationProvider struct {
|
||||
observationKey happydns.ObservationKey
|
||||
endpoint string // base URL without trailing slash
|
||||
}
|
||||
|
||||
// NewHTTPObservationProvider creates a new HTTP-backed observation provider.
|
||||
// endpoint is the base URL of the remote checker (e.g. "http://checker-ping:8080").
|
||||
func NewHTTPObservationProvider(key happydns.ObservationKey, endpoint string) *HTTPObservationProvider {
|
||||
return &HTTPObservationProvider{
|
||||
observationKey: key,
|
||||
endpoint: strings.TrimSuffix(endpoint, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// Key returns the observation key this provider handles.
|
||||
func (p *HTTPObservationProvider) Key() happydns.ObservationKey {
|
||||
return p.observationKey
|
||||
}
|
||||
|
||||
// Collect sends the observation request to the remote endpoint and returns
|
||||
// the raw JSON data. The returned value is a json.RawMessage which
|
||||
// ObservationContext.Get() will marshal without double-encoding.
|
||||
func (p *HTTPObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
reqBody := happydns.ExternalCollectRequest{
|
||||
Key: p.observationKey,
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to marshal request: %w", p.observationKey, err)
|
||||
}
|
||||
|
||||
url := p.endpoint + "/collect"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to create request: %w", p.observationKey, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: request failed: %w", p.observationKey, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("HTTP provider %s: endpoint returned status %d: %s", p.observationKey, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result happydns.ExternalCollectResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: failed to decode response: %w", p.observationKey, err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("HTTP provider %s: remote error: %s", p.observationKey, result.Error)
|
||||
}
|
||||
|
||||
if result.Data == nil {
|
||||
return nil, fmt.Errorf("HTTP provider %s: remote returned empty data", p.observationKey)
|
||||
}
|
||||
|
||||
// Return json.RawMessage directly — it implements json.Marshaler,
|
||||
// so ObservationContext.Get() won't double-encode it.
|
||||
return result.Data, nil
|
||||
}
|
||||
|
|
@ -39,6 +39,24 @@ func RegisterChecker(c *happydns.CheckerDefinition) {
|
|||
checkerRegistry[c.ID] = 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) {
|
||||
c.Options.AdminOpts = append(c.Options.AdminOpts,
|
||||
happydns.CheckerOptionDocumentation{
|
||||
Id: "endpoint",
|
||||
Type: "string",
|
||||
Label: "Remote checker endpoint URL",
|
||||
Description: "If set, delegate observation collection to this HTTP endpoint instead of running locally.",
|
||||
Placeholder: "http://checker-" + c.ID + ":8080",
|
||||
NoOverride: true,
|
||||
},
|
||||
)
|
||||
RegisterChecker(c)
|
||||
}
|
||||
|
||||
// GetCheckers returns all registered checker definitions.
|
||||
func GetCheckers() map[string]*happydns.CheckerDefinition {
|
||||
return checkerRegistry
|
||||
|
|
|
|||
|
|
@ -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,42 @@ 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 plan != nil && 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)
|
||||
|
||||
// If an endpoint is configured, override observation providers with HTTP transport.
|
||||
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
|
||||
for _, key := range def.ObservationKeys {
|
||||
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate all rules, skipping disabled ones.
|
||||
states := make([]happydns.CheckState, 0, len(def.Rules))
|
||||
|
|
@ -174,6 +212,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,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func (p *testObservationProvider) Key() happydns.ObservationKey {
|
|||
return "test_obs"
|
||||
}
|
||||
|
||||
func (p *testObservationProvider) Collect(ctx context.Context, target happydns.CheckTarget, opts happydns.CheckerOptions) (any, error) {
|
||||
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
|
||||
return map[string]any{"value": 42}, nil
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -96,12 +96,14 @@ type SchedulerStatus struct {
|
|||
// Scheduler manages periodic execution of checkers.
|
||||
type Scheduler struct {
|
||||
queue SchedulerQueue
|
||||
jobKeys map[string]bool
|
||||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
wake chan struct{}
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
|
|
@ -119,6 +121,8 @@ func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore C
|
|||
domainStore: domainStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
jobKeys: make(map[string]bool),
|
||||
wake: make(chan struct{}, 1),
|
||||
maxConcurrency: maxConcurrency,
|
||||
}
|
||||
}
|
||||
|
|
@ -206,6 +210,8 @@ func (s *Scheduler) run(ctx context.Context) {
|
|||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
continue
|
||||
case <-time.After(1 * time.Minute):
|
||||
s.mu.Lock()
|
||||
s.buildQueue()
|
||||
|
|
@ -228,6 +234,9 @@ func (s *Scheduler) run(ctx context.Context) {
|
|||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
timer.Stop()
|
||||
continue
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
|
@ -287,14 +296,17 @@ func (s *Scheduler) run(ctx context.Context) {
|
|||
}
|
||||
// Add jitter for next cycle.
|
||||
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
s.mu.Lock()
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) buildQueue() {
|
||||
s.queue = s.queue[:0]
|
||||
s.jobKeys = make(map[string]bool)
|
||||
|
||||
var lastRun time.Time
|
||||
if s.stateStore != nil {
|
||||
|
|
@ -370,6 +382,7 @@ func (s *Scheduler) buildQueue() {
|
|||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
}
|
||||
|
||||
// Service-level discovery: load the latest zone and match services.
|
||||
|
|
@ -403,12 +416,137 @@ func (s *Scheduler) buildQueue() {
|
|||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainChange incrementally adds scheduler jobs for a domain
|
||||
// without rebuilding the entire queue. Call this after a domain is
|
||||
// created or its zone is imported/published.
|
||||
func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
|
||||
// Load plans relevant to this domain.
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
domainTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did}
|
||||
|
||||
plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
|
||||
}
|
||||
disabledSet := make(map[string]bool)
|
||||
planMap := make(map[string]*happydns.CheckPlan)
|
||||
for _, p := range plans {
|
||||
key := p.CheckerID + "|" + p.Target.String()
|
||||
planMap[key] = p
|
||||
if p.IsFullyDisabled() {
|
||||
disabledSet[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
var added int
|
||||
s.mu.Lock()
|
||||
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
key := checkerID + "|" + domainTarget.String()
|
||||
if s.jobKeys[key] || disabledSet[key] {
|
||||
continue
|
||||
}
|
||||
plan := planMap[key]
|
||||
interval := s.effectiveInterval(def, plan)
|
||||
job := &SchedulerJob{
|
||||
CheckerID: checkerID,
|
||||
Target: domainTarget,
|
||||
Interval: interval,
|
||||
NextRun: time.Now().Add(computeJitter(checkerID, domainTarget.String(), time.Now(), interval)),
|
||||
}
|
||||
if plan != nil {
|
||||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
added++
|
||||
}
|
||||
|
||||
if def.Availability.ApplyToService {
|
||||
services := s.loadDomainServices(domain)
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did, ServiceId: &sid, ServiceType: svc.Type}
|
||||
key := checkerID + "|" + svcTarget.String()
|
||||
if s.jobKeys[key] || disabledSet[key] {
|
||||
continue
|
||||
}
|
||||
plan := planMap[key]
|
||||
interval := s.effectiveInterval(def, plan)
|
||||
job := &SchedulerJob{
|
||||
CheckerID: checkerID,
|
||||
Target: svcTarget,
|
||||
Interval: interval,
|
||||
NextRun: time.Now().Add(computeJitter(checkerID, svcTarget.String(), time.Now(), interval)),
|
||||
}
|
||||
if plan != nil {
|
||||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if added > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs", domain.DomainName, added)
|
||||
// Wake the run loop so it re-evaluates the queue head.
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainRemoved removes all scheduler jobs for the given domain.
|
||||
func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
|
||||
s.mu.Lock()
|
||||
n := 0
|
||||
for i := 0; i < len(s.queue); {
|
||||
job := s.queue[i]
|
||||
if job.Target.DomainId != nil && job.Target.DomainId.Equals(domainID) {
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
delete(s.jobKeys, key)
|
||||
// Swap with last and shrink.
|
||||
s.queue[i] = s.queue[len(s.queue)-1]
|
||||
s.queue[len(s.queue)-1] = nil
|
||||
s.queue = s.queue[:len(s.queue)-1]
|
||||
n++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
heap.Init(&s.queue)
|
||||
// Re-index after Init.
|
||||
for i, job := range s.queue {
|
||||
job.index = i
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) {
|
||||
iter, err := s.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ type ZoneGetter interface {
|
|||
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
|
||||
}
|
||||
|
||||
// SchedulerDomainNotifier is a narrow interface for notifying the scheduler
|
||||
// about domain changes so it can incrementally update its job queue.
|
||||
type SchedulerDomainNotifier interface {
|
||||
NotifyDomainChange(domain *happydns.Domain)
|
||||
NotifyDomainRemoved(domainID happydns.Identifier)
|
||||
}
|
||||
|
||||
// CheckAutoFillStorage provides access to domain, zone and user data
|
||||
// needed to resolve auto-fill field values at execution time.
|
||||
type CheckAutoFillStorage interface {
|
||||
|
|
@ -112,3 +119,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,12 +41,20 @@ type DomainExistenceTester interface {
|
|||
TestDomainExistence(ctx context.Context, provider *happydns.Provider, name string) error
|
||||
}
|
||||
|
||||
// SchedulerDomainNotifier is an optional callback to notify the scheduler
|
||||
// about domain changes so it can incrementally update its job queue.
|
||||
type SchedulerDomainNotifier interface {
|
||||
NotifyDomainChange(domain *happydns.Domain)
|
||||
NotifyDomainRemoved(domainID happydns.Identifier)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store DomainStorage
|
||||
providerService ProviderGetter
|
||||
getZone *zoneUC.GetZoneUsecase
|
||||
domainExistence DomainExistenceTester
|
||||
domainLogAppender domainLogUC.DomainLogAppender
|
||||
store DomainStorage
|
||||
providerService ProviderGetter
|
||||
getZone *zoneUC.GetZoneUsecase
|
||||
domainExistence DomainExistenceTester
|
||||
domainLogAppender domainLogUC.DomainLogAppender
|
||||
schedulerNotifier SchedulerDomainNotifier
|
||||
}
|
||||
|
||||
func NewService(
|
||||
|
|
@ -65,6 +73,12 @@ func NewService(
|
|||
}
|
||||
}
|
||||
|
||||
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
|
||||
// queue updates on domain creation/deletion.
|
||||
func (s *Service) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
|
||||
s.schedulerNotifier = notifier
|
||||
}
|
||||
|
||||
// CreateDomain creates a new domain for the given user.
|
||||
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *happydns.Domain) error {
|
||||
uz, err := happydns.NewDomain(user, uz.DomainName, uz.ProviderId)
|
||||
|
|
@ -93,6 +107,10 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, uz *hap
|
|||
s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName)))
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyDomainChange(uz)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -194,5 +212,9 @@ func (s *Service) DeleteDomain(domainID happydns.Identifier) error {
|
|||
}
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyDomainRemoved(domainID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ type ZoneCorrector interface {
|
|||
ListZoneCorrections(ctx context.Context, provider *happydns.Provider, domain *happydns.Domain, records []happydns.Record) ([]*happydns.Correction, int, error)
|
||||
}
|
||||
|
||||
// SchedulerDomainNotifier is an optional callback to notify the scheduler
|
||||
// about domain changes so it can incrementally update its job queue.
|
||||
type SchedulerDomainNotifier interface {
|
||||
NotifyDomainChange(domain *happydns.Domain)
|
||||
}
|
||||
|
||||
// Orchestrator aggregates the use-cases that together implement the DNS zone
|
||||
// lifecycle: importing zones from a provider, listing required corrections, and
|
||||
// applying those corrections back to the provider.
|
||||
|
|
@ -91,3 +97,10 @@ func NewOrchestrator(
|
|||
ZoneImporter: zoneImporter,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSchedulerNotifier sets the optional scheduler notifier on the
|
||||
// sub-usecases that create or publish zones.
|
||||
func (o *Orchestrator) SetSchedulerNotifier(notifier SchedulerDomainNotifier) {
|
||||
o.RemoteZoneImporter.schedulerNotifier = notifier
|
||||
o.ZoneCorrectionApplier.schedulerNotifier = notifier
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ import (
|
|||
// from the provider and delegates to ZoneImporterUsecase to persist them. It
|
||||
// also appends a domain log entry on success.
|
||||
type RemoteZoneImporterUsecase struct {
|
||||
appendDomainLog domainlogUC.DomainLogAppender
|
||||
providerService ProviderGetter
|
||||
zoneImporter happydns.ZoneImporterUsecase
|
||||
zoneRetriever ZoneRetriever
|
||||
appendDomainLog domainlogUC.DomainLogAppender
|
||||
providerService ProviderGetter
|
||||
zoneImporter happydns.ZoneImporterUsecase
|
||||
zoneRetriever ZoneRetriever
|
||||
schedulerNotifier SchedulerDomainNotifier
|
||||
}
|
||||
|
||||
// NewRemoteZoneImporterUsecase creates a RemoteZoneImporterUsecase wired to
|
||||
|
|
@ -79,5 +80,9 @@ func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.
|
|||
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
|
||||
}
|
||||
|
||||
if uc.schedulerNotifier != nil {
|
||||
uc.schedulerNotifier.NotifyDomainChange(domain)
|
||||
}
|
||||
|
||||
return myZone, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,13 +41,14 @@ import (
|
|||
// in the domain history. The WIP zone at ZoneHistory[0] is never modified.
|
||||
type ZoneCorrectionApplierUsecase struct {
|
||||
*ZoneCorrectionListerUsecase
|
||||
appendDomainLog domainlogUC.DomainLogAppender
|
||||
domainUpdater DomainUpdater
|
||||
zoneCreator *zoneUC.CreateZoneUsecase
|
||||
zoneGetter *zoneUC.GetZoneUsecase
|
||||
zoneRetriever ZoneRetriever
|
||||
zoneUpdater *zoneUC.UpdateZoneUsecase
|
||||
clock func() time.Time
|
||||
appendDomainLog domainlogUC.DomainLogAppender
|
||||
domainUpdater DomainUpdater
|
||||
zoneCreator *zoneUC.CreateZoneUsecase
|
||||
zoneGetter *zoneUC.GetZoneUsecase
|
||||
zoneRetriever ZoneRetriever
|
||||
zoneUpdater *zoneUC.UpdateZoneUsecase
|
||||
schedulerNotifier SchedulerDomainNotifier
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewZoneCorrectionApplierUsecase creates a ZoneCorrectionApplierUsecase with
|
||||
|
|
@ -288,6 +289,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
|
|||
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
|
||||
}
|
||||
|
||||
if uc.schedulerNotifier != nil {
|
||||
uc.schedulerNotifier.NotifyDomainChange(domain)
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -238,10 +238,16 @@ type CheckEvaluation struct {
|
|||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -286,7 +292,7 @@ type Execution struct {
|
|||
// 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)
|
||||
Collect(ctx context.Context, opts CheckerOptions) (any, error)
|
||||
}
|
||||
|
||||
// CheckRuleInfo is the JSON-serializable description of a rule, for API/UI listing.
|
||||
|
|
@ -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.
|
||||
|
|
@ -338,16 +345,17 @@ type CheckerMetricsReporter interface {
|
|||
|
||||
// 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"`
|
||||
HasHTMLReport bool `json:"has_html_report,omitempty"`
|
||||
HasMetrics bool `json:"has_metrics,omitempty"`
|
||||
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"`
|
||||
HasHTMLReport bool `json:"has_html_report,omitempty"`
|
||||
HasMetrics bool `json:"has_metrics,omitempty"`
|
||||
ObservationKeys []ObservationKey `json:"observationKeys,omitempty"`
|
||||
}
|
||||
|
||||
// BuildRulesInfo populates RulesInfo from the Rules slice.
|
||||
|
|
@ -422,3 +430,16 @@ func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier,
|
|||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ExternalCollectRequest is sent to POST /collect on a remote checker endpoint.
|
||||
type ExternalCollectRequest struct {
|
||||
Key ObservationKey `json:"key"`
|
||||
Target CheckTarget `json:"target"`
|
||||
Options CheckerOptions `json:"options"`
|
||||
}
|
||||
|
||||
// ExternalCollectResponse is returned by POST /collect on a remote checker endpoint.
|
||||
type ExternalCollectResponse struct {
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
|
|
|||
62
sdk/checker/sdk.go
Normal file
62
sdk/checker/sdk.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// 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 provides the public API for checker plugins.
|
||||
// External checker plugins should import this package for registration
|
||||
// and option helpers instead of the internal checker package.
|
||||
package checker
|
||||
|
||||
import (
|
||||
internal "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// RegisterChecker registers a checker definition globally.
|
||||
func RegisterChecker(c *happydns.CheckerDefinition) {
|
||||
internal.RegisterChecker(c)
|
||||
}
|
||||
|
||||
// RegisterObservationProvider registers an observation provider globally.
|
||||
func RegisterObservationProvider(p happydns.ObservationProvider) {
|
||||
internal.RegisterObservationProvider(p)
|
||||
}
|
||||
|
||||
// GetFloatOption extracts a float64 option with a default value.
|
||||
func GetFloatOption(options happydns.CheckerOptions, key string, defaultVal float64) float64 {
|
||||
return internal.GetFloatOption(options, key, defaultVal)
|
||||
}
|
||||
|
||||
// GetIntOption extracts an int option with a default value.
|
||||
func GetIntOption(options happydns.CheckerOptions, key string, defaultVal int) int {
|
||||
return internal.GetIntOption(options, key, defaultVal)
|
||||
}
|
||||
|
||||
// GetBoolOption extracts a bool option with a default value.
|
||||
func GetBoolOption(options happydns.CheckerOptions, key string, defaultVal bool) bool {
|
||||
return internal.GetBoolOption(options, key, defaultVal)
|
||||
}
|
||||
|
||||
// GetOption extracts a typed value from checker options, handling both
|
||||
// native Go types (in-process providers) and map[string]any values
|
||||
// (from JSON round-tripping through HTTP providers).
|
||||
func GetOption[T any](options happydns.CheckerOptions, key string) (T, bool) {
|
||||
return internal.GetOption[T](options, key)
|
||||
}
|
||||
121
sdk/checker/server.go
Normal file
121
sdk/checker/server.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Server is a generic HTTP server for external checkers.
|
||||
// It handles the /health and /collect endpoints, taking care of
|
||||
// JSON serialization and error formatting.
|
||||
type Server struct {
|
||||
provider happydns.ObservationProvider
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer creates a new checker HTTP server backed by the given observation provider.
|
||||
func NewServer(provider happydns.ObservationProvider) *Server {
|
||||
s := &Server{provider: provider}
|
||||
s.mux = http.NewServeMux()
|
||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.mux.HandleFunc("POST /collect", s.handleCollect)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the http.Handler for this server, allowing callers
|
||||
// to embed it in a custom server or add middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return requestLogger(s.mux)
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server on the given address.
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
log.Printf("checker listening on %s", addr)
|
||||
return http.ListenAndServe(addr, requestLogger(s.mux))
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
|
||||
var req happydns.ExternalCollectRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, happydns.ExternalCollectResponse{
|
||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := s.provider.Collect(r.Context(), req.Options)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
|
||||
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, happydns.ExternalCollectResponse{
|
||||
Data: json.RawMessage(raw),
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue