From 6fa9e5c090f06f680ee6be3dbf364f293fec6807 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:44:59 +0700 Subject: [PATCH 01/54] checkers: introduce checker subsystem foundation Add the checker-sdk-go dependency and build the core checker infrastructure: - Domain model types: CheckTarget, CheckPlan, Execution, CheckEvaluation, CheckerDefinition, CheckerOptions, ObservationSnapshot, and associated interfaces - Observation collection engine with concurrent per-key gathering - Checker and observation provider registries (wrapping checker-sdk-go) - WorstStatusAggregator for combining rule evaluation results --- go.mod | 1 + go.sum | 2 + internal/checker/aggregator.go | 48 +++++ internal/checker/observation.go | 282 ++++++++++++++++++++++++++ internal/checker/observation_test.go | 180 +++++++++++++++++ internal/checker/registry.go | 60 ++++++ model/checker.go | 287 +++++++++++++++++++++++++++ model/form.go | 19 ++ 8 files changed, 879 insertions(+) create mode 100644 internal/checker/aggregator.go create mode 100644 internal/checker/observation.go create mode 100644 internal/checker/observation_test.go create mode 100644 internal/checker/registry.go create mode 100644 model/checker.go diff --git a/go.mod b/go.mod index 1da23faf..aba88388 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.26.2 require ( + git.happydns.org/checker-sdk-go v0.2.0 github.com/StackExchange/dnscontrol/v4 v4.34.0 github.com/altcha-org/altcha-lib-go v1.0.0 github.com/coreos/go-oidc/v3 v3.18.0 diff --git a/go.sum b/go.sum index a53eee90..fc3ede06 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPE codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY= codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc= +git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= diff --git a/internal/checker/aggregator.go b/internal/checker/aggregator.go new file mode 100644 index 00000000..be8b790e --- /dev/null +++ b/internal/checker/aggregator.go @@ -0,0 +1,48 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "strings" + + "git.happydns.org/happyDomain/model" +) + +// WorstStatusAggregator aggregates check states by taking the worst status. +type WorstStatusAggregator struct{} + +func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState { + worst := happydns.StatusUnknown + var messages []string + for _, s := range states { + if s.Status > worst { + worst = s.Status + } + if s.Message != "" { + messages = append(messages, s.Message) + } + } + return happydns.CheckState{ + Status: worst, + Message: strings.Join(messages, "; "), + } +} diff --git a/internal/checker/observation.go b/internal/checker/observation.go new file mode 100644 index 00000000..35c98407 --- /dev/null +++ b/internal/checker/observation.go @@ -0,0 +1,282 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +// observation.go implements the observation subsystem, which is the data +// collection layer for the checker framework. An observation represents a +// piece of raw data gathered about a check target (e.g. DNS records, HTTP +// headers, TLS certificate details). Observations are identified by an +// ObservationKey and collected on demand by registered ObservationProviders. +// +// The ObservationContext provides lazy-loading, cached, thread-safe access to +// observations: the first checker that requests a given observation triggers +// its collection, and subsequent checkers reuse the cached result. This +// design decouples data collection from evaluation: checkers declare which +// observations they need, and the context ensures each is collected at most +// once per check run. Observations can also be persisted as snapshots and +// reused across runs when freshness requirements allow. +// +// Observation providers may optionally implement reporting interfaces +// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable +// reports or extract time-series metrics from collected data. + +package checker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/model" +) + +// ObservationCacheLookup resolves a cached observation for a target+key. +// Returns the raw data and collection time, or an error if not cached. +type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) + +// ObservationContext provides lazy-loading, cached, thread-safe access to observation data. +// Collected data is serialized to json.RawMessage immediately after collection. +type ObservationContext struct { + target happydns.CheckTarget + opts happydns.CheckerOptions + cache map[happydns.ObservationKey]json.RawMessage + errors map[happydns.ObservationKey]error + mu sync.RWMutex + cacheLookup ObservationCacheLookup // nil = no DB cache + freshness time.Duration // 0 = always collect + providerOverride map[happydns.ObservationKey]happydns.ObservationProvider +} + +// NewObservationContext creates a new ObservationContext for the given target and options. +// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots. +// Pass nil and 0 to disable DB-based caching. +func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext { + return &ObservationContext{ + target: target, + opts: opts, + cache: make(map[happydns.ObservationKey]json.RawMessage), + errors: make(map[happydns.ObservationKey]error), + cacheLookup: cacheLookup, + freshness: freshness, + } +} + +// SetProviderOverride registers a per-context provider that takes precedence +// over the global registry for the given observation key. This is used to +// substitute local providers with HTTP-backed ones when an endpoint is configured. +func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) { + if oc.providerOverride == nil { + oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider) + } + oc.providerOverride[key] = p +} + +// getProvider returns the observation provider for the given key, checking +// per-context overrides first, then falling back to the global registry. +func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider { + if oc.providerOverride != nil { + if p, ok := oc.providerOverride[key]; ok { + return p + } + } + return sdk.FindObservationProvider(key) +} + +// Get collects observation data for the given key (lazily) and unmarshals it into dest. +// Thread-safe: concurrent calls for the same key will only trigger one collection. +func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error { + // Fast path: check cache under read lock. + oc.mu.RLock() + if raw, ok := oc.cache[key]; ok { + oc.mu.RUnlock() + return json.Unmarshal(raw, dest) + } + if err, ok := oc.errors[key]; ok { + oc.mu.RUnlock() + return err + } + oc.mu.RUnlock() + + // Slow path: acquire write lock and collect. + oc.mu.Lock() + defer oc.mu.Unlock() + + // Double-check after acquiring write lock. + if raw, ok := oc.cache[key]; ok { + return json.Unmarshal(raw, dest) + } + if err, ok := oc.errors[key]; ok { + return err + } + + // Try DB cache before collecting fresh data. + if oc.cacheLookup != nil && oc.freshness > 0 { + if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil { + if time.Since(collectedAt) < oc.freshness { + oc.cache[key] = raw + return json.Unmarshal(raw, dest) + } + } + } + + provider := oc.getProvider(key) + if provider == nil { + err := fmt.Errorf("no observation provider registered for key %q", key) + oc.errors[key] = err + return err + } + + val, err := provider.Collect(ctx, oc.opts) + if err != nil { + oc.errors[key] = err + return err + } + + raw, err := json.Marshal(val) + if err != nil { + err = fmt.Errorf("observation %q: marshal failed: %w", key, err) + oc.errors[key] = err + return err + } + + oc.cache[key] = json.RawMessage(raw) + return json.Unmarshal(raw, dest) +} + +// Data returns all cached observation data as pre-serialized JSON. +func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage { + oc.mu.RLock() + defer oc.mu.RUnlock() + + data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache)) + for k, v := range oc.cache { + data[k] = v + } + return data +} + +// Provider registration is startup-only (see comments on the registries in +// internal/service/registry.go and internal/provider/registry.go), so the +// "any provider implements X reporter" question has a fixed answer for the +// process lifetime. We compute it once on first call and cache it. +var ( + htmlReporterOnce sync.Once + htmlReporterCached bool + metricsReporterOnce sync.Once + metricsReporterCached bool +) + +// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter. +func HasHTMLReporter() bool { + htmlReporterOnce.Do(func() { + for _, p := range sdk.GetObservationProviders() { + if _, ok := p.(happydns.CheckerHTMLReporter); ok { + htmlReporterCached = true + return + } + } + }) + return htmlReporterCached +} + +// GetHTMLReport renders an HTML report for the given observation key and raw JSON data. +// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not. +func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + return getHTMLReport(sdk.FindObservationProvider(key), key, raw) +} + +// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through +// the ObservationContext, respecting per-context overrides. +func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + return getHTMLReport(oc.getProvider(key), key, raw) +} + +func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + if provider == nil { + return "", false, fmt.Errorf("no observation provider registered for key %q", key) + } + + hr, ok := provider.(happydns.CheckerHTMLReporter) + if !ok { + return "", false, nil + } + html, err := hr.GetHTMLReport(raw) + return html, true, err +} + +// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter. +func HasMetricsReporter() bool { + metricsReporterOnce.Do(func() { + for _, p := range sdk.GetObservationProviders() { + if _, ok := p.(happydns.CheckerMetricsReporter); ok { + metricsReporterCached = true + return + } + } + }) + return metricsReporterCached +} + +// GetMetrics extracts metrics for the given observation key and raw JSON data. +// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not. +func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt) +} + +// GetMetricsCtx is like GetMetrics but resolves the provider through +// the ObservationContext, respecting per-context overrides. +func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + return getMetrics(oc.getProvider(key), key, raw, collectedAt) +} + +func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + if provider == nil { + return nil, false, fmt.Errorf("no observation provider registered for key %q", key) + } + + mr, ok := provider.(happydns.CheckerMetricsReporter) + if !ok { + return nil, false, nil + } + metrics, err := mr.ExtractMetrics(raw, collectedAt) + return metrics, true, err +} + +// GetAllMetrics extracts metrics from all observation keys in a snapshot. +func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) { + var allMetrics []happydns.CheckMetric + var errs []error + for key, raw := range snap.Data { + metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt) + if err != nil { + errs = append(errs, fmt.Errorf("observation %q: %w", key, err)) + continue + } + if !supported { + continue + } + allMetrics = append(allMetrics, metrics...) + } + return allMetrics, errors.Join(errs...) +} diff --git a/internal/checker/observation_test.go b/internal/checker/observation_test.go new file mode 100644 index 00000000..bbd7b6a2 --- /dev/null +++ b/internal/checker/observation_test.go @@ -0,0 +1,180 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "git.happydns.org/happyDomain/model" +) + +// blockingProvider is an ObservationProvider whose Collect blocks on the +// release channel until the test signals it. It records how many concurrent +// Collect calls are in flight at any moment. +type blockingProvider struct { + key happydns.ObservationKey + release chan struct{} + calls int32 + maxCalls int32 +} + +func (b *blockingProvider) Key() happydns.ObservationKey { return b.key } + +func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) { + atomic.AddInt32(&b.calls, 1) + defer atomic.AddInt32(&b.calls, -1) + for { + current := atomic.LoadInt32(&b.calls) + max := atomic.LoadInt32(&b.maxCalls) + if current > max { + if atomic.CompareAndSwapInt32(&b.maxCalls, max, current) { + break + } + continue + } + break + } + select { + case <-b.release: + return map[string]string{string(b.key): "ok"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls +// for distinct observation keys can run their Collect concurrently, i.e. +// the per-context lock is not held across provider.Collect. +func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) { + release := make(chan struct{}) + defer close(release) + + pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release} + pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release} + + oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0) + oc.SetProviderOverride(pa.key, pa) + oc.SetProviderOverride(pb.key, pb) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + results := make([]error, 2) + for i, key := range []happydns.ObservationKey{pa.key, pb.key} { + wg.Add(1) + go func(idx int, k happydns.ObservationKey) { + defer wg.Done() + var dst map[string]string + results[idx] = oc.Get(ctx, k, &dst) + }(i, key) + } + + // Wait until both providers are blocked inside Collect simultaneously. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 { + t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b) + } + + // Release both Collects and wait for the Get calls to return. + release <- struct{}{} + release <- struct{}{} + wg.Wait() + + for i, err := range results { + if err != nil { + t.Errorf("Get %d returned error: %v", i, err) + } + } +} + +// TestObservationContext_DedupesSameKey verifies that concurrent Get calls +// for the *same* key only invoke provider.Collect once. +func TestObservationContext_DedupesSameKey(t *testing.T) { + release := make(chan struct{}) + + var collectCount int32 + prov := &countingProvider{ + key: happydns.ObservationKey("test-dedup"), + release: release, + count: &collectCount, + } + + oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0) + oc.SetProviderOverride(prov.key, prov) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const N = 8 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func() { + defer wg.Done() + var dst map[string]string + if err := oc.Get(ctx, prov.key, &dst); err != nil { + t.Errorf("Get error: %v", err) + } + }() + } + + // Wait for at least one collect to be in flight, then release it. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 { + time.Sleep(5 * time.Millisecond) + } + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&collectCount); got != 1 { + t.Errorf("expected exactly 1 Collect call, got %d", got) + } +} + +type countingProvider struct { + key happydns.ObservationKey + release chan struct{} + count *int32 +} + +func (c *countingProvider) Key() happydns.ObservationKey { return c.key } + +func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) { + atomic.AddInt32(c.count, 1) + select { + case <-c.release: + return map[string]string{"k": "v"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} diff --git a/internal/checker/registry.go b/internal/checker/registry.go new file mode 100644 index 00000000..b8db47aa --- /dev/null +++ b/internal/checker/registry.go @@ -0,0 +1,60 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/model" +) + +// The checker definition registry lives in the Apache-2.0 licensed +// checker-sdk-go module, so external plugins can register themselves +// without depending on AGPL code. These wrappers preserve the existing +// happyDomain call sites. + +// RegisterChecker registers a checker definition globally. +func RegisterChecker(c *happydns.CheckerDefinition) { + sdk.RegisterChecker(c) +} + +// RegisterExternalizableChecker registers a checker that supports being +// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt +// so the administrator can optionally configure a remote URL. +// When the endpoint is left empty, the checker runs locally as usual. +func RegisterExternalizableChecker(c *happydns.CheckerDefinition) { + sdk.RegisterExternalizableChecker(c) +} + +// RegisterObservationProvider registers an observation provider globally. +func RegisterObservationProvider(p happydns.ObservationProvider) { + sdk.RegisterObservationProvider(p) +} + +// GetCheckers returns all registered checker definitions. +func GetCheckers() map[string]*happydns.CheckerDefinition { + return sdk.GetCheckers() +} + +// FindChecker returns the checker definition with the given ID, or nil. +func FindChecker(id string) *happydns.CheckerDefinition { + return sdk.FindChecker(id) +} diff --git a/model/checker.go b/model/checker.go new file mode 100644 index 00000000..9b8ee631 --- /dev/null +++ b/model/checker.go @@ -0,0 +1,287 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package happydns + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// The types and helpers needed by external checker plugins live in the +// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as +// aliases so the rest of the happyDomain codebase keeps relying on this model. +// +// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain +// defined in this file because they describe orchestration state that is +// internal to the happyDomain server and never crosses the plugin boundary. + +// --- Re-exports from checker-sdk-go --- + +type CheckScopeType = sdk.CheckScopeType + +const ( + CheckScopeAdmin = sdk.CheckScopeAdmin + CheckScopeUser = sdk.CheckScopeUser + CheckScopeDomain = sdk.CheckScopeDomain + CheckScopeZone = sdk.CheckScopeZone + CheckScopeService = sdk.CheckScopeService +) + +const ( + AutoFillDomainName = sdk.AutoFillDomainName + AutoFillSubdomain = sdk.AutoFillSubdomain + AutoFillZone = sdk.AutoFillZone + AutoFillServiceType = sdk.AutoFillServiceType + AutoFillService = sdk.AutoFillService +) + +type ( + CheckTarget = sdk.CheckTarget + CheckerAvailability = sdk.CheckerAvailability + CheckerOptions = sdk.CheckerOptions + CheckerOptionDocumentation = sdk.CheckerOptionDocumentation + CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation + Status = sdk.Status + CheckState = sdk.CheckState + CheckMetric = sdk.CheckMetric + ObservationKey = sdk.ObservationKey + CheckIntervalSpec = sdk.CheckIntervalSpec + ObservationProvider = sdk.ObservationProvider + CheckRuleInfo = sdk.CheckRuleInfo + CheckRule = sdk.CheckRule + CheckRuleWithOptions = sdk.CheckRuleWithOptions + ObservationGetter = sdk.ObservationGetter + CheckAggregator = sdk.CheckAggregator + CheckerHTMLReporter = sdk.CheckerHTMLReporter + CheckerMetricsReporter = sdk.CheckerMetricsReporter + CheckerDefinitionProvider = sdk.CheckerDefinitionProvider + CheckerDefinition = sdk.CheckerDefinition + OptionsValidator = sdk.OptionsValidator + ExternalCollectRequest = sdk.ExternalCollectRequest + ExternalCollectResponse = sdk.ExternalCollectResponse + ExternalEvaluateRequest = sdk.ExternalEvaluateRequest + ExternalEvaluateResponse = sdk.ExternalEvaluateResponse + ExternalReportRequest = sdk.ExternalReportRequest +) + +const ( + StatusOK = sdk.StatusOK + StatusInfo = sdk.StatusInfo + StatusUnknown = sdk.StatusUnknown + StatusWarn = sdk.StatusWarn + StatusCrit = sdk.StatusCrit + StatusError = sdk.StatusError +) + +// --- Helpers for converting between target identifier strings and *Identifier --- + +// TargetIdentifier parses a target identifier string into an *Identifier. +// Returns nil if the string is empty or cannot be parsed. +func TargetIdentifier(s string) *Identifier { + if s == "" { + return nil + } + id, err := NewIdentifierFromString(s) + if err != nil { + return nil + } + return &id +} + +// FormatIdentifier returns the string representation of id, or "" if nil. +func FormatIdentifier(id *Identifier) string { + if id == nil { + return "" + } + return id.String() +} + +// --- Host-only types (orchestration state) --- + +// CheckerRunRequest is the JSON body for manually triggering a checker. +type CheckerRunRequest struct { + Options CheckerOptions `json:"options,omitempty"` + EnabledRules map[string]bool `json:"enabledRules,omitempty"` +} + +// CheckerOptionsPositional stores options with their positional key components. +type CheckerOptionsPositional struct { + CheckName string `json:"checkName"` + UserId *Identifier `json:"userId,omitempty"` + DomainId *Identifier `json:"domainId,omitempty"` + ServiceId *Identifier `json:"serviceId,omitempty"` + + Options CheckerOptions `json:"options"` +} + +// CheckPlan is an optional user override for a checker on a specific target. +type CheckPlan struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + CheckerID string `json:"checkerId" binding:"required" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"` + Enabled map[string]bool `json:"enabled,omitempty"` +} + +// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false. +func (p *CheckPlan) IsFullyDisabled() bool { + if len(p.Enabled) == 0 { + return false + } + for _, v := range p.Enabled { + if v { + return false + } + } + return true +} + +// IsRuleEnabled returns whether a specific rule is enabled. +// A nil or empty map means all rules are enabled. A missing key means enabled. +func (p *CheckPlan) IsRuleEnabled(ruleName string) bool { + if len(p.Enabled) == 0 { + return true + } + v, ok := p.Enabled[ruleName] + if !ok { + return true + } + return v +} + +// CheckerStatus combines a checker definition with its latest execution and plan for a target. +type CheckerStatus struct { + *CheckerDefinition + LatestExecution *Execution `json:"latestExecution,omitempty"` + Plan *CheckPlan `json:"plan,omitempty"` + Enabled bool `json:"enabled"` + EnabledRules map[string]bool `json:"enabledRules"` +} + +// CheckEvaluation is the result of running a checker on observed data. +type CheckEvaluation struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"` + CheckerID string `json:"checkerId" binding:"required"` + Target CheckTarget `json:"target" binding:"required"` + SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"` + EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"` + States []CheckState `json:"states" binding:"required" readonly:"true"` +} + +// ObservationSnapshot holds data collected during an execution. +type ObservationSnapshot struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"` + Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"` +} + +// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot. +type ObservationCacheEntry struct { + SnapshotID Identifier `json:"snapshotId"` + CollectedAt time.Time `json:"collectedAt"` +} + +// ExecutionStatus represents the lifecycle state of an execution. +type ExecutionStatus int + +const ( + ExecutionPending ExecutionStatus = iota + ExecutionRunning + ExecutionDone + ExecutionFailed +) + +// TriggerType represents what initiated an execution. +type TriggerType int + +const ( + TriggerManual TriggerType = iota + TriggerSchedule +) + +// TriggerInfo describes the trigger for an execution. +type TriggerInfo struct { + Type TriggerType `json:"type"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"` +} + +// Execution represents a single run of a checker pipeline. +type Execution struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + CheckerID string `json:"checkerId" binding:"required" readonly:"true"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"` + StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"` + EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"` + Status ExecutionStatus `json:"status" binding:"required" readonly:"true"` + Error string `json:"error,omitempty" readonly:"true"` + Result CheckState `json:"result" readonly:"true"` + EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"` +} + +// CheckerEngine orchestrates the full checker pipeline. +type CheckerEngine interface { + CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error) + RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error) +} + +// CheckerOptionsKey builds the positional KV key for checker options. +// Format: chckrcfg-{checkerName}|{userId}|{domainId}|{serviceId} +func CheckerOptionsKey(checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) string { + return fmt.Sprintf("chckrcfg-%s|%s|%s|%s", checkerName, + FormatIdentifier(userId), FormatIdentifier(domainId), FormatIdentifier(serviceId)) +} + +// ParseCheckerOptionsKey extracts the positional components from a KV key. +func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) { + trimmed := strings.TrimPrefix(key, "chckrcfg-") + parts := strings.SplitN(trimmed, "|", 4) + if len(parts) < 4 { + return trimmed, nil, nil, nil + } + + checkerName = parts[0] + if parts[1] != "" { + if id, err := NewIdentifierFromString(parts[1]); err == nil { + userId = &id + } + } + if parts[2] != "" { + if id, err := NewIdentifierFromString(parts[2]); err == nil { + domainId = &id + } + } + if parts[3] != "" { + if id, err := NewIdentifierFromString(parts[3]); err == nil { + serviceId = &id + } + } + return +} diff --git a/model/form.go b/model/form.go index 1e95ec53..996ee67b 100644 --- a/model/form.go +++ b/model/form.go @@ -106,6 +106,25 @@ type Field struct { Description string `json:"description,omitempty"` } +// FieldFromCheckerOption converts a CheckerOptionDocumentation into a Field, +// mapping the common subset of attributes. Keep this in sync when either +// struct gains new fields. +func FieldFromCheckerOption(opt CheckerOptionDocumentation) Field { + return Field{ + Id: opt.Id, + Type: opt.Type, + Label: opt.Label, + Placeholder: opt.Placeholder, + Default: opt.Default, + Choices: opt.Choices, + Required: opt.Required, + Secret: opt.Secret, + Hide: opt.Hide, + Textarea: opt.Textarea, + Description: opt.Description, + } +} + type FormState struct { // Id for an already existing element. Id *Identifier `json:"_id,omitempty" swaggertype:"string"` From 2f5b494a80ff64c5836194a3d514f18d4d939dd4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 16:56:22 +0700 Subject: [PATCH 02/54] checkers: concurrent per-key observation collection with deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the RWMutex-based serialised collection with a per-key inflight channel model. The outer mutex now only guards the cache/errors/inflight maps for short critical sections — provider.Collect runs without the lock held, so two Get calls for different keys collect in parallel. Same-key concurrent calls are deduplicated: the first caller installs an inflight channel, runs collection, then closes it; others wait and read from the cache. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/checker/observation.go | 129 +++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 44 deletions(-) diff --git a/internal/checker/observation.go b/internal/checker/observation.go index 35c98407..65d5b674 100644 --- a/internal/checker/observation.go +++ b/internal/checker/observation.go @@ -57,12 +57,20 @@ type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.Obser // ObservationContext provides lazy-loading, cached, thread-safe access to observation data. // Collected data is serialized to json.RawMessage immediately after collection. +// +// Concurrency model: the outer mu protects only the cache/errors/inflight +// maps and is held for short critical sections. Provider collection runs +// *without* mu held, so two calls to Get for *different* keys can collect +// concurrently. Two calls for the *same* key are deduplicated: the first +// installs an inflight channel, runs the collection, then closes the +// channel; the others wait on it and read the cached result afterwards. type ObservationContext struct { target happydns.CheckTarget opts happydns.CheckerOptions cache map[happydns.ObservationKey]json.RawMessage errors map[happydns.ObservationKey]error - mu sync.RWMutex + inflight map[happydns.ObservationKey]chan struct{} + mu sync.Mutex cacheLookup ObservationCacheLookup // nil = no DB cache freshness time.Duration // 0 = always collect providerOverride map[happydns.ObservationKey]happydns.ObservationProvider @@ -77,6 +85,7 @@ func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOpt opts: opts, cache: make(map[happydns.ObservationKey]json.RawMessage), errors: make(map[happydns.ObservationKey]error), + inflight: make(map[happydns.ObservationKey]chan struct{}), cacheLookup: cacheLookup, freshness: freshness, } @@ -86,6 +95,8 @@ func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOpt // 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) { + oc.mu.Lock() + defer oc.mu.Unlock() if oc.providerOverride == nil { oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider) } @@ -94,9 +105,13 @@ func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p // getProvider returns the observation provider for the given key, checking // per-context overrides first, then falling back to the global registry. +// Safe to call without holding oc.mu — it acquires the lock internally. func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider { - if oc.providerOverride != nil { - if p, ok := oc.providerOverride[key]; ok { + oc.mu.Lock() + override := oc.providerOverride + oc.mu.Unlock() + if override != nil { + if p, ok := override[key]; ok { return p } } @@ -104,70 +119,96 @@ func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns. } // 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. +// Thread-safe: concurrent calls for the same key are deduplicated; concurrent +// calls for different keys collect in parallel. func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error { - // Fast path: check cache under read lock. - oc.mu.RLock() - if raw, ok := oc.cache[key]; ok { - oc.mu.RUnlock() + for { + oc.mu.Lock() + if raw, ok := oc.cache[key]; ok { + oc.mu.Unlock() + return json.Unmarshal(raw, dest) + } + if err, ok := oc.errors[key]; ok { + oc.mu.Unlock() + return err + } + if ch, ok := oc.inflight[key]; ok { + // Another goroutine is already collecting this key. Release + // the lock, wait for it to finish, then re-check the cache. + oc.mu.Unlock() + select { + case <-ch: + case <-ctx.Done(): + return ctx.Err() + } + continue + } + + // We are the leader for this key. Install the inflight channel + // before releasing the lock so concurrent callers wait on us. + ch := make(chan struct{}) + oc.inflight[key] = ch + oc.mu.Unlock() + + raw, collectErr := oc.collect(ctx, key) + + // Collection errors are cached for the lifetime of this + // ObservationContext (i.e. a single execution run). This is + // intentional: within one run the same transient failure would + // keep recurring, and retrying would slow down the pipeline. + // A new execution creates a fresh context, giving the provider + // another chance. + oc.mu.Lock() + if collectErr != nil { + oc.errors[key] = collectErr + } else { + oc.cache[key] = raw + } + delete(oc.inflight, key) + close(ch) + oc.mu.Unlock() + + if collectErr != nil { + return collectErr + } return json.Unmarshal(raw, dest) } - if err, ok := oc.errors[key]; ok { - oc.mu.RUnlock() - return err - } - oc.mu.RUnlock() +} - // Slow path: acquire write lock and collect. - oc.mu.Lock() - defer oc.mu.Unlock() - - // Double-check after acquiring write lock. - if raw, ok := oc.cache[key]; ok { - return json.Unmarshal(raw, dest) - } - if err, ok := oc.errors[key]; ok { - return err - } - - // Try DB cache before collecting fresh data. +// collect runs the DB-cache lookup and provider collection for a single key +// without holding oc.mu, so collections for different keys can run in +// parallel. Callers are responsible for installing the result into the cache +// or errors map and signalling waiters. +func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) { 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) + return raw, nil } } } provider := oc.getProvider(key) if provider == nil { - err := fmt.Errorf("no observation provider registered for key %q", key) - oc.errors[key] = err - return err + return nil, fmt.Errorf("no observation provider registered for key %q", key) } val, err := provider.Collect(ctx, oc.opts) if err != nil { - oc.errors[key] = err - return err + return nil, err } raw, err := json.Marshal(val) if err != nil { - err = fmt.Errorf("observation %q: marshal failed: %w", key, err) - oc.errors[key] = err - return err + return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err) } - - oc.cache[key] = json.RawMessage(raw) - return json.Unmarshal(raw, dest) + return json.RawMessage(raw), nil } // 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() + oc.mu.Lock() + defer oc.mu.Unlock() data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache)) for k, v := range oc.cache { @@ -181,9 +222,9 @@ func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage // "any provider implements X reporter" question has a fixed answer for the // process lifetime. We compute it once on first call and cache it. var ( - htmlReporterOnce sync.Once - htmlReporterCached bool - metricsReporterOnce sync.Once + htmlReporterOnce sync.Once + htmlReporterCached bool + metricsReporterOnce sync.Once metricsReporterCached bool ) From fd8d2d8080662ee311898a10d705842c79b9700e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:49:36 +0700 Subject: [PATCH 03/54] checkers: add map-based option validation for checker fields Add ValidateMapValues() to the forms package for validating checker option maps against field documentation (required fields, allowed choices, type checking). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/forms/field.go | 81 +++++++++++++++++++++++++ internal/forms/field_test.go | 113 +++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 internal/forms/field_test.go diff --git a/internal/forms/field.go b/internal/forms/field.go index e41f3723..55531337 100644 --- a/internal/forms/field.go +++ b/internal/forms/field.go @@ -127,6 +127,87 @@ func ValidateStructValues(data any) error { return nil } +// ValidateMapValues validates a map[string]any against a slice of Field definitions. +// It checks required fields, choices constraints, basic type compatibility, +// and rejects unknown keys not declared in any field definition. +func ValidateMapValues(opts map[string]any, fields []happydns.Field) error { + known := make(map[string]*happydns.Field, len(fields)) + for i := range fields { + known[fields[i].Id] = &fields[i] + } + + // Reject unknown keys. + for k := range opts { + if _, ok := known[k]; !ok { + return fmt.Errorf("unknown option %q", k) + } + } + + for _, f := range fields { + v, exists := opts[f.Id] + + label := f.Label + if label == "" { + label = f.Id + } + + // Required check. + if f.Required { + if !exists || v == nil { + return fmt.Errorf("field %q is required", label) + } + if s, ok := v.(string); ok && s == "" { + return fmt.Errorf("field %q is required", label) + } + } + + if !exists || v == nil { + continue + } + + // Choices check. + if len(f.Choices) > 0 { + s, ok := v.(string) + if !ok { + return fmt.Errorf("field %q: expected a string value for choices field", label) + } + if s != "" && !slices.Contains(f.Choices, s) { + return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, f.Choices) + } + } + + // Basic type check. + if f.Type != "" { + if err := checkMapValueType(f.Type, v, label); err != nil { + return err + } + } + } + + return nil +} + +// checkMapValueType performs a basic type compatibility check between a Field.Type +// string and the actual value from a map[string]any (JSON-decoded). +func checkMapValueType(fieldType string, value any, label string) error { + switch { + case strings.HasPrefix(fieldType, "string"): + if _, ok := value.(string); !ok { + return fmt.Errorf("field %q: expected string, got %T", label, value) + } + case strings.HasPrefix(fieldType, "int") || strings.HasPrefix(fieldType, "uint") || strings.HasPrefix(fieldType, "float"): + // JSON numbers decode as float64. + if _, ok := value.(float64); !ok { + return fmt.Errorf("field %q: expected number, got %T", label, value) + } + case fieldType == "bool": + if _, ok := value.(bool); !ok { + return fmt.Errorf("field %q: expected bool, got %T", label, value) + } + } + return nil +} + // GenStructFields generates corresponding SourceFields of the given Source. func GenStructFields(data any) (fields []*happydns.Field) { if data != nil { diff --git a/internal/forms/field_test.go b/internal/forms/field_test.go new file mode 100644 index 00000000..57a9cded --- /dev/null +++ b/internal/forms/field_test.go @@ -0,0 +1,113 @@ +package forms + +import ( + "testing" + + happydns "git.happydns.org/happyDomain/model" +) + +func TestValidateMapValues_Required(t *testing.T) { + fields := []happydns.Field{ + {Id: "name", Type: "string", Required: true, Label: "Name"}, + } + + // Missing required field. + if err := ValidateMapValues(map[string]any{}, fields); err == nil { + t.Fatal("expected error for missing required field") + } + + // Nil value. + if err := ValidateMapValues(map[string]any{"name": nil}, fields); err == nil { + t.Fatal("expected error for nil required field") + } + + // Empty string value. + if err := ValidateMapValues(map[string]any{"name": ""}, fields); err == nil { + t.Fatal("expected error for empty string required field") + } + + // Valid value. + if err := ValidateMapValues(map[string]any{"name": "hello"}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateMapValues_Choices(t *testing.T) { + fields := []happydns.Field{ + {Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}}, + } + + if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil { + t.Fatal("expected error for invalid choice") + } + + // Empty string is allowed (field not required). + if err := ValidateMapValues(map[string]any{"color": ""}, fields); err != nil { + t.Fatalf("unexpected error for empty choice: %v", err) + } +} + +func TestValidateMapValues_TypeCheck(t *testing.T) { + fields := []happydns.Field{ + {Id: "count", Type: "int"}, + {Id: "label", Type: "string"}, + {Id: "enabled", Type: "bool"}, + } + + // Valid types. + if err := ValidateMapValues(map[string]any{"count": float64(5), "label": "test", "enabled": true}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wrong type for int field. + if err := ValidateMapValues(map[string]any{"count": "notanumber"}, fields); err == nil { + t.Fatal("expected error for wrong type on int field") + } + + // Wrong type for string field. + if err := ValidateMapValues(map[string]any{"label": float64(42)}, fields); err == nil { + t.Fatal("expected error for wrong type on string field") + } + + // Wrong type for bool field. + if err := ValidateMapValues(map[string]any{"enabled": "yes"}, fields); err == nil { + t.Fatal("expected error for wrong type on bool field") + } +} + +func TestValidateMapValues_UnknownKeys(t *testing.T) { + fields := []happydns.Field{ + {Id: "name", Type: "string"}, + } + + if err := ValidateMapValues(map[string]any{"name": "ok", "unknown": "bad"}, fields); err == nil { + t.Fatal("expected error for unknown key") + } +} + +func TestValidateMapValues_EmptyFieldsAndOpts(t *testing.T) { + // No fields defined, empty options: valid. + if err := ValidateMapValues(map[string]any{}, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // No fields defined, but has options: rejected as unknown. + if err := ValidateMapValues(map[string]any{"x": 1}, nil); err == nil { + t.Fatal("expected error for unknown key with no fields") + } +} + +func TestValidateMapValues_ChoicesNonString(t *testing.T) { + fields := []happydns.Field{ + {Id: "mode", Type: "string", Choices: []string{"a", "b"}}, + } + + // Non-string value on a choices field. + if err := ValidateMapValues(map[string]any{"mode": float64(1)}, fields); err == nil { + t.Fatal("expected error for non-string choices value") + } +} From 3ddf621c559cb3a4612fb62804fd6c1798b6e5d9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:52:48 +0700 Subject: [PATCH 04/54] checkers: add storage interfaces and implementations Add the persistence layer for the checker system: - Storage interfaces (CheckPlanStorage, CheckerOptionsStorage, CheckEvaluationStorage, ExecutionStorage, ObservationSnapshotStorage, SchedulerStateStorage) in the usecase/checker package - KV-based implementations for LevelDB/Oracle NoSQL backends - In-memory implementation for testing - Integrate checker storage into the main Storage interface Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/storage/inmemory/checker.go | 579 ++++++++++++++++++ internal/storage/inmemory/database.go | 12 + internal/storage/interface.go | 7 + internal/storage/kvtpl/check_evaluation.go | 189 ++++++ internal/storage/kvtpl/check_plan.go | 126 ++++ internal/storage/kvtpl/checker_options.go | 119 ++++ internal/storage/kvtpl/execution.go | 188 ++++++ .../storage/kvtpl/observation_snapshot.go | 63 ++ internal/storage/kvtpl/scheduler_state.go | 44 ++ internal/usecase/checker/doc.go | 23 + internal/usecase/checker/storage.go | 108 ++++ model/errors.go | 23 +- 12 files changed, 1472 insertions(+), 9 deletions(-) create mode 100644 internal/storage/inmemory/checker.go create mode 100644 internal/storage/kvtpl/check_evaluation.go create mode 100644 internal/storage/kvtpl/check_plan.go create mode 100644 internal/storage/kvtpl/checker_options.go create mode 100644 internal/storage/kvtpl/execution.go create mode 100644 internal/storage/kvtpl/observation_snapshot.go create mode 100644 internal/storage/kvtpl/scheduler_state.go create mode 100644 internal/usecase/checker/doc.go create mode 100644 internal/usecase/checker/storage.go diff --git a/internal/storage/inmemory/checker.go b/internal/storage/inmemory/checker.go new file mode 100644 index 00000000..44ade511 --- /dev/null +++ b/internal/storage/inmemory/checker.go @@ -0,0 +1,579 @@ +// 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 . +// +// 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 . + +package inmemory + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.happydns.org/happyDomain/model" +) + +// sliceIterator implements happydns.Iterator[T] over an in-memory slice. +type sliceIterator[T any] struct { + keys []string + items []*T + index int + deleteFn func(key string) error +} + +func newSliceIterator[T any](keys []string, items []*T, deleteFn func(key string) error) *sliceIterator[T] { + return &sliceIterator[T]{keys: keys, items: items, index: -1, deleteFn: deleteFn} +} + +func (it *sliceIterator[T]) Next() bool { + it.index++ + return it.index < len(it.items) +} + +func (it *sliceIterator[T]) NextWithError() bool { return it.Next() } + +func (it *sliceIterator[T]) Item() *T { + if it.index < 0 || it.index >= len(it.items) { + return nil + } + return it.items[it.index] +} + +func (it *sliceIterator[T]) DropItem() error { + if it.index < 0 || it.index >= len(it.keys) { + return fmt.Errorf("DropItem: iterator is not valid") + } + if it.deleteFn != nil { + return it.deleteFn(it.keys[it.index]) + } + return nil +} + +func (it *sliceIterator[T]) Key() string { + if it.index < 0 || it.index >= len(it.keys) { + return "" + } + return it.keys[it.index] +} + +func (it *sliceIterator[T]) Raw() any { return it.Item() } +func (it *sliceIterator[T]) Err() error { return nil } +func (it *sliceIterator[T]) Close() {} + + +// --- CheckPlanStorage --- + +func (s *InMemoryStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + s.mu.Lock() + defer s.mu.Unlock() + + keys := make([]string, 0, len(s.checkPlans)) + items := make([]*happydns.CheckPlan, 0, len(s.checkPlans)) + for k, p := range s.checkPlans { + keys = append(keys, k) + cp := *p + items = append(items, &cp) + } + return newSliceIterator(keys, items, func(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkPlans, key) + return nil + }), nil +} + +func (s *InMemoryStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.Target.String() == target.String() { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.CheckerID == checkerID { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.Target.UserId == userId.String() { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.checkPlans[planID.String()] + if !ok { + return nil, happydns.ErrCheckPlanNotFound + } + cp := *p + return &cp, nil +} + +func (s *InMemoryStorage) CreateCheckPlan(plan *happydns.CheckPlan) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + plan.Id = id + cp := *plan + s.checkPlans[id.String()] = &cp + return nil +} + +func (s *InMemoryStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error { + s.mu.Lock() + defer s.mu.Unlock() + + cp := *plan + s.checkPlans[plan.Id.String()] = &cp + return nil +} + +func (s *InMemoryStorage) DeleteCheckPlan(planID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkPlans, planID.String()) + return nil +} + +func (s *InMemoryStorage) ClearCheckPlans() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.checkPlans = make(map[string]*happydns.CheckPlan) + return nil +} + +// --- CheckerOptionsStorage --- + +func (s *InMemoryStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + s.mu.Lock() + defer s.mu.Unlock() + + keys := make([]string, 0, len(s.checkerOptions)) + items := make([]*happydns.CheckerOptions, 0, len(s.checkerOptions)) + for k, opts := range s.checkerOptions { + keys = append(keys, k) + co := opts + items = append(items, &co) + } + return newSliceIterator(keys, items, func(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkerOptions, key) + return nil + }), nil +} + +func (s *InMemoryStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + prefix := fmt.Sprintf("chckrcfg-%s/", checkerName) + + s.mu.Lock() + defer s.mu.Unlock() + + var results []*happydns.CheckerOptionsPositional + for k, opts := range s.checkerOptions { + if !strings.HasPrefix(k, prefix) { + continue + } + cn, uid, did, sid := happydns.ParseCheckerOptionsKey(k) + co := opts + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: co, + }) + } + return results, nil +} + +func (s *InMemoryStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var results []*happydns.CheckerOptionsPositional + + tryGet := func(cn string, uid, did, sid *happydns.Identifier) { + key := happydns.CheckerOptionsKey(cn, uid, did, sid) + if opts, ok := s.checkerOptions[key]; ok { + co := opts + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: co, + }) + } + } + + tryGet(checkerName, nil, nil, nil) + if userId != nil { + tryGet(checkerName, userId, nil, nil) + } + if userId != nil && domainId != nil { + tryGet(checkerName, userId, domainId, nil) + } + if userId != nil && domainId != nil && serviceId != nil { + tryGet(checkerName, userId, domainId, serviceId) + } + return results, nil +} + +func (s *InMemoryStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + + s.mu.Lock() + defer s.mu.Unlock() + + s.checkerOptions[key] = opts + return nil +} + +func (s *InMemoryStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkerOptions, key) + return nil +} + +func (s *InMemoryStorage) ClearCheckerConfigurations() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.checkerOptions = make(map[string]happydns.CheckerOptions) + return nil +} + +// --- CheckEvaluationStorage --- + +func (s *InMemoryStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var evals []*happydns.CheckEvaluation + for _, e := range s.evaluations { + if e.PlanID != nil && e.PlanID.String() == planID.String() { + ce := *e + evals = append(evals, &ce) + } + } + return evals, nil +} + +func (s *InMemoryStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + var evals []*happydns.CheckEvaluation + for _, e := range s.evaluations { + if e.CheckerID == checkerID && e.Target.String() == tStr { + ce := *e + evals = append(evals, &ce) + } + } + + sort.Slice(evals, func(i, j int) bool { + return evals[i].EvaluatedAt.After(evals[j].EvaluatedAt) + }) + if limit > 0 && len(evals) > limit { + evals = evals[:limit] + } + return evals, nil +} + +func (s *InMemoryStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.evaluations[evalID.String()] + if !ok { + return nil, happydns.ErrCheckEvaluationNotFound + } + ce := *e + return &ce, nil +} + +func (s *InMemoryStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) { + evals, err := s.ListEvaluationsByPlan(planID) + if err != nil { + return nil, err + } + if len(evals) == 0 { + return nil, happydns.ErrCheckEvaluationNotFound + } + latest := evals[0] + for _, e := range evals[1:] { + if e.EvaluatedAt.After(latest.EvaluatedAt) { + latest = e + } + } + return latest, nil +} + +func (s *InMemoryStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + eval.Id = id + ce := *eval + s.evaluations[id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) DeleteEvaluation(evalID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.evaluations, evalID.String()) + return nil +} + +func (s *InMemoryStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + for k, e := range s.evaluations { + if e.CheckerID == checkerID && e.Target.String() == tStr { + delete(s.evaluations, k) + } + } + return nil +} + +func (s *InMemoryStorage) ClearEvaluations() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.evaluations = make(map[string]*happydns.CheckEvaluation) + return nil +} + +// --- ExecutionStorage --- + +func (s *InMemoryStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var execs []*happydns.Execution + for _, e := range s.executions { + if e.PlanID != nil && e.PlanID.String() == planID.String() { + ce := *e + execs = append(execs, &ce) + } + } + return execs, nil +} + +func (s *InMemoryStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + var execs []*happydns.Execution + for _, e := range s.executions { + if e.CheckerID == checkerID && e.Target.String() == tStr { + ce := *e + execs = append(execs, &ce) + } + } + + sort.Slice(execs, func(i, j int) bool { + return execs[i].StartedAt.After(execs[j].StartedAt) + }) + if limit > 0 && len(execs) > limit { + execs = execs[:limit] + } + return execs, nil +} + +func (s *InMemoryStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.executions[execID.String()] + if !ok { + return nil, happydns.ErrExecutionNotFound + } + ce := *e + return &ce, nil +} + +func (s *InMemoryStorage) CreateExecution(exec *happydns.Execution) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + exec.Id = id + ce := *exec + s.executions[id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) UpdateExecution(exec *happydns.Execution) error { + s.mu.Lock() + defer s.mu.Unlock() + + ce := *exec + s.executions[exec.Id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) DeleteExecution(execID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.executions, execID.String()) + return nil +} + +func (s *InMemoryStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + for k, e := range s.executions { + if e.CheckerID == checkerID && e.Target.String() == tStr { + delete(s.executions, k) + } + } + return nil +} + +func (s *InMemoryStorage) ClearExecutions() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.executions = make(map[string]*happydns.Execution) + return nil +} + +// --- ObservationSnapshotStorage --- + +func (s *InMemoryStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + + snap, ok := s.snapshots[snapID.String()] + if !ok { + return nil, happydns.ErrSnapshotNotFound + } + cs := *snap + return &cs, nil +} + +func (s *InMemoryStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + snap.Id = id + cs := *snap + s.snapshots[id.String()] = &cs + return nil +} + +func (s *InMemoryStorage) DeleteSnapshot(snapID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshots, snapID.String()) + return nil +} + +func (s *InMemoryStorage) ClearSnapshots() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.snapshots = make(map[string]*happydns.ObservationSnapshot) + return nil +} + +// --- SchedulerStateStorage --- + +func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.schedulerLastRun == nil { + return time.Time{}, nil + } + return *s.schedulerLastRun, nil +} + +func (s *InMemoryStorage) SetLastSchedulerRun(t time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.schedulerLastRun = &t + return nil +} diff --git a/internal/storage/inmemory/database.go b/internal/storage/inmemory/database.go index a0bd9e4e..a85278b2 100644 --- a/internal/storage/inmemory/database.go +++ b/internal/storage/inmemory/database.go @@ -50,6 +50,13 @@ type InMemoryStorage struct { zones map[string]*happydns.ZoneMessage lastInsightsRun *time.Time lastInsightsID happydns.Identifier + // Checker-related storage + checkPlans map[string]*happydns.CheckPlan + checkerOptions map[string]happydns.CheckerOptions + evaluations map[string]*happydns.CheckEvaluation + executions map[string]*happydns.Execution + snapshots map[string]*happydns.ObservationSnapshot + schedulerLastRun *time.Time } // NewInMemoryStorage creates a new instance of InMemoryStorage. @@ -66,6 +73,11 @@ func NewInMemoryStorage() (*InMemoryStorage, error) { users: make(map[string]*happydns.User), usersByEmail: make(map[string]*happydns.User), zones: make(map[string]*happydns.ZoneMessage), + checkPlans: make(map[string]*happydns.CheckPlan), + checkerOptions: make(map[string]happydns.CheckerOptions), + evaluations: make(map[string]*happydns.CheckEvaluation), + executions: make(map[string]*happydns.Execution), + snapshots: make(map[string]*happydns.ObservationSnapshot), }, nil } diff --git a/internal/storage/interface.go b/internal/storage/interface.go index bc2da7bc..fbcb9245 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage" import ( "git.happydns.org/happyDomain/internal/usecase/authuser" + "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/internal/usecase/domain" "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/insight" @@ -40,6 +41,12 @@ type ProviderAndDomainStorage interface { type Storage interface { authuser.AuthUserStorage + checker.CheckPlanStorage + checker.CheckerOptionsStorage + checker.CheckEvaluationStorage + checker.ExecutionStorage + checker.ObservationSnapshotStorage + checker.SchedulerStateStorage domain.DomainStorage domainlog.DomainLogStorage insight.InsightStorage diff --git a/internal/storage/kvtpl/check_evaluation.go b/internal/storage/kvtpl/check_evaluation.go new file mode 100644 index 00000000..17675f6a --- /dev/null +++ b/internal/storage/kvtpl/check_evaluation.go @@ -0,0 +1,189 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + "sort" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) { + prefix := fmt.Sprintf("chckeval-plan|%s|", planID.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var evals []*happydns.CheckEvaluation + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + evals = append(evals, &eval) + } + return evals, nil +} + +func (s *KVStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) { + eval := &happydns.CheckEvaluation{} + err := s.db.Get(fmt.Sprintf("chckeval-%s", evalID.String()), eval) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckEvaluationNotFound + } + return eval, err +} + +func (s *KVStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) { + evals, err := s.ListEvaluationsByPlan(planID) + if err != nil { + return nil, err + } + if len(evals) == 0 { + return nil, happydns.ErrCheckEvaluationNotFound + } + + latest := evals[0] + for _, e := range evals[1:] { + if e.EvaluatedAt.After(latest.EvaluatedAt) { + latest = e + } + } + return latest, nil +} + +func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) { + prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var evals []*happydns.CheckEvaluation + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + evals = append(evals, &eval) + } + + // Sort by EvaluatedAt descending (most recent first). + sort.Slice(evals, func(i, j int) bool { + return evals[i].EvaluatedAt.After(evals[j].EvaluatedAt) + }) + + if limit > 0 && len(evals) > limit { + evals = evals[:limit] + } + return evals, nil +} + +func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error { + key, id, err := s.db.FindIdentifierKey("chckeval-") + if err != nil { + return err + } + eval.Id = id + + // Store the primary record. + if err := s.db.Put(key, eval); err != nil { + return err + } + + // Store secondary index by plan if applicable. + if eval.PlanID != nil { + indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + if err := s.db.Put(indexKey, eval); err != nil { + return err + } + } + + // Store secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String()) + if err := s.db.Put(checkerIndexKey, eval); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error { + // Load first to find plan ID for index cleanup. + eval, err := s.GetEvaluation(evalID) + if err != nil { + return err + } + + if eval.PlanID != nil { + indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + _ = s.db.Delete(indexKey) + } + + // Clean up checker+target index. + checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String()) + _ = s.db.Delete(checkerIndexKey) + + return s.db.Delete(fmt.Sprintf("chckeval-%s", evalID.String())) +} + +func (s *KVStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error { + prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + + // Delete primary record. + _ = s.db.Delete(fmt.Sprintf("chckeval-%s", eval.Id.String())) + + // Delete plan index if applicable. + if eval.PlanID != nil { + planIndexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + _ = s.db.Delete(planIndexKey) + } + + // Delete this checker index entry. + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} + +func (s *KVStorage) ClearEvaluations() error { + // A single prefix scan covers primary records and all secondary indexes + // (chckeval-plan|... and chckeval-chkr|...) in one pass, avoiding + // double-delete errors that occurred when the indexes were deleted first + // and then matched again by the broader "chckeval-" prefix. + iter := s.db.Search("chckeval-") + defer iter.Release() + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/check_plan.go b/internal/storage/kvtpl/check_plan.go new file mode 100644 index 00000000..2a31e864 --- /dev/null +++ b/internal/storage/kvtpl/check_plan.go @@ -0,0 +1,126 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + iter := s.db.Search("chckpln-") + return NewKVIterator[happydns.CheckPlan](s.db, iter), nil +} + +func (s *KVStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.Target.String() == target.String() { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.CheckerID == checkerID { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.Target.UserId == userId.String() { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + plan := &happydns.CheckPlan{} + err := s.db.Get(fmt.Sprintf("chckpln-%s", planID.String()), plan) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckPlanNotFound + } + return plan, err +} + +func (s *KVStorage) CreateCheckPlan(plan *happydns.CheckPlan) error { + key, id, err := s.db.FindIdentifierKey("chckpln-") + if err != nil { + return err + } + plan.Id = id + return s.db.Put(key, plan) +} + +func (s *KVStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error { + return s.db.Put(fmt.Sprintf("chckpln-%s", plan.Id.String()), plan) +} + +func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("chckpln-%s", planID.String())) +} + +func (s *KVStorage) ClearCheckPlans() error { + iter, err := s.ListAllCheckPlans() + if err != nil { + return err + } + defer iter.Close() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/checker_options.go b/internal/storage/kvtpl/checker_options.go new file mode 100644 index 00000000..0dabe99f --- /dev/null +++ b/internal/storage/kvtpl/checker_options.go @@ -0,0 +1,119 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "fmt" + + "git.happydns.org/happyDomain/model" +) + + + +func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + iter := s.db.Search("chckrcfg-") + return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil +} + +func (s *KVStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + prefix := fmt.Sprintf("chckrcfg-%s/", checkerName) + iter := s.db.Search(prefix) + defer iter.Release() + + var results []*happydns.CheckerOptionsPositional + for iter.Next() { + var opts happydns.CheckerOptions + if err := s.db.DecodeData(iter.Value(), &opts); err != nil { + continue + } + + cn, uid, did, sid := happydns.ParseCheckerOptionsKey(iter.Key()) + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: opts, + }) + } + return results, nil +} + +func (s *KVStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + var results []*happydns.CheckerOptionsPositional + + // Try each scope level from admin up to the requested specificity. + scopes := []struct { + uid, did, sid *happydns.Identifier + }{ + {nil, nil, nil}, + {userId, nil, nil}, + {userId, domainId, nil}, + {userId, domainId, serviceId}, + } + + for _, sc := range scopes { + // Skip levels that require identifiers not provided. + if (sc.uid != nil && userId == nil) || (sc.did != nil && domainId == nil) || (sc.sid != nil && serviceId == nil) { + continue + } + + key := happydns.CheckerOptionsKey(checkerName, sc.uid, sc.did, sc.sid) + var opts happydns.CheckerOptions + if err := s.db.Get(key, &opts); err == nil { + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, + UserId: sc.uid, + DomainId: sc.did, + ServiceId: sc.sid, + Options: opts, + }) + } + } + + return results, nil +} + +func (s *KVStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + return s.db.Put(key, opts) +} + +func (s *KVStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + return s.db.Delete(key) +} + +func (s *KVStorage) ClearCheckerConfigurations() error { + iter, err := s.ListAllCheckerConfigurations() + if err != nil { + return err + } + defer iter.Close() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/execution.go b/internal/storage/kvtpl/execution.go new file mode 100644 index 00000000..e4c2d58e --- /dev/null +++ b/internal/storage/kvtpl/execution.go @@ -0,0 +1,188 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + "sort" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) { + prefix := fmt.Sprintf("chckexec-plan|%s|", planID.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var execs []*happydns.Execution + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + execs = append(execs, &exec) + } + return execs, nil +} + +func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var execs []*happydns.Execution + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + execs = append(execs, &exec) + } + + sort.Slice(execs, func(i, j int) bool { + return execs[i].StartedAt.After(execs[j].StartedAt) + }) + + if limit > 0 && len(execs) > limit { + execs = execs[:limit] + } + return execs, nil +} + +func (s *KVStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + exec := &happydns.Execution{} + err := s.db.Get(fmt.Sprintf("chckexec-%s", execID.String()), exec) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrExecutionNotFound + } + return exec, err +} + +func (s *KVStorage) CreateExecution(exec *happydns.Execution) error { + key, id, err := s.db.FindIdentifierKey("chckexec-") + if err != nil { + return err + } + exec.Id = id + + if err := s.db.Put(key, exec); err != nil { + return err + } + + // Secondary index by plan. + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + if err := s.db.Put(indexKey, exec); err != nil { + return err + } + } + + // Secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String()) + if err := s.db.Put(checkerIndexKey, exec); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error { + if err := s.db.Put(fmt.Sprintf("chckexec-%s", exec.Id.String()), exec); err != nil { + return err + } + + // Update secondary index by plan if applicable. + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + if err := s.db.Put(indexKey, exec); err != nil { + return err + } + } + + // Update secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String()) + if err := s.db.Put(checkerIndexKey, exec); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) DeleteExecution(execID happydns.Identifier) error { + exec, err := s.GetExecution(execID) + if err != nil { + return err + } + + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), execID.String()) + if err := s.db.Delete(indexKey); err != nil { + return err + } + } + + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), execID.String()) + if err := s.db.Delete(checkerIndexKey); err != nil { + return err + } + + return s.db.Delete(fmt.Sprintf("chckexec-%s", execID.String())) +} + +func (s *KVStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + + _ = s.db.Delete(fmt.Sprintf("chckexec-%s", exec.Id.String())) + + if exec.PlanID != nil { + planIndexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + _ = s.db.Delete(planIndexKey) + } + + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} + +func (s *KVStorage) ClearExecutions() error { + // A single prefix scan covers primary records and all secondary indexes + // (chckexec-plan|... and chckexec-chkr|...) in one pass. + iter := s.db.Search("chckexec-") + defer iter.Release() + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/observation_snapshot.go b/internal/storage/kvtpl/observation_snapshot.go new file mode 100644 index 00000000..1768cadd --- /dev/null +++ b/internal/storage/kvtpl/observation_snapshot.go @@ -0,0 +1,63 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + snap := &happydns.ObservationSnapshot{} + err := s.db.Get(fmt.Sprintf("chcksnap-%s", snapID.String()), snap) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrSnapshotNotFound + } + return snap, err +} + +func (s *KVStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error { + key, id, err := s.db.FindIdentifierKey("chcksnap-") + if err != nil { + return err + } + snap.Id = id + return s.db.Put(key, snap) +} + +func (s *KVStorage) DeleteSnapshot(snapID happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("chcksnap-%s", snapID.String())) +} + +func (s *KVStorage) ClearSnapshots() error { + iter := s.db.Search("chcksnap-") + defer iter.Release() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/scheduler_state.go b/internal/storage/kvtpl/scheduler_state.go new file mode 100644 index 00000000..8e09f87d --- /dev/null +++ b/internal/storage/kvtpl/scheduler_state.go @@ -0,0 +1,44 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "time" + + "git.happydns.org/happyDomain/model" +) + +const schedulerLastRunKey = "scheduler-lastrun" + +func (s *KVStorage) GetLastSchedulerRun() (time.Time, error) { + var t time.Time + err := s.db.Get(schedulerLastRunKey, &t) + if errors.Is(err, happydns.ErrNotFound) { + return time.Time{}, nil + } + return t, err +} + +func (s *KVStorage) SetLastSchedulerRun(t time.Time) error { + return s.db.Put(schedulerLastRunKey, t) +} diff --git a/internal/usecase/checker/doc.go b/internal/usecase/checker/doc.go new file mode 100644 index 00000000..caf59737 --- /dev/null +++ b/internal/usecase/checker/doc.go @@ -0,0 +1,23 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +// Package checker provides the usecase layer for the checker/monitoring system. +package checker // import "git.happydns.org/happyDomain/internal/usecase/checker" diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go new file mode 100644 index 00000000..26dc1ff3 --- /dev/null +++ b/internal/usecase/checker/storage.go @@ -0,0 +1,108 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "time" + + "git.happydns.org/happyDomain/model" +) + +// SchedulerStateStorage provides persistence for scheduler state (e.g. last run time). +type SchedulerStateStorage interface { + GetLastSchedulerRun() (time.Time, error) + SetLastSchedulerRun(t time.Time) error +} + +// DomainLister is the minimal interface needed by the scheduler to enumerate domains. +type DomainLister interface { + ListAllDomains() (happydns.Iterator[happydns.Domain], error) +} + +// CheckAutoFillStorage provides access to domain, zone and user data +// needed to resolve auto-fill field values at execution time. +type CheckAutoFillStorage interface { + GetDomain(id happydns.Identifier) (*happydns.Domain, error) + GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) + ListDomains(u *happydns.User) ([]*happydns.Domain, error) + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// CheckPlanStorage provides persistence for CheckPlan entities. +type CheckPlanStorage interface { + ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) + ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) + ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) + ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) + GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) + CreateCheckPlan(plan *happydns.CheckPlan) error + UpdateCheckPlan(plan *happydns.CheckPlan) error + DeleteCheckPlan(planID happydns.Identifier) error + ClearCheckPlans() error +} + +// CheckerOptionsStorage provides persistence for checker options at different levels. +type CheckerOptionsStorage interface { + ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) + ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) + GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) + UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error + DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error + ClearCheckerConfigurations() error +} + +// CheckEvaluationStorage provides persistence for check evaluation results. +type CheckEvaluationStorage interface { + ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) + ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) + GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) + GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) + CreateEvaluation(eval *happydns.CheckEvaluation) error + DeleteEvaluation(evalID happydns.Identifier) error + DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error + ClearEvaluations() error +} + +// ExecutionStorage provides persistence for execution records. +type ExecutionStorage interface { + ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) + ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) + GetExecution(execID happydns.Identifier) (*happydns.Execution, error) + CreateExecution(exec *happydns.Execution) error + UpdateExecution(exec *happydns.Execution) error + DeleteExecution(execID happydns.Identifier) error + DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error + ClearExecutions() error +} + +// PlannedJobProvider exposes upcoming scheduler jobs from the in-memory queue. +type PlannedJobProvider interface { + GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob +} + +// ObservationSnapshotStorage provides persistence for observation snapshots. +type ObservationSnapshotStorage interface { + GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) + CreateSnapshot(snap *happydns.ObservationSnapshot) error + DeleteSnapshot(snapID happydns.Identifier) error + ClearSnapshots() error +} diff --git a/model/errors.go b/model/errors.go index d3bb697b..07c760c7 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,15 +27,20 @@ import ( ) var ( - ErrAuthUserNotFound = errors.New("user not found") - ErrDomainNotFound = errors.New("domain not found") - ErrDomainLogNotFound = errors.New("domain log not found") - ErrProviderNotFound = errors.New("provider not found") - ErrSessionNotFound = errors.New("session not found") - ErrUserNotFound = errors.New("user not found") - ErrUserAlreadyExist = errors.New("user already exists") - ErrZoneNotFound = errors.New("zone not found") - ErrNotFound = errors.New("not found") + ErrAuthUserNotFound = errors.New("user not found") + ErrDomainNotFound = errors.New("domain not found") + ErrDomainLogNotFound = errors.New("domain log not found") + ErrProviderNotFound = errors.New("provider not found") + ErrSessionNotFound = errors.New("session not found") + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExist = errors.New("user already exists") + ErrZoneNotFound = errors.New("zone not found") + ErrCheckerNotFound = errors.New("checker not found") + ErrCheckPlanNotFound = errors.New("check plan not found") + ErrCheckEvaluationNotFound = errors.New("check evaluation not found") + ErrExecutionNotFound = errors.New("execution not found") + ErrSnapshotNotFound = errors.New("snapshot not found") + ErrNotFound = errors.New("not found") ) const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later." From 8f261cee869f605b5f24d707347a8f394ff4c3ec Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:53:05 +0700 Subject: [PATCH 05/54] checkers: add usecases, engine, and scheduler Implement the checker business logic: - CheckerOptionsUsecase: scope-based option resolution, validation, auto-fill from execution context (domain, zone, service) - CheckPlanUsecase: CRUD for user scheduling configurations - CheckStatusUsecase: aggregated status queries, execution history - CheckerEngine: full execution pipeline (observe, evaluate, aggregate) - Scheduler: background job executor with auto-discovery, min-heap queue, worker pool, and jitter-based scheduling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../usecase/checker/check_plan_usecase.go | 79 + .../checker/check_plan_usecase_test.go | 264 +++ .../usecase/checker/check_status_usecase.go | 219 +++ .../checker/check_status_usecase_test.go | 188 +++ internal/usecase/checker/checker_engine.go | 191 +++ .../usecase/checker/checker_engine_test.go | 290 ++++ .../checker/checker_options_usecase.go | 628 +++++++ .../checker/checker_options_usecase_test.go | 1442 +++++++++++++++++ internal/usecase/checker/scheduler.go | 579 +++++++ internal/usecase/checker/scheduler_test.go | 76 + internal/usecase/checker/storage.go | 5 + 11 files changed, 3961 insertions(+) create mode 100644 internal/usecase/checker/check_plan_usecase.go create mode 100644 internal/usecase/checker/check_plan_usecase_test.go create mode 100644 internal/usecase/checker/check_status_usecase.go create mode 100644 internal/usecase/checker/check_status_usecase_test.go create mode 100644 internal/usecase/checker/checker_engine.go create mode 100644 internal/usecase/checker/checker_engine_test.go create mode 100644 internal/usecase/checker/checker_options_usecase.go create mode 100644 internal/usecase/checker/checker_options_usecase_test.go create mode 100644 internal/usecase/checker/scheduler.go create mode 100644 internal/usecase/checker/scheduler_test.go diff --git a/internal/usecase/checker/check_plan_usecase.go b/internal/usecase/checker/check_plan_usecase.go new file mode 100644 index 00000000..7ff6f1f6 --- /dev/null +++ b/internal/usecase/checker/check_plan_usecase.go @@ -0,0 +1,79 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "fmt" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckPlanUsecase handles business logic for check plans. +type CheckPlanUsecase struct { + store CheckPlanStorage +} + +// NewCheckPlanUsecase creates a new CheckPlanUsecase. +func NewCheckPlanUsecase(store CheckPlanStorage) *CheckPlanUsecase { + return &CheckPlanUsecase{store: store} +} + +// ListCheckPlansByTarget returns all check plans matching the given target. +func (u *CheckPlanUsecase) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + return u.store.ListCheckPlansByTarget(target) +} + +// CreateCheckPlan validates that the checker exists and persists the plan. +func (u *CheckPlanUsecase) CreateCheckPlan(plan *happydns.CheckPlan) error { + if checkerPkg.FindChecker(plan.CheckerID) == nil { + return fmt.Errorf("checker %q not found", plan.CheckerID) + } + return u.store.CreateCheckPlan(plan) +} + +// GetCheckPlan retrieves a check plan by ID. +func (u *CheckPlanUsecase) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + return u.store.GetCheckPlan(planID) +} + +// UpdateCheckPlan fetches the existing plan, preserves Id and Target (immutable), +// and persists the merged result. +func (u *CheckPlanUsecase) UpdateCheckPlan(planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) { + existing, err := u.store.GetCheckPlan(planID) + if err != nil { + return nil, err + } + + updated.Id = existing.Id + updated.Target = existing.Target + + if err := u.store.UpdateCheckPlan(updated); err != nil { + return nil, err + } + return updated, nil +} + +// DeleteCheckPlan deletes a check plan by ID. +func (u *CheckPlanUsecase) DeleteCheckPlan(planID happydns.Identifier) error { + return u.store.DeleteCheckPlan(planID) +} diff --git a/internal/usecase/checker/check_plan_usecase_test.go b/internal/usecase/checker/check_plan_usecase_test.go new file mode 100644 index 00000000..d2b1b356 --- /dev/null +++ b/internal/usecase/checker/check_plan_usecase_test.go @@ -0,0 +1,264 @@ +// 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 . +// +// 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 . + +package checker_test + +import ( + "testing" + + "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +func setupPlanUC(t *testing.T) (*checkerUC.CheckPlanUsecase, *planStore) { + t.Helper() + // Register a checker so CreateCheckPlan validation passes. + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "plan_test_checker", + Name: "Plan Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_a", status: happydns.StatusOK}, + }, + }) + + store := newPlanStore() + uc := checkerUC.NewCheckPlanUsecase(store) + return uc, store +} + +func TestCheckPlanUsecase_CreateAndGet(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + if plan.Id.IsEmpty() { + t.Fatal("expected plan to get an ID assigned") + } + + got, err := uc.GetCheckPlan(plan.Id) + if err != nil { + t.Fatalf("GetCheckPlan() error: %v", err) + } + if got.CheckerID != "plan_test_checker" { + t.Errorf("expected CheckerID plan_test_checker, got %s", got.CheckerID) + } +} + +func TestCheckPlanUsecase_CreateUnknownChecker(t *testing.T) { + uc, _ := setupPlanUC(t) + + plan := &happydns.CheckPlan{ + CheckerID: "nonexistent_checker", + } + + if err := uc.CreateCheckPlan(plan); err == nil { + t.Fatal("expected error for unknown checker") + } +} + +func TestCheckPlanUsecase_ListByTarget(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + plans, err := uc.ListCheckPlansByTarget(target) + if err != nil { + t.Fatalf("ListCheckPlansByTarget() error: %v", err) + } + if len(plans) != 1 { + t.Errorf("expected 1 plan, got %d", len(plans)) + } + + // Different target should return empty. + uid2, _ := happydns.NewRandomIdentifier() + other := happydns.CheckTarget{UserId: uid2.String()} + plans2, err := uc.ListCheckPlansByTarget(other) + if err != nil { + t.Fatalf("ListCheckPlansByTarget() error: %v", err) + } + if len(plans2) != 0 { + t.Errorf("expected 0 plans for different target, got %d", len(plans2)) + } +} + +func TestCheckPlanUsecase_UpdatePreservesIdAndTarget(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + origID := plan.Id + + // Update with different target and ID — they should be preserved. + uid2, _ := happydns.NewRandomIdentifier() + fakeID, _ := happydns.NewRandomIdentifier() + updated := &happydns.CheckPlan{ + Id: fakeID, + CheckerID: "plan_test_checker", + Target: happydns.CheckTarget{UserId: uid2.String()}, + Enabled: map[string]bool{"rule_a": false}, + } + + result, err := uc.UpdateCheckPlan(origID, updated) + if err != nil { + t.Fatalf("UpdateCheckPlan() error: %v", err) + } + + if !result.Id.Equals(origID) { + t.Errorf("expected Id to be preserved as %s, got %s", origID, result.Id) + } + if result.Target.String() != target.String() { + t.Errorf("expected Target to be preserved") + } + if result.Enabled["rule_a"] != false { + t.Errorf("expected Enabled to be updated") + } +} + +func TestCheckPlanUsecase_UpdateNotFound(t *testing.T) { + uc, _ := setupPlanUC(t) + + fakeID, _ := happydns.NewRandomIdentifier() + _, err := uc.UpdateCheckPlan(fakeID, &happydns.CheckPlan{}) + if err == nil { + t.Fatal("expected error for nonexistent plan") + } +} + +func TestCheckPlanUsecase_Delete(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + if err := uc.DeleteCheckPlan(plan.Id); err != nil { + t.Fatalf("DeleteCheckPlan() error: %v", err) + } + + _, err := uc.GetCheckPlan(plan.Id) + if err == nil { + t.Fatal("expected error after deletion") + } +} + +// --- planStore: minimal in-memory CheckPlanStorage --- + +type planStore struct { + plans map[string]*happydns.CheckPlan +} + +func newPlanStore() *planStore { + return &planStore{plans: make(map[string]*happydns.CheckPlan)} +} + +func (s *planStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + return nil, nil +} + +func (s *planStore) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + var result []*happydns.CheckPlan + for _, p := range s.plans { + if p.Target.String() == target.String() { + result = append(result, p) + } + } + return result, nil +} + +func (s *planStore) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + return nil, nil +} + +func (s *planStore) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + return nil, nil +} + +func (s *planStore) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + p, ok := s.plans[planID.String()] + if !ok { + return nil, happydns.ErrCheckPlanNotFound + } + return p, nil +} + +func (s *planStore) CreateCheckPlan(plan *happydns.CheckPlan) error { + id, _ := happydns.NewRandomIdentifier() + plan.Id = id + s.plans[plan.Id.String()] = plan + return nil +} + +func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { + s.plans[plan.Id.String()] = plan + return nil +} + +func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error { + delete(s.plans, planID.String()) + return nil +} + +func (s *planStore) ClearCheckPlans() error { + s.plans = make(map[string]*happydns.CheckPlan) + return nil +} diff --git a/internal/usecase/checker/check_status_usecase.go b/internal/usecase/checker/check_status_usecase.go new file mode 100644 index 00000000..b8362b3a --- /dev/null +++ b/internal/usecase/checker/check_status_usecase.go @@ -0,0 +1,219 @@ +// 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 . +// +// 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 . + +package checker + +import ( + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckStatusUsecase handles aggregation of checker statuses and evaluation/execution queries. +type CheckStatusUsecase struct { + planStore CheckPlanStorage + evalStore CheckEvaluationStorage + execStore ExecutionStorage + snapStore ObservationSnapshotStorage + plannedProvider PlannedJobProvider +} + +// NewCheckStatusUsecase creates a new CheckStatusUsecase. +func NewCheckStatusUsecase(planStore CheckPlanStorage, evalStore CheckEvaluationStorage, execStore ExecutionStorage, snapStore ObservationSnapshotStorage) *CheckStatusUsecase { + return &CheckStatusUsecase{ + planStore: planStore, + evalStore: evalStore, + execStore: execStore, + snapStore: snapStore, + } +} + +// SetPlannedJobProvider attaches an optional scheduler for planned execution queries. +func (u *CheckStatusUsecase) SetPlannedJobProvider(p PlannedJobProvider) { + u.plannedProvider = p +} + +// ListPlannedExecutions returns synthetic Execution records for upcoming scheduled jobs. +// Returns nil if no PlannedJobProvider is configured. +func (u *CheckStatusUsecase) ListPlannedExecutions(checkerID string, target happydns.CheckTarget) []*happydns.Execution { + if u.plannedProvider == nil { + return nil + } + jobs := u.plannedProvider.GetPlannedJobsForChecker(checkerID, target) + result := make([]*happydns.Execution, 0, len(jobs)) + for _, job := range jobs { + exec := &happydns.Execution{ + CheckerID: job.CheckerID, + PlanID: job.PlanID, + Target: job.Target, + Trigger: happydns.TriggerInfo{Type: happydns.TriggerSchedule}, + StartedAt: job.NextRun, + Status: happydns.ExecutionPending, + } + result = append(result, exec) + } + return result +} + +// ListCheckerStatuses aggregates checkers, plans, and latest evaluations into a status list. +func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([]happydns.CheckerStatus, error) { + checkers := checkerPkg.GetCheckers() + plans, err := u.planStore.ListCheckPlansByTarget(target) + if err != nil { + return nil, err + } + + planByChecker := make(map[string]*happydns.CheckPlan) + for _, p := range plans { + planByChecker[p.CheckerID] = p + } + + var result []happydns.CheckerStatus + for _, def := range checkers { + switch target.Scope() { + case happydns.CheckScopeDomain: + if !def.Availability.ApplyToDomain { + continue + } + case happydns.CheckScopeService: + if !def.Availability.ApplyToService { + continue + } + } + + status := happydns.CheckerStatus{ + CheckerDefinition: def, + Plan: planByChecker[def.ID], + Enabled: true, + } + + enabledRules := make(map[string]bool, len(def.Rules)) + for _, rule := range def.Rules { + enabledRules[rule.Name()] = true + } + if status.Plan != nil { + status.Enabled = !status.Plan.IsFullyDisabled() + for ruleName := range enabledRules { + enabledRules[ruleName] = status.Plan.IsRuleEnabled(ruleName) + } + } + status.EnabledRules = enabledRules + + execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1) + if err == nil && len(execs) > 0 { + status.LatestExecution = execs[0] + } + + result = append(result, status) + } + + if result == nil { + result = []happydns.CheckerStatus{} + } + return result, nil +} + +// GetExecution returns a specific execution by ID. +func (u *CheckStatusUsecase) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + return u.execStore.GetExecution(execID) +} + +// ListExecutionsByChecker returns executions for a checker on a target, up to limit. +func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + return u.execStore.ListExecutionsByChecker(checkerID, target, limit) +} + +// GetObservationsByExecution returns the observation snapshot for an execution. +func (u *CheckStatusUsecase) GetObservationsByExecution(execID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + exec, err := u.execStore.GetExecution(execID) + if err != nil { + return nil, err + } + if exec.EvaluationID == nil { + return nil, happydns.ErrCheckEvaluationNotFound + } + eval, err := u.evalStore.GetEvaluation(*exec.EvaluationID) + if err != nil { + return nil, err + } + return u.snapStore.GetSnapshot(eval.SnapshotID) +} + +// DeleteExecution deletes an execution record by ID. +func (u *CheckStatusUsecase) DeleteExecution(execID happydns.Identifier) error { + return u.execStore.DeleteExecution(execID) +} + +// DeleteExecutionsByChecker deletes all executions for a checker on a target. +func (u *CheckStatusUsecase) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + return u.execStore.DeleteExecutionsByChecker(checkerID, target) +} + +// GetWorstServiceStatuses returns the worst check status for each service in the zone. +// It iterates all services and all registered checkers, fetching the latest execution +// for each (service, checker) pair, and returns the worst status per service. +func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier, zone *happydns.Zone) (map[string]*happydns.Status, error) { + checkers := checkerPkg.GetCheckers() + if len(checkers) == 0 { + return nil, nil + } + + result := make(map[string]*happydns.Status) + for subdomain := range zone.Services { + for _, svc := range zone.Services[subdomain] { + target := happydns.CheckTarget{ + UserId: &userId, + DomainId: &domainId, + ServiceId: &svc.Id, + } + var worst *happydns.Status + for _, def := range checkers { + execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1) + if err != nil || len(execs) == 0 { + continue + } + s := execs[0].Result.Status + if worst == nil || s > *worst { + worst = &s + } + } + if worst != nil { + result[svc.Id.String()] = worst + } + } + } + + if len(result) == 0 { + return nil, nil + } + return result, nil +} + +// GetResultsByExecution returns the evaluation (with per-rule states) for an execution. +func (u *CheckStatusUsecase) GetResultsByExecution(execID happydns.Identifier) (*happydns.CheckEvaluation, error) { + exec, err := u.execStore.GetExecution(execID) + if err != nil { + return nil, err + } + if exec.EvaluationID == nil { + return nil, happydns.ErrCheckEvaluationNotFound + } + return u.evalStore.GetEvaluation(*exec.EvaluationID) +} diff --git a/internal/usecase/checker/check_status_usecase_test.go b/internal/usecase/checker/check_status_usecase_test.go new file mode 100644 index 00000000..16a17030 --- /dev/null +++ b/internal/usecase/checker/check_status_usecase_test.go @@ -0,0 +1,188 @@ +// 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 . +// +// 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 . + +package checker_test + +import ( + "testing" + "time" + + "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/storage/inmemory" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +func setupStatusUC(t *testing.T) (*checkerUC.CheckStatusUsecase, *planStore, *inmemory.InMemoryStorage) { + t.Helper() + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "status_test_checker", + Name: "Status Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_x", status: happydns.StatusOK}, + &testCheckRule{name: "rule_y", status: happydns.StatusWarn}, + }, + }) + + ps := newPlanStore() + ms, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + uc := checkerUC.NewCheckStatusUsecase(ps, ms, ms, ms) + return uc, ps, ms +} + +func TestCheckStatusUsecase_ListCheckerStatuses(t *testing.T) { + uc, _, _ := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + if len(statuses) == 0 { + t.Fatal("expected at least one checker status") + } + + // All should be enabled by default (no plans). + for _, s := range statuses { + if !s.Enabled { + t.Errorf("expected checker %s to be enabled by default", s.ID) + } + } +} + +func TestCheckStatusUsecase_ListCheckerStatuses_WithPlan(t *testing.T) { + uc, ps, _ := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Create a plan that fully disables the checker. + plan := &happydns.CheckPlan{ + CheckerID: "status_test_checker", + Target: target, + Enabled: map[string]bool{"rule_x": false, "rule_y": false}, + } + if err := ps.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + found := false + for _, s := range statuses { + if s.ID == "status_test_checker" { + found = true + if s.Enabled { + t.Error("expected status_test_checker to be disabled when all rules are off") + } + if s.Plan == nil { + t.Error("expected Plan to be set") + } + if s.EnabledRules["rule_x"] { + t.Error("expected rule_x to be disabled") + } + if s.EnabledRules["rule_y"] { + t.Error("expected rule_y to be disabled") + } + } + } + if !found { + t.Error("status_test_checker not found in statuses") + } +} + +func TestCheckStatusUsecase_ListCheckerStatuses_WithEvaluation(t *testing.T) { + uc, _, ms := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Create an execution for the checker. + exec := &happydns.Execution{ + CheckerID: "status_test_checker", + Target: target, + StartedAt: time.Now(), + Status: happydns.ExecutionDone, + Result: happydns.CheckState{Status: happydns.StatusOK, Message: "all good"}, + } + if err := ms.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution() error: %v", err) + } + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + for _, s := range statuses { + if s.ID == "status_test_checker" { + if s.LatestExecution == nil { + t.Error("expected LatestExecution to be set") + } else if s.LatestExecution.Result.Status != happydns.StatusOK { + t.Errorf("expected latest execution result status OK, got %s", s.LatestExecution.Result.Status) + } + } + } +} + +func TestCheckStatusUsecase_GetExecution(t *testing.T) { + uc, _, ms := setupStatusUC(t) + + exec := &happydns.Execution{ + Status: happydns.ExecutionDone, + } + if err := ms.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution() error: %v", err) + } + + got, err := uc.GetExecution(exec.Id) + if err != nil { + t.Fatalf("GetExecution() error: %v", err) + } + if got.Status != happydns.ExecutionDone { + t.Errorf("expected status Done, got %d", got.Status) + } +} + +func TestCheckStatusUsecase_GetExecutionNotFound(t *testing.T) { + uc, _, _ := setupStatusUC(t) + + fakeID, _ := happydns.NewRandomIdentifier() + _, err := uc.GetExecution(fakeID) + if err == nil { + t.Fatal("expected error for nonexistent execution") + } +} diff --git a/internal/usecase/checker/checker_engine.go b/internal/usecase/checker/checker_engine.go new file mode 100644 index 00000000..e69a2a61 --- /dev/null +++ b/internal/usecase/checker/checker_engine.go @@ -0,0 +1,191 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "context" + "fmt" + "log" + "time" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// checkerEngine implements the happydns.CheckerEngine interface. +type checkerEngine struct { + optionsUC *CheckerOptionsUsecase + evalStore CheckEvaluationStorage + execStore ExecutionStorage + snapStore ObservationSnapshotStorage +} + +// NewCheckerEngine creates a new CheckerEngine implementation. +func NewCheckerEngine( + optionsUC *CheckerOptionsUsecase, + evalStore CheckEvaluationStorage, + execStore ExecutionStorage, + snapStore ObservationSnapshotStorage, +) happydns.CheckerEngine { + return &checkerEngine{ + optionsUC: optionsUC, + evalStore: evalStore, + execStore: execStore, + snapStore: snapStore, + } +} + +// CreateExecution validates the checker and creates a pending Execution record. +func (e *checkerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) { + if checkerPkg.FindChecker(checkerID) == nil { + return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, checkerID) + } + + // Determine trigger info. + trigger := happydns.TriggerInfo{Type: happydns.TriggerManual} + var planID *happydns.Identifier + if plan != nil { + planID = &plan.Id + trigger.PlanID = planID + trigger.Type = happydns.TriggerSchedule + } + + // Create execution record. + exec := &happydns.Execution{ + CheckerID: checkerID, + PlanID: planID, + Target: target, + Trigger: trigger, + StartedAt: time.Now(), + Status: happydns.ExecutionPending, + } + if err := e.execStore.CreateExecution(exec); err != nil { + return nil, fmt.Errorf("creating execution: %w", err) + } + + return exec, nil +} + +// RunExecution takes an existing execution and runs the checker pipeline. +func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) { + log.Printf("CheckerEngine: running checker %s on %s", exec.CheckerID, exec.Target.String()) + + def := checkerPkg.FindChecker(exec.CheckerID) + if def == nil { + endTime := time.Now() + exec.Status = happydns.ExecutionFailed + exec.EndedAt = &endTime + exec.Error = fmt.Sprintf("checker not found: %s", exec.CheckerID) + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, exec.CheckerID) + } + + // Mark as running. + exec.Status = happydns.ExecutionRunning + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + + // Run the pipeline and handle failure. + result, eval, err := e.runPipeline(ctx, def, exec.Target, plan, exec.PlanID, runOpts) + if err != nil { + log.Printf("CheckerEngine: checker %s on %s failed: %v", exec.CheckerID, exec.Target.String(), err) + endTime := time.Now() + exec.Status = happydns.ExecutionFailed + exec.EndedAt = &endTime + exec.Error = err.Error() + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + return nil, err + } + + // Mark as done. + endTime := time.Now() + exec.Status = happydns.ExecutionDone + exec.EndedAt = &endTime + exec.Result = result + exec.EvaluationID = &eval.Id + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + + return eval, nil +} + +func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDefinition, target happydns.CheckTarget, plan *happydns.CheckPlan, planID *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckState, *happydns.CheckEvaluation, error) { + // Resolve options (stored + run + auto-fill). + mergedOpts, err := e.optionsUC.BuildMergedCheckerOptionsWithAutoFill(def.ID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), runOpts) + if err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err) + } + + // Create observation context for lazy data collection. + obsCtx := checkerPkg.NewObservationContext(target, mergedOpts) + + // Evaluate all rules, skipping disabled ones. + states := make([]happydns.CheckState, 0, len(def.Rules)) + for _, rule := range def.Rules { + if plan != nil && !plan.IsRuleEnabled(rule.Name()) { + continue + } + state := rule.Evaluate(ctx, obsCtx, mergedOpts) + if state.Code == "" { + state.Code = rule.Name() + } + states = append(states, state) + } + + // Aggregate results. + aggregator := def.Aggregator + if aggregator == nil { + aggregator = checkerPkg.WorstStatusAggregator{} + } + result := aggregator.Aggregate(states) + + // Persist observation snapshot. + snap := &happydns.ObservationSnapshot{ + Target: target, + CollectedAt: time.Now(), + Data: obsCtx.Data(), + } + if err := e.snapStore.CreateSnapshot(snap); err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err) + } + + // Persist evaluation. + eval := &happydns.CheckEvaluation{ + PlanID: planID, + CheckerID: def.ID, + Target: target, + SnapshotID: snap.Id, + EvaluatedAt: time.Now(), + States: states, + } + if err := e.evalStore.CreateEvaluation(eval); err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("creating evaluation: %w", err) + } + + return result, eval, nil +} diff --git a/internal/usecase/checker/checker_engine_test.go b/internal/usecase/checker/checker_engine_test.go new file mode 100644 index 00000000..777614ac --- /dev/null +++ b/internal/usecase/checker/checker_engine_test.go @@ -0,0 +1,290 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker_test + +import ( + "context" + "testing" + + "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/storage/inmemory" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// testObservationProvider returns static test data. +type testObservationProvider struct{} + +func (p *testObservationProvider) Key() happydns.ObservationKey { + return "test_obs" +} + +func (p *testObservationProvider) Collect(ctx context.Context, target happydns.CheckTarget, opts happydns.CheckerOptions) (any, error) { + return map[string]any{"value": 42}, nil +} + +// testCheckRule produces a state based on observations. +type testCheckRule struct { + name string + status happydns.Status +} + +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 { + return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()} + } + return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name} +} + +func TestCheckerEngine_RunOK(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + // Register test provider and checker. + checker.RegisterObservationProvider(&testObservationProvider{}) + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker", + Name: "Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_ok", status: happydns.StatusOK}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + exec, err := engine.CreateExecution("test_checker", target, nil) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + + eval, err := engine.RunExecution(context.Background(), exec, nil, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + if eval == nil { + t.Fatal("RunExecution() returned nil evaluation") + } + + if exec.Result.Status != happydns.StatusOK { + t.Errorf("expected status OK, got %s", exec.Result.Status) + } + + if len(eval.States) != 1 { + t.Errorf("expected 1 state, got %d", len(eval.States)) + } + + // Verify execution was persisted. + execs, err := store.ListExecutionsByChecker("test_checker", target, 0) + if err != nil { + t.Fatalf("ListExecutionsByChecker() returned error: %v", err) + } + if len(execs) != 1 { + t.Errorf("expected 1 execution, got %d", len(execs)) + } + + // Verify the execution ended as Done. + for _, ex := range execs { + if ex.Status != happydns.ExecutionDone { + t.Errorf("expected execution status Done, got %d", ex.Status) + } + } +} + +func TestCheckerEngine_RunWarn(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker_warn", + Name: "Test Checker Warn", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_ok", status: happydns.StatusOK}, + &testCheckRule{name: "rule_warn", status: happydns.StatusWarn}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + exec, err := engine.CreateExecution("test_checker_warn", target, nil) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + eval, err := engine.RunExecution(context.Background(), exec, nil, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + // Worst status aggregation: WARN should win over OK. + if exec.Result.Status != happydns.StatusWarn { + t.Errorf("expected aggregated status WARN, got %s", exec.Result.Status) + } + + if len(eval.States) != 2 { + t.Errorf("expected 2 states, got %d", len(eval.States)) + } +} + +func TestCheckerEngine_RunPerRuleDisable(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker_per_rule", + Name: "Test Checker Per Rule", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_a", status: happydns.StatusOK}, + &testCheckRule{name: "rule_b", status: happydns.StatusWarn}, + &testCheckRule{name: "rule_c", status: happydns.StatusCrit}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Disable rule_b and rule_c, only rule_a should run. + plan := &happydns.CheckPlan{ + CheckerID: "test_checker_per_rule", + Target: target, + Enabled: map[string]bool{ + "rule_a": true, + "rule_b": false, + "rule_c": false, + }, + } + + exec, err := engine.CreateExecution("test_checker_per_rule", target, plan) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + eval, err := engine.RunExecution(context.Background(), exec, plan, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + if len(eval.States) != 1 { + t.Fatalf("expected 1 state (only rule_a), got %d", len(eval.States)) + } + + if exec.Result.Status != happydns.StatusOK { + t.Errorf("expected status OK (only rule_a active), got %s", exec.Result.Status) + } + + if eval.States[0].Code != "rule_a" { + t.Errorf("expected rule_a state, got code %s", eval.States[0].Code) + } +} + +func TestCheckPlan_IsFullyDisabled(t *testing.T) { + // Nil map = not disabled. + p := &happydns.CheckPlan{} + if p.IsFullyDisabled() { + t.Error("nil map should not be fully disabled") + } + + // All false = disabled. + p.Enabled = map[string]bool{"a": false, "b": false} + if !p.IsFullyDisabled() { + t.Error("all-false map should be fully disabled") + } + + // Mixed = not disabled. + p.Enabled = map[string]bool{"a": true, "b": false} + if p.IsFullyDisabled() { + t.Error("mixed map should not be fully disabled") + } +} + +func TestCheckPlan_IsRuleEnabled(t *testing.T) { + // Nil map = all enabled. + p := &happydns.CheckPlan{} + if !p.IsRuleEnabled("any") { + t.Error("nil map should enable all rules") + } + + // Missing key = enabled. + p.Enabled = map[string]bool{"a": false} + if !p.IsRuleEnabled("b") { + t.Error("missing key should be enabled") + } + + // Explicit false = disabled. + if p.IsRuleEnabled("a") { + t.Error("explicit false should be disabled") + } + + // Explicit true = enabled. + p.Enabled["c"] = true + if !p.IsRuleEnabled("c") { + t.Error("explicit true should be enabled") + } +} + +func TestCheckerEngine_RunNotFound(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String()} + + _, err = engine.CreateExecution("nonexistent_checker", target, nil) + if err == nil { + t.Fatal("expected error for nonexistent checker") + } +} diff --git a/internal/usecase/checker/checker_options_usecase.go b/internal/usecase/checker/checker_options_usecase.go new file mode 100644 index 00000000..68ce569c --- /dev/null +++ b/internal/usecase/checker/checker_options_usecase.go @@ -0,0 +1,628 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "fmt" + "maps" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/forms" + "git.happydns.org/happyDomain/model" +) + +// isEmptyValue returns true if v is nil or an empty string. +func isEmptyValue(v any) bool { + if v == nil { + return true + } + if s, ok := v.(string); ok && s == "" { + return true + } + return false +} + +// identifiersEqual returns true when both identifiers are nil or point to the same value. +func identifiersEqual(a, b *happydns.Identifier) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Equals(*b) +} + +// getScopedOptions returns options stored exactly at the requested scope level, +// without merging parent scopes. +func (u *CheckerOptionsUsecase) getScopedOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) (happydns.CheckerOptions, error) { + positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) + if err != nil { + return make(happydns.CheckerOptions), err + } + for _, p := range positionals { + if identifiersEqual(p.UserId, userId) && identifiersEqual(p.DomainId, domainId) && identifiersEqual(p.ServiceId, serviceId) { + if p.Options != nil { + return p.Options, nil + } + return make(happydns.CheckerOptions), nil + } + } + return make(happydns.CheckerOptions), nil +} + +// CheckerOptionsUsecase handles the resolution and persistence of checker options. +type CheckerOptionsUsecase struct { + store CheckerOptionsStorage + autoFillStore CheckAutoFillStorage +} + +// NewCheckerOptionsUsecase creates a new CheckerOptionsUsecase. +func NewCheckerOptionsUsecase(store CheckerOptionsStorage, autoFillStore CheckAutoFillStorage) *CheckerOptionsUsecase { + return &CheckerOptionsUsecase{store: store, autoFillStore: autoFillStore} +} + +// GetCheckerOptionsPositional returns the raw positional options from all scope levels, +// ordered from least to most specific (admin < user < domain < service). +func (u *CheckerOptionsUsecase) GetCheckerOptionsPositional( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) ([]*happydns.CheckerOptionsPositional, error) { + return u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) +} + +// GetAutoFillOptions resolves auto-fill values for a checker and target, +// returning only the auto-filled key/value pairs. +func (u *CheckerOptionsUsecase) GetAutoFillOptions( + checkerName string, + target happydns.CheckTarget, +) (happydns.CheckerOptions, error) { + result, err := u.resolveAutoFill(checkerName, target) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, nil + } + return result, nil +} + +// GetCheckerOptions retrieves and merges options from all applicable levels +// (admin < user < domain < service), returning the merged result. +func (u *CheckerOptionsUsecase) GetCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) (happydns.CheckerOptions, error) { + positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) + if err != nil { + 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 { + 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 +} + +// BuildMergedCheckerOptions merges stored options with runtime overrides. +// RunOpts are applied last and win over all stored levels. +func BuildMergedCheckerOptions(storedOpts happydns.CheckerOptions, runOpts happydns.CheckerOptions) happydns.CheckerOptions { + result := make(happydns.CheckerOptions) + maps.Copy(result, storedOpts) + maps.Copy(result, runOpts) + return result +} + +// SetCheckerOptions persists options at the given positional level (full replace). +// Keys with nil or empty-string values are excluded from the stored map. +// Auto-fill keys are also stripped since they are system-provided at runtime. +func (u *CheckerOptionsUsecase) SetCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + opts happydns.CheckerOptions, +) error { + // 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] != "" { + 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) +} + +// AddCheckerOptions merges new options into existing ones at the given scope level. +// Keys with nil or empty-string values are deleted from the scope rather than stored. +func (u *CheckerOptionsUsecase) AddCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + newOpts happydns.CheckerOptions, +) (happydns.CheckerOptions, error) { + existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId) + 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 { + existing[k] = v + } + } + if err := u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing); err != nil { + return nil, err + } + return existing, nil +} + +// GetCheckerOption returns a single option value from the merged options. +func (u *CheckerOptionsUsecase) GetCheckerOption( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + optName string, +) (any, error) { + opts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId) + if err != nil { + return nil, err + } + return opts[optName], nil +} + +// scopeFromIdentifiers determines the CheckScopeType based on which identifiers are set. +func scopeFromIdentifiers(userId, domainId, serviceId *happydns.Identifier) happydns.CheckScopeType { + if serviceId != nil { + return happydns.CheckScopeService + } + if domainId != nil { + return happydns.CheckScopeDomain + } + if userId != nil { + return happydns.CheckScopeUser + } + return happydns.CheckScopeAdmin +} + +// collectFieldsForScope returns the fields from a CheckerOptionsDocumentation +// that are valid at the given scope level. RunOpts are never included for +// persisted scopes. +func collectFieldsForScope(doc happydns.CheckerOptionsDocumentation, scope happydns.CheckScopeType) []happydns.CheckerOptionDocumentation { + var fields []happydns.CheckerOptionDocumentation + switch scope { + case happydns.CheckScopeAdmin: + fields = append(fields, doc.AdminOpts...) + case happydns.CheckScopeUser: + fields = append(fields, doc.UserOpts...) + case happydns.CheckScopeDomain, happydns.CheckScopeZone: + fields = append(fields, doc.DomainOpts...) + case happydns.CheckScopeService: + fields = append(fields, doc.ServiceOpts...) + } + return fields +} + +// ValidateOptions validates checker options against the checker's field definitions +// for the given scope level, and any OptionsValidator interface implemented by rules. +// When withRunOpts is true, RunOpts fields are also included so that required run-time +// options are enforced (used at trigger time). For persisted scopes, pass false. +func (u *CheckerOptionsUsecase) ValidateOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + opts happydns.CheckerOptions, + withRunOpts bool, +) error { + def := checkerPkg.FindChecker(checkerName) + if def == nil { + return fmt.Errorf("checker %q not found", checkerName) + } + + scope := scopeFromIdentifiers(userId, domainId, serviceId) + + // Collect fields for this scope from the checker definition. + // When withRunOpts is true (trigger time), also include all persisted-scope + // fields so that options already stored at a different scope level (e.g. + // admin-level options merged into the final opts map) are not rejected as + // unknown. + var allFields []happydns.CheckerOptionDocumentation + if withRunOpts { + allFields = append(allFields, def.Options.AdminOpts...) + allFields = append(allFields, def.Options.UserOpts...) + allFields = append(allFields, def.Options.DomainOpts...) + allFields = append(allFields, def.Options.ServiceOpts...) + allFields = append(allFields, def.Options.RunOpts...) + } else { + allFields = collectFieldsForScope(def.Options, scope) + } + + // Collect fields from rules that declare their own options at this scope. + for _, rule := range def.Rules { + if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok { + ruleDoc := rwo.Options() + if withRunOpts { + allFields = append(allFields, ruleDoc.AdminOpts...) + allFields = append(allFields, ruleDoc.UserOpts...) + allFields = append(allFields, ruleDoc.DomainOpts...) + allFields = append(allFields, ruleDoc.ServiceOpts...) + allFields = append(allFields, ruleDoc.RunOpts...) + } else { + allFields = append(allFields, collectFieldsForScope(ruleDoc, scope)...) + } + } + } + + // Filter out auto-fill fields — they are system-provided at runtime + // and should not be validated against user input. + autoFillIds := getAutoFillFieldIds(def) + var validatableFields []happydns.CheckerOptionDocumentation + for _, f := range allFields { + if _, isAutoFill := autoFillIds[f.Id]; !isAutoFill { + validatableFields = append(validatableFields, f) + } + } + + // Validate against field definitions. ValidateMapValues lives in the + // forms package and works with happydns.Field; CheckerOptionDocumentation + // is structurally identical so an element-wise conversion is enough. + if len(validatableFields) > 0 { + asFields := make([]happydns.Field, len(validatableFields)) + for i, opt := range validatableFields { + asFields[i] = happydns.FieldFromCheckerOption(opt) + } + if err := forms.ValidateMapValues(opts, asFields); err != nil { + return err + } + } + + // Check if any rule implements OptionsValidator. + for _, rule := range def.Rules { + if v, ok := rule.(happydns.OptionsValidator); ok { + if err := v.ValidateOptions(opts); err != nil { + return err + } + } + } + + return nil +} + +// SetCheckerOption sets a single option value at the given scope level. +// If value is nil or empty string, the key is deleted from the scope. +func (u *CheckerOptionsUsecase) SetCheckerOption( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + 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 + } + if isEmptyValue(value) { + delete(existing, optName) + } else { + existing[optName] = value + } + return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing) +} + +// collectAutoFillFromDoc scans all option groups in a CheckerOptionsDocumentation +// and adds any fields with AutoFill set to the result map. +func collectAutoFillFromDoc(doc happydns.CheckerOptionsDocumentation, result map[string]string) { + for _, group := range [][]happydns.CheckerOptionDocumentation{ + doc.AdminOpts, + doc.UserOpts, + doc.DomainOpts, + doc.ServiceOpts, + doc.RunOpts, + } { + for _, f := range group { + if f.AutoFill != "" { + result[f.Id] = f.AutoFill + } + } + } +} + +// getAutoFillFieldIds returns a set of field IDs that have AutoFill set +// for the given checker definition across all option groups. +func getAutoFillFieldIds(def *happydns.CheckerDefinition) map[string]string { + result := make(map[string]string) + collectAutoFillFromDoc(def.Options, result) + for _, rule := range def.Rules { + if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok { + collectAutoFillFromDoc(rwo.Options(), result) + } + } + 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.CheckerOptionDocumentation{ + 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( + target happydns.CheckTarget, +) (map[string]any, error) { + ctx := make(map[string]any) + if u.autoFillStore == nil { + return ctx, nil + } + + domainId := happydns.TargetIdentifier(target.DomainId) + if domainId == nil { + return ctx, nil + } + + domain, err := u.autoFillStore.GetDomain(*domainId) + if err != nil { + return ctx, fmt.Errorf("loading domain for auto-fill: %w", err) + } + + ctx[happydns.AutoFillDomainName] = domain.DomainName + + // Load the latest zone from domain history. + if len(domain.ZoneHistory) == 0 { + return ctx, nil + } + + latestZoneId := domain.ZoneHistory[len(domain.ZoneHistory)-1] + zone, err := u.autoFillStore.GetZone(latestZoneId) + if err != nil { + return ctx, fmt.Errorf("loading zone for auto-fill: %w", err) + } + ctx[happydns.AutoFillZone] = zone + + // Resolve service if target has a ServiceId. + // Search from the most recent zone backwards through history, + // since the service may not exist in the latest zone if it was + // updated or reimported. + if serviceId := happydns.TargetIdentifier(target.ServiceId); serviceId != nil { + for i := len(domain.ZoneHistory) - 1; i >= 0; i-- { + z := zone + if i < len(domain.ZoneHistory)-1 { + z, err = u.autoFillStore.GetZone(domain.ZoneHistory[i]) + if err != nil { + continue + } + } + for subdomain, services := range z.Services { + for _, svc := range services { + if svc.Id.Equals(*serviceId) { + ctx[happydns.AutoFillSubdomain] = string(subdomain) + ctx[happydns.AutoFillServiceType] = svc.Type + ctx[happydns.AutoFillService] = svc + return ctx, nil + } + } + } + } + } + + return ctx, nil +} + +// resolveAutoFill looks up the checker definition, scans its fields for AutoFill +// attributes, builds the execution context from storage, and returns a map of +// field ID to resolved value. Returns an empty map (not nil) when there is +// nothing to fill. +func (u *CheckerOptionsUsecase) resolveAutoFill( + checkerName string, + target happydns.CheckTarget, +) (happydns.CheckerOptions, error) { + def := checkerPkg.FindChecker(checkerName) + if def == nil { + return make(happydns.CheckerOptions), nil + } + + autoFillFields := getAutoFillFieldIds(def) + if len(autoFillFields) == 0 { + return make(happydns.CheckerOptions), nil + } + + ctx, err := u.buildAutoFillContext(target) + if err != nil { + return nil, err + } + + result := make(happydns.CheckerOptions, len(autoFillFields)) + for fieldId, autoFillKey := range autoFillFields { + if val, ok := ctx[autoFillKey]; ok { + result[fieldId] = val + } + } + return result, nil +} + +// BuildMergedCheckerOptionsWithAutoFill merges stored options, runtime overrides, +// and auto-fill values. Auto-fill values are applied last and always win. +func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + runOpts happydns.CheckerOptions, +) (happydns.CheckerOptions, error) { + storedOpts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId) + if err != nil { + return nil, err + } + + 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: happydns.FormatIdentifier(userId), + DomainId: happydns.FormatIdentifier(domainId), + ServiceId: happydns.FormatIdentifier(serviceId), + } + + autoFilled, err := u.resolveAutoFill(checkerName, target) + if err != nil { + return nil, err + } + maps.Copy(merged, autoFilled) + + return merged, nil +} diff --git a/internal/usecase/checker/checker_options_usecase_test.go b/internal/usecase/checker/checker_options_usecase_test.go new file mode 100644 index 00000000..5b48cd7f --- /dev/null +++ b/internal/usecase/checker/checker_options_usecase_test.go @@ -0,0 +1,1442 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// --- helpers --- + +func idPtr() *happydns.Identifier { + id, _ := happydns.NewRandomIdentifier() + return &id +} + +// optionsStore is a minimal in-memory CheckerOptionsStorage that supports +// multi-scope positional lookup and update. +type optionsStore struct { + // key: "checker|userId|domainId|serviceId" + data map[string]happydns.CheckerOptions +} + +func newOptionsStore() *optionsStore { + return &optionsStore{data: make(map[string]happydns.CheckerOptions)} +} + +func posKey(checkerName string, userId, domainId, serviceId *happydns.Identifier) string { + f := func(id *happydns.Identifier) string { + if id == nil { + return "" + } + return id.String() + } + return checkerName + "|" + f(userId) + "|" + f(domainId) + "|" + f(serviceId) +} + +func (s *optionsStore) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + return nil, nil +} +func (s *optionsStore) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + return nil, nil +} + +// GetCheckerConfiguration returns positionals from least to most specific. +// It constructs the hierarchy: admin -> user -> domain -> service. +func (s *optionsStore) GetCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + var result []*happydns.CheckerOptionsPositional + + // admin level + if opts, ok := s.data[posKey(checkerName, nil, nil, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, Options: opts, + }) + } + // user level + if userId != nil { + if opts, ok := s.data[posKey(checkerName, userId, nil, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, Options: opts, + }) + } + } + // domain level + if domainId != nil { + if opts, ok := s.data[posKey(checkerName, userId, domainId, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, DomainId: domainId, Options: opts, + }) + } + } + // service level + if serviceId != nil { + if opts, ok := s.data[posKey(checkerName, userId, domainId, serviceId)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, DomainId: domainId, ServiceId: serviceId, Options: opts, + }) + } + } + + return result, nil +} + +func (s *optionsStore) UpdateCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + s.data[posKey(checkerName, userId, domainId, serviceId)] = opts + return nil +} + +func (s *optionsStore) DeleteCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier) error { + delete(s.data, posKey(checkerName, userId, domainId, serviceId)) + return nil +} + +func (s *optionsStore) ClearCheckerConfigurations() error { + s.data = make(map[string]happydns.CheckerOptions) + return nil +} + +// --- test rule/checker types --- + +// validatingRule is a CheckRule that also implements OptionsValidator. +type validatingRule struct { + name string + validateErr error +} + +func (r *validatingRule) Name() string { return r.name } +func (r *validatingRule) Description() string { return "validating rule" } +func (r *validatingRule) Evaluate(_ context.Context, _ happydns.ObservationGetter, _ happydns.CheckerOptions) happydns.CheckState { + return happydns.CheckState{Status: happydns.StatusOK} +} +func (r *validatingRule) ValidateOptions(_ happydns.CheckerOptions) error { + return r.validateErr +} + +// ruleWithOptions is a CheckRule that implements CheckRuleWithOptions. +type ruleWithOptions struct { + name string + opts happydns.CheckerOptionsDocumentation +} + +func (r *ruleWithOptions) Name() string { return r.name } +func (r *ruleWithOptions) Description() string { return "rule with options" } +func (r *ruleWithOptions) Evaluate(_ context.Context, _ happydns.ObservationGetter, _ happydns.CheckerOptions) happydns.CheckState { + return happydns.CheckState{Status: happydns.StatusOK} +} +func (r *ruleWithOptions) Options() happydns.CheckerOptionsDocumentation { + return r.opts +} + +// --- CRUD tests --- + +func TestSetAndGetCheckerOptions(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + opts := happydns.CheckerOptions{"key1": "value1", "key2": float64(42)} + + if err := uc.SetCheckerOptions("c1", uid, nil, nil, opts); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if got["key1"] != "value1" || got["key2"] != float64(42) { + t.Errorf("unexpected options: %v", got) + } +} + +func TestSetCheckerOptions_FiltersEmptyValues(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + opts := happydns.CheckerOptions{"keep": "yes", "drop_nil": nil, "drop_empty": ""} + if err := uc.SetCheckerOptions("c1", nil, nil, nil, opts); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["drop_nil"]; ok { + t.Error("nil value should have been filtered") + } + if _, ok := got["drop_empty"]; ok { + t.Error("empty string value should have been filtered") + } + if got["keep"] != "yes" { + t.Error("non-empty value should be kept") + } +} + +func TestAddCheckerOptions_MergesIntoExisting(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "updated", "c": "3"}) + if err != nil { + t.Fatal(err) + } + if merged["a"] != "1" { + t.Errorf("existing key 'a' should be preserved, got %v", merged["a"]) + } + if merged["b"] != "updated" { + t.Errorf("key 'b' should be updated, got %v", merged["b"]) + } + if merged["c"] != "3" { + t.Errorf("key 'c' should be added, got %v", merged["c"]) + } +} + +func TestAddCheckerOptions_DeletesEmptyValues(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": nil, "b": ""}) + if err != nil { + t.Fatal(err) + } + if _, ok := merged["a"]; ok { + t.Error("nil value should delete the key") + } + if _, ok := merged["b"]; ok { + t.Error("empty string value should delete the key") + } +} + +func TestGetCheckerOption_SingleKey(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"x": "hello"}) + + val, err := uc.GetCheckerOption("c1", uid, nil, nil, "x") + if err != nil { + t.Fatal(err) + } + if val != "hello" { + t.Errorf("expected 'hello', got %v", val) + } + + val, err = uc.GetCheckerOption("c1", uid, nil, nil, "missing") + if err != nil { + t.Fatal(err) + } + if val != nil { + t.Errorf("expected nil for missing key, got %v", val) + } +} + +func TestSetCheckerOption_SingleKey(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1"}) + + if err := uc.SetCheckerOption("c1", uid, nil, nil, "b", "2"); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if got["a"] != "1" || got["b"] != "2" { + t.Errorf("unexpected options: %v", got) + } +} + +func TestSetCheckerOption_DeletesEmpty(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + if err := uc.SetCheckerOption("c1", uid, nil, nil, "a", nil); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["a"]; ok { + t.Error("key 'a' should have been deleted") + } + if got["b"] != "2" { + t.Error("key 'b' should be preserved") + } +} + +// --- Scope merging tests --- + +func TestGetCheckerOptions_MergesScopes(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Admin sets defaults. + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"a": "admin", "shared": "admin"}) + // User overrides shared. + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "user", "shared": "user"}) + // Domain overrides shared again. + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"c": "domain", "shared": "domain"}) + + got, err := uc.GetCheckerOptions("c1", uid, did, nil) + if err != nil { + t.Fatal(err) + } + + if got["a"] != "admin" { + t.Errorf("admin key 'a' should be visible, got %v", got["a"]) + } + if got["b"] != "user" { + t.Errorf("user key 'b' should be visible, got %v", got["b"]) + } + if got["c"] != "domain" { + t.Errorf("domain key 'c' should be visible, got %v", got["c"]) + } + if got["shared"] != "domain" { + t.Errorf("'shared' should be overridden to 'domain', got %v", got["shared"]) + } +} + +func TestGetCheckerOptions_ServiceScopeOverridesAll(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"key": "admin"}) + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"key": "user"}) + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"key": "domain"}) + _ = uc.SetCheckerOptions("c1", uid, did, sid, happydns.CheckerOptions{"key": "service"}) + + got, err := uc.GetCheckerOptions("c1", uid, did, sid) + if err != nil { + t.Fatal(err) + } + if got["key"] != "service" { + t.Errorf("service scope should win, got %v", got["key"]) + } +} + +func TestGetCheckerOptionsPositional_ReturnsAllLevels(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"a": "1"}) + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "2"}) + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"c": "3"}) + + positionals, err := uc.GetCheckerOptionsPositional("c1", uid, did, nil) + if err != nil { + t.Fatal(err) + } + if len(positionals) != 3 { + t.Fatalf("expected 3 positional levels, got %d", len(positionals)) + } + // Least specific first. + if positionals[0].UserId != nil { + t.Error("first positional should be admin (no userId)") + } + if positionals[1].DomainId != nil { + t.Error("second positional should be user (no domainId)") + } + if positionals[2].DomainId == nil { + t.Error("third positional should be domain level") + } +} + +func TestGetCheckerOptions_EmptyStore(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + got, err := uc.GetCheckerOptions("nonexistent", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("expected empty options, got %v", got) + } +} + +func TestAddCheckerOptions_CreatesNewScope(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"new": "value"}) + if err != nil { + t.Fatal(err) + } + if merged["new"] != "value" { + t.Errorf("expected 'value', got %v", merged["new"]) + } +} + +// --- BuildMergedCheckerOptions tests --- + +func TestBuildMergedCheckerOptions(t *testing.T) { + stored := happydns.CheckerOptions{"a": "stored", "shared": "stored"} + run := happydns.CheckerOptions{"b": "run", "shared": "run"} + + result := checkerUC.BuildMergedCheckerOptions(stored, run) + if result["a"] != "stored" { + t.Errorf("stored key should be preserved") + } + if result["b"] != "run" { + t.Errorf("run key should be added") + } + if result["shared"] != "run" { + t.Errorf("run should override stored, got %v", result["shared"]) + } +} + +func TestBuildMergedCheckerOptions_NilInputs(t *testing.T) { + result := checkerUC.BuildMergedCheckerOptions(nil, nil) + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + + result = checkerUC.BuildMergedCheckerOptions(happydns.CheckerOptions{"a": "1"}, nil) + if result["a"] != "1" { + t.Errorf("stored key should be preserved with nil runOpts") + } + + result = checkerUC.BuildMergedCheckerOptions(nil, happydns.CheckerOptions{"b": "2"}) + if result["b"] != "2" { + t.Errorf("run key should be present with nil storedOpts") + } +} + +// --- Validation tests --- + +// registerTestChecker is a helper that registers a checker in the global +// registry and returns its ID. Each call should use a unique ID. +func registerTestChecker(id string, def *happydns.CheckerDefinition) { + def.ID = id + def.Name = id + checker.RegisterChecker(def) +} + +func TestValidateOptions_UnknownChecker(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("no_such_checker", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error for unknown checker") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateOptions_AdminScope_AcceptsAdminOpts(t *testing.T) { + registerTestChecker("val_admin_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Admin scope (all nil) — admin_key is valid. + err := uc.ValidateOptions("val_admin_ok", nil, nil, nil, happydns.CheckerOptions{"admin_key": "hello"}, false) + if err != nil { + t.Fatalf("expected no error for valid admin opt, got: %v", err) + } +} + +func TestValidateOptions_AdminScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_admin_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Admin scope — domain_key should be rejected as unknown. + err := uc.ValidateOptions("val_admin_reject_domain", nil, nil, nil, happydns.CheckerOptions{"domain_key": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at admin scope") + } + if !strings.Contains(err.Error(), "unknown") { + t.Errorf("expected 'unknown' error, got: %v", err) + } +} + +func TestValidateOptions_DomainScope_AcceptsDomainOpts(t *testing.T) { + registerTestChecker("val_domain_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("val_domain_ok", uid, did, nil, happydns.CheckerOptions{"domain_key": "hello"}, false) + if err != nil { + t.Fatalf("expected no error for valid domain opt, got: %v", err) + } +} + +func TestValidateOptions_DomainScope_RejectsAdminOpt(t *testing.T) { + registerTestChecker("val_domain_reject_admin", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("val_domain_reject_admin", uid, did, nil, happydns.CheckerOptions{"admin_key": "x"}, false) + if err == nil { + t.Fatal("expected error for admin opt at domain scope") + } +} + +func TestValidateOptions_UserScope_AcceptsUserOpts(t *testing.T) { + registerTestChecker("val_user_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + err := uc.ValidateOptions("val_user_ok", uid, nil, nil, happydns.CheckerOptions{"user_key": "val"}, false) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_ServiceScope_AcceptsServiceOpts(t *testing.T) { + registerTestChecker("val_service_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_service_ok", uid, did, sid, happydns.CheckerOptions{"svc_key": "val"}, false) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_ServiceScope_RejectsRunOpts(t *testing.T) { + registerTestChecker("val_service_reject_run", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_service_reject_run", uid, did, sid, happydns.CheckerOptions{"run_key": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt at service scope") + } +} + +func TestValidateOptions_DomainScope_RejectsRunOpts(t *testing.T) { + registerTestChecker("val_domain_reject_run", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + err := uc.ValidateOptions("val_domain_reject_run", uid, did, nil, happydns.CheckerOptions{"run_key": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt at domain scope") + } +} + +func TestValidateOptions_RequiredField(t *testing.T) { + registerTestChecker("val_required", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_have", Type: "string", Required: true, Label: "Must Have"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Missing required field. + err := uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error for missing required field") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + // Present but empty. + err = uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{"must_have": ""}, false) + if err == nil { + t.Fatal("expected error for empty required field") + } + + // Valid. + err = uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{"must_have": "ok"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_ChoicesField(t *testing.T) { + registerTestChecker("val_choices", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "mode", Type: "string", Choices: []string{"fast", "slow", "auto"}}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + + err := uc.ValidateOptions("val_choices", uid, nil, nil, happydns.CheckerOptions{"mode": "fast"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = uc.ValidateOptions("val_choices", uid, nil, nil, happydns.CheckerOptions{"mode": "invalid"}, false) + if err == nil { + t.Fatal("expected error for invalid choice") + } +} + +func TestValidateOptions_TypeCheckNumber(t *testing.T) { + registerTestChecker("val_type_num", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "count", Type: "int"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // float64 is fine (JSON numbers are float64). + err := uc.ValidateOptions("val_type_num", uid, did, nil, happydns.CheckerOptions{"count": float64(10)}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // String is not a number. + err = uc.ValidateOptions("val_type_num", uid, did, nil, happydns.CheckerOptions{"count": "ten"}, false) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestValidateOptions_TypeCheckBool(t *testing.T) { + registerTestChecker("val_type_bool", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "enabled", Type: "bool"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_type_bool", nil, nil, nil, happydns.CheckerOptions{"enabled": true}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = uc.ValidateOptions("val_type_bool", nil, nil, nil, happydns.CheckerOptions{"enabled": "true"}, false) + if err == nil { + t.Fatal("expected error for string instead of bool") + } +} + +func TestValidateOptions_EmptyOptionsValid(t *testing.T) { + registerTestChecker("val_empty_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "optional_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + err := uc.ValidateOptions("val_empty_ok", uid, did, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("empty options should be valid when no required fields, got: %v", err) + } +} + +func TestValidateOptions_NoFieldsAtScope_AcceptsEmpty(t *testing.T) { + registerTestChecker("val_no_fields_scope", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // At admin scope there are no fields defined — empty opts should pass. + err := uc.ValidateOptions("val_no_fields_scope", nil, nil, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("empty options at scope with no fields should be valid, got: %v", err) + } +} + +func TestValidateOptions_NoFieldsAtScope_AcceptsAnything(t *testing.T) { + // When no fields are defined at the target scope, validation is skipped + // (the OptionsValidator may still reject), so unknown keys at a scope + // without field definitions pass through. + registerTestChecker("val_no_fields_pass", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // At user scope, no fields are declared, so any key is accepted. + uid := idPtr() + err := uc.ValidateOptions("val_no_fields_pass", uid, nil, nil, happydns.CheckerOptions{"anything": "value"}, false) + if err != nil { + t.Fatalf("scope with no fields should skip validation, got: %v", err) + } +} + +// --- OptionsValidator interface tests --- + +func TestValidateOptions_OptionsValidatorCalled(t *testing.T) { + registerTestChecker("val_validator_err", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: fmt.Errorf("custom validation failed")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_validator_err", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error from OptionsValidator") + } + if !strings.Contains(err.Error(), "custom validation failed") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateOptions_OptionsValidatorPasses(t *testing.T) { + registerTestChecker("val_validator_ok", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: nil}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_validator_ok", nil, nil, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_MultipleValidators_StopsAtFirst(t *testing.T) { + registerTestChecker("val_multi_validators", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: nil}, + &validatingRule{name: "r2", validateErr: fmt.Errorf("r2 failed")}, + &validatingRule{name: "r3", validateErr: fmt.Errorf("r3 failed")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_multi_validators", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error from second validator") + } + if !strings.Contains(err.Error(), "r2 failed") { + t.Errorf("expected r2 error, got: %v", err) + } +} + +// --- Rule-level options tests --- + +func TestValidateOptions_RuleOptionsAtCorrectScope(t *testing.T) { + registerTestChecker("val_rule_opts", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_with_domain_opt", + opts: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_domain_opt", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_run_opt", Type: "string"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Domain scope should accept rule's domain opt. + err := uc.ValidateOptions("val_rule_opts", uid, did, nil, happydns.CheckerOptions{"rule_domain_opt": "val"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Domain scope should reject rule's run opt. + err = uc.ValidateOptions("val_rule_opts", uid, did, nil, happydns.CheckerOptions{"rule_run_opt": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt from rule at domain scope") + } +} + +func TestValidateOptions_CombinesDefAndRuleFields(t *testing.T) { + registerTestChecker("val_combined", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "def_opt", Type: "string"}, + }, + }, + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_extra", + opts: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_opt", Type: "string"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Both def and rule opts should be accepted. + err := uc.ValidateOptions("val_combined", uid, did, nil, happydns.CheckerOptions{ + "def_opt": "a", + "rule_opt": "b", + }, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Unknown key should be rejected. + err = uc.ValidateOptions("val_combined", uid, did, nil, happydns.CheckerOptions{"unknown": "x"}, false) + if err == nil { + t.Fatal("expected error for unknown key") + } +} + +// --- Validation + OptionsValidator combined --- + +func TestValidateOptions_FieldValidationRunsBeforeOptionsValidator(t *testing.T) { + registerTestChecker("val_order", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "a", Type: "string", Required: true, Label: "A"}, + }, + }, + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: fmt.Errorf("should not reach here")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Field validation should fail before reaching OptionsValidator. + err := uc.ValidateOptions("val_order", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "should not reach here") { + t.Error("OptionsValidator should not have been called — field validation should fail first") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } +} + +// --- Scope isolation tests --- + +func TestValidateOptions_DomainScope_DoesNotEnforceUserRequired(t *testing.T) { + registerTestChecker("val_scope_isolation", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_required", Type: "string", Required: true}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Domain scope should not enforce user-level required field. + err := uc.ValidateOptions("val_scope_isolation", uid, did, nil, happydns.CheckerOptions{"domain_opt": "val"}, false) + if err != nil { + t.Fatalf("domain scope should not enforce user required field, got: %v", err) + } +} + +func TestValidateOptions_AdminScope_DoesNotEnforceServiceRequired(t *testing.T) { + registerTestChecker("val_admin_no_svc", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_opt", Type: "string"}, + }, + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_required", Type: "string", Required: true}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_admin_no_svc", nil, nil, nil, happydns.CheckerOptions{"admin_opt": "val"}, false) + if err != nil { + t.Fatalf("admin scope should not enforce service required field, got: %v", err) + } +} + +func TestValidateOptions_UserScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_user_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_opt", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + err := uc.ValidateOptions("val_user_reject_domain", uid, nil, nil, happydns.CheckerOptions{"domain_opt": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at user scope") + } +} + +func TestValidateOptions_ServiceScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_svc_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_svc_reject_domain", uid, did, sid, happydns.CheckerOptions{"domain_opt": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at service scope") + } +} + +// --- withRunOpts=true tests --- + +func TestValidateOptions_WithRunOpts_AcceptsRunOptKeys(t *testing.T) { + registerTestChecker("trig_run_accept", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // With withRunOpts=true, run_key should be accepted alongside domain_key. + err := uc.ValidateOptions("trig_run_accept", uid, did, nil, happydns.CheckerOptions{ + "domain_key": "foo", + "run_key": "bar", + }, true) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_EnforcesRequiredRunOpt(t *testing.T) { + registerTestChecker("trig_run_required", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_run", Type: "string", Required: true, Label: "Must Run"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Missing required run opt. + err := uc.ValidateOptions("trig_run_required", uid, did, nil, happydns.CheckerOptions{}, true) + if err == nil { + t.Fatal("expected error for missing required run opt") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + // Present and non-empty. + err = uc.ValidateOptions("trig_run_required", uid, did, nil, happydns.CheckerOptions{"must_run": "ok"}, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_StillRejectsUnknownKeys(t *testing.T) { + registerTestChecker("trig_run_unknown", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("trig_run_unknown", uid, did, nil, happydns.CheckerOptions{"totally_unknown": "x"}, true) + if err == nil { + t.Fatal("expected error for unknown key even with withRunOpts=true") + } +} + +func TestValidateOptions_WithRunOpts_RequiredRunOptNotEnforcedWhenFalse(t *testing.T) { + registerTestChecker("trig_run_not_enforced", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_run", Type: "string", Required: true}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // withRunOpts=false: required run opt is not enforced, run_key is not known. + err := uc.ValidateOptions("trig_run_not_enforced", uid, did, nil, happydns.CheckerOptions{"domain_key": "val"}, false) + if err != nil { + t.Fatalf("persisted scope should not enforce run opt required field, got: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_RuleRunOptsAccepted(t *testing.T) { + registerTestChecker("trig_rule_run_accept", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "def_domain_opt", Type: "string"}, + }, + }, + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_with_run", + opts: happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_run_opt", Type: "string", Required: true, Label: "Rule Run Opt"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // withRunOpts=true: rule run opt is accepted and required. + err := uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "def_domain_opt": "x", + }, true) + if err == nil { + t.Fatal("expected error: rule's required run opt is missing") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + err = uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "def_domain_opt": "x", + "rule_run_opt": "y", + }, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // withRunOpts=false: rule run opt is unknown (rejected). + err = uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "rule_run_opt": "y", + }, false) + if err == nil { + t.Fatal("expected error: rule run opt should be unknown at domain scope without withRunOpts") + } +} + +// --- Auto-fill tests --- + +// autoFillStore is a minimal in-memory store satisfying CheckAutoFillStorage. +type autoFillStore struct { + domains map[string]*happydns.Domain + zones map[string]*happydns.ZoneMessage + users map[string]*happydns.User +} + +func newAutoFillStore() *autoFillStore { + return &autoFillStore{ + domains: make(map[string]*happydns.Domain), + zones: make(map[string]*happydns.ZoneMessage), + users: make(map[string]*happydns.User), + } +} + +func (s *autoFillStore) GetDomain(id happydns.Identifier) (*happydns.Domain, error) { + if d, ok := s.domains[id.String()]; ok { + return d, nil + } + return nil, fmt.Errorf("domain %s not found", id) +} + +func (s *autoFillStore) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) { + if z, ok := s.zones[id.String()]; ok { + return z, nil + } + return nil, fmt.Errorf("zone %s not found", id) +} + +func (s *autoFillStore) ListDomains(u *happydns.User) ([]*happydns.Domain, error) { + return nil, nil +} + +func (s *autoFillStore) GetUser(id happydns.Identifier) (*happydns.User, error) { + if u, ok := s.users[id.String()]; ok { + return u, nil + } + return nil, fmt.Errorf("user %s not found", id) +} + +func TestBuildMergedCheckerOptionsWithAutoFill_InjectsValues(t *testing.T) { + registerTestChecker("af_inject", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_name_field", Type: "string", AutoFill: happydns.AutoFillDomainName}, + {Id: "user_opt", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + afStore := newAutoFillStore() + + uid := idPtr() + did := idPtr() + + // Set up domain in auto-fill store. + zoneId, _ := happydns.NewRandomIdentifier() + afStore.domains[did.String()] = &happydns.Domain{ + Id: *did, + Owner: *uid, + DomainName: "example.com.", + ZoneHistory: []happydns.Identifier{zoneId}, + } + afStore.zones[zoneId.String()] = &happydns.ZoneMessage{} + + uc := checkerUC.NewCheckerOptionsUsecase(optStore, afStore) + _ = uc.SetCheckerOptions("af_inject", uid, nil, nil, happydns.CheckerOptions{"user_opt": "hello"}) + + merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("af_inject", uid, did, nil, nil) + if err != nil { + t.Fatal(err) + } + + if merged["domain_name_field"] != "example.com." { + t.Errorf("expected auto-filled domain name, got %v", merged["domain_name_field"]) + } + if merged["user_opt"] != "hello" { + t.Errorf("expected stored opt to be preserved, got %v", merged["user_opt"]) + } +} + +func TestBuildMergedCheckerOptionsWithAutoFill_OverridesRunOpts(t *testing.T) { + registerTestChecker("af_override", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName}, + }, + }, + }) + + optStore := newOptionsStore() + afStore := newAutoFillStore() + + uid := idPtr() + did := idPtr() + zoneId, _ := happydns.NewRandomIdentifier() + afStore.domains[did.String()] = &happydns.Domain{ + Id: *did, + Owner: *uid, + DomainName: "real.example.com.", + ZoneHistory: []happydns.Identifier{zoneId}, + } + afStore.zones[zoneId.String()] = &happydns.ZoneMessage{} + + uc := checkerUC.NewCheckerOptionsUsecase(optStore, afStore) + + // Even if runOpts tries to set the auto-fill field, auto-fill wins. + merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("af_override", uid, did, nil, + happydns.CheckerOptions{"dn": "user-provided.com."}) + if err != nil { + t.Fatal(err) + } + + if merged["dn"] != "real.example.com." { + t.Errorf("auto-fill should override run opts, got %v", merged["dn"]) + } +} + +func TestSetCheckerOptions_StripsAutoFillKeys(t *testing.T) { + registerTestChecker("af_strip", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName}, + {Id: "normal", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(optStore, nil) + + uid := idPtr() + err := uc.SetCheckerOptions("af_strip", uid, nil, nil, happydns.CheckerOptions{ + "dn": "should-be-stripped", + "normal": "kept", + }) + if err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("af_strip", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["dn"]; ok { + t.Error("auto-fill key should have been stripped from persisted options") + } + if got["normal"] != "kept" { + t.Errorf("normal key should be preserved, got %v", got["normal"]) + } +} + +func TestValidateOptions_SkipsAutoFillFields(t *testing.T) { + registerTestChecker("af_validate_skip", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName, Required: true}, + {Id: "normal", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(optStore, nil) + + uid := idPtr() + did := idPtr() + + // The auto-fill field "dn" is required, but since it's auto-filled, + // validation should not enforce it as a user-provided requirement. + err := uc.ValidateOptions("af_validate_skip", uid, did, nil, happydns.CheckerOptions{ + "normal": "val", + }, false) + if err != nil { + t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err) + } +} diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go new file mode 100644 index 00000000..c72f697a --- /dev/null +++ b/internal/usecase/checker/scheduler.go @@ -0,0 +1,579 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "container/heap" + "context" + "hash/fnv" + "log" + "slices" + "sort" + "sync" + "time" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +const ( + minSpacing = 2 * time.Second + maxCatchUpWindow = 10 * time.Minute + defaultInterval = 24 * time.Hour +) + +// SchedulerJob represents a single scheduled checker execution. +type SchedulerJob struct { + CheckerID string `json:"checkerID"` + Target happydns.CheckTarget `json:"target"` + PlanID *happydns.Identifier `json:"planID" swaggertype:"string"` + Interval time.Duration `json:"interval" swaggertype:"integer"` + NextRun time.Time `json:"nextRun"` + index int // heap index +} + +// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun. +type SchedulerQueue []*SchedulerJob + +func (q SchedulerQueue) Len() int { return len(q) } +func (q SchedulerQueue) Less(i, j int) bool { return q[i].NextRun.Before(q[j].NextRun) } +func (q SchedulerQueue) Swap(i, j int) { + q[i], q[j] = q[j], q[i] + q[i].index = i + q[j].index = j +} + +func (q *SchedulerQueue) Push(x any) { + n := len(*q) + job := x.(*SchedulerJob) + job.index = n + *q = append(*q, job) +} + +func (q *SchedulerQueue) Pop() any { + old := *q + n := len(old) + job := old[n-1] + old[n-1] = nil + job.index = -1 + *q = old[:n-1] + return job +} + +func (q *SchedulerQueue) Peek() *SchedulerJob { + if len(*q) == 0 { + return nil + } + return (*q)[0] +} + +// SchedulerStatus holds a snapshot of the scheduler's current state. +type SchedulerStatus struct { + Running bool `json:"running"` + JobCount int `json:"job_count"` + NextJobs []*SchedulerJob `json:"next_jobs,omitempty"` +} + +// Scheduler manages periodic execution of checkers. +type Scheduler struct { + queue SchedulerQueue + engine happydns.CheckerEngine + planStore CheckPlanStorage + domainStore DomainLister + zoneStore ZoneGetter + stateStore SchedulerStateStorage + cancel context.CancelFunc + mu sync.RWMutex + running bool + ctx context.Context + maxConcurrency int +} + +// NewScheduler creates a new Scheduler. +func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, zoneStore ZoneGetter, stateStore SchedulerStateStorage) *Scheduler { + if maxConcurrency <= 0 { + maxConcurrency = 1 + } + return &Scheduler{ + engine: engine, + planStore: planStore, + domainStore: domainStore, + zoneStore: zoneStore, + stateStore: stateStore, + maxConcurrency: maxConcurrency, + } +} + +// Start begins the scheduler loop in a goroutine. +func (s *Scheduler) Start(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + s.mu.Lock() + s.ctx = ctx + s.cancel = cancel + s.running = true + s.buildQueue() + s.spreadOverdueJobs() + s.mu.Unlock() + go s.run(ctx) +} + +// Stop halts the scheduler. +func (s *Scheduler) Stop() { + s.mu.Lock() + s.running = false + cancel := s.cancel + s.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// GetStatus returns a snapshot of the scheduler's current state. +func (s *Scheduler) GetStatus() SchedulerStatus { + s.mu.RLock() + defer s.mu.RUnlock() + + status := SchedulerStatus{ + Running: s.running, + JobCount: s.queue.Len(), + } + + n := min(20, s.queue.Len()) + if n > 0 { + all := make([]*SchedulerJob, s.queue.Len()) + copy(all, s.queue) + sort.Slice(all, func(i, j int) bool { + return all[i].NextRun.Before(all[j].NextRun) + }) + status.NextJobs = all[:n] + } + + return status +} + +// SetEnabled starts or stops the scheduler. +func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error { + s.Stop() + if enabled { + s.mu.Lock() + parentCtx := s.ctx + s.mu.Unlock() + if parentCtx == nil { + parentCtx = ctx + } + s.Start(parentCtx) + } + return nil +} + +// RebuildQueue rebuilds the scheduler queue and returns the new job count. +func (s *Scheduler) RebuildQueue() int { + s.mu.Lock() + defer s.mu.Unlock() + s.buildQueue() + s.spreadOverdueJobs() + return s.queue.Len() +} + +func (s *Scheduler) run(ctx context.Context) { + sem := make(chan struct{}, s.maxConcurrency) + + for { + s.mu.RLock() + qLen := s.queue.Len() + s.mu.RUnlock() + + if qLen == 0 { + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Minute): + s.mu.Lock() + s.buildQueue() + s.mu.Unlock() + continue + } + } + + s.mu.RLock() + next := s.queue.Peek() + var delay time.Duration + if next != nil { + delay = time.Until(next.NextRun) + } + s.mu.RUnlock() + + if delay > 0 { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + + s.mu.Lock() + if s.queue.Len() == 0 { + s.mu.Unlock() + continue + } + job := heap.Pop(&s.queue).(*SchedulerJob) + s.mu.Unlock() + + // Find plan if applicable. + var plan *happydns.CheckPlan + if job.PlanID != nil { + p, err := s.planStore.GetCheckPlan(*job.PlanID) + if err == nil { + plan = p + } + } + + // Acquire a concurrency slot, but stay responsive to cancellation. + select { + case sem <- struct{}{}: + default: + log.Printf("Scheduler: all %d workers busy, waiting for a slot (checker %s on %s)", s.maxConcurrency, job.CheckerID, job.Target.String()) + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + } + + go func(j *SchedulerJob, p *happydns.CheckPlan) { + defer func() { <-sem }() + log.Printf("Scheduler: running checker %s on %s", j.CheckerID, j.Target.String()) + exec, err := s.engine.CreateExecution(j.CheckerID, j.Target, p) + if err != nil { + log.Printf("Scheduler: checker %s on %s failed to create execution: %v", j.CheckerID, j.Target.String(), err) + return + } + _, err = s.engine.RunExecution(ctx, exec, p, nil) + if err != nil { + log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err) + } + if s.stateStore != nil { + if err := s.stateStore.SetLastSchedulerRun(time.Now()); err != nil { + log.Printf("Scheduler: failed to persist last run time: %v", err) + } + } + }(job, plan) + + // Advance to next cycle, skipping past cycles. + now := time.Now() + for job.NextRun.Before(now) { + job.NextRun = job.NextRun.Add(job.Interval) + } + // Add jitter for next cycle. + job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval)) + s.mu.Lock() + heap.Push(&s.queue, job) + s.mu.Unlock() + } +} + +func (s *Scheduler) buildQueue() { + s.queue = s.queue[:0] + + var lastRun time.Time + if s.stateStore != nil { + if t, err := s.stateStore.GetLastSchedulerRun(); err != nil { + log.Printf("Scheduler: failed to read last run time: %v", err) + } else { + lastRun = t + } + } + + checkers := checkerPkg.GetCheckers() + plans, err := s.loadAllPlans() + if err != nil { + log.Printf("Scheduler: failed to load plans: %v", err) + } + + // Build a set of disabled (checker, target) pairs. + 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 + } + } + + // 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 + domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + for _, c := range domainCheckers { + key := c.id + "|" + domainTarget.String() + if disabledSet[key] { + continue + } + plan := planMap[key] + + interval := s.effectiveInterval(c.def, plan) + offset := computeOffset(c.id, domainTarget.String(), interval) + nextRun := computeNextRun(interval, offset, lastRun) + + job := &SchedulerJob{ + CheckerID: c.id, + Target: domainTarget, + Interval: interval, + NextRun: nextRun, + } + if plan != nil { + job.PlanID = &plan.Id + } + 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.String(), DomainId: did.String(), ServiceId: sid.String(), 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) + } + } + } + } +} + +func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) { + iter, err := s.planStore.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plans = append(plans, iter.Item()) + } + return plans, nil +} + +func (s *Scheduler) loadAllDomains() []*happydns.Domain { + if s.domainStore == nil { + return nil + } + iter, err := s.domainStore.ListAllDomains() + if err != nil { + log.Printf("Scheduler: failed to list domains for auto-discovery: %v", err) + return nil + } + defer iter.Close() + + var domains []*happydns.Domain + for iter.Next() { + d := iter.Item() + domains = append(domains, d) + } + 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 { + interval = def.Interval.Default + } + + if plan != nil && plan.Interval != nil { + interval = *plan.Interval + } + + // Clamp to bounds. + if def.Interval != nil { + if interval < def.Interval.Min { + interval = def.Interval.Min + } + if interval > def.Interval.Max { + interval = def.Interval.Max + } + } + + return interval +} + +func (s *Scheduler) spreadOverdueJobs() { + now := time.Now() + var overdue []*SchedulerJob + + for s.queue.Len() > 0 && s.queue.Peek().NextRun.Before(now) { + overdue = append(overdue, heap.Pop(&s.queue).(*SchedulerJob)) + } + + if len(overdue) == 0 { + return + } + + window := time.Duration(len(overdue)) * minSpacing + window = min(window, maxCatchUpWindow) + + for i, job := range overdue { + delay := window * time.Duration(i) / time.Duration(len(overdue)) + job.NextRun = now.Add(delay) + heap.Push(&s.queue, job) + } +} + +// GetPlannedJobsForChecker returns a snapshot of scheduled jobs for the given checker and target. +func (s *Scheduler) GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob { + s.mu.RLock() + defer s.mu.RUnlock() + tStr := target.String() + var result []*SchedulerJob + for _, job := range s.queue { + if job.CheckerID == checkerID && job.Target.String() == tStr { + cp := *job + result = append(result, &cp) + } + } + return result +} + +// computeOffset returns a deterministic offset within the interval. +func computeOffset(checkerID, targetStr string, interval time.Duration) time.Duration { + h := fnv.New64a() + h.Write([]byte(checkerID + targetStr)) + return time.Duration(h.Sum64()%uint64(interval.Nanoseconds())) * time.Nanosecond +} + +// computeJitter returns a small deterministic jitter (~5% of interval). +func computeJitter(checkerID, targetStr string, cycleTime time.Time, interval time.Duration) time.Duration { + h := fnv.New64a() + h.Write([]byte(checkerID + targetStr + cycleTime.Format(time.RFC3339))) + maxJitter := interval / 20 // 5% + if maxJitter <= 0 { + return 0 + } + return time.Duration(h.Sum64()%uint64(maxJitter.Nanoseconds())) * time.Nanosecond +} + +// computeNextRun calculates the next run time based on interval, offset, and +// the last time the scheduler was known to be active. When lastActive is zero +// (first execution), it behaves as before. Otherwise it detects jobs that were +// missed during downtime (slot in (lastActive, now]) and schedules them +// immediately so spreadOverdueJobs can stagger them, while skipping jobs that +// already ran (slot <= lastActive). +func computeNextRun(interval, offset time.Duration, lastActive time.Time) time.Time { + now := time.Now() + + // Use Unix nanoseconds to avoid time.Duration overflow with ancient epochs. + nowNano := now.UnixNano() + intervalNano := int64(interval) + offsetNano := int64(offset) % intervalNano + + // Find the most recent grid slot <= now. + cycleN := (nowNano - offsetNano) / intervalNano + slotNano := cycleN*intervalNano + offsetNano + if slotNano > nowNano { + slotNano -= intervalNano + } + slot := time.Unix(0, slotNano) + + if lastActive.IsZero() { + // First execution: schedule at the next future slot. + if !slot.After(now) { + return slot.Add(interval) + } + return slot + } + + // Slot was missed during downtime — schedule now for catch-up. + if slot.After(lastActive) && !slot.After(now) { + return now + } + + // Slot already executed before shutdown — advance to next cycle. + return slot.Add(interval) +} diff --git a/internal/usecase/checker/scheduler_test.go b/internal/usecase/checker/scheduler_test.go new file mode 100644 index 00000000..f9ceaf68 --- /dev/null +++ b/internal/usecase/checker/scheduler_test.go @@ -0,0 +1,76 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "testing" + "time" +) + +func TestComputeNextRun_ZeroLastActive(t *testing.T) { + interval := 1 * time.Hour + offset := 10 * time.Minute + + nextRun := computeNextRun(interval, offset, time.Time{}) + now := time.Now() + + if !nextRun.After(now) { + t.Errorf("expected nextRun (%v) to be in the future (now=%v)", nextRun, now) + } + if nextRun.After(now.Add(interval)) { + t.Errorf("expected nextRun (%v) to be within one interval from now (%v)", nextRun, now.Add(interval)) + } +} + +func TestComputeNextRun_RecentLastActive_NoRerun(t *testing.T) { + interval := 1 * time.Hour + offset := computeOffset("test-checker", "test-target", interval) + now := time.Now() + + // lastActive is very recent — the current slot was already executed. + lastActive := now.Add(-1 * time.Minute) + + nextRun := computeNextRun(interval, offset, lastActive) + + if !nextRun.After(now) { + t.Errorf("expected nextRun (%v) to be in the future when lastActive is recent (now=%v)", nextRun, now) + } +} + +func TestComputeNextRun_OldLastActive_CatchUp(t *testing.T) { + interval := 1 * time.Hour + offset := 0 * time.Minute + now := time.Now() + + // lastActive is several hours ago — there should be a missed slot. + lastActive := now.Add(-3 * time.Hour) + + nextRun := computeNextRun(interval, offset, lastActive) + + // The missed slot should be scheduled at now (catch-up). + if nextRun.After(now.Add(1 * time.Second)) { + t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now) + } + if nextRun.Before(now.Add(-1 * time.Second)) { + t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now) + } +} diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go index 26dc1ff3..f1ba3a33 100644 --- a/internal/usecase/checker/storage.go +++ b/internal/usecase/checker/storage.go @@ -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 { From 36069c12650b0cc70a851dd14d9753fba717d608 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:53:22 +0700 Subject: [PATCH 06/54] checkers: add API controllers, routes, and app wiring Wire up the checker system to the HTTP layer: - API controllers for checker operations, options, plans, and results - Scoped routes at domain and service level - Admin controllers for checker config and scheduler management - App initialization: create usecases, start/stop scheduler - Zone controller updated to include per-service check status --- .drone.yml | 4 + cmd/happyDomain/main.go | 1 + generate.go | 4 +- .../api-admin/controller/check_controller.go | 64 ++++ .../controller/scheduler_controller.go | 100 ++++++ .../api-admin/controller/zone_controller.go | 2 +- internal/api-admin/route/check.go | 51 +++ internal/api-admin/route/route.go | 5 + internal/api-admin/route/scheduler.go | 41 +++ internal/api/controller/checker.go | 273 +++++++++++++++ internal/api/controller/checker_options.go | 219 ++++++++++++ internal/api/controller/checker_plans.go | 225 ++++++++++++ internal/api/controller/checker_results.go | 329 ++++++++++++++++++ internal/api/controller/zone.go | 22 +- internal/api/route/checker.go | 99 ++++++ internal/api/route/domain.go | 7 + internal/api/route/route.go | 20 ++ internal/api/route/service.go | 6 + internal/api/route/zone.go | 9 + internal/app/admin.go | 6 + internal/app/app.go | 33 ++ internal/config/cli.go | 2 + .../usecase/checker/check_status_usecase.go | 7 + model/config.go | 4 + model/zone.go | 8 + 25 files changed, 1535 insertions(+), 6 deletions(-) create mode 100644 internal/api-admin/controller/check_controller.go create mode 100644 internal/api-admin/controller/scheduler_controller.go create mode 100644 internal/api-admin/route/check.go create mode 100644 internal/api-admin/route/scheduler.go create mode 100644 internal/api/controller/checker.go create mode 100644 internal/api/controller/checker_options.go create mode 100644 internal/api/controller/checker_plans.go create mode 100644 internal/api/controller/checker_results.go create mode 100644 internal/api/route/checker.go diff --git a/.drone.yml b/.drone.yml index 691a9cd5..8602760d 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,6 +14,8 @@ steps: - sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go - go install github.com/swaggo/swag/cmd/swag@latest - go generate ./... + environment: + CGO_ENABLED: 0 - name: update frontend version image: node:24-alpine @@ -220,6 +222,8 @@ steps: - sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go - go install github.com/swaggo/swag/cmd/swag@latest - go generate ./... + environment: + CGO_ENABLED: 0 - name: update frontend version image: node:24-alpine diff --git a/cmd/happyDomain/main.go b/cmd/happyDomain/main.go index 30cffc04..6d2a0522 100644 --- a/cmd/happyDomain/main.go +++ b/cmd/happyDomain/main.go @@ -38,6 +38,7 @@ import ( _ "git.happydns.org/happyDomain/internal/storage/oracle-nosql" _ "git.happydns.org/happyDomain/internal/storage/postgresql" "git.happydns.org/happyDomain/model" + _ "git.happydns.org/happyDomain/checkers" _ "git.happydns.org/happyDomain/services/abstract" _ "git.happydns.org/happyDomain/services/providers/google" ) diff --git a/generate.go b/generate.go index 86ee819a..6d636279 100644 --- a/generate.go +++ b/generate.go @@ -26,5 +26,5 @@ package main //go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts //go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts //go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go -//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go -//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go +//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go +//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go diff --git a/internal/api-admin/controller/check_controller.go b/internal/api-admin/controller/check_controller.go new file mode 100644 index 00000000..b439c552 --- /dev/null +++ b/internal/api-admin/controller/check_controller.go @@ -0,0 +1,64 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + apicontroller "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" +) + +// AdminCheckerController handles admin checker-related API endpoints. +// It embeds CheckerController and overrides GetCheckerOptions to return a flat +// (non-positional) map scoped to nil (global/admin) level. +type AdminCheckerController struct { + *apicontroller.CheckerController +} + +// NewAdminCheckerController creates a new AdminCheckerController. +func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController { + return &AdminCheckerController{ + CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil), + } +} + +// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map. +// +// @Summary Get admin-level checker options +// @Tags admin,checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [get] +func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) { + checkerID := c.Param("checkerId") + opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, opts) +} diff --git a/internal/api-admin/controller/scheduler_controller.go b/internal/api-admin/controller/scheduler_controller.go new file mode 100644 index 00000000..c58438b0 --- /dev/null +++ b/internal/api-admin/controller/scheduler_controller.go @@ -0,0 +1,100 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" +) + +// AdminSchedulerController handles admin scheduler API endpoints. +type AdminSchedulerController struct { + scheduler *checkerUC.Scheduler +} + +// NewAdminSchedulerController creates a new AdminSchedulerController. +func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController { + return &AdminSchedulerController{scheduler: scheduler} +} + +// GetSchedulerStatus returns the current scheduler status. +// +// @Summary Get scheduler status +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Router /scheduler [get] +func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) { + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// EnableScheduler starts the scheduler and returns updated status. +// +// @Summary Enable the scheduler +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Failure 500 {object} object +// @Router /scheduler/enable [post] +func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) { + if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// DisableScheduler stops the scheduler and returns updated status. +// +// @Summary Disable the scheduler +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Failure 500 {object} object +// @Router /scheduler/disable [post] +func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) { + if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// RescheduleUpcoming rebuilds the job queue and returns the new count. +// +// @Summary Rebuild the scheduler queue +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} map[string]int +// @Router /scheduler/reschedule-upcoming [post] +func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) { + n := s.scheduler.RebuildQueue() + c.JSON(http.StatusOK, gin.H{"rescheduled": n}) +} diff --git a/internal/api-admin/controller/zone_controller.go b/internal/api-admin/controller/zone_controller.go index 695c0bab..ae5fd176 100644 --- a/internal/api-admin/controller/zone_controller.go +++ b/internal/api-admin/controller/zone_controller.go @@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) { // @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get] // @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get] func (zc *ZoneController) GetZone(c *gin.Context) { - apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService) + apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil) apizc.GetZone(c) } diff --git a/internal/api-admin/route/check.go b/internal/api-admin/route/check.go new file mode 100644 index 00000000..a16831d3 --- /dev/null +++ b/internal/api-admin/route/check.go @@ -0,0 +1,51 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) { + if dep.CheckerOptionsUC == nil { + return + } + cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC) + + apiCheckersRoutes := router.Group("/checkers") + apiCheckersRoutes.GET("", cc.ListCheckers) + + apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId") + apiCheckerRoutes.Use(cc.CheckerHandler) + apiCheckerRoutes.GET("", cc.GetChecker) + + apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options") + apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions) + apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions) + apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions) + + apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname") + apiCheckerOptionRoutes.GET("", cc.GetCheckerOption) + apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption) +} diff --git a/internal/api-admin/route/route.go b/internal/api-admin/route/route.go index 3a328b2e..e70ab6a9 100644 --- a/internal/api-admin/route/route.go +++ b/internal/api-admin/route/route.go @@ -26,6 +26,7 @@ import ( api "git.happydns.org/happyDomain/internal/api/route" "git.happydns.org/happyDomain/internal/storage" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -41,14 +42,18 @@ type Dependencies struct { ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase ZoneImporter happydns.ZoneImporterUsecase ZoneService happydns.ZoneServiceUsecase + CheckerOptionsUC *checkerUC.CheckerOptionsUsecase + CheckScheduler *checkerUC.Scheduler } func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) { apiRoutes := router.Group("/api") declareBackupRoutes(cfg, apiRoutes, s) + declareChecksRoutes(apiRoutes, dep) declareDomainRoutes(apiRoutes, dep, s) declareProviderRoutes(apiRoutes, dep, s) + declareSchedulerRoutes(apiRoutes, dep) declareSessionsRoutes(cfg, apiRoutes, s) declareUserAuthsRoutes(apiRoutes, dep, s) declareUsersRoutes(apiRoutes, dep, s) diff --git a/internal/api-admin/route/scheduler.go b/internal/api-admin/route/scheduler.go new file mode 100644 index 00000000..a0580698 --- /dev/null +++ b/internal/api-admin/route/scheduler.go @@ -0,0 +1,41 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) { + if dep.CheckScheduler == nil { + return + } + ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler) + + schedulerRoute := router.Group("/scheduler") + schedulerRoute.GET("", ctrl.GetSchedulerStatus) + schedulerRoute.POST("/enable", ctrl.EnableScheduler) + schedulerRoute.POST("/disable", ctrl.DisableScheduler) + schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming) +} diff --git a/internal/api/controller/checker.go b/internal/api/controller/checker.go new file mode 100644 index 00000000..5009a27f --- /dev/null +++ b/internal/api/controller/checker.go @@ -0,0 +1,273 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + checkerPkg "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckerController handles checker-related API endpoints. +type CheckerController struct { + engine happydns.CheckerEngine + OptionsUC *checkerUC.CheckerOptionsUsecase + planUC *checkerUC.CheckPlanUsecase + statusUC *checkerUC.CheckStatusUsecase +} + +// NewCheckerController creates a new CheckerController. +func NewCheckerController( + engine happydns.CheckerEngine, + optionsUC *checkerUC.CheckerOptionsUsecase, + planUC *checkerUC.CheckPlanUsecase, + statusUC *checkerUC.CheckStatusUsecase, +) *CheckerController { + return &CheckerController{ + engine: engine, + OptionsUC: optionsUC, + planUC: planUC, + statusUC: statusUC, + } +} + +// StatusUC returns the CheckStatusUsecase for use by other controllers. +func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase { + return cc.statusUC +} + +// targetFromContext builds a CheckTarget from middleware context values. +func targetFromContext(c *gin.Context) happydns.CheckTarget { + user := middleware.MyUser(c) + target := happydns.CheckTarget{} + if user != nil { + target.UserId = user.Id.String() + } + if domain, exists := c.Get("domain"); exists { + d := domain.(*happydns.Domain) + target.DomainId = d.Id.String() + } + if sid, exists := c.Get("serviceid"); exists { + id := sid.(happydns.Identifier) + target.ServiceId = id.String() + if z, zExists := c.Get("zone"); zExists { + zone := z.(*happydns.Zone) + if _, svc := zone.FindService(id); svc != nil { + target.ServiceType = svc.Type + } + } + } + return target +} + +// targetMatchesContext verifies that every non-empty field in contextTarget +// matches the corresponding field in resourceTarget. Returns false if any +// context-specified scope does not match, indicating the resource belongs +// to a different user/domain/service than the request scope. +func targetMatchesContext(contextTarget, resourceTarget happydns.CheckTarget) bool { + if contextTarget.UserId != "" && contextTarget.UserId != resourceTarget.UserId { + return false + } + if contextTarget.DomainId != "" && contextTarget.DomainId != resourceTarget.DomainId { + return false + } + if contextTarget.ServiceId != "" && contextTarget.ServiceId != resourceTarget.ServiceId { + return false + } + return true +} + +// --- Global checker routes --- + +// ListCheckers returns all registered checker definitions. +// +// @Summary List available checkers +// @Tags checkers +// @Produce json +// @Success 200 {object} map[string]checker.CheckerDefinition +// @Router /checkers [get] +func (cc *CheckerController) ListCheckers(c *gin.Context) { + c.JSON(http.StatusOK, checkerPkg.GetCheckers()) +} + +// GetChecker returns a specific checker definition. +// +// @Summary Get a checker definition +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Success 200 {object} checker.CheckerDefinition +// @Failure 404 {object} happydns.ErrorResponse +// @Router /checkers/{checkerId} [get] +func (cc *CheckerController) GetChecker(c *gin.Context) { + checkerID := c.Param("checkerId") + def := checkerPkg.FindChecker(checkerID) + if def == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"}) + return + } + c.JSON(http.StatusOK, def) +} + +// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context. +func (cc *CheckerController) CheckerHandler(c *gin.Context) { + checkerID := c.Param("checkerId") + def := checkerPkg.FindChecker(checkerID) + if def == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"}) + return + } + c.Set("checker", def) + c.Next() +} + +// --- Scoped routes (domain/service) --- + +// ListAvailableChecks lists all checkers with their latest status for a target. +// +// @Summary List available checks with status +// @Tags checkers +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckerStatus +// @Router /domains/{domain}/checkers [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get] +func (cc *CheckerController) ListAvailableChecks(c *gin.Context) { + target := targetFromContext(c) + + result, err := cc.statusUC.ListCheckerStatuses(target) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, result) +} + +// TriggerCheck manually triggers a checker execution. +// By default the check runs asynchronously and returns an Execution (HTTP 202). +// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200). +// +// @Summary Trigger a manual check +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param sync query bool false "Run synchronously" +// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckEvaluation +// @Success 202 {object} happydns.Execution +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post] +func (cc *CheckerController) TriggerCheck(c *gin.Context) { + cname := c.Param("checkerId") + if checkerPkg.FindChecker(cname) == nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Checker not found"}) + return + } + + var req happydns.CheckerRunRequest + _ = c.ShouldBindJSON(&req) + + target := targetFromContext(c) + if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + // Build a temporary plan from enabled rules if provided. + var plan *happydns.CheckPlan + if len(req.EnabledRules) > 0 { + plan = &happydns.CheckPlan{ + CheckerID: cname, + Target: target, + Enabled: req.EnabledRules, + } + } + + exec, err := cc.engine.CreateExecution(cname, target, plan) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + if c.Query("sync") == "true" { + eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, eval) + } else { + go cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options) + c.JSON(http.StatusAccepted, exec) + } +} + +// GetExecutionStatus returns the status of an execution. +// +// @Summary Get execution status +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.Execution +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get] +func (cc *CheckerController) GetExecutionStatus(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + c.JSON(http.StatusOK, exec) +} diff --git a/internal/api/controller/checker_options.go b/internal/api/controller/checker_options.go new file mode 100644 index 00000000..281a3a41 --- /dev/null +++ b/internal/api/controller/checker_options.go @@ -0,0 +1,219 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// GetCheckerOptions returns layered options for a checker, from least to most specific scope. +// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes). +// +// @Summary Get checker options by scope +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckerOptionsPositional +// @Router /checkers/{checkerId}/options [get] +// @Router /domains/{domain}/checkers/{checkerId}/options [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get] +func (cc *CheckerController) GetCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if positionals == nil { + positionals = []*happydns.CheckerOptionsPositional{} + } + + // Append auto-fill resolved values so the frontend can display them. + autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target) + if err == nil && autoFillOpts != nil { + positionals = append(positionals, &happydns.CheckerOptionsPositional{ + CheckName: checkerID, + UserId: happydns.TargetIdentifier(target.UserId), + DomainId: happydns.TargetIdentifier(target.DomainId), + ServiceId: happydns.TargetIdentifier(target.ServiceId), + Options: autoFillOpts, + }) + } + + c.JSON(http.StatusOK, positionals) +} + +// AddCheckerOptions partially merges options at the current scope. +// +// @Summary Merge checker options +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param options body checker.CheckerOptions true "Options to merge" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [post] +// @Router /domains/{domain}/checkers/{checkerId}/options [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post] +func (cc *CheckerController) AddCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + var opts happydns.CheckerOptions + if err := c.ShouldBindJSON(&opts); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + merged, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + c.JSON(http.StatusOK, merged) +} + +// ChangeCheckerOptions fully replaces options at the current scope. +// +// @Summary Replace checker options +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param options body checker.CheckerOptions true "Options to set" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [put] +// @Router /domains/{domain}/checkers/{checkerId}/options [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put] +func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + var opts happydns.CheckerOptions + if err := c.ShouldBindJSON(&opts); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, opts) +} + +// GetCheckerOption returns a single option value at the current scope. +// +// @Summary Get a single checker option +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param optname path string true "Option name" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Router /checkers/{checkerId}/options/{optname} [get] +// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get] +func (cc *CheckerController) GetCheckerOption(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + optname := c.Param("optname") + val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if val == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"}) + return + } + c.JSON(http.StatusOK, val) +} + +// SetCheckerOption sets a single option value at the current scope. +// +// @Summary Set a single checker option +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param optname path string true "Option name" +// @Param value body any true "Option value" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Router /checkers/{checkerId}/options/{optname} [put] +// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put] +func (cc *CheckerController) SetCheckerOption(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + optname := c.Param("optname") + var value any + if err := c.ShouldBindJSON(&value); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + // Validate the full merged options after inserting the key. + existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + existing[optname] = value + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, value) +} diff --git a/internal/api/controller/checker_plans.go b/internal/api/controller/checker_plans.go new file mode 100644 index 00000000..1583ea1d --- /dev/null +++ b/internal/api/controller/checker_plans.go @@ -0,0 +1,225 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// ListCheckPlans returns all check plans for a domain. +// +// @Summary List check plans for a domain +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckPlan +// @Router /domains/{domain}/checkers/{checkerId}/plans [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get] +func (cc *CheckerController) ListCheckPlans(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + + plans, err := cc.planUC.ListCheckPlansByTarget(target) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + filtered := make([]*happydns.CheckPlan, 0, len(plans)) + for _, p := range plans { + if p.CheckerID == checkerID { + filtered = append(filtered, p) + } + } + c.JSON(http.StatusOK, filtered) +} + +// CreateCheckPlan creates a new check plan. +// +// @Summary Create a check plan +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param plan body happydns.CheckPlan true "Check plan to create" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 201 {object} happydns.CheckPlan +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post] +func (cc *CheckerController) CreateCheckPlan(c *gin.Context) { + target := targetFromContext(c) + + var plan happydns.CheckPlan + if err := c.ShouldBindJSON(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + plan.Target = target + + if err := cc.planUC.CreateCheckPlan(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Cannot create check plan: %s", err.Error())}) + return + } + + c.JSON(http.StatusCreated, plan) +} + +// GetCheckPlan returns a specific check plan. +// +// @Summary Get a check plan +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckPlan +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get] +func (cc *CheckerController) GetCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + plan, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), plan.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + c.JSON(http.StatusOK, plan) +} + +// UpdateCheckPlan updates an existing check plan. +// +// @Summary Update a check plan +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param plan body happydns.CheckPlan true "Updated check plan" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckPlan +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put] +func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + existing, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), existing.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + var plan happydns.CheckPlan + if err := c.ShouldBindJSON(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + updated, err := cc.planUC.UpdateCheckPlan(planID, &plan) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Cannot update check plan: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, updated) +} + +// DeleteCheckPlan deletes a check plan. +// +// @Summary Delete a check plan +// @Tags checkers +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete] +func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + plan, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), plan.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if err := cc.planUC.DeleteCheckPlan(planID); err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/controller/checker_results.go b/internal/api/controller/checker_results.go new file mode 100644 index 00000000..4644b1f6 --- /dev/null +++ b/internal/api/controller/checker_results.go @@ -0,0 +1,329 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// ListExecutions returns executions for a checker on a target. +// +// @Summary List executions for a checker +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param limit query int false "Maximum number of results" +// @Param include_planned query bool false "Include upcoming planned executions from the scheduler" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.Execution +// @Router /domains/{domain}/checkers/{checkerId}/executions [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get] +func (cc *CheckerController) ListExecutions(c *gin.Context) { + cname := c.Param("checkerId") + target := targetFromContext(c) + + limit := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if execs == nil { + execs = []*happydns.Execution{} + } + + if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" { + planned := cc.statusUC.ListPlannedExecutions(cname, target) + execs = append(planned, execs...) + } + + c.JSON(http.StatusOK, execs) +} + +// DeleteExecution deletes an execution record. +// +// @Summary Delete an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete] +func (cc *CheckerController) DeleteExecution(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if err := cc.statusUC.DeleteExecution(execID); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +// DeleteCheckerExecutions deletes all executions for a checker on a target. +// +// @Summary Delete all executions for a checker +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete] +func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) { + cname := c.Param("checkerId") + target := targetFromContext(c) + + if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +// GetExecutionObservations returns the observation snapshot for an execution. +// +// @Summary Get observations for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.ObservationSnapshot +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get] +func (cc *CheckerController) GetExecutionObservations(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + snap, err := cc.statusUC.GetObservationsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"}) + return + } + + c.JSON(http.StatusOK, snap) +} + +// GetExecutionObservation returns a specific observation key from an execution's snapshot. +// +// @Summary Get a specific observation for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param obsKey path string true "Observation key" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get] +func (cc *CheckerController) GetExecutionObservation(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + snap, err := cc.statusUC.GetObservationsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"}) + return + } + + obsKey := c.Param("obsKey") + val, ok := snap.Data[obsKey] + if !ok { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"}) + return + } + + c.JSON(http.StatusOK, val) +} + +// GetExecutionResults returns the evaluation (per-rule states) for an execution. +// +// @Summary Get results for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckEvaluation +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get] +func (cc *CheckerController) GetExecutionResults(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + eval, err := cc.statusUC.GetResultsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"}) + return + } + + c.JSON(http.StatusOK, eval) +} + +// GetExecutionResult returns a specific rule's result from an execution. +// +// @Summary Get a specific rule result for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param ruleName path string true "Rule name" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckState +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get] +func (cc *CheckerController) GetExecutionResult(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + eval, err := cc.statusUC.GetResultsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"}) + return + } + + ruleName := c.Param("ruleName") + for _, state := range eval.States { + if state.Code == ruleName { + c.JSON(http.StatusOK, state) + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"}) +} diff --git a/internal/api/controller/zone.go b/internal/api/controller/zone.go index 04798e06..48d4f189 100644 --- a/internal/api/controller/zone.go +++ b/internal/api/controller/zone.go @@ -31,6 +31,7 @@ import ( "git.happydns.org/happyDomain/internal/api/middleware" "git.happydns.org/happyDomain/internal/helpers" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/model" ) @@ -38,13 +39,15 @@ type ZoneController struct { domainService happydns.DomainUsecase zoneCorrectionService happydns.ZoneCorrectionApplierUsecase zoneService happydns.ZoneUsecase + checkStatusUC *checkerUC.CheckStatusUsecase } -func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController { +func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *ZoneController { return &ZoneController{ domainService: domainService, zoneCorrectionService: zoneCorrectionService, zoneService: zoneService, + checkStatusUC: checkStatusUC, } } @@ -59,14 +62,27 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns. // @Security securitydefinitions.basic // @Param domainId path string true "Domain identifier" // @Param zoneId path string true "Zone identifier" -// @Success 200 {object} happydns.Zone +// @Success 200 {object} happydns.ZoneWithServicesCheckStatus // @Failure 401 {object} happydns.ErrorResponse "Authentication failure" // @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found" // @Router /domains/{domainId}/zone/{zoneId} [get] func (zc *ZoneController) GetZone(c *gin.Context) { zone := c.MustGet("zone").(*happydns.Zone) - c.JSON(http.StatusOK, zone) + result := &happydns.ZoneWithServicesCheckStatus{Zone: zone} + + if zc.checkStatusUC != nil { + user := c.MustGet("LoggedUser").(*happydns.User) + domain := c.MustGet("domain").(*happydns.Domain) + statusByService, err := zc.checkStatusUC.GetWorstServiceStatuses(user.Id, domain.Id, zone) + if err != nil { + log.Printf("GetWorstServiceStatuses: %s", err.Error()) + } else { + result.ServicesCheckStatus = statusByService + } + } + + c.JSON(http.StatusOK, result) } // GetZoneSubdomain returns the services associated with a given subdomain. diff --git a/internal/api/route/checker.go b/internal/api/route/checker.go new file mode 100644 index 00000000..9c94ce0f --- /dev/null +++ b/internal/api/route/checker.go @@ -0,0 +1,99 @@ +// 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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// DeclareCheckerRoutes registers global checker routes under /api/checkers. +// Returns the controller so it can be reused for scoped routes. +func DeclareCheckerRoutes( + apiRoutes *gin.RouterGroup, + engine happydns.CheckerEngine, + optionsUC *checkerUC.CheckerOptionsUsecase, + planUC *checkerUC.CheckPlanUsecase, + statusUC *checkerUC.CheckStatusUsecase, +) *controller.CheckerController { + cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC) + + // Global: /api/checkers + checkers := apiRoutes.Group("/checkers") + checkers.GET("", cc.ListCheckers) + + checkerID := checkers.Group("/:checkerId") + checkerID.GET("", cc.GetChecker) + + // User-scoped options (scope determined by context — user-only here). + checkerID.GET("/options", cc.GetCheckerOptions) + checkerID.POST("/options", cc.AddCheckerOptions) + checkerID.PUT("/options", cc.ChangeCheckerOptions) + checkerID.GET("/options/:optname", cc.GetCheckerOption) + checkerID.PUT("/options/:optname", cc.SetCheckerOption) + + return cc +} + +// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service. +// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers. +func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) { + checkers := scopedRouter.Group("/checkers") + checkers.GET("", cc.ListAvailableChecks) + + checkerID := checkers.Group("/:checkerId") + + // Scoped options (scope determined by context — domain/service here). + checkerID.GET("/options", cc.GetCheckerOptions) + checkerID.POST("/options", cc.AddCheckerOptions) + checkerID.PUT("/options", cc.ChangeCheckerOptions) + checkerID.GET("/options/:optname", cc.GetCheckerOption) + checkerID.PUT("/options/:optname", cc.SetCheckerOption) + + // Plans (schedules). + checkerID.GET("/plans", cc.ListCheckPlans) + checkerID.POST("/plans", cc.CreateCheckPlan) + checkerID.GET("/plans/:planId", cc.GetCheckPlan) + checkerID.PUT("/plans/:planId", cc.UpdateCheckPlan) + checkerID.DELETE("/plans/:planId", cc.DeleteCheckPlan) + + // Executions. + executions := checkerID.Group("/executions") + executions.GET("", cc.ListExecutions) + executions.POST("", cc.TriggerCheck) + executions.DELETE("", cc.DeleteCheckerExecutions) + + executionID := executions.Group("/:executionId") + executionID.GET("", cc.GetExecutionStatus) + executionID.DELETE("", cc.DeleteExecution) + + // Observations (under execution). + executionID.GET("/observations", cc.GetExecutionObservations) + executionID.GET("/observations/:obsKey", cc.GetExecutionObservation) + + // Results (under execution). + executionID.GET("/results", cc.GetExecutionResults) + executionID.GET("/results/:ruleName", cc.GetExecutionResult) +} diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index 23af0db6..94eb6c18 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -39,6 +39,7 @@ func DeclareDomainRoutes( zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, + cc *controller.CheckerController, ) { dc := controller.NewDomainController( domainUC, @@ -61,6 +62,11 @@ func DeclareDomainRoutes( apiDomainsRoutes.POST("/zone", dc.ImportZone) apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone) + // Mount domain-scoped checker routes. + if cc != nil { + DeclareScopedCheckerRoutes(apiDomainsRoutes, cc) + } + DeclareZoneRoutes( apiDomainsRoutes, zoneUC, @@ -68,5 +74,6 @@ func DeclareDomainRoutes( zoneCorrApplier, zoneServiceUC, serviceUC, + cc, ) } diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 94478288..d2df1b9a 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -24,7 +24,9 @@ package route import ( "github.com/gin-gonic/gin" + "git.happydns.org/happyDomain/internal/api/controller" "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -50,6 +52,11 @@ type Dependencies struct { ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase ZoneImporter happydns.ZoneImporterUsecase ZoneService happydns.ZoneServiceUsecase + + CheckerEngine happydns.CheckerEngine + CheckerOptionsUC *checkerUC.CheckerOptionsUsecase + CheckPlanUC *checkerUC.CheckPlanUsecase + CheckStatusUC *checkerUC.CheckStatusUsecase } // @title happyDomain API @@ -105,6 +112,18 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc } apiAuthRoutes.Use(middleware.AuthRequired()) + // Initialize checker controller if checker engine is available. + var cc *controller.CheckerController + if dep.CheckerEngine != nil { + cc = DeclareCheckerRoutes( + apiAuthRoutes, + dep.CheckerEngine, + dep.CheckerOptionsUC, + dep.CheckPlanUC, + dep.CheckStatusUC, + ) + } + DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc) DeclareDomainRoutes( apiAuthRoutes, @@ -116,6 +135,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service, + cc, ) DeclareProviderRoutes(apiAuthRoutes, dep.Provider) DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings) diff --git a/internal/api/route/service.go b/internal/api/route/service.go index 906fbe88..ded089a8 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes( zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase, + cc *controller.CheckerController, ) { sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC) @@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes( apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC)) apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService) apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService) + + // Mount service-scoped checker routes. + if cc != nil { + DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc) + } } diff --git a/internal/api/route/zone.go b/internal/api/route/zone.go index f5ae8c84..bb6da44c 100644 --- a/internal/api/route/zone.go +++ b/internal/api/route/zone.go @@ -26,6 +26,7 @@ import ( "git.happydns.org/happyDomain/internal/api/controller" "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -36,11 +37,18 @@ func DeclareZoneRoutes( zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, + cc *controller.CheckerController, ) { + var checkStatusUC *checkerUC.CheckStatusUsecase + if cc != nil { + checkStatusUC = cc.StatusUC() + } + zc := controller.NewZoneController( zoneUC, domainUC, zoneCorrApplier, + checkStatusUC, ) apiZonesRoutes := router.Group("/zone/:zoneid") @@ -65,6 +73,7 @@ func DeclareZoneRoutes( zoneServiceUC, serviceUC, zoneUC, + cc, ) apiZonesRoutes.POST("/records", zc.AddRecords) diff --git a/internal/app/admin.go b/internal/app/admin.go index 3544d376..60658399 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -33,6 +33,7 @@ import ( "github.com/gin-gonic/gin" admin "git.happydns.org/happyDomain/internal/api-admin/route" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" providerUC "git.happydns.org/happyDomain/internal/usecase/provider" "git.happydns.org/happyDomain/model" "git.happydns.org/happyDomain/web-admin" @@ -55,6 +56,9 @@ func NewAdmin(app *App) *Admin { // Prepare usecases (admin uses unrestricted provider access) app.usecases.providerAdmin = providerUC.NewService(app.store, nil) + if app.usecases.checkerOptionsUC == nil { + app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store) + } admin.DeclareRoutes( app.cfg, @@ -71,6 +75,8 @@ func NewAdmin(app *App) *Admin { ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier, ZoneImporter: app.usecases.orchestrator.ZoneImporter, ZoneService: app.usecases.zoneService, + CheckerOptionsUC: app.usecases.checkerOptionsUC, + CheckScheduler: app.usecases.checkerScheduler, }, ) web.DeclareRoutes(app.cfg, router) diff --git a/internal/app/app.go b/internal/app/app.go index 178a6b9e..9f184ba1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,6 +38,7 @@ import ( "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/internal/usecase" authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" domainUC "git.happydns.org/happyDomain/internal/usecase/domain" domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/orchestrator" @@ -69,6 +70,12 @@ type Usecases struct { zoneService happydns.ZoneServiceUsecase orchestrator *orchestrator.Orchestrator + + checkerEngine happydns.CheckerEngine + checkerOptionsUC *checkerUC.CheckerOptionsUsecase + checkerPlanUC *checkerUC.CheckPlanUsecase + checkerStatusUC *checkerUC.CheckStatusUsecase + checkerScheduler *checkerUC.Scheduler } type App struct { @@ -246,6 +253,19 @@ func (app *App) initUsecases() { providerAdminService, zoneService.UpdateZoneUC, ) + + // Checker system. + app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store) + app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store) + app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store) + app.usecases.checkerEngine = checkerUC.NewCheckerEngine( + app.usecases.checkerOptionsUC, + 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) } func (app *App) setupRouter() { @@ -291,6 +311,11 @@ func (app *App) setupRouter() { ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier, ZoneImporter: app.usecases.orchestrator.ZoneImporter, ZoneService: app.usecases.zoneService, + + CheckerEngine: app.usecases.checkerEngine, + CheckerOptionsUC: app.usecases.checkerOptionsUC, + CheckPlanUC: app.usecases.checkerPlanUC, + CheckStatusUC: app.usecases.checkerStatusUC, }, ) web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier) @@ -308,6 +333,10 @@ func (app *App) Start() { go app.insights.Run() } + if app.usecases.checkerScheduler != nil { + app.usecases.checkerScheduler.Start(context.Background()) + } + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -321,6 +350,10 @@ func (app *App) Stop() { log.Fatal("Server Shutdown:", err) } + if app.usecases.checkerScheduler != nil { + app.usecases.checkerScheduler.Stop() + } + // Close storage if app.store != nil { app.store.Close() diff --git a/internal/config/cli.go b/internal/config/cli.go index 01de6850..6d54507a 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -24,6 +24,7 @@ package config // import "git.happydns.org/happyDomain/config" import ( "flag" "fmt" + "runtime" "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/model" @@ -45,6 +46,7 @@ func declareFlags(o *happydns.Options) { flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)") flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)") flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.") + flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously") flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server") flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user") diff --git a/internal/usecase/checker/check_status_usecase.go b/internal/usecase/checker/check_status_usecase.go index b8362b3a..d201fa3e 100644 --- a/internal/usecase/checker/check_status_usecase.go +++ b/internal/usecase/checker/check_status_usecase.go @@ -22,6 +22,8 @@ package checker import ( + "slices" + checkerPkg "git.happydns.org/happyDomain/internal/checker" "git.happydns.org/happyDomain/model" ) @@ -96,6 +98,11 @@ func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([ if !def.Availability.ApplyToService { continue } + if len(def.Availability.LimitToServices) > 0 && target.ServiceType != "" { + if !slices.Contains(def.Availability.LimitToServices, target.ServiceType) { + continue + } + } } status := happydns.CheckerStatus{ diff --git a/model/config.go b/model/config.go index f1187985..fe572f90 100644 --- a/model/config.go +++ b/model/config.go @@ -93,6 +93,10 @@ type Options struct { OIDCClients []OIDCSettings + // CheckerMaxConcurrency is the maximum number of checker jobs that can + // run simultaneously. Defaults to runtime.NumCPU(). + CheckerMaxConcurrency int + // CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or ""). CaptchaProvider string diff --git a/model/zone.go b/model/zone.go index 68d4308e..738c6ddb 100644 --- a/model/zone.go +++ b/model/zone.go @@ -154,6 +154,14 @@ type ZoneServices struct { Services []*Service `json:"services"` } +// ZoneWithServicesCheckStatus wraps a Zone with the worst check status for each service. +type ZoneWithServicesCheckStatus struct { + *Zone + // ServicesCheckStatus holds the worst check status for each service, + // keyed by service identifier string. Nil/absent if no results exist yet. + ServicesCheckStatus map[string]*Status `json:"services_check_status,omitempty"` +} + type ZoneUsecase interface { AddRecord(*Zone, string, Record) error CreateZone(*Zone) error From a5320686c58ff8f678cddb609b4140ff99d44a0e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:55:31 +0700 Subject: [PATCH 07/54] checkers: add frontend API client, stores, and utilities Add the frontend infrastructure for the checker UI: - API client with scoped helpers for domain/service-level operations - Svelte stores for checker state (currentExecution, currentCheckInfo) - Utility functions for status colors, icons, i18n keys, date formatting - Shared helpers: withInheritedPlaceholders, downloadBlob, collectAllOptionDocs - English translations for all checker UI strings - Zone model and form types extended for checker support --- web/src/lib/api/checkers.ts | 601 ++++++++++++++++++++++++ web/src/lib/locales/en.json | 244 ++++++++++ web/src/lib/model/custom_form.svelte.ts | 1 + web/src/lib/model/zone.ts | 3 +- web/src/lib/stores/checkers.ts | 42 ++ web/src/lib/translations.ts | 2 + web/src/lib/utils/checkers.ts | 134 ++++++ web/src/lib/utils/datetime.ts | 39 ++ web/src/lib/utils/index.ts | 3 +- 9 files changed, 1067 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/api/checkers.ts create mode 100644 web/src/lib/stores/checkers.ts create mode 100644 web/src/lib/utils/checkers.ts diff --git a/web/src/lib/api/checkers.ts b/web/src/lib/api/checkers.ts new file mode 100644 index 00000000..8c819271 --- /dev/null +++ b/web/src/lib/api/checkers.ts @@ -0,0 +1,601 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { + getCheckers, + getCheckersByCheckerId, + getCheckersByCheckerIdOptions, + putCheckersByCheckerIdOptions, + getDomainsByDomainCheckers, + getDomainsByDomainCheckersByCheckerIdExecutions, + postDomainsByDomainCheckersByCheckerIdExecutions, + deleteDomainsByDomainCheckersByCheckerIdExecutions, + deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults, + getDomainsByDomainCheckersByCheckerIdOptions, + putDomainsByDomainCheckersByCheckerIdOptions, + getDomainsByDomainCheckersByCheckerIdPlans, + postDomainsByDomainCheckersByCheckerIdPlans, + putDomainsByDomainCheckersByCheckerIdPlansByPlanId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, + putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans, + postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans, + putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId, +} from "$lib/api-base/sdk.gen"; +import type { + HappydnsCheckEvaluation, + HappydnsCheckPlan, + HappydnsCheckerDefinition, + HappydnsCheckerOptions, + HappydnsCheckerOptionsPositional, + HappydnsCheckerRunRequest, + HappydnsCheckerStatus, + HappydnsExecution, + HappydnsObservationSnapshot, +} from "$lib/api-base/types.gen"; +import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors"; + +export async function listCheckers(): Promise> { + return unwrapSdkResponse(await getCheckers()) as Record; +} + +export async function getCheckStatus(checkerId: string): Promise { + return unwrapSdkResponse( + await getCheckersByCheckerId({ path: { checkerId } }), + ) as HappydnsCheckerDefinition; +} + +export async function getCheckOptions(checkerId: string): Promise { + return (unwrapSdkResponse( + await getCheckersByCheckerIdOptions({ path: { checkerId } }), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateCheckOptions( + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as any }), + ) as HappydnsCheckerOptions; +} + +// Domain-scoped checker API functions + +export async function listDomainCheckers(domain: string): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckers({ path: { domain } } as any), + ) as HappydnsCheckerStatus[]) ?? []; +} + +export async function listDomainExecutions( + domain: string, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + query: { + ...(options?.includePlanned ? { include_planned: "true" } : {}), + ...(options?.limit ? { limit: String(options.limit) } : {}), + }, + } as any), + ) as HappydnsExecution[]) ?? []; +} + +export async function triggerDomainCheck( + domain: string, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + body: request, + } as any), + ) as HappydnsExecution; +} + +export async function getDomainExecution( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsExecution; +} + +export async function deleteDomainExecution( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, checkerId, executionId }, + } as any), + ); +} + +export async function deleteAllDomainExecutions( + domain: string, + checkerId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + } as any), + ); +} + +export async function getDomainExecutionResults( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsCheckEvaluation; +} + +export async function getDomainExecutionObservations( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsObservationSnapshot; +} + +export async function getDomainCheckOptions( + domain: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdOptions({ + path: { domain, checkerId }, + } as any), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateDomainCheckOptions( + domain: string, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainCheckersByCheckerIdOptions({ + path: { domain, checkerId }, + body: options as any, + } as any), + ) as HappydnsCheckerOptions; +} + +export async function getDomainCheckPlans( + domain: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdPlans({ + path: { domain, checkerId }, + } as any), + ) as HappydnsCheckPlan[]) ?? []; +} + +export async function createDomainCheckPlan( + domain: string, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainCheckersByCheckerIdPlans({ + path: { domain, checkerId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +export async function updateDomainCheckPlan( + domain: string, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({ + path: { domain, checkerId, planId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +// Service-scoped checker API functions + +export async function listServiceCheckers( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({ + path: { domain, zoneid, subdomain, serviceid }, + } as any), + ) as HappydnsCheckerStatus[]) ?? []; +} + +export async function listServiceExecutions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + query: { + ...(options?.includePlanned ? { include_planned: "true" } : {}), + ...(options?.limit ? { limit: String(options.limit) } : {}), + }, + } as any), + ) as HappydnsExecution[]) ?? []; +} + +export async function triggerServiceCheck( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: request, + } as any), + ) as HappydnsExecution; +} + +export async function getServiceExecution( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsExecution; +} + +export async function deleteServiceExecution( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ); +} + +export async function deleteAllServiceExecutions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ); +} + +export async function getServiceExecutionResults( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsCheckEvaluation; +} + +export async function getServiceExecutionObservations( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsObservationSnapshot; +} + +export async function getServiceCheckOptions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateServiceCheckOptions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: options as any, + } as any), + ) as HappydnsCheckerOptions; +} + +export async function getServiceCheckPlans( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ) as HappydnsCheckPlan[]) ?? []; +} + +export async function createServiceCheckPlan( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +export async function updateServiceCheckPlan( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, planId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +// Scope-aware helpers + +export interface CheckerScope { + domainId: string; + zoneId?: string; + subdomain?: string; + serviceId?: string; +} + +function isServiceScope(scope: CheckerScope): scope is CheckerScope & { zoneId: string; subdomain: string; serviceId: string } { + return !!(scope.zoneId && scope.subdomain !== undefined && scope.serviceId); +} + +export async function listScopedCheckers( + scope: CheckerScope, +): Promise { + if (isServiceScope(scope)) { + return listServiceCheckers(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId); + } + return listDomainCheckers(scope.domainId); +} + +export async function listScopedExecutions( + scope: CheckerScope, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + if (isServiceScope(scope)) { + return listServiceExecutions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, options); + } + return listDomainExecutions(scope.domainId, checkerId, options); +} + +export async function triggerScopedCheck( + scope: CheckerScope, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + if (isServiceScope(scope)) { + return triggerServiceCheck(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, request); + } + return triggerDomainCheck(scope.domainId, checkerId, request); +} + +export async function getScopedExecution( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecution(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecution(scope.domainId, checkerId, executionId); +} + +export async function deleteScopedExecution( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return deleteServiceExecution(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return deleteDomainExecution(scope.domainId, checkerId, executionId); +} + +export async function deleteAllScopedExecutions( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return deleteAllServiceExecutions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return deleteAllDomainExecutions(scope.domainId, checkerId); +} + +export async function getScopedExecutionResults( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecutionResults(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecutionResults(scope.domainId, checkerId, executionId); +} + +export async function getScopedExecutionObservations( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecutionObservations(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecutionObservations(scope.domainId, checkerId, executionId); +} + +export async function getScopedCheckOptions( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceCheckOptions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return getDomainCheckOptions(scope.domainId, checkerId); +} + +export async function updateScopedCheckOptions( + scope: CheckerScope, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + if (isServiceScope(scope)) { + return updateServiceCheckOptions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, options); + } + return updateDomainCheckOptions(scope.domainId, checkerId, options); +} + +export async function getScopedCheckPlans( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceCheckPlans(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return getDomainCheckPlans(scope.domainId, checkerId); +} + +export async function createScopedCheckPlan( + scope: CheckerScope, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + if (isServiceScope(scope)) { + return createServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, plan); + } + return createDomainCheckPlan(scope.domainId, checkerId, plan); +} + +export async function updateScopedCheckPlan( + scope: CheckerScope, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + if (isServiceScope(scope)) { + return updateServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, planId, plan); + } + return updateDomainCheckPlan(scope.domainId, checkerId, planId, plan); +} diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index f0ee39a2..8a98da9e 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -77,6 +77,7 @@ "subdomain": "subdomain", "actions": { "audit": "View changes logs", + "checks": "Domain checks", "do-migration": "Migrate now", "history": "View changes history", "propagate": "Publish my changes", @@ -255,6 +256,7 @@ "my-domains": "My domains", "my-providers": "My domain providers", "dns-resolver": "DNS resolver", + "checkers": "Configure Checkers", "my-account": "My account", "logout": "Sign out", "provider-features": "Supported providers", @@ -603,5 +605,247 @@ "import-text": "Import from text", "import-file": "Import from file", "return-to": "Go to the zone" + }, + "checkers": { + "run-check": { + "title": "Run Check", + "loading-options": "Loading checker options...", + "select-rule": "Rule to check", + "configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.", + "no-options": "This checker has no configurable options. Click \"Run Check\" to execute with default settings.", + "no-run-options": "This checker has no run-time options. You can still override advanced settings below.", + "error-loading-options": "Error loading checker options: {{error}}", + "run-button": "Run Check", + "triggered-success": "Check triggered successfully! Execution ID: {{id}}", + "trigger-failed": "Failed to trigger check: {{error}}", + "advanced-options": "Advanced options", + "rules": "Rules" + }, + "never": "Never", + "na": "N/A", + "relative": { + "in-less-than-a-minute": "in less than a minute", + "just-now": "just now", + "in": "in {{label}}", + "ago": "{{label}} ago" + }, + "status": { + "ok": "OK", + "info": "Info", + "warning": "Warning", + "critical": "Critical", + "error": "Error", + "unknown": "Unknown", + "pending": "Pending", + "planned": "Planned", + "running": "Running", + "not-run": "Not run" + }, + "list": { + "title": "Checks for ", + "title-service": "Checks for {{service}}", + "loading": "Loading checkers...", + "loading-checkers": "Loading checker information...", + "no-checks": "No checks available for this domain.", + "no-checks-service": "No checks available for this service.", + "run-check": "Run Check", + "view-results": "View Results", + "configure": "Configure", + "error-loading": "Error loading checkers: {{error}}", + "unknown-version": "Unknown", + "table": { + "checker": "Checker", + "status": "Status", + "last-run": "Last Run", + "schedule": "Schedule", + "actions": "Actions" + }, + "schedule": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "loading-checks": "Loading checker information..." + }, + "other-checkers": { + "title": "Other available checkers", + "description": "These checkers are not directly associated with this domain but can be configured with domain-specific options.", + "no-checkers": "No other checkers available.", + "configure": "Configure" + }, + "schedule": { + "title": "Schedule", + "card-title": "Automatic scheduling", + "auto-enabled": "Run automatically", + "auto-disabled": "Disabled (run manually only)", + "interval-label": "Check interval", + "hours": "hours", + "interval-hint": "Minimum 1 hour. The check will run once per interval.", + "interval-hint-bounded": "Between {{min}} and {{max}} hours.", + "next-run": "Next scheduled run", + "last-run": "Last run", + "no-schedule-yet": "No schedule created yet. Save to create one.", + "save": "Save", + "save-failed": "Failed to save schedule", + "saved": "Schedule saved successfully." + }, + "executions": { + "loading": "Loading check results...", + "no-results": "No check results yet. Click \"Run Check Now\" to execute the check.", + "title": "Check Executions ({{count}})", + "run-check-now": "Run Check Now", + "back-to-checks": "Back to checks", + "delete-all": "Delete All", + "delete-confirm": "Are you sure you want to delete this check result?", + "delete-all-confirm": "Are you sure you want to delete ALL check results for this checker? This cannot be undone.", + "deleted-all": "All check results have been deleted.", + "delete-failed": "Failed to delete result", + "delete-all-failed": "Failed to delete results", + "configure": "Configure", + "domain-level": "Domain-level", + "error-loading": "Error loading checker results: {{error}}", + "error-deleting": "Error deleting execution: {{error}}", + "table": { + "executed-at": "Executed At", + "status": "Status", + "message": "Message", + "duration": "Duration", + "type": "Type", + "actions": "Actions" + }, + "type": { + "scheduled": "Scheduled", + "manual": "Manual" + }, + "pending": { + "queued": "Queued", + "queued-description": "Queued, waiting to run\u2026", + "running": "Running", + "running-description": "Check is currently running\u2026" + }, + "view": "View" + }, + "execution": { + "title": "Check Execution Details", + "field": { + "ended-at": "Ended At:", + "trigger": "Trigger:", + "error": "Error:" + }, + "status": { + "pending": "Pending", + "running": "Running", + "done": "Done", + "failed": "Failed", + "unknown": "Unknown" + } + }, + "result": { + "title": "Check Result Details", + "loading": "Loading check result...", + "relaunch": "Relaunch Check", + "delete": "Delete Result", + "relaunch-failed": "Failed to relaunch check", + "delete-confirm": "Are you sure you want to delete this check?", + "delete-failed": "Failed to delete result", + "error-loading": "Error loading check: {{error}}", + "milliseconds": "milliseconds", + "seconds": "seconds", + "type": { + "scheduled": "Scheduled Check", + "manual": "Manual Check" + }, + "check-options": "Check Options", + "full-report": "Full Report", + "field": { + "domain": "Domain:", + "executed-at": "Executed At:", + "duration": "Duration:", + "status": "Status:", + "status-message": "Message:", + "error": "Error:" + }, + "view-metrics": "Metrics", + "view-html": "HTML Report", + "view-json": "Raw JSON", + "download-html": "Download HTML", + "download-json": "Download JSON" + }, + "title": "Checkers", + "description": "Configure automated checks for your domains", + "available-count": "Available: {{count}} checkers", + "search-placeholder": "Search checkers...", + "loading": "Loading checkers...", + "loading-info": "Loading checker information...", + "no-checkers": "No checkers available", + "service-checks": "Service Checks", + "view-all": "View all", + "no-checks": "No checks available", + "load-error": "Error loading checks", + "error-loading": "Error loading checkers: {{error}}", + "error-loading-checker": "Error loading checker: {{error}}", + "checker-info-not-found": "Error: Checker information not found", + "back-to-checkers": "Back to checkers", + "table": { + "name": "Checker Name", + "availability": "Availability", + "actions": "Actions", + "manage": "Manage" + }, + "availability": { + "domain": "Domain", + "zone": "Zone", + "provider-specific": "Provider-specific", + "service-specific": "Service-specific", + "general": "General", + "user-level": "User-level", + "domain-level": "Domain-level", + "zone-level": "Zone-level", + "service-level": "Service-level", + "providers": "Providers: {{providers}}", + "services": "Services: {{services}}" + }, + "actions": { + "configure": "Configure" + }, + "sidebar": { + "back-to-list": "Back to checkers list" + }, + "detail": { + "checker-information": "Checker Information", + "name": "Name:", + "availability": "Availability:", + "loading-options": "Loading options...", + "check-rules": "Check Individual Rules", + "admin-options": "Admin Options", + "configuration": "Configuration", + "save": "Save", + "save-changes": "Save Changes", + "no-configurable-options": "This checker has no configurable options.", + "error-loading-options": "Error loading options: {{error}}", + "orphaned-options": "Orphaned options detected: {{options}}", + "clean-up": "Clean Up", + "read-only": "Read-only" + }, + "option-groups": { + "global-settings": "Global Settings", + "domain-settings": "Domain-specific Settings", + "service-settings": "Service-specific Settings", + "checker-parameters": "Checker Parameters", + "type": "Type: {{type}}", + "required": "Required", + "auto-fill": "Auto-filled Fields" + }, + "auto-fill": { + "domain_name": "auto-filled: domain name", + "subdomain": "auto-filled: subdomain", + "service_type": "auto-filled: service type", + "generic": "auto-filled: {{key}}" + }, + "messages": { + "options-updated": "Checker options updated successfully", + "options-cleaned": "Orphaned options removed successfully", + "update-failed": "Failed to update options: {{error}}", + "clean-failed": "Failed to clean options: {{error}}" + } } } diff --git a/web/src/lib/model/custom_form.svelte.ts b/web/src/lib/model/custom_form.svelte.ts index 975474ad..56a595e5 100644 --- a/web/src/lib/model/custom_form.svelte.ts +++ b/web/src/lib/model/custom_form.svelte.ts @@ -31,6 +31,7 @@ export class Field { required? = $state(); secret? = $state(); textarea? = $state(); + autoFill? = $state(); } export class CustomForm { diff --git a/web/src/lib/model/zone.ts b/web/src/lib/model/zone.ts index 97530689..49157bfd 100644 --- a/web/src/lib/model/zone.ts +++ b/web/src/lib/model/zone.ts @@ -19,7 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { HappydnsZoneMeta } from "$lib/api-base/types.gen"; +import type { HappydnsStatus, HappydnsZoneMeta } from "$lib/api-base/types.gen"; import type { ServiceWithValue } from "$lib/model/service.svelte"; export interface ServiceRecord { @@ -34,4 +34,5 @@ export type ZoneMeta = HappydnsZoneMeta; export interface Zone extends ZoneMeta { services: Record>; + services_check_status?: Record; } diff --git a/web/src/lib/stores/checkers.ts b/web/src/lib/stores/checkers.ts new file mode 100644 index 00000000..b9b2bfab --- /dev/null +++ b/web/src/lib/stores/checkers.ts @@ -0,0 +1,42 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { writable, type Writable } from "svelte/store"; +import { listCheckers } from "$lib/api/checkers"; +import type { + HappydnsCheckerDefinition, + HappydnsExecution, + HappydnsObservationSnapshot, +} from "$lib/api-base/types.gen"; + +export const checkers: Writable | undefined> = + writable(undefined); + +export async function refreshCheckers() { + const data = await listCheckers(); + checkers.set(data); + return data; +} + +// Stores for the currently viewed execution detail page +export const currentExecution: Writable = writable(undefined); +export const currentCheckInfo: Writable = writable(undefined); +export const currentObservations: Writable = writable(undefined); diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index 4c34066d..cd034c59 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -44,6 +44,8 @@ interface Params { nbDiffs?: number; nbSelected?: number; countdown?: string; + error?: string; + options?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts new file mode 100644 index 00000000..5d8e25eb --- /dev/null +++ b/web/src/lib/utils/checkers.ts @@ -0,0 +1,134 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import type { HappydnsCheckerOptionDocumentation, HappydnsExecutionStatus, HappydnsStatus } from "$lib/api-base/types.gen"; + +// HappydnsStatus: 0=Unknown, 1=OK, 2=Info, 3=Warn, 4=Crit, 5=Error + +export function getStatusColor(status: HappydnsStatus | undefined): string { + switch (status) { + case 0: return "secondary"; + case 1: return "success"; + case 2: return "info"; + case 3: return "warning"; + case 4: return "danger"; + case 5: return "danger"; + default: return "secondary"; + } +} + +export function getStatusI18nKey(status: HappydnsStatus | undefined): string { + switch (status) { + case 0: return "checkers.status.unknown"; + case 1: return "checkers.status.ok"; + case 2: return "checkers.status.info"; + case 3: return "checkers.status.warning"; + case 4: return "checkers.status.critical"; + case 5: return "checkers.status.error"; + default: return "checkers.status.not-run"; + } +} + +export function getStatusIcon(status: HappydnsStatus | undefined): string { + switch (status) { + case 1: return "check-circle-fill"; + case 2: return "info-circle-fill"; + case 3: return "exclamation-triangle-fill"; + case 4: return "exclamation-octagon-fill"; + case 5: return "exclamation-octagon-fill"; + default: return "question-circle-fill"; + } +} + +// HappydnsExecutionStatus: 0=Pending, 1=Running, 2=Done, 3=Failed + +export function getExecutionStatusColor(status: HappydnsExecutionStatus | undefined): string { + switch (status) { + case 0: return "secondary"; + case 1: return "primary"; + case 2: return "success"; + case 3: return "danger"; + default: return "secondary"; + } +} + +export function getExecutionStatusI18nKey(status: HappydnsExecutionStatus | undefined): string { + switch (status) { + case 0: return "checkers.execution.status.pending"; + case 1: return "checkers.execution.status.running"; + case 2: return "checkers.execution.status.done"; + case 3: return "checkers.execution.status.failed"; + default: return "checkers.execution.status.unknown"; + } +} + +export function withInheritedPlaceholders( + opts: HappydnsCheckerOptionDocumentation[], + optionValues: Record, + inheritedValues: Record, +): HappydnsCheckerOptionDocumentation[] { + return opts.map((opt) => { + if ( + opt.id && + optionValues[opt.id] === undefined && + inheritedValues[opt.id] !== undefined + ) { + return { ...opt, placeholder: String(inheritedValues[opt.id]) }; + } + return opt; + }); +} + +export function collectAllOptionDocs( + status: { options?: { runOpts?: HappydnsCheckerOptionDocumentation[]; adminOpts?: HappydnsCheckerOptionDocumentation[]; userOpts?: HappydnsCheckerOptionDocumentation[]; domainOpts?: HappydnsCheckerOptionDocumentation[] }; rules?: { options?: { runOpts?: HappydnsCheckerOptionDocumentation[]; adminOpts?: HappydnsCheckerOptionDocumentation[]; userOpts?: HappydnsCheckerOptionDocumentation[]; domainOpts?: HappydnsCheckerOptionDocumentation[] } }[] }, +): HappydnsCheckerOptionDocumentation[] { + return [ + ...(status.options?.runOpts || []), + ...(status.options?.adminOpts || []), + ...(status.options?.userOpts || []), + ...(status.options?.domainOpts || []), + ...(status.rules || []).flatMap((r) => [ + ...(r.options?.runOpts || []), + ...(r.options?.adminOpts || []), + ...(r.options?.userOpts || []), + ...(r.options?.domainOpts || []), + ]), + ]; +} + +export function downloadBlob(content: string, filename: string, mime: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function formatCheckDate(date: string | undefined): string { + if (!date) return ""; + try { + return new Date(date).toLocaleString(); + } catch { + return date; + } +} diff --git a/web/src/lib/utils/datetime.ts b/web/src/lib/utils/datetime.ts index ee38c5be..ab587699 100644 --- a/web/src/lib/utils/datetime.ts +++ b/web/src/lib/utils/datetime.ts @@ -18,6 +18,45 @@ export function toDatetimeLocal(isoString: string | null | undefined): string { } } +/** + * Format a Go time.Duration (nanoseconds) into a human-readable string. + * @param ns Duration in nanoseconds + * @returns Human-readable string such as "30s", "5m", "2h", "3d" + */ +export function formatDuration(ns: number | undefined): string { + if (ns == null) return "—"; + const s = ns / 1e9; + if (s < 60) return `${s}s`; + const m = s / 60; + if (m < 60) return `${m}m`; + const h = m / 60; + if (h < 24) return `${h}h`; + return `${h / 24}d`; +} + +/** + * Format a date string to relative time (e.g. "in 5m", "3h ago"). + * @param dateStr ISO 8601 date string + * @returns Human-readable relative time string + */ +export function formatRelative(dateStr: string | undefined): string { + if (!dateStr) return "—"; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const absMs = Math.abs(diffMs); + const s = Math.round(absMs / 1000); + const m = Math.round(s / 60); + const h = Math.round(m / 60); + + let rel: string; + if (s < 60) rel = `${s}s`; + else if (m < 60) rel = `${m}m`; + else rel = `${h}h`; + + return diffMs >= 0 ? `in ${rel}` : `${rel} ago`; +} + /** * Convert datetime-local format back to ISO 8601 string * @param datetimeLocal Datetime-local format string (YYYY-MM-DDTHH:mm) diff --git a/web/src/lib/utils/index.ts b/web/src/lib/utils/index.ts index 8f586662..eec9a9ba 100644 --- a/web/src/lib/utils/index.ts +++ b/web/src/lib/utils/index.ts @@ -2,4 +2,5 @@ * Centralized utility exports */ -export { toDatetimeLocal, fromDatetimeLocal } from './datetime'; +export { toDatetimeLocal, fromDatetimeLocal, formatDuration } from './datetime'; +export { getStatusColor, getStatusIcon, getStatusI18nKey, getExecutionStatusColor, getExecutionStatusI18nKey, formatCheckDate, withInheritedPlaceholders, downloadBlob, collectAllOptionDocs } from './checkers'; From cf6bc420b18686f4a6a72e5149e6698d3624d8d3 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:55:59 +0700 Subject: [PATCH 08/54] checkers: add frontend UI components and routes Add all checker UI pages and components: - Checker list, config, schedule, and rules pages - Execution list, detail, results, and rules pages - Sidebar components for domain/service checker status - Run check modal with option overrides and rule selection - Domain-scoped and service-scoped check routes - Admin pages for checker configuration and scheduler management - Header navigation link for checkers section --- web-admin/src/routes/+layout.svelte | 6 + web-admin/src/routes/checkers/+page.svelte | 141 ++++++ .../routes/checkers/[checkerId]/+page.svelte | 452 ++++++++++++++++++ web-admin/src/routes/scheduler/+page.svelte | 262 ++++++++++ web/src/lib/api/checkers.ts | 87 ++-- web/src/lib/components/Header.svelte | 8 + .../checkers/CheckResultSidebar.svelte | 276 +++++++++++ .../checkers/CheckerConfigPage.svelte | 187 ++++++++ .../checkers/CheckerListPage.svelte | 181 +++++++ .../checkers/CheckerOptionsGroups.svelte | 70 +++ .../checkers/CheckerOptionsPanel.svelte | 197 ++++++++ .../checkers/CheckerRulesCard.svelte | 165 +++++++ .../checkers/CheckerScheduleCard.svelte | 147 ++++++ .../components/checkers/CheckerSidebar.svelte | 81 ++++ .../checkers/CheckersAvailabilityTable.svelte | 83 ++++ .../checkers/ChecksSidebarContent.svelte | 103 ++++ .../checkers/DomainCheckerSidebar.svelte | 152 ++++++ .../checkers/ExecutionDetailPage.svelte | 102 ++++ .../checkers/ExecutionListPage.svelte | 275 +++++++++++ .../checkers/ExecutionResultsCard.svelte | 63 +++ .../checkers/ExecutionRulesPage.svelte | 79 +++ .../checkers/ExecutionSidebarContent.svelte | 253 ++++++++++ .../checkers/ObservationReportCard.svelte | 43 ++ web/src/lib/components/inputs/Resource.svelte | 3 +- .../components/modals/RunCheckModal.svelte | 326 +++++++++++++ web/src/lib/translations.ts | 1 + web/src/lib/utils/checkers.ts | 5 +- web/src/routes/checkers/+layout.ts | 31 ++ web/src/routes/checkers/+page.svelte | 99 ++++ .../checkers/[checkerId]/+layout.svelte | 53 ++ .../routes/checkers/[checkerId]/+page.svelte | 239 +++++++++ web/src/routes/domains/[dn]/+layout.svelte | 69 ++- .../[dn]/ServiceDetailsOffcanvas.svelte | 70 +++ .../routes/domains/[dn]/ZoneSidebar.svelte | 3 + .../[dn]/[[historyid]]/ServiceCard.svelte | 8 +- .../[subdomain]/[serviceid]/+page.svelte | 8 + .../[subdomain]/[serviceid]/checks/+layout.ts | 19 + .../[serviceid]/checks/+page.svelte | 48 ++ .../checks/[checkerId]/+page.svelte | 74 +++ .../[checkerId]/executions/+page.svelte | 47 ++ .../executions/[execId]/+page.svelte | 42 ++ .../executions/[execId]/rules/+page.svelte | 44 ++ web/src/routes/domains/[dn]/checks/+layout.ts | 14 + .../routes/domains/[dn]/checks/+page.svelte | 42 ++ .../[dn]/checks/[checkerId]/+page.svelte | 68 +++ .../[checkerId]/executions/+page.svelte | 41 ++ .../executions/[execId]/+page.svelte | 39 ++ .../[execId]/ObservationReportCard.svelte | 35 ++ .../executions/[execId]/rules/+page.svelte | 40 ++ 49 files changed, 4821 insertions(+), 60 deletions(-) create mode 100644 web-admin/src/routes/checkers/+page.svelte create mode 100644 web-admin/src/routes/checkers/[checkerId]/+page.svelte create mode 100644 web-admin/src/routes/scheduler/+page.svelte create mode 100644 web/src/lib/components/checkers/CheckResultSidebar.svelte create mode 100644 web/src/lib/components/checkers/CheckerConfigPage.svelte create mode 100644 web/src/lib/components/checkers/CheckerListPage.svelte create mode 100644 web/src/lib/components/checkers/CheckerOptionsGroups.svelte create mode 100644 web/src/lib/components/checkers/CheckerOptionsPanel.svelte create mode 100644 web/src/lib/components/checkers/CheckerRulesCard.svelte create mode 100644 web/src/lib/components/checkers/CheckerScheduleCard.svelte create mode 100644 web/src/lib/components/checkers/CheckerSidebar.svelte create mode 100644 web/src/lib/components/checkers/CheckersAvailabilityTable.svelte create mode 100644 web/src/lib/components/checkers/ChecksSidebarContent.svelte create mode 100644 web/src/lib/components/checkers/DomainCheckerSidebar.svelte create mode 100644 web/src/lib/components/checkers/ExecutionDetailPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionListPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionResultsCard.svelte create mode 100644 web/src/lib/components/checkers/ExecutionRulesPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionSidebarContent.svelte create mode 100644 web/src/lib/components/checkers/ObservationReportCard.svelte create mode 100644 web/src/lib/components/modals/RunCheckModal.svelte create mode 100644 web/src/routes/checkers/+layout.ts create mode 100644 web/src/routes/checkers/+page.svelte create mode 100644 web/src/routes/checkers/[checkerId]/+layout.svelte create mode 100644 web/src/routes/checkers/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/+layout.ts create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/[execId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/[execId]/rules/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/+layout.ts create mode 100644 web/src/routes/domains/[dn]/checks/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/ObservationReportCard.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/rules/+page.svelte diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index f28ed70e..b0bc7659 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -101,6 +101,12 @@ Sessions + + Checkers + + + Scheduler + diff --git a/web-admin/src/routes/checkers/+page.svelte b/web-admin/src/routes/checkers/+page.svelte new file mode 100644 index 00000000..5117cf04 --- /dev/null +++ b/web-admin/src/routes/checkers/+page.svelte @@ -0,0 +1,141 @@ + + + + + + + +

+ + Checkers +

+

+ Manage all checkers + {#await checkersQ then checkersR} + Total: {Object.keys(checkersR.data ?? {}).length} checkers + {/await} +

+ +
+ + + + + + + + + + + + + {#await checkersQ} + Please wait... + {:then checkersR} + {@const checkers = checkersR.data} +
+ + + + + + + + + + {#if !checkers || Object.keys(checkers).length == 0} + + + + {:else} + {#each Object.entries(checkers ?? {}).filter(([name, _info]) => name + .toLowerCase() + .indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]} + + + + + + {/each} + {/if} + +
Plugin NameAvailabilityActions
+ No checkers available +
{checkerInfo.name || checkerId} + {#if availabilityBadges(checkerInfo.availability).length > 0} + {#each availabilityBadges(checkerInfo.availability) as badge} + {badge.label} + {/each} + {:else} + General + {/if} + + + + Manage + +
+
+ {:catch error} + +

+ + Error loading checkers: {error.message} +

+
+ {/await} +
diff --git a/web-admin/src/routes/checkers/[checkerId]/+page.svelte b/web-admin/src/routes/checkers/[checkerId]/+page.svelte new file mode 100644 index 00000000..0c73776d --- /dev/null +++ b/web-admin/src/routes/checkers/[checkerId]/+page.svelte @@ -0,0 +1,452 @@ + + + + + + + + +

+ + {checkerId} +

+ +
+ + {#await checkerQ} + +

+ + Loading checker status... +

+
+ {:then checkerR} + {@const checker = checkerR.data} + {#if checker} + + + + + Checker Information + + +
+
Name:
+
{checker.name}
+ +
Availability:
+
+ {#if availabilityBadges(checker.availability).length > 0} +
+ {#each availabilityBadges(checker.availability) as badge} + {badge.label}-level + {/each} +
+ {:else} + General + {/if} + {#if checker.availability?.limitToProviders?.length} +
+ Providers: {checker.availability.limitToProviders.join( + ", ", + )} +
+ {/if} + {#if checker.availability?.limitToServices?.length} +
+ Services: {checker.availability.limitToServices.join( + ", ", + )} +
+ {/if} +
+ + {#if checker.interval} +
Interval:
+
+ default {formatDuration(checker.interval.default)} + + (min {formatDuration(checker.interval.min)} / max {formatDuration(checker.interval.max)}) + +
+ {/if} +
+
+
+ + {#if checker.rules && checker.rules.length > 0} + + +
+ Check Rules + + {checker.rules.length} + +
+ {#if checker.rules.reduce((acc, rule) => acc + rule.options?.adminOpts?.length, 0) > 0} + + {/if} +
+ + {#each checker.rules as rule, i} + {@const ruleOpts = rule.options?.adminOpts || []} + +
+ +
+ {rule.name} + {#if rule.description} +

+ {rule.description} +

+ {/if} +
+
+ {#if ruleOpts.length > 0} +
+
+ {#each ruleOpts as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+ {/if} +
+ {/each} +
+
+ {/if} + + + + {#await checkerOptionsQ} + + +

+ + Loading options... +

+
+
+ {:then _optionsR} + {@const adminOpts = checker.options?.adminOpts || []} + {@const readOnlyOptGroups = [ + { + key: "userOpts", + label: "User Options", + opts: checker.options?.userOpts || [], + }, + { + key: "domainOpts", + label: "Domain Options", + opts: checker.options?.domainOpts || [], + }, + { + key: "serviceOpts", + label: "Service Options", + opts: checker.options?.serviceOpts || [], + }, + { + key: "runOpts", + label: "Run Options", + opts: checker.options?.runOpts || [], + }, + ]} + {@const rulesAdminOpts = (checker.rules || []).flatMap( + (r) => r.options?.adminOpts || [], + )} + {@const allAdminOpts = [...adminOpts, ...rulesAdminOpts]} + {@const hasAnyOpts = + allAdminOpts.length > 0 || + readOnlyOptGroups.some((g) => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(allAdminOpts)} + + {#if orphanedOpts.length > 0} + +
+
+ + Orphaned options detected: + {orphanedOpts.join(", ")} +
+ +
+
+ {/if} + + {#if adminOpts.length > 0} + + + Admin Options + + + +
+ {#each adminOpts as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+
+ {/if} + + {#each readOnlyOptGroups.filter((g) => g.opts.length > 0) as group} + + + {group.label} + read-only + + +
+ {#each group.opts as opt} +
{opt.label || opt.id}
+
+ {opt.type || "string"} + {#if opt.description} +
{opt.description}
+ {/if} +
+ {/each} +
+
+
+ {/each} + + {#if !hasAnyOpts} + + + + + This checker has no configurable options. + + + + {/if} + {:catch error} + + + + + Error loading options: {error.message} + + + + {/await} + +
+ {:else} + + + Error: checker data not found + + {/if} + {:catch error} + + + Error loading checker: {error.message} + + {/await} +
diff --git a/web-admin/src/routes/scheduler/+page.svelte b/web-admin/src/routes/scheduler/+page.svelte new file mode 100644 index 00000000..d62df7f6 --- /dev/null +++ b/web-admin/src/routes/scheduler/+page.svelte @@ -0,0 +1,262 @@ + + + + + + + +

+ + Scheduler +

+

Monitor and control the checker scheduler

+ +
+ + {#if error} + + + {error} + + {/if} + + {#if loading} +
+ + Loading scheduler status... +
+ {:else if status} + + +
+ + + Scheduler Status + +
+ + + +
+
+
+ +
+
+ Status + {#if status.running} + Running + {:else} + Stopped + {/if} +
+
+ Jobs in queue + {status.job_count ?? 0} +
+
+
+
+ + + + + Next scheduled jobs + {status.next_jobs?.length ?? 0} + + +
+ + + + + + + + + + + {#if !status.next_jobs || status.next_jobs.length === 0} + + + + {:else} + {#each status.next_jobs as job} + + + + + + + {/each} + {/if} + +
CheckerTargetIntervalNext run
+ No jobs scheduled +
+ {job.checkerID ?? "—"} + + {#if job.target?.domainId} + + domain + + {/if} + {#if job.target?.serviceId} + + service + + {/if} + {#if job.target?.userId} + + user + + {/if} + {#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId} + + {/if} + {formatDuration(job.interval)} + {formatRelative(job.nextRun)} +
+
+
+
+ {/if} +
diff --git a/web/src/lib/api/checkers.ts b/web/src/lib/api/checkers.ts index 8c819271..aac15c4d 100644 --- a/web/src/lib/api/checkers.ts +++ b/web/src/lib/api/checkers.ts @@ -54,6 +54,7 @@ import { import type { HappydnsCheckEvaluation, HappydnsCheckPlan, + HappydnsCheckPlanWritable, HappydnsCheckerDefinition, HappydnsCheckerOptions, HappydnsCheckerOptionsPositional, @@ -85,7 +86,7 @@ export async function updateCheckOptions( options: HappydnsCheckerOptions, ): Promise { return unwrapSdkResponse( - await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as any }), + await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options }), ) as HappydnsCheckerOptions; } @@ -93,7 +94,7 @@ export async function updateCheckOptions( export async function listDomainCheckers(domain: string): Promise { return (unwrapSdkResponse( - await getDomainsByDomainCheckers({ path: { domain } } as any), + await getDomainsByDomainCheckers({ path: { domain } }), ) as HappydnsCheckerStatus[]) ?? []; } @@ -106,10 +107,10 @@ export async function listDomainExecutions( await getDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, query: { - ...(options?.includePlanned ? { include_planned: "true" } : {}), - ...(options?.limit ? { limit: String(options.limit) } : {}), + ...(options?.includePlanned ? { include_planned: true } : {}), + ...(options?.limit ? { limit: options.limit } : {}), }, - } as any), + }), ) as HappydnsExecution[]) ?? []; } @@ -122,7 +123,7 @@ export async function triggerDomainCheck( await postDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, body: request, - } as any), + }), ) as HappydnsExecution; } @@ -134,7 +135,7 @@ export async function getDomainExecution( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsExecution; } @@ -146,7 +147,7 @@ export async function deleteDomainExecution( return unwrapEmptyResponse( await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, checkerId, executionId }, - } as any), + }), ); } @@ -157,7 +158,7 @@ export async function deleteAllDomainExecutions( return unwrapEmptyResponse( await deleteDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, - } as any), + }), ); } @@ -169,7 +170,7 @@ export async function getDomainExecutionResults( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsCheckEvaluation; } @@ -181,7 +182,7 @@ export async function getDomainExecutionObservations( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsObservationSnapshot; } @@ -192,7 +193,7 @@ export async function getDomainCheckOptions( return (unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdOptions({ path: { domain, checkerId }, - } as any), + }), ) as HappydnsCheckerOptionsPositional[]) ?? []; } @@ -204,8 +205,8 @@ export async function updateDomainCheckOptions( return unwrapSdkResponse( await putDomainsByDomainCheckersByCheckerIdOptions({ path: { domain, checkerId }, - body: options as any, - } as any), + body: options, + }), ) as HappydnsCheckerOptions; } @@ -216,20 +217,20 @@ export async function getDomainCheckPlans( return (unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdPlans({ path: { domain, checkerId }, - } as any), + }), ) as HappydnsCheckPlan[]) ?? []; } export async function createDomainCheckPlan( domain: string, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await postDomainsByDomainCheckersByCheckerIdPlans({ path: { domain, checkerId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -237,13 +238,13 @@ export async function updateDomainCheckPlan( domain: string, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({ path: { domain, checkerId, planId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -258,7 +259,7 @@ export async function listServiceCheckers( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({ path: { domain, zoneid, subdomain, serviceid }, - } as any), + }), ) as HappydnsCheckerStatus[]) ?? []; } @@ -274,10 +275,10 @@ export async function listServiceExecutions( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, query: { - ...(options?.includePlanned ? { include_planned: "true" } : {}), - ...(options?.limit ? { limit: String(options.limit) } : {}), + ...(options?.includePlanned ? { include_planned: true } : {}), + ...(options?.limit ? { limit: options.limit } : {}), }, - } as any), + }), ) as HappydnsExecution[]) ?? []; } @@ -293,7 +294,7 @@ export async function triggerServiceCheck( await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, body: request, - } as any), + }), ) as HappydnsExecution; } @@ -308,7 +309,7 @@ export async function getServiceExecution( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsExecution; } @@ -323,7 +324,7 @@ export async function deleteServiceExecution( return unwrapEmptyResponse( await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ); } @@ -337,7 +338,7 @@ export async function deleteAllServiceExecutions( return unwrapEmptyResponse( await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ); } @@ -352,7 +353,7 @@ export async function getServiceExecutionResults( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsCheckEvaluation; } @@ -367,7 +368,7 @@ export async function getServiceExecutionObservations( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsObservationSnapshot; } @@ -381,7 +382,7 @@ export async function getServiceCheckOptions( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ) as HappydnsCheckerOptionsPositional[]) ?? []; } @@ -396,8 +397,8 @@ export async function updateServiceCheckOptions( return unwrapSdkResponse( await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - body: options as any, - } as any), + body: options, + }), ) as HappydnsCheckerOptions; } @@ -411,7 +412,7 @@ export async function getServiceCheckPlans( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ) as HappydnsCheckPlan[]) ?? []; } @@ -421,13 +422,13 @@ export async function createServiceCheckPlan( subdomain: string, serviceid: string, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -438,13 +439,13 @@ export async function updateServiceCheckPlan( serviceid: string, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({ path: { domain, zoneid, subdomain, serviceid, checkerId, planId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -580,7 +581,7 @@ export async function getScopedCheckPlans( export async function createScopedCheckPlan( scope: CheckerScope, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { if (isServiceScope(scope)) { return createServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, plan); @@ -592,7 +593,7 @@ export async function updateScopedCheckPlan( scope: CheckerScope, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { if (isServiceScope(scope)) { return updateServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, planId, plan); diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index a3ffa88b..68e09069 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -138,6 +138,14 @@ > {$t("menu.dns-resolver")} + + {$t("menu.checkers")} + {$t("menu.my-account")} diff --git a/web/src/lib/components/checkers/CheckResultSidebar.svelte b/web/src/lib/components/checkers/CheckResultSidebar.svelte new file mode 100644 index 00000000..12f7ba40 --- /dev/null +++ b/web/src/lib/components/checkers/CheckResultSidebar.svelte @@ -0,0 +1,276 @@ + + + + +{#if $currentExecution} + + +
+ {$currentCheckInfo?.name || checkerId} + {#if $currentExecution.planId} + + + {$t("checkers.result.type.scheduled")} + + {:else} + + + {$t("checkers.result.type.manual")} + + {/if} +
+
+
+ + + + + + + {#if $currentExecution.endedAt && $currentExecution.startedAt} + + + + + {/if} + {#if $currentExecution.result} + + + + + {#if $currentExecution.result.message} + + + + + {/if} + {/if} + {#if $currentExecution.error} + + + + + {/if} + +
+ {$t("checkers.result.field.executed-at")} + {formatCheckDate($currentExecution.startedAt)}
{$t("checkers.result.field.duration")}{formatDuration(($currentExecution.endedAt.getTime() - $currentExecution.startedAt.getTime()) * 1e6)}
{$t("checkers.result.field.status")} + + {$t(getStatusI18nKey($currentExecution.result.status))} + +
{$t("checkers.result.field.status-message")} + {$currentExecution.result.message} +
{$t("checkers.result.field.error")} + {$currentExecution.error} +
+
+
+ +
+ + {#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentExecution.result != null} + {#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report} + + {#if $currentCheckInfo?.has_metrics} + + {/if} + {#if $currentCheckInfo?.has_html_report} + + {/if} + + + {/if} + + {#if $currentCheckInfo?.has_html_report} + + {/if} + {#if $currentExecution.result != null} + + {/if} + + {/if} +{:else} +
+{/if} + +
+ + +
diff --git a/web/src/lib/components/checkers/CheckerConfigPage.svelte b/web/src/lib/components/checkers/CheckerConfigPage.svelte new file mode 100644 index 00000000..77407013 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerConfigPage.svelte @@ -0,0 +1,187 @@ + + + + + + {resolvedStatus?.name ?? checkerId} - {domainName} - happyDomain + + +
+ + {#if $checkers && (!$checkers[checkerId]?.availability || $checkers[checkerId].availability.applyToDomain || $checkers[checkerId].availability.applyToZone)} + + {/if} + + + {#await checkStatusPromise} + +

+ + {$t("checkers.loading-info")} +

+
+ {:then status} + {#if status} + {@const editable = editableGroups(status)} + {@const readOnly = readOnlyGroups(status)} + + + + + {#if status.rules && status.rules.length > 0} + + {/if} + + + + + + + {:else} + + + {$t("checkers.checker-info-not-found")} + + {/if} + {:catch error} + + + {$t("checkers.error-loading-checker", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/CheckerListPage.svelte b/web/src/lib/components/checkers/CheckerListPage.svelte new file mode 100644 index 00000000..b3014c67 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerListPage.svelte @@ -0,0 +1,181 @@ + + + + + + {$t("checkers.list.title")}{domainName} - happyDomain + + +
+ + + {#await checkersPromise} + +

+ + {$t("checkers.list.loading")} +

+
+ {:then checkerStatuses} + {#if checkerStatuses.length > 0} +
+ + + + + + + + + + + + {#each checkerStatuses as checker} + {@const status = checker.latestExecution?.result?.status} + + + + + + + + {/each} + +
{$t("checkers.list.table.checker")}{$t("checkers.list.table.status")}{$t("checkers.list.table.last-run")}{$t("checkers.list.table.schedule")}{$t("checkers.list.table.actions")}
+ {checker.name || checker.id} + + {#if checker.latestExecution} + + {$t(getStatusI18nKey(status))} + + {:else} + + {$t("checkers.status.not-run")} + + {/if} + + {#if checker.latestExecution?.startedAt} + {formatCheckDate(checker.latestExecution.startedAt)} + {:else} + {$t("checkers.never")} + {/if} + + {#if checker.enabled} + + {$t("checkers.list.schedule.enabled")} + + {:else} + + {$t("checkers.list.schedule.disabled")} + + {/if} + + +
+
+ {:else} + + + {$t("checkers.list.no-checks")} + + {/if} + + {@const configuredIds = getConfiguredCheckerIds(checkerStatuses)} + {@const unconfigured = getUnconfiguredCheckers(configuredIds)} + {#if unconfigured.length > 0} + + + {$t("checkers.other-checkers.title")} + + +

{$t("checkers.other-checkers.description")}

+ +
+
+ {/if} + {:catch error} + + + {$t("checkers.list.error-loading", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/CheckerOptionsGroups.svelte b/web/src/lib/components/checkers/CheckerOptionsGroups.svelte new file mode 100644 index 00000000..58717d56 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerOptionsGroups.svelte @@ -0,0 +1,70 @@ + + + + +{#each groups.filter((g) => g.opts.length > 0) as group} + + + {group.label} + {$t("checkers.detail.read-only")} + + +
+ {#each group.opts as opt} +
+ {opt.label || opt.id} + {#if opt.autoFill} + {autoFillLabel(opt.autoFill)} + {/if} +
+
+ {opt.type || "string"} + {#if opt.description} +
{opt.description}
+ {/if} +
+ {/each} +
+
+
+{/each} diff --git a/web/src/lib/components/checkers/CheckerOptionsPanel.svelte b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte new file mode 100644 index 00000000..1369588f --- /dev/null +++ b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte @@ -0,0 +1,197 @@ + + + + +{#await checkOptionsPromise} + + +

+ + {$t("checkers.detail.loading-options")} +

+
+
+{:then _options} + {#if orphanedOpts.length > 0 && onclean} + +
+
+ + {$t("checkers.detail.orphaned-options", { + options: orphanedOpts.join(", "), + })} +
+ +
+
+ {/if} + + {#each filteredEditableGroups.filter((g) => g.opts.length > 0) as group, gid} + + + {group.label} + + + +
+ {#each withInheritedPlaceholders(group.opts, optionValues, inheritedValues) as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+
+ {/each} + + {#if autoFillOpts.length > 0} + + {/if} + + + + {#if !hasAnyOpts} + + + + + {$t("checkers.detail.no-configurable-options")} + + + + {/if} +{:catch error} + + + + + {$t("checkers.detail.error-loading-options", { + error: error.message, + })} + + + +{/await} diff --git a/web/src/lib/components/checkers/CheckerRulesCard.svelte b/web/src/lib/components/checkers/CheckerRulesCard.svelte new file mode 100644 index 00000000..1b6b7539 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerRulesCard.svelte @@ -0,0 +1,165 @@ + + + + + + +
+ {$t("checkers.detail.check-rules")} + {rules.length} +
+ {#if plan} +
+
+ + +
+
+ {:else if hasRuleOpts} + + {/if} +
+ + {#each rules as rule} + {@const ruleOpts = [ + ...(rule.options?.adminOpts || []), + ...(rule.options?.userOpts || []), + ]} + +
+ {#if plan} +
+ { + if (rule.name && plan) { + plan.enabled = { + ...plan.enabled, + [rule.name]: !(plan.enabled?.[rule.name] ?? false), + }; + } + }} + /> +
+ {:else} + + {/if} +
+ {rule.name} + {#if rule.description} +

{rule.description}

+ {/if} +
+
+ {#if ruleOpts.length > 0} +
+
+ {#each withInheritedPlaceholders(ruleOpts, optionValues, inheritedValues) as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+ {/if} +
+ {/each} +
+
diff --git a/web/src/lib/components/checkers/CheckerScheduleCard.svelte b/web/src/lib/components/checkers/CheckerScheduleCard.svelte new file mode 100644 index 00000000..840f8e73 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerScheduleCard.svelte @@ -0,0 +1,147 @@ + + + + + + + {$t("checkers.schedule.card-title")} + + + +
+ + +
+ + setIntervalHours(parseInt((e.target as HTMLInputElement).value) || 1)} + style="width: 100px" + /> + {$t("checkers.schedule.hours")} +
+ {$t("checkers.schedule.interval-hint")} +
+
+ + {#if !existingPlanId} + + + {$t("checkers.schedule.no-schedule-yet")} + + {/if} +
+
diff --git a/web/src/lib/components/checkers/CheckerSidebar.svelte b/web/src/lib/components/checkers/CheckerSidebar.svelte new file mode 100644 index 00000000..ec7c0005 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerSidebar.svelte @@ -0,0 +1,81 @@ + + + + +
+ + + {$t("checkers.title")} + + + {#if $checkers} + + {:else} +
+ +
+ {/if} +
+ + diff --git a/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte b/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte new file mode 100644 index 00000000..94d7d0d3 --- /dev/null +++ b/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte @@ -0,0 +1,83 @@ + + + + +
+ + + + + + + + + + {#each checkers as [checkerId, checkerInfo]} + {@const badges = availabilityBadges(checkerInfo.availability)} + + + + + + {/each} + +
{$t("checkers.table.name")}{$t("checkers.table.availability")}{$t("checkers.table.actions")}
{checkerInfo.name || checkerId} + {#if badges.length > 0} +
+ {#each badges as badge} + + {badge.label} + + {/each} +
+ {:else} + {$t("checkers.availability.general")} + {/if} +
+ + {$t("checkers.table.manage")} + +
+
diff --git a/web/src/lib/components/checkers/ChecksSidebarContent.svelte b/web/src/lib/components/checkers/ChecksSidebarContent.svelte new file mode 100644 index 00000000..71910b49 --- /dev/null +++ b/web/src/lib/components/checkers/ChecksSidebarContent.svelte @@ -0,0 +1,103 @@ + + + + +{#if page.params.execId} + + + {$t("zones.return-to-results")} + + +{:else if page.params.checkerId} + + + {$t("checkers.title")} + + +
+{:else} + + + {$t("zones.return-to")} + +{/if} diff --git a/web/src/lib/components/checkers/DomainCheckerSidebar.svelte b/web/src/lib/components/checkers/DomainCheckerSidebar.svelte new file mode 100644 index 00000000..27276c1d --- /dev/null +++ b/web/src/lib/components/checkers/DomainCheckerSidebar.svelte @@ -0,0 +1,152 @@ + + + + + + + diff --git a/web/src/lib/components/checkers/ExecutionDetailPage.svelte b/web/src/lib/components/checkers/ExecutionDetailPage.svelte new file mode 100644 index 00000000..9c04c046 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionDetailPage.svelte @@ -0,0 +1,102 @@ + + + + + + {$t("checkers.execution.title")} - {checkerName || checkerId} - happyDomain + + +{#if loading} + + +

+ + {$t("checkers.result.loading")} +

+
+
+{:else if error} + + + + {$t("checkers.result.error-loading", { error })} + + +{:else if $currentObservations} + +{/if} diff --git a/web/src/lib/components/checkers/ExecutionListPage.svelte b/web/src/lib/components/checkers/ExecutionListPage.svelte new file mode 100644 index 00000000..28a90b9e --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionListPage.svelte @@ -0,0 +1,275 @@ + + + + + + + {$t("checkers.executions.title", { count: executions.length })} - {resolvedName || + checkerId} - happyDomain + + + +
+ +
+ + + +
+
+ + {#await executionsPromise} + +

+ + {$t("checkers.executions.loading")} +

+
+ {:then _executions} + {#if executions.length === 0} + + + {$t("checkers.executions.no-results")} + + {:else} + + + + + + + + + + + {#each executions.toSorted((a, b) => { + const aTime = a.startedAt ? new Date(a.startedAt).getTime() : Infinity; + const bTime = b.startedAt ? new Date(b.startedAt).getTime() : Infinity; + return bTime - aTime; + }) as execution} + {@const isPending = !execution.id} + {@const isRunning = + execution.id && execution.startedAt && !execution.endedAt} + {@const status = execution.status} + {@const duration = + execution.startedAt && execution.endedAt + ? Math.round( + (new Date(execution.endedAt).getTime() - + new Date(execution.startedAt).getTime()) / + 1000, + ) + : null} + + + + + + + {/each} + +
{$t("checkers.executions.table.executed-at")}{$t("checkers.executions.table.status")}{$t("checkers.executions.table.duration")}{$t("checkers.executions.table.actions")}
+ {#if !execution.startedAt} + + {$t("checkers.status.planned")} + + {:else if isPending} + + {formatCheckDate(execution.startedAt)} + + {:else} + {formatCheckDate(execution.startedAt)} + {/if} + + {#if isPending} + {$t("checkers.status.planned")} + {:else if status == 2 && execution.result} + + {$t(getStatusI18nKey(execution.result.status))} + + {:else} + + {$t(getExecutionStatusI18nKey(status))} + + {/if} + + {#if isRunning} + + {$t("checkers.status.running")} + + {:else if duration !== null} + {duration}s + {:else} + - + {/if} + +
+ + {$t("checkers.executions.view")} + + +
+
+ {/if} + {/await} +
+ + pollForNewExecution()} + bind:this={runCheckModal} +/> diff --git a/web/src/lib/components/checkers/ExecutionResultsCard.svelte b/web/src/lib/components/checkers/ExecutionResultsCard.svelte new file mode 100644 index 00000000..bec9fe40 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionResultsCard.svelte @@ -0,0 +1,63 @@ + + + + + + + {$t("checkers.detail.check-rules")} + + + {#if evaluation.states && evaluation.states.length > 0} + + + + + + + + + {#each evaluation.states as state} + + + + + {/each} + +
{$t("checkers.result.field.rule")}{$t("checkers.result.field.message")}
{state.code ?? ""}{state.message ?? ""}
+ {:else} +
{JSON.stringify(evaluation, null, 2)}
+ {/if} +
+
diff --git a/web/src/lib/components/checkers/ExecutionRulesPage.svelte b/web/src/lib/components/checkers/ExecutionRulesPage.svelte new file mode 100644 index 00000000..453879fa --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionRulesPage.svelte @@ -0,0 +1,79 @@ + + + + + + {$t("checkers.detail.check-rules")} - {checkerName || checkerId} - happyDomain + + +
+ + + {#await resultsPromise} +

+ + {$t("checkers.result.loading")} +

+ {:then evaluation} + {#if evaluation} + + {:else} + + + {$t("checkers.result.no-results")} + + {/if} + {:catch error} + + + {$t("checkers.result.error-loading", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/ExecutionSidebarContent.svelte b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte new file mode 100644 index 00000000..d36253f2 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte @@ -0,0 +1,253 @@ + + + + +{#if $currentExecution} + + +
+ {$currentCheckInfo?.name || checkerId} + + {$t(getExecutionStatusI18nKey($currentExecution.status))} + +
+
+
+ + + + + + + {#if $currentExecution.endedAt} + + + + + {/if} + + + + + {#if $currentExecution.result?.message} + + + + + {/if} + {#if $currentExecution.error} + + + + + {/if} + {#if $currentExecution.trigger} + + + + + {/if} + +
+ {$t("checkers.result.field.executed-at")} + {formatCheckDate($currentExecution.startedAt)}
{$t("checkers.execution.field.ended-at")}{formatCheckDate($currentExecution.endedAt)}
{$t("checkers.result.field.status")} + + {$t(getStatusI18nKey($currentExecution.result?.status))} + + + {$t("checkers.detail.check-rules")} + +
{$t("checkers.result.field.status-message")} + {$currentExecution.result.message} +
{$t("checkers.result.field.error")} + {$currentExecution.error} +
{$t("checkers.execution.field.trigger")}{JSON.stringify($currentExecution.trigger)}
+
+
+ +
+ + + + + + + + + + + + +{:else} +
+{/if} + +
+ + +
diff --git a/web/src/lib/components/checkers/ObservationReportCard.svelte b/web/src/lib/components/checkers/ObservationReportCard.svelte new file mode 100644 index 00000000..6d7dd822 --- /dev/null +++ b/web/src/lib/components/checkers/ObservationReportCard.svelte @@ -0,0 +1,43 @@ + + + + +{#if observations?.data && Object.keys(observations.data).length > 0} +
+
{JSON.stringify(observations.data, null, 2)}
+
+{/if} diff --git a/web/src/lib/components/inputs/Resource.svelte b/web/src/lib/components/inputs/Resource.svelte index f0836beb..0e3af6e1 100644 --- a/web/src/lib/components/inputs/Resource.svelte +++ b/web/src/lib/components/inputs/Resource.svelte @@ -31,6 +31,7 @@ import TableInput from "$lib/components/inputs/table.svelte"; import type { Field } from "$lib/model/custom_form.svelte"; import type { ServiceInfos } from "$lib/model/service_specs.svelte"; + import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base/types.gen"; const dispatch = createEventDispatcher(); @@ -41,7 +42,7 @@ noDecorate?: boolean; readonly?: boolean; showDescription?: boolean; - specs?: Field | ServiceInfos; + specs?: Field | ServiceInfos | HappydnsCheckerOptionDocumentation; type: string; value: any; } diff --git a/web/src/lib/components/modals/RunCheckModal.svelte b/web/src/lib/components/modals/RunCheckModal.svelte new file mode 100644 index 00000000..d3951beb --- /dev/null +++ b/web/src/lib/components/modals/RunCheckModal.svelte @@ -0,0 +1,326 @@ + + + + + + + {$t("checkers.run-check.title")}: {checkDisplayName} + + + {#if checkStatusPromise && scopedOptionsPromise} + {#await Promise.all([checkStatusPromise, scopedOptionsPromise])} +
+ +

{$t("checkers.run-check.loading-options")}

+
+ {:then [status, _domainOpts]} + {@const rules = status.rules || []} + {@const activeRulesForOpts = rules.map( + (r: HappydnsCheckerDefinition | null, i: number) => + activeRules[i] !== false ? r : null, + )} + {@const runOpts = [ + ...(status.options?.runOpts || []), + ...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []), + ]} + {@const otherOpts = [ + ...(status.options?.adminOpts || []), + ...(status.options?.userOpts || []), + ...(status.options?.domainOpts || []), + ...activeRulesForOpts.flatMap((r: any) => [ + ...(r?.options?.adminOpts || []), + ...(r?.options?.userOpts || []), + ...(r?.options?.domainOpts || []), + ]), + ].filter((o: any) => o.id)} +
{ + e.preventDefault(); + handleRunCheck(); + }} + > + {#if runOpts.length > 0 || otherOpts.length > 0} +

+ {#if runOpts.length > 0} + {$t("checkers.run-check.configure-info")} + {:else} + + {$t("checkers.run-check.no-run-options")} + {/if} +

+ {#each runOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} + {#if otherOpts.length > 0} + + {#if showAdvanced} + {#each otherOpts as optDoc} + {@const optName = (optDoc as any).id} + + + + {/each} + {/if} + {/if} + {:else} + + + {$t("checkers.run-check.no-options")} + + {/if} + {#if rules.length >= 1} +
+ + + {#each rules as rule, idx} + {@const isActive = activeRules[idx] !== false} +
+ (activeRules[idx] = !isActive)} + /> +
+ {/each} +
+ {/if} +
+ {:catch error} + + + {$t("checkers.run-check.error-loading-options", { error: error.message })} + + {/await} + {/if} +
+ + + + +
diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index cd034c59..3794012a 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -46,6 +46,7 @@ interface Params { countdown?: string; error?: string; options?: string; + key?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts index 5d8e25eb..7c91d0e9 100644 --- a/web/src/lib/utils/checkers.ts +++ b/web/src/lib/utils/checkers.ts @@ -124,11 +124,12 @@ export function downloadBlob(content: string, filename: string, mime: string) { URL.revokeObjectURL(url); } -export function formatCheckDate(date: string | undefined): string { +export function formatCheckDate(date: string | Date | undefined): string { if (!date) return ""; try { + if (date instanceof Date) return date.toLocaleString(); return new Date(date).toLocaleString(); } catch { - return date; + return String(date); } } diff --git a/web/src/routes/checkers/+layout.ts b/web/src/routes/checkers/+layout.ts new file mode 100644 index 00000000..f3b81ba1 --- /dev/null +++ b/web/src/routes/checkers/+layout.ts @@ -0,0 +1,31 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { type Load } from "@sveltejs/kit"; +import { get } from "svelte/store"; + +import { checkers, refreshCheckers } from "$lib/stores/checkers"; + +export const load: Load = async ({ parent }) => { + if (get(checkers) === undefined) refreshCheckers(); + + return await parent(); +}; diff --git a/web/src/routes/checkers/+page.svelte b/web/src/routes/checkers/+page.svelte new file mode 100644 index 00000000..9fd0962b --- /dev/null +++ b/web/src/routes/checkers/+page.svelte @@ -0,0 +1,99 @@ + + + + + + {$t("checkers.title")} - happyDomain + + + + + {#if $checkers} + {$t("checkers.available-count", { + count: Object.keys($checkers).length, + })} + {/if} + + + + + + + + + + + + + + {#if !$checkers} + +

+ + {$t("checkers.loading")} +

+
+ {:else} + {#if Object.keys($checkers).length == 0} +

+ {$t("checkers.no-checkers")} +

+ {:else} + + {/if} + {/if} +
diff --git a/web/src/routes/checkers/[checkerId]/+layout.svelte b/web/src/routes/checkers/[checkerId]/+layout.svelte new file mode 100644 index 00000000..78ac2f36 --- /dev/null +++ b/web/src/routes/checkers/[checkerId]/+layout.svelte @@ -0,0 +1,53 @@ + + + + + + + + + + + {@render children?.()} + + + diff --git a/web/src/routes/checkers/[checkerId]/+page.svelte b/web/src/routes/checkers/[checkerId]/+page.svelte new file mode 100644 index 00000000..ecb6763a --- /dev/null +++ b/web/src/routes/checkers/[checkerId]/+page.svelte @@ -0,0 +1,239 @@ + + + + + + {resolvedStatus?.name ?? checkerId} - {$t("checkers.title")} - happyDomain + + +
+ + + {#await checkStatusPromise} + +

+ + {$t("checkers.loading-info")} +

+
+ {:then status} + {#if status} + {@const adminOpts = status.options?.adminOpts || []} + {@const userOpts = status.options?.userOpts || []} + {@const rulesAdminOpts = (status.rules || []).flatMap((r) => r.options?.adminOpts || [])} + {@const rulesUserOpts = (status.rules || []).flatMap((r) => r.options?.userOpts || [])} + {@const allEditableOpts = [...adminOpts, ...userOpts, ...rulesAdminOpts, ...rulesUserOpts]} + {@const editableGroups = [ + { label: $t("checkers.detail.admin-options"), opts: adminOpts }, + { label: $t("checkers.detail.configuration"), opts: userOpts }, + ]} + {@const readOnlyGroups = [ + { key: "domainOpts", label: $t("checkers.option-groups.domain-settings"), opts: status.options?.domainOpts || [] }, + { key: "serviceOpts", label: $t("checkers.option-groups.service-settings"), opts: status.options?.serviceOpts || [] }, + { key: "runOpts", label: $t("checkers.option-groups.checker-parameters"), opts: status.options?.runOpts || [] }, + ]} + {@const orphanedOpts = getOrphanedOptions(allEditableOpts, readOnlyGroups)} + + + + + {$t("checkers.detail.checker-information")} + + +
+
{$t("checkers.detail.name")}
+
{status.name}
+ +
{$t("checkers.detail.availability")}
+
+ {#each getAvailBadges(status.availability) as badge} + {badge.label} + {:else} + + {$t("checkers.availability.general")} + + {/each} +
+
+
+
+ + {#if status.rules && status.rules.length > 0} + + {/if} + + + + cleanOrphanedOptions(allEditableOpts)} + /> + +
+ {:else} + + + {$t("checkers.checker-info-not-found")} + + {/if} + {:catch error} + + + {$t("checkers.error-loading-checker", { error: error.message })} + + {/await} +
diff --git a/web/src/routes/domains/[dn]/+layout.svelte b/web/src/routes/domains/[dn]/+layout.svelte index fd5dc9cc..7376cf6e 100644 --- a/web/src/routes/domains/[dn]/+layout.svelte +++ b/web/src/routes/domains/[dn]/+layout.svelte @@ -29,6 +29,7 @@ import { Button, Col, Container, Icon, Row, Spinner } from "@sveltestrap/sveltestrap"; import { deleteDomain as APIDeleteDomain } from "$lib/api/domains"; + import ChecksSidebarContent from "$lib/components/checkers/ChecksSidebarContent.svelte"; import SelectDomain from "$lib/components/domains/SelectDomain.svelte"; import type { Domain } from "$lib/model/domain"; import type { ZoneMeta } from "$lib/model/zone"; @@ -42,6 +43,7 @@ import ServiceDetailsOffcanvas from "./ServiceDetailsOffcanvas.svelte"; import ServiceSidebar from "./ServiceSidebar.svelte"; import ZoneSidebar from "./ZoneSidebar.svelte"; + import { thisZone } from "$lib/stores/thiszone"; interface Props { data: { domain: Domain }; @@ -57,13 +59,15 @@ "/domains/" + encodeURIComponent(domainLink(dn)) + (page.route.id - ? page.route.id.startsWith("/domains/[dn]/logs") - ? "/logs" - : page.route.id.startsWith("/domains/[dn]/history") - ? "/history" - : page.route.id.startsWith("/domains/[dn]/[[historyid]]/export") - ? "/export" - : "" + ? page.route.id.startsWith("/domains/[dn]/checks") + ? "/checks" + : page.route.id.startsWith("/domains/[dn]/logs") + ? "/logs" + : page.route.id.startsWith("/domains/[dn]/history") + ? "/history" + : page.route.id.startsWith("/domains/[dn]/[[historyid]]/export") + ? "/export" + : "" : ""), ); } @@ -145,7 +149,15 @@ - {#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/[[historyid]]/export"))} + {#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks")} + + {:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/[[historyid]]/export"))} {$t("zones.return-to")} + {:else if page.route.id && page.route.id.startsWith("/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks")} + {:else if page.route.id === "/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]"} {/if} -
- {#if !(data.domain.zone_history && $domains_idx[selectedDomain] && data.domain.id === $domains_idx[selectedDomain].id && selectedHistory)} +
- - + {/if} + - - + {#if $currentCheckInfo?.has_html_report} + + {/if} + {#if $currentCheckInfo?.has_metrics} + + {/if} {#if $currentCheckInfo?.has_html_report} + + + From 2d8f37cbecc0bbe5a0ce41e9f1db1ec6df16ccbc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:51:01 +0700 Subject: [PATCH 23/54] checker: add tiered RetentionPolicy Introduce a pure RetentionPolicy.Decide function that partitions check executions into keep/drop sets according to a tiered policy: - 0..7 days -> every execution - 7..30 days -> 2 per day per (checker, target) - 30..D/2 -> 1 per week per (checker, target) - D/2..D days -> 1 per month per (checker, target) - > D days -> dropped The function is intentionally storage-agnostic so the upcoming janitor goroutine can call it on any execution slice and so it can be unit tested directly. All thresholds are configurable to allow per-user overrides via UserQuota. --- internal/usecase/checker/retention.go | 183 +++++++++++++++++++++ internal/usecase/checker/retention_test.go | 128 ++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 internal/usecase/checker/retention.go create mode 100644 internal/usecase/checker/retention_test.go diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go new file mode 100644 index 00000000..1c67840b --- /dev/null +++ b/internal/usecase/checker/retention.go @@ -0,0 +1,183 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "sort" + "time" + + "git.happydns.org/happyDomain/model" +) + +// RetentionPolicy describes how check executions are thinned out as they age. +// +// The policy is intentionally tiered: users care about full detail for recent +// runs, but only need sparse historical samples to spot long-term trends. +// +// Default behaviour, given a RetentionDays of D: +// +// age window | kept +// ------------------------- | ------------------------------------------ +// 0 .. 7 days | every execution +// 7 .. 30 days | up to 2 executions per day per (checker,target) +// 30 .. D/2 days | up to 1 execution per week per (checker,target) +// D/2 .. D days | up to 1 execution per month per (checker,target) +// > D days | dropped +// +// All thresholds and bucket counts are configurable so the policy can be +// tuned per-user via the admin UserQuota. +type RetentionPolicy struct { + // RetentionDays is the hard cap on age. Executions older than this are + // always dropped. Must be > 0. + RetentionDays int + + // FullDetailDays: every execution kept under this age. + FullDetailDays int + // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep + // PerDayKept executions per UTC day per (checker,target). + DailyBucketDays int + PerDayKept int + // WeeklyBucketDays: between DailyBucketDays and WeeklyBucketDays, keep + // PerWeekKept executions per ISO week per (checker,target). + WeeklyBucketDays int + PerWeekKept int + // Beyond WeeklyBucketDays and up to RetentionDays, keep PerMonthKept + // executions per calendar month per (checker,target). + PerMonthKept int +} + +// DefaultRetentionPolicy returns the standard tiered policy for the given +// retention horizon. +func DefaultRetentionPolicy(retentionDays int) RetentionPolicy { + if retentionDays <= 0 { + retentionDays = 365 + } + return RetentionPolicy{ + RetentionDays: retentionDays, + FullDetailDays: 7, + DailyBucketDays: 30, + PerDayKept: 2, + WeeklyBucketDays: max(retentionDays/2, 31), + PerWeekKept: 1, + PerMonthKept: 1, + } +} + +// Decide partitions executions into the ones to keep and the ones to drop +// according to the policy. The function is pure: it does not touch storage. +// +// Executions are grouped by (CheckerID, Target) and ordered most-recent-first +// inside each group, so the newest execution in a bucket is the one preserved. +func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) { + if len(executions) == 0 { + return nil, nil + } + + // Group by (checker, target). + groups := map[string][]*happydns.Execution{} + for _, e := range executions { + if e == nil { + continue + } + key := e.CheckerID + "|" + e.Target.String() + groups[key] = append(groups[key], e) + } + + hardCutoff := now.AddDate(0, 0, -p.RetentionDays) + fullCutoff := now.AddDate(0, 0, -p.FullDetailDays) + dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays) + weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays) + + for _, group := range groups { + // Most recent first. + sort.Slice(group, func(i, j int) bool { + return group[i].StartedAt.After(group[j].StartedAt) + }) + + dayBuckets := map[string]int{} + weekBuckets := map[string]int{} + monthBuckets := map[string]int{} + + for _, e := range group { + t := e.StartedAt + switch { + case t.Before(hardCutoff): + drop = append(drop, e.Id) + case !t.Before(fullCutoff): + // 0 .. FullDetailDays — keep everything. + keep = append(keep, e.Id) + case !t.Before(dailyCutoff): + k := t.UTC().Format("2006-01-02") + if dayBuckets[k] < p.PerDayKept { + dayBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + case !t.Before(weeklyCutoff): + y, w := t.UTC().ISOWeek() + k := isoWeekKey(y, w) + if weekBuckets[k] < p.PerWeekKept { + weekBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + default: + k := t.UTC().Format("2006-01") + if monthBuckets[k] < p.PerMonthKept { + monthBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + } + } + } + + return keep, drop +} + +func isoWeekKey(year, week int) string { + return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006") + "-W" + twoDigits(week) +} + +func twoDigits(n int) string { + if n < 10 { + return "0" + itoa(n) + } + return itoa(n) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [4]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go new file mode 100644 index 00000000..7d1383b1 --- /dev/null +++ b/internal/usecase/checker/retention_test.go @@ -0,0 +1,128 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "fmt" + "testing" + "time" + + "git.happydns.org/happyDomain/model" +) + +func mkExec(id string, age time.Duration, now time.Time) *happydns.Execution { + return &happydns.Execution{ + Id: happydns.Identifier(id), + CheckerID: "ping", + Target: happydns.CheckTarget{DomainId: "example.com"}, + StartedAt: now.Add(-age), + } +} + +func TestDecide_Empty(t *testing.T) { + p := DefaultRetentionPolicy(365) + keep, drop := p.Decide(nil, time.Now()) + if len(keep) != 0 || len(drop) != 0 { + t.Fatalf("expected empty results, got keep=%d drop=%d", len(keep), len(drop)) + } +} + +func TestDecide_FullDetailWindow(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + var execs []*happydns.Execution + for i := 0; i < 50; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now)) + } + + keep, drop := p.Decide(execs, now) + if len(drop) != 0 { + t.Fatalf("expected no drops in <7d window, got %d", len(drop)) + } + if len(keep) != 50 { + t.Fatalf("expected 50 keeps, got %d", len(keep)) + } +} + +func TestDecide_DailyBucket(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 10 executions on the same day, ~10 days ago (inside daily window). + var execs []*happydns.Execution + for i := 0; i < 10; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), 10*24*time.Hour+time.Duration(i)*time.Hour, now)) + } + + keep, drop := p.Decide(execs, now) + if len(keep) != p.PerDayKept { + t.Fatalf("expected %d keeps in daily bucket, got %d", p.PerDayKept, len(keep)) + } + if len(drop) != 10-p.PerDayKept { + t.Fatalf("expected %d drops, got %d", 10-p.PerDayKept, len(drop)) + } +} + +func TestDecide_HardCutoff(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(30) + + execs := []*happydns.Execution{ + mkExec("recent", 1*24*time.Hour, now), + mkExec("old", 100*24*time.Hour, now), + } + + keep, drop := p.Decide(execs, now) + if len(keep) != 1 || string(keep[0]) != "recent" { + t.Fatalf("expected 'recent' to be kept, got %v", keep) + } + if len(drop) != 1 || string(drop[0]) != "old" { + t.Fatalf("expected 'old' to be dropped, got %v", drop) + } +} + +func TestDecide_GroupedByTarget(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 5 executions same day, 10 days ago, two different targets. + mk := func(id, dom string) *happydns.Execution { + return &happydns.Execution{ + Id: happydns.Identifier(id), + CheckerID: "ping", + Target: happydns.CheckTarget{DomainId: dom}, + StartedAt: now.Add(-10 * 24 * time.Hour), + } + } + var execs []*happydns.Execution + for i := 0; i < 5; i++ { + execs = append(execs, mk(fmt.Sprintf("a%d", i), "a.example")) + execs = append(execs, mk(fmt.Sprintf("b%d", i), "b.example")) + } + + keep, _ := p.Decide(execs, now) + // PerDayKept per group => 2 * 2 groups = 4 + if len(keep) != 2*p.PerDayKept { + t.Fatalf("expected %d keeps, got %d", 2*p.PerDayKept, len(keep)) + } +} From a4f1fe830282dc7991675ac1ca8308a354cf6a57 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:51:52 +0700 Subject: [PATCH 24/54] checker: add Janitor goroutine to enforce retention policy The Janitor periodically walks every CheckPlan, loads its executions, and deletes the ones that the tiered RetentionPolicy says to drop. Per-user overrides are honoured: if a user's UserQuota.RetentionDays is set, that horizon replaces the system default for the user's plans. User lookups are cached per sweep to avoid repeated storage hits. The janitor is the long-tail counterpart of the (still TODO) cheap hard cap that will be applied at execution-creation time. It runs immediately on Start() and then every configured interval (default 6h). --- internal/usecase/checker/janitor.go | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 internal/usecase/checker/janitor.go diff --git a/internal/usecase/checker/janitor.go b/internal/usecase/checker/janitor.go new file mode 100644 index 00000000..5dff150a --- /dev/null +++ b/internal/usecase/checker/janitor.go @@ -0,0 +1,185 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "context" + "log" + "sync" + "time" + + "git.happydns.org/happyDomain/model" +) + +// JanitorUserResolver resolves a user from a CheckTarget so the janitor can +// honour per-user retention overrides stored in UserQuota. +type JanitorUserResolver interface { + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// Janitor periodically prunes old check executions according to the tiered +// RetentionPolicy. It is the long-tail enforcement counterpart of the cheap +// hard cap applied at execution-creation time. +type Janitor struct { + planStore CheckPlanStorage + execStore ExecutionStorage + userResolver JanitorUserResolver + defaultPolicy RetentionPolicy + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + running bool +} + +// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy +// is applied to executions of users that did not customize their retention +// horizon via UserQuota. +func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor { + if interval <= 0 { + interval = 6 * time.Hour + } + return &Janitor{ + planStore: planStore, + execStore: execStore, + userResolver: userResolver, + defaultPolicy: defaultPolicy, + interval: interval, + } +} + +// Start launches the janitor loop in a goroutine. It runs an immediate sweep +// once the loop is up. +func (j *Janitor) Start(ctx context.Context) { + j.mu.Lock() + if j.running { + j.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + j.cancel = cancel + j.running = true + j.mu.Unlock() + + go j.loop(ctx) +} + +// Stop halts the janitor. +func (j *Janitor) Stop() { + j.mu.Lock() + defer j.mu.Unlock() + if j.cancel != nil { + j.cancel() + } + j.running = false +} + +func (j *Janitor) loop(ctx context.Context) { + // Run immediately, then on the configured interval. + j.RunOnce(ctx) + + ticker := time.NewTicker(j.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + j.RunOnce(ctx) + } + } +} + +// RunOnce performs a single sweep over all check plans, applying the per-user +// retention policy. Returns the number of executions deleted. +func (j *Janitor) RunOnce(ctx context.Context) int { + iter, err := j.planStore.ListAllCheckPlans() + if err != nil { + log.Printf("Janitor: failed to list check plans: %v", err) + return 0 + } + + now := time.Now() + deleted := 0 + + // Cache user policies to avoid resolving the same user repeatedly. + policyByUser := map[string]RetentionPolicy{} + + for iter.Next() { + select { + case <-ctx.Done(): + return deleted + default: + } + + plan := iter.Item() + if plan == nil { + continue + } + + execs, err := j.execStore.ListExecutionsByPlan(plan.Id) + if err != nil { + log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err) + continue + } + if len(execs) == 0 { + continue + } + + policy := j.policyForTarget(plan.Target, policyByUser) + _, drop := policy.Decide(execs, now) + + for _, id := range drop { + if err := j.execStore.DeleteExecution(id); err != nil { + log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err) + continue + } + deleted++ + } + } + + if deleted > 0 { + log.Printf("Janitor: pruned %d executions", deleted) + } + return deleted +} + +func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy { + uid := target.UserId + if uid == "" || j.userResolver == nil { + return j.defaultPolicy + } + if p, ok := cache[uid]; ok { + return p + } + policy := j.defaultPolicy + id, err := happydns.NewIdentifierFromString(uid) + if err == nil { + if user, err := j.userResolver.GetUser(id); err == nil && user != nil { + if user.Quota.RetentionDays > 0 { + policy = DefaultRetentionPolicy(user.Quota.RetentionDays) + } + } + } + cache[uid] = policy + return policy +} From ead51586528dcbb529787836e6f5d528d1353e3f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:52:46 +0700 Subject: [PATCH 25/54] checker: pause scheduling for paused or inactive users Add a job-level gate to the scheduler. When set, the gate is consulted on every popped job; if it returns false, the job is skipped and re-enqueued for its next interval without invoking the engine. A new UserGater builds such a gate from a user resolver and an inactivity threshold: - users with UserQuota.SchedulingPaused are always blocked (admin kill switch); - users whose LastSeen is older than their effective inactivity horizon (UserQuota.InactivityPauseDays, falling back to a system default) are blocked until they log in again; - lookups are cached for 5 minutes so the scheduler hot path stays cheap, with an Invalidate hook for use on user updates. This addresses the "free trial then forgotten" failure mode described in the design notes. --- internal/usecase/checker/scheduler.go | 30 +++++++ internal/usecase/checker/user_gate.go | 119 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/usecase/checker/user_gate.go diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go index 31af4afd..3e74fb5e 100644 --- a/internal/usecase/checker/scheduler.go +++ b/internal/usecase/checker/scheduler.go @@ -108,6 +108,19 @@ type Scheduler struct { running bool ctx context.Context maxConcurrency int + + // gate, if set, is consulted before launching each job. Returning false + // causes the scheduler to skip (and reschedule) the job, e.g. when the + // owning user is paused or has been inactive for too long. + gate func(target happydns.CheckTarget) bool +} + +// SetGate installs a job gate evaluated before each execution. It is safe to +// call after Start(); the gate is consulted on every job pop. +func (s *Scheduler) SetGate(gate func(target happydns.CheckTarget) bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.gate = gate } // NewScheduler creates a new Scheduler. @@ -247,8 +260,25 @@ func (s *Scheduler) run(ctx context.Context) { continue } job := heap.Pop(&s.queue).(*SchedulerJob) + gate := s.gate s.mu.Unlock() + // Honour the user-level gate before doing any work. + if gate != nil && !gate(job.Target) { + log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String()) + now := time.Now() + for job.NextRun.Before(now) { + job.NextRun = job.NextRun.Add(job.Interval) + } + 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() + continue + } + // Find plan if applicable. var plan *happydns.CheckPlan if job.PlanID != nil { diff --git a/internal/usecase/checker/user_gate.go b/internal/usecase/checker/user_gate.go new file mode 100644 index 00000000..ffcf9458 --- /dev/null +++ b/internal/usecase/checker/user_gate.go @@ -0,0 +1,119 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "sync" + "time" + + "git.happydns.org/happyDomain/model" +) + +// UserGater builds a Scheduler gate function that filters out check jobs +// belonging to users that are paused or have been inactive for too long. +// +// Lookups are cached for a short TTL so the scheduler hot path does not hit +// storage on every job pop. +type UserGater struct { + resolver JanitorUserResolver + defaultInactivityDays int + cacheTTL time.Duration + + mu sync.Mutex + cache map[string]gateCacheEntry +} + +type gateCacheEntry struct { + allow bool + expires time.Time +} + +// NewUserGater creates a UserGater. defaultInactivityDays is used for users +// whose UserQuota.InactivityPauseDays is zero. A negative effective value +// disables inactivity-based pausing for that user. +func NewUserGater(resolver JanitorUserResolver, defaultInactivityDays int) *UserGater { + return &UserGater{ + resolver: resolver, + defaultInactivityDays: defaultInactivityDays, + cacheTTL: 5 * time.Minute, + cache: map[string]gateCacheEntry{}, + } +} + +// Allow returns true if the scheduler should run jobs for the given target. +func (g *UserGater) Allow(target happydns.CheckTarget) bool { + uid := target.UserId + if uid == "" || g.resolver == nil { + return true + } + + g.mu.Lock() + if e, ok := g.cache[uid]; ok && time.Now().Before(e.expires) { + g.mu.Unlock() + return e.allow + } + g.mu.Unlock() + + allow := g.compute(uid) + + g.mu.Lock() + g.cache[uid] = gateCacheEntry{allow: allow, expires: time.Now().Add(g.cacheTTL)} + g.mu.Unlock() + + return allow +} + +// Invalidate drops any cached decision for the given user. Call this when a +// user's quota or LastSeen changes (e.g. on login or admin update). +func (g *UserGater) Invalidate(userID string) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.cache, userID) +} + +func (g *UserGater) compute(uid string) bool { + id, err := happydns.NewIdentifierFromString(uid) + if err != nil { + return true + } + user, err := g.resolver.GetUser(id) + if err != nil || user == nil { + // Be conservative: allow rather than silently dropping work. + return true + } + if user.Quota.SchedulingPaused { + return false + } + + days := user.Quota.InactivityPauseDays + if days == 0 { + days = g.defaultInactivityDays + } + if days <= 0 { + return true + } + if user.LastSeen.IsZero() { + return true + } + cutoff := time.Now().AddDate(0, 0, -days) + return user.LastSeen.After(cutoff) +} From 4ffc0f449eeb17dc5545a36aa3883aabc7e34254 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:54:43 +0700 Subject: [PATCH 26/54] app: wire checker retention janitor and user gate Construct the retention janitor and the user gate alongside the checker scheduler. Three new options drive their behaviour: --checker-retention-days (default 365) --checker-janitor-interval (default 6h) --checker-inactivity-pause-days (default 90) The janitor starts immediately on App.Start and is shut down on App.Stop. The user gate is installed on the scheduler with the same storage-backed user resolver, so paused users and users that haven't logged in for the configured horizon stop being checked until they come back. --- internal/app/app.go | 24 ++++++++++++++++++++++++ internal/config/cli.go | 4 ++++ model/config.go | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index 94f3c54b..764102a0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -76,6 +76,7 @@ type Usecases struct { checkerPlanUC *checkerUC.CheckPlanUsecase checkerStatusUC *checkerUC.CheckStatusUsecase checkerScheduler *checkerUC.Scheduler + checkerJanitor *checkerUC.Janitor } type App struct { @@ -268,6 +269,21 @@ func (app *App) initUsecases() { 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) + // Install the user-level gate so paused or long-inactive users do not + // get checked. The same user resolver is reused by the janitor for + // per-user retention overrides. + gater := checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays) + app.usecases.checkerScheduler.SetGate(gater.Allow) + + // Retention janitor. + app.usecases.checkerJanitor = checkerUC.NewJanitor( + app.store, + app.store, + app.store, + checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays), + app.cfg.CheckerJanitorInterval, + ) + // Wire scheduler notifications for incremental queue updates. domainService.SetSchedulerNotifier(app.usecases.checkerScheduler) app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler) @@ -342,6 +358,10 @@ func (app *App) Start() { app.usecases.checkerScheduler.Start(context.Background()) } + if app.usecases.checkerJanitor != nil { + app.usecases.checkerJanitor.Start(context.Background()) + } + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -359,6 +379,10 @@ func (app *App) Stop() { app.usecases.checkerScheduler.Stop() } + if app.usecases.checkerJanitor != nil { + app.usecases.checkerJanitor.Stop() + } + // Close storage if app.store != nil { app.store.Close() diff --git a/internal/config/cli.go b/internal/config/cli.go index 6d54507a..c8eb7f24 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -25,6 +25,7 @@ import ( "flag" "fmt" "runtime" + "time" "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/model" @@ -47,6 +48,9 @@ func declareFlags(o *happydns.Options) { flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)") flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.") flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously") + flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)") + flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs") + flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)") flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server") flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user") diff --git a/model/config.go b/model/config.go index fe572f90..5d685e13 100644 --- a/model/config.go +++ b/model/config.go @@ -26,6 +26,7 @@ import ( "net/mail" "net/url" "path" + "time" ) // Options stores the configuration of the software. @@ -97,6 +98,20 @@ type Options struct { // run simultaneously. Defaults to runtime.NumCPU(). CheckerMaxConcurrency int + // CheckerRetentionDays is the system-wide default for how many days of + // check execution history are kept. Per-user UserQuota.RetentionDays + // overrides this value. + CheckerRetentionDays int + + // CheckerJanitorInterval is how often the retention janitor runs. + CheckerJanitorInterval time.Duration + + // CheckerInactivityPauseDays is the system-wide default number of days + // without login after which the scheduler stops running checks for a + // user. 0 disables inactivity pausing globally; per-user UserQuota + // overrides this value. + CheckerInactivityPauseDays int + // CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or ""). CaptchaProvider string From 2e0de8cf2c4cc408b86c697e516ae5b757a488df Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 12:02:04 +0700 Subject: [PATCH 27/54] checker: keep 1 report per hour after the first day Insert an hourly tier between the full-detail window and the daily bucket so users still get sub-day resolution for the first week: 0..1 day -> all 1..7 days -> 1 per hour 7..30 -> 2 per day ... --- internal/usecase/checker/retention.go | 23 +++++++++++++--- internal/usecase/checker/retention_test.go | 31 ++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go index 1c67840b..51483fde 100644 --- a/internal/usecase/checker/retention.go +++ b/internal/usecase/checker/retention.go @@ -37,7 +37,8 @@ import ( // // age window | kept // ------------------------- | ------------------------------------------ -// 0 .. 7 days | every execution +// 0 .. 1 day | every execution +// 1 .. 7 days | up to 1 execution per hour per (checker,target) // 7 .. 30 days | up to 2 executions per day per (checker,target) // 30 .. D/2 days | up to 1 execution per week per (checker,target) // D/2 .. D days | up to 1 execution per month per (checker,target) @@ -52,7 +53,11 @@ type RetentionPolicy struct { // FullDetailDays: every execution kept under this age. FullDetailDays int - // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep + // HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep + // PerHourKept executions per UTC hour per (checker,target). + HourlyBucketDays int + PerHourKept int + // DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep // PerDayKept executions per UTC day per (checker,target). DailyBucketDays int PerDayKept int @@ -73,7 +78,9 @@ func DefaultRetentionPolicy(retentionDays int) RetentionPolicy { } return RetentionPolicy{ RetentionDays: retentionDays, - FullDetailDays: 7, + FullDetailDays: 1, + HourlyBucketDays: 7, + PerHourKept: 1, DailyBucketDays: 30, PerDayKept: 2, WeeklyBucketDays: max(retentionDays/2, 31), @@ -104,6 +111,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) hardCutoff := now.AddDate(0, 0, -p.RetentionDays) fullCutoff := now.AddDate(0, 0, -p.FullDetailDays) + hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays) dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays) weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays) @@ -113,6 +121,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) return group[i].StartedAt.After(group[j].StartedAt) }) + hourBuckets := map[string]int{} dayBuckets := map[string]int{} weekBuckets := map[string]int{} monthBuckets := map[string]int{} @@ -125,6 +134,14 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) case !t.Before(fullCutoff): // 0 .. FullDetailDays — keep everything. keep = append(keep, e.Id) + case !t.Before(hourlyCutoff): + k := t.UTC().Format("2006-01-02T15") + if hourBuckets[k] < p.PerHourKept { + hourBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } case !t.Before(dailyCutoff): k := t.UTC().Format("2006-01-02") if dayBuckets[k] < p.PerDayKept { diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go index 7d1383b1..3badb107 100644 --- a/internal/usecase/checker/retention_test.go +++ b/internal/usecase/checker/retention_test.go @@ -50,17 +50,38 @@ func TestDecide_FullDetailWindow(t *testing.T) { now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) p := DefaultRetentionPolicy(365) + // 20 executions in the first 20 minutes — all inside 0..1 day window. var execs []*happydns.Execution - for i := 0; i < 50; i++ { - execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now)) + for i := 0; i < 20; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Minute, now)) } keep, drop := p.Decide(execs, now) if len(drop) != 0 { - t.Fatalf("expected no drops in <7d window, got %d", len(drop)) + t.Fatalf("expected no drops in <1d window, got %d", len(drop)) } - if len(keep) != 50 { - t.Fatalf("expected 50 keeps, got %d", len(keep)) + if len(keep) != 20 { + t.Fatalf("expected 20 keeps, got %d", len(keep)) + } +} + +func TestDecide_HourlyBucket(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 6 executions in the same hour ~3 days ago (inside hourly window). + var execs []*happydns.Execution + base := 3*24*time.Hour + 30*time.Minute + for i := 0; i < 6; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Minute, now)) + } + + keep, drop := p.Decide(execs, now) + if len(keep) != p.PerHourKept { + t.Fatalf("expected %d keeps in hourly bucket, got %d", p.PerHourKept, len(keep)) + } + if len(drop) != 6-p.PerHourKept { + t.Fatalf("expected %d drops, got %d", 6-p.PerHourKept, len(drop)) } } From 4289301dd15897f94ba0b9bc183165f221793939 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:44:59 +0700 Subject: [PATCH 28/54] checkers: introduce checker subsystem foundation Add the checker-sdk-go dependency and build the core checker infrastructure: - Domain model types: CheckTarget, CheckPlan, Execution, CheckEvaluation, CheckerDefinition, CheckerOptions, ObservationSnapshot, and associated interfaces - Observation collection engine with concurrent per-key gathering - Checker and observation provider registries (wrapping checker-sdk-go) - WorstStatusAggregator for combining rule evaluation results --- go.mod | 1 + go.sum | 2 + internal/checker/aggregator.go | 48 ++++ internal/checker/observation.go | 323 +++++++++++++++++++++++++++ internal/checker/observation_test.go | 168 ++++++++++++++ internal/checker/registry.go | 60 +++++ model/checker.go | 287 ++++++++++++++++++++++++ model/form.go | 19 ++ 8 files changed, 908 insertions(+) create mode 100644 internal/checker/aggregator.go create mode 100644 internal/checker/observation.go create mode 100644 internal/checker/observation_test.go create mode 100644 internal/checker/registry.go create mode 100644 model/checker.go diff --git a/go.mod b/go.mod index 1da23faf..aba88388 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.26.2 require ( + git.happydns.org/checker-sdk-go v0.2.0 github.com/StackExchange/dnscontrol/v4 v4.34.0 github.com/altcha-org/altcha-lib-go v1.0.0 github.com/coreos/go-oidc/v3 v3.18.0 diff --git a/go.sum b/go.sum index a53eee90..fc3ede06 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPE codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY= codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc= +git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= diff --git a/internal/checker/aggregator.go b/internal/checker/aggregator.go new file mode 100644 index 00000000..be8b790e --- /dev/null +++ b/internal/checker/aggregator.go @@ -0,0 +1,48 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "strings" + + "git.happydns.org/happyDomain/model" +) + +// WorstStatusAggregator aggregates check states by taking the worst status. +type WorstStatusAggregator struct{} + +func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState { + worst := happydns.StatusUnknown + var messages []string + for _, s := range states { + if s.Status > worst { + worst = s.Status + } + if s.Message != "" { + messages = append(messages, s.Message) + } + } + return happydns.CheckState{ + Status: worst, + Message: strings.Join(messages, "; "), + } +} diff --git a/internal/checker/observation.go b/internal/checker/observation.go new file mode 100644 index 00000000..65d5b674 --- /dev/null +++ b/internal/checker/observation.go @@ -0,0 +1,323 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +// observation.go implements the observation subsystem, which is the data +// collection layer for the checker framework. An observation represents a +// piece of raw data gathered about a check target (e.g. DNS records, HTTP +// headers, TLS certificate details). Observations are identified by an +// ObservationKey and collected on demand by registered ObservationProviders. +// +// The ObservationContext provides lazy-loading, cached, thread-safe access to +// observations: the first checker that requests a given observation triggers +// its collection, and subsequent checkers reuse the cached result. This +// design decouples data collection from evaluation: checkers declare which +// observations they need, and the context ensures each is collected at most +// once per check run. Observations can also be persisted as snapshots and +// reused across runs when freshness requirements allow. +// +// Observation providers may optionally implement reporting interfaces +// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable +// reports or extract time-series metrics from collected data. + +package checker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/model" +) + +// ObservationCacheLookup resolves a cached observation for a target+key. +// Returns the raw data and collection time, or an error if not cached. +type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) + +// ObservationContext provides lazy-loading, cached, thread-safe access to observation data. +// Collected data is serialized to json.RawMessage immediately after collection. +// +// Concurrency model: the outer mu protects only the cache/errors/inflight +// maps and is held for short critical sections. Provider collection runs +// *without* mu held, so two calls to Get for *different* keys can collect +// concurrently. Two calls for the *same* key are deduplicated: the first +// installs an inflight channel, runs the collection, then closes the +// channel; the others wait on it and read the cached result afterwards. +type ObservationContext struct { + target happydns.CheckTarget + opts happydns.CheckerOptions + cache map[happydns.ObservationKey]json.RawMessage + errors map[happydns.ObservationKey]error + inflight map[happydns.ObservationKey]chan struct{} + mu sync.Mutex + cacheLookup ObservationCacheLookup // nil = no DB cache + freshness time.Duration // 0 = always collect + providerOverride map[happydns.ObservationKey]happydns.ObservationProvider +} + +// NewObservationContext creates a new ObservationContext for the given target and options. +// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots. +// Pass nil and 0 to disable DB-based caching. +func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext { + return &ObservationContext{ + target: target, + opts: opts, + cache: make(map[happydns.ObservationKey]json.RawMessage), + errors: make(map[happydns.ObservationKey]error), + inflight: make(map[happydns.ObservationKey]chan struct{}), + cacheLookup: cacheLookup, + freshness: freshness, + } +} + +// SetProviderOverride registers a per-context provider that takes precedence +// over the global registry for the given observation key. This is used to +// substitute local providers with HTTP-backed ones when an endpoint is configured. +func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) { + oc.mu.Lock() + defer oc.mu.Unlock() + 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. +// Safe to call without holding oc.mu — it acquires the lock internally. +func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider { + oc.mu.Lock() + override := oc.providerOverride + oc.mu.Unlock() + if override != nil { + if p, ok := override[key]; ok { + return p + } + } + return sdk.FindObservationProvider(key) +} + +// Get collects observation data for the given key (lazily) and unmarshals it into dest. +// Thread-safe: concurrent calls for the same key are deduplicated; concurrent +// calls for different keys collect in parallel. +func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error { + for { + oc.mu.Lock() + if raw, ok := oc.cache[key]; ok { + oc.mu.Unlock() + return json.Unmarshal(raw, dest) + } + if err, ok := oc.errors[key]; ok { + oc.mu.Unlock() + return err + } + if ch, ok := oc.inflight[key]; ok { + // Another goroutine is already collecting this key. Release + // the lock, wait for it to finish, then re-check the cache. + oc.mu.Unlock() + select { + case <-ch: + case <-ctx.Done(): + return ctx.Err() + } + continue + } + + // We are the leader for this key. Install the inflight channel + // before releasing the lock so concurrent callers wait on us. + ch := make(chan struct{}) + oc.inflight[key] = ch + oc.mu.Unlock() + + raw, collectErr := oc.collect(ctx, key) + + // Collection errors are cached for the lifetime of this + // ObservationContext (i.e. a single execution run). This is + // intentional: within one run the same transient failure would + // keep recurring, and retrying would slow down the pipeline. + // A new execution creates a fresh context, giving the provider + // another chance. + oc.mu.Lock() + if collectErr != nil { + oc.errors[key] = collectErr + } else { + oc.cache[key] = raw + } + delete(oc.inflight, key) + close(ch) + oc.mu.Unlock() + + if collectErr != nil { + return collectErr + } + return json.Unmarshal(raw, dest) + } +} + +// collect runs the DB-cache lookup and provider collection for a single key +// without holding oc.mu, so collections for different keys can run in +// parallel. Callers are responsible for installing the result into the cache +// or errors map and signalling waiters. +func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) { + if oc.cacheLookup != nil && oc.freshness > 0 { + if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil { + if time.Since(collectedAt) < oc.freshness { + return raw, nil + } + } + } + + provider := oc.getProvider(key) + if provider == nil { + return nil, fmt.Errorf("no observation provider registered for key %q", key) + } + + val, err := provider.Collect(ctx, oc.opts) + if err != nil { + return nil, err + } + + raw, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err) + } + return json.RawMessage(raw), nil +} + +// Data returns all cached observation data as pre-serialized JSON. +func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage { + oc.mu.Lock() + defer oc.mu.Unlock() + + data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache)) + for k, v := range oc.cache { + data[k] = v + } + return data +} + +// Provider registration is startup-only (see comments on the registries in +// internal/service/registry.go and internal/provider/registry.go), so the +// "any provider implements X reporter" question has a fixed answer for the +// process lifetime. We compute it once on first call and cache it. +var ( + htmlReporterOnce sync.Once + htmlReporterCached bool + metricsReporterOnce sync.Once + metricsReporterCached bool +) + +// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter. +func HasHTMLReporter() bool { + htmlReporterOnce.Do(func() { + for _, p := range sdk.GetObservationProviders() { + if _, ok := p.(happydns.CheckerHTMLReporter); ok { + htmlReporterCached = true + return + } + } + }) + return htmlReporterCached +} + +// GetHTMLReport renders an HTML report for the given observation key and raw JSON data. +// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not. +func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + return getHTMLReport(sdk.FindObservationProvider(key), key, raw) +} + +// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through +// the ObservationContext, respecting per-context overrides. +func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + return getHTMLReport(oc.getProvider(key), key, raw) +} + +func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) { + if provider == nil { + return "", false, fmt.Errorf("no observation provider registered for key %q", key) + } + + hr, ok := provider.(happydns.CheckerHTMLReporter) + if !ok { + return "", false, nil + } + html, err := hr.GetHTMLReport(raw) + return html, true, err +} + +// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter. +func HasMetricsReporter() bool { + metricsReporterOnce.Do(func() { + for _, p := range sdk.GetObservationProviders() { + if _, ok := p.(happydns.CheckerMetricsReporter); ok { + metricsReporterCached = true + return + } + } + }) + return metricsReporterCached +} + +// GetMetrics extracts metrics for the given observation key and raw JSON data. +// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not. +func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt) +} + +// GetMetricsCtx is like GetMetrics but resolves the provider through +// the ObservationContext, respecting per-context overrides. +func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + return getMetrics(oc.getProvider(key), key, raw, collectedAt) +} + +func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) { + if provider == nil { + return nil, false, fmt.Errorf("no observation provider registered for key %q", key) + } + + mr, ok := provider.(happydns.CheckerMetricsReporter) + if !ok { + return nil, false, nil + } + metrics, err := mr.ExtractMetrics(raw, collectedAt) + return metrics, true, err +} + +// GetAllMetrics extracts metrics from all observation keys in a snapshot. +func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) { + var allMetrics []happydns.CheckMetric + var errs []error + for key, raw := range snap.Data { + metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt) + if err != nil { + errs = append(errs, fmt.Errorf("observation %q: %w", key, err)) + continue + } + if !supported { + continue + } + allMetrics = append(allMetrics, metrics...) + } + return allMetrics, errors.Join(errs...) +} diff --git a/internal/checker/observation_test.go b/internal/checker/observation_test.go new file mode 100644 index 00000000..5a3b632c --- /dev/null +++ b/internal/checker/observation_test.go @@ -0,0 +1,168 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "git.happydns.org/happyDomain/model" +) + +// blockingProvider is an ObservationProvider whose Collect blocks on the +// release channel until the test signals it. It records how many concurrent +// Collect calls are in flight at any moment. +type blockingProvider struct { + key happydns.ObservationKey + release chan struct{} + calls int32 +} + +func (b *blockingProvider) Key() happydns.ObservationKey { return b.key } + +func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) { + atomic.AddInt32(&b.calls, 1) + defer atomic.AddInt32(&b.calls, -1) + select { + case <-b.release: + return map[string]string{string(b.key): "ok"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls +// for distinct observation keys can run their Collect concurrently, i.e. +// the per-context lock is not held across provider.Collect. +func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) { + release := make(chan struct{}) + defer close(release) + + pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release} + pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release} + + oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0) + oc.SetProviderOverride(pa.key, pa) + oc.SetProviderOverride(pb.key, pb) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + results := make([]error, 2) + for i, key := range []happydns.ObservationKey{pa.key, pb.key} { + wg.Add(1) + go func(idx int, k happydns.ObservationKey) { + defer wg.Done() + var dst map[string]string + results[idx] = oc.Get(ctx, k, &dst) + }(i, key) + } + + // Wait until both providers are blocked inside Collect simultaneously. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 { + t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b) + } + + // Release both Collects and wait for the Get calls to return. + release <- struct{}{} + release <- struct{}{} + wg.Wait() + + for i, err := range results { + if err != nil { + t.Errorf("Get %d returned error: %v", i, err) + } + } +} + +// TestObservationContext_DedupesSameKey verifies that concurrent Get calls +// for the *same* key only invoke provider.Collect once. +func TestObservationContext_DedupesSameKey(t *testing.T) { + release := make(chan struct{}) + + var collectCount int32 + prov := &countingProvider{ + key: happydns.ObservationKey("test-dedup"), + release: release, + count: &collectCount, + } + + oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0) + oc.SetProviderOverride(prov.key, prov) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const N = 8 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func() { + defer wg.Done() + var dst map[string]string + if err := oc.Get(ctx, prov.key, &dst); err != nil { + t.Errorf("Get error: %v", err) + } + }() + } + + // Wait for at least one collect to be in flight, then release it. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 { + time.Sleep(5 * time.Millisecond) + } + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&collectCount); got != 1 { + t.Errorf("expected exactly 1 Collect call, got %d", got) + } +} + +type countingProvider struct { + key happydns.ObservationKey + release chan struct{} + count *int32 +} + +func (c *countingProvider) Key() happydns.ObservationKey { return c.key } + +func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) { + atomic.AddInt32(c.count, 1) + select { + case <-c.release: + return map[string]string{"k": "v"}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} diff --git a/internal/checker/registry.go b/internal/checker/registry.go new file mode 100644 index 00000000..b8db47aa --- /dev/null +++ b/internal/checker/registry.go @@ -0,0 +1,60 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/model" +) + +// The checker definition registry lives in the Apache-2.0 licensed +// checker-sdk-go module, so external plugins can register themselves +// without depending on AGPL code. These wrappers preserve the existing +// happyDomain call sites. + +// RegisterChecker registers a checker definition globally. +func RegisterChecker(c *happydns.CheckerDefinition) { + sdk.RegisterChecker(c) +} + +// RegisterExternalizableChecker registers a checker that supports being +// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt +// so the administrator can optionally configure a remote URL. +// When the endpoint is left empty, the checker runs locally as usual. +func RegisterExternalizableChecker(c *happydns.CheckerDefinition) { + sdk.RegisterExternalizableChecker(c) +} + +// RegisterObservationProvider registers an observation provider globally. +func RegisterObservationProvider(p happydns.ObservationProvider) { + sdk.RegisterObservationProvider(p) +} + +// GetCheckers returns all registered checker definitions. +func GetCheckers() map[string]*happydns.CheckerDefinition { + return sdk.GetCheckers() +} + +// FindChecker returns the checker definition with the given ID, or nil. +func FindChecker(id string) *happydns.CheckerDefinition { + return sdk.FindChecker(id) +} diff --git a/model/checker.go b/model/checker.go new file mode 100644 index 00000000..9b8ee631 --- /dev/null +++ b/model/checker.go @@ -0,0 +1,287 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package happydns + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// The types and helpers needed by external checker plugins live in the +// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as +// aliases so the rest of the happyDomain codebase keeps relying on this model. +// +// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain +// defined in this file because they describe orchestration state that is +// internal to the happyDomain server and never crosses the plugin boundary. + +// --- Re-exports from checker-sdk-go --- + +type CheckScopeType = sdk.CheckScopeType + +const ( + CheckScopeAdmin = sdk.CheckScopeAdmin + CheckScopeUser = sdk.CheckScopeUser + CheckScopeDomain = sdk.CheckScopeDomain + CheckScopeZone = sdk.CheckScopeZone + CheckScopeService = sdk.CheckScopeService +) + +const ( + AutoFillDomainName = sdk.AutoFillDomainName + AutoFillSubdomain = sdk.AutoFillSubdomain + AutoFillZone = sdk.AutoFillZone + AutoFillServiceType = sdk.AutoFillServiceType + AutoFillService = sdk.AutoFillService +) + +type ( + CheckTarget = sdk.CheckTarget + CheckerAvailability = sdk.CheckerAvailability + CheckerOptions = sdk.CheckerOptions + CheckerOptionDocumentation = sdk.CheckerOptionDocumentation + CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation + Status = sdk.Status + CheckState = sdk.CheckState + CheckMetric = sdk.CheckMetric + ObservationKey = sdk.ObservationKey + CheckIntervalSpec = sdk.CheckIntervalSpec + ObservationProvider = sdk.ObservationProvider + CheckRuleInfo = sdk.CheckRuleInfo + CheckRule = sdk.CheckRule + CheckRuleWithOptions = sdk.CheckRuleWithOptions + ObservationGetter = sdk.ObservationGetter + CheckAggregator = sdk.CheckAggregator + CheckerHTMLReporter = sdk.CheckerHTMLReporter + CheckerMetricsReporter = sdk.CheckerMetricsReporter + CheckerDefinitionProvider = sdk.CheckerDefinitionProvider + CheckerDefinition = sdk.CheckerDefinition + OptionsValidator = sdk.OptionsValidator + ExternalCollectRequest = sdk.ExternalCollectRequest + ExternalCollectResponse = sdk.ExternalCollectResponse + ExternalEvaluateRequest = sdk.ExternalEvaluateRequest + ExternalEvaluateResponse = sdk.ExternalEvaluateResponse + ExternalReportRequest = sdk.ExternalReportRequest +) + +const ( + StatusOK = sdk.StatusOK + StatusInfo = sdk.StatusInfo + StatusUnknown = sdk.StatusUnknown + StatusWarn = sdk.StatusWarn + StatusCrit = sdk.StatusCrit + StatusError = sdk.StatusError +) + +// --- Helpers for converting between target identifier strings and *Identifier --- + +// TargetIdentifier parses a target identifier string into an *Identifier. +// Returns nil if the string is empty or cannot be parsed. +func TargetIdentifier(s string) *Identifier { + if s == "" { + return nil + } + id, err := NewIdentifierFromString(s) + if err != nil { + return nil + } + return &id +} + +// FormatIdentifier returns the string representation of id, or "" if nil. +func FormatIdentifier(id *Identifier) string { + if id == nil { + return "" + } + return id.String() +} + +// --- Host-only types (orchestration state) --- + +// CheckerRunRequest is the JSON body for manually triggering a checker. +type CheckerRunRequest struct { + Options CheckerOptions `json:"options,omitempty"` + EnabledRules map[string]bool `json:"enabledRules,omitempty"` +} + +// CheckerOptionsPositional stores options with their positional key components. +type CheckerOptionsPositional struct { + CheckName string `json:"checkName"` + UserId *Identifier `json:"userId,omitempty"` + DomainId *Identifier `json:"domainId,omitempty"` + ServiceId *Identifier `json:"serviceId,omitempty"` + + Options CheckerOptions `json:"options"` +} + +// CheckPlan is an optional user override for a checker on a specific target. +type CheckPlan struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + CheckerID string `json:"checkerId" binding:"required" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"` + Enabled map[string]bool `json:"enabled,omitempty"` +} + +// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false. +func (p *CheckPlan) IsFullyDisabled() bool { + if len(p.Enabled) == 0 { + return false + } + for _, v := range p.Enabled { + if v { + return false + } + } + return true +} + +// IsRuleEnabled returns whether a specific rule is enabled. +// A nil or empty map means all rules are enabled. A missing key means enabled. +func (p *CheckPlan) IsRuleEnabled(ruleName string) bool { + if len(p.Enabled) == 0 { + return true + } + v, ok := p.Enabled[ruleName] + if !ok { + return true + } + return v +} + +// CheckerStatus combines a checker definition with its latest execution and plan for a target. +type CheckerStatus struct { + *CheckerDefinition + LatestExecution *Execution `json:"latestExecution,omitempty"` + Plan *CheckPlan `json:"plan,omitempty"` + Enabled bool `json:"enabled"` + EnabledRules map[string]bool `json:"enabledRules"` +} + +// CheckEvaluation is the result of running a checker on observed data. +type CheckEvaluation struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"` + CheckerID string `json:"checkerId" binding:"required"` + Target CheckTarget `json:"target" binding:"required"` + SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"` + EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"` + States []CheckState `json:"states" binding:"required" readonly:"true"` +} + +// ObservationSnapshot holds data collected during an execution. +type ObservationSnapshot struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"` + Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"` +} + +// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot. +type ObservationCacheEntry struct { + SnapshotID Identifier `json:"snapshotId"` + CollectedAt time.Time `json:"collectedAt"` +} + +// ExecutionStatus represents the lifecycle state of an execution. +type ExecutionStatus int + +const ( + ExecutionPending ExecutionStatus = iota + ExecutionRunning + ExecutionDone + ExecutionFailed +) + +// TriggerType represents what initiated an execution. +type TriggerType int + +const ( + TriggerManual TriggerType = iota + TriggerSchedule +) + +// TriggerInfo describes the trigger for an execution. +type TriggerInfo struct { + Type TriggerType `json:"type"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"` +} + +// Execution represents a single run of a checker pipeline. +type Execution struct { + Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"` + CheckerID string `json:"checkerId" binding:"required" readonly:"true"` + PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"` + Target CheckTarget `json:"target" binding:"required" readonly:"true"` + Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"` + StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"` + EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"` + Status ExecutionStatus `json:"status" binding:"required" readonly:"true"` + Error string `json:"error,omitempty" readonly:"true"` + Result CheckState `json:"result" readonly:"true"` + EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"` +} + +// CheckerEngine orchestrates the full checker pipeline. +type CheckerEngine interface { + CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error) + RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error) +} + +// CheckerOptionsKey builds the positional KV key for checker options. +// Format: chckrcfg-{checkerName}|{userId}|{domainId}|{serviceId} +func CheckerOptionsKey(checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) string { + return fmt.Sprintf("chckrcfg-%s|%s|%s|%s", checkerName, + FormatIdentifier(userId), FormatIdentifier(domainId), FormatIdentifier(serviceId)) +} + +// ParseCheckerOptionsKey extracts the positional components from a KV key. +func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) { + trimmed := strings.TrimPrefix(key, "chckrcfg-") + parts := strings.SplitN(trimmed, "|", 4) + if len(parts) < 4 { + return trimmed, nil, nil, nil + } + + checkerName = parts[0] + if parts[1] != "" { + if id, err := NewIdentifierFromString(parts[1]); err == nil { + userId = &id + } + } + if parts[2] != "" { + if id, err := NewIdentifierFromString(parts[2]); err == nil { + domainId = &id + } + } + if parts[3] != "" { + if id, err := NewIdentifierFromString(parts[3]); err == nil { + serviceId = &id + } + } + return +} diff --git a/model/form.go b/model/form.go index 1e95ec53..996ee67b 100644 --- a/model/form.go +++ b/model/form.go @@ -106,6 +106,25 @@ type Field struct { Description string `json:"description,omitempty"` } +// FieldFromCheckerOption converts a CheckerOptionDocumentation into a Field, +// mapping the common subset of attributes. Keep this in sync when either +// struct gains new fields. +func FieldFromCheckerOption(opt CheckerOptionDocumentation) Field { + return Field{ + Id: opt.Id, + Type: opt.Type, + Label: opt.Label, + Placeholder: opt.Placeholder, + Default: opt.Default, + Choices: opt.Choices, + Required: opt.Required, + Secret: opt.Secret, + Hide: opt.Hide, + Textarea: opt.Textarea, + Description: opt.Description, + } +} + type FormState struct { // Id for an already existing element. Id *Identifier `json:"_id,omitempty" swaggertype:"string"` From 4e80012a48167f024769e0c9ad3950be30d2e5d1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 02:59:36 +0700 Subject: [PATCH 29/54] checkers: load external checker plugins from .so files Scan -plugins-directory paths at startup, open each .so via plugin.Open, look up the NewCheckerPlugin symbol from checker-sdk-go, and register the returned definition and observation provider in the global checker registries. A pluginLoader indirection keeps the door open for future plugin kinds. --- docs/plugins/checker-plugin.md | 149 +++++++++++++++++ internal/app/app.go | 6 + internal/app/plugins.go | 238 +++++++++++++++++++++++++++ internal/app/plugins_checker_test.go | 143 ++++++++++++++++ internal/app/plugins_stub.go | 37 +++++ internal/app/plugins_test.go | 172 +++++++++++++++++++ internal/config/cli.go | 2 + internal/config/custom.go | 19 +++ model/config.go | 4 + 9 files changed, 770 insertions(+) create mode 100644 docs/plugins/checker-plugin.md create mode 100644 internal/app/plugins.go create mode 100644 internal/app/plugins_checker_test.go create mode 100644 internal/app/plugins_stub.go create mode 100644 internal/app/plugins_test.go diff --git a/docs/plugins/checker-plugin.md b/docs/plugins/checker-plugin.md new file mode 100644 index 00000000..728bdcd1 --- /dev/null +++ b/docs/plugins/checker-plugin.md @@ -0,0 +1,149 @@ +# Building a happyDomain Checker Plugin + +This page documents how to ship a **checker** as an in-process Go plugin +that happyDomain loads at startup. Checker plugins extend happyDomain with +automated diagnostics on zones, domains, services or users. + +If you've never built a happyDomain plugin before, read +[`checker-dummy`](https://git.happydns.org/checker-dummy) first; it is the +reference implementation that this page mirrors. + +> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process +> and runs with the same privileges. happyDomain refuses to load plugins from +> a directory that is group- or world-writable; keep your plugin directory +> owned and writable only by the happyDomain user. + +--- + +## What a checker plugin must export + +happyDomain's loader looks for a single exported symbol named +`NewCheckerPlugin` with this exact signature: + +```go +func NewCheckerPlugin() ( + *checker.CheckerDefinition, + checker.ObservationProvider, + error, +) +``` + +where `checker` is `git.happydns.org/checker-sdk-go/checker` (see +[Licensing](#licensing) below for why the SDK lives in a separate module). + +- `*CheckerDefinition` describes the checker: ID, name, version, options + documentation, rules, optional aggregator, scheduling interval, and + whether the checker exposes HTML reports or metrics. The `ID` field is + the persistent key: pick something stable and namespaced + (`com.example.dnssec-freshness`, not `dnssec`). +- `ObservationProvider` is the data-collection half of the checker. It + exposes a `Key()` (the observation key the rules will look up) and a + `Collect(ctx, opts)` method that returns the raw observation payload. + happyDomain serialises the result to JSON and caches it per + `ObservationContext`. +- Return a non-nil `error` if your plugin cannot initialise (missing + environment variable, broken cgo dependency, …); the host will log it and + skip the file rather than aborting startup. + +### Registration and collisions + +The loader calls `RegisterExternalizableChecker` and +`RegisterObservationProvider` from the SDK registry. Pick globally unique +identifiers: if your checker ID or observation key collides with a built-in +or another plugin, the duplicate is ignored. + +The same `.so` may export both `NewCheckerPlugin` and (e.g.) +`NewProviderPlugin`. The loader runs every known plugin loader against +every file, so a single binary can ship a checker, a provider and a service +at once. + +--- + +## Minimal example + +```go +// Command plugin is the happyDomain plugin entrypoint for the dummy checker. +// +// Build with: +// go build -buildmode=plugin -o checker-dummy.so ./plugin +package main + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type dummyProvider struct{} + +func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" } + +func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + return map[string]string{"hello": "world"}, nil +} + +// NewCheckerPlugin is the symbol resolved by happyDomain at startup. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + def := &sdk.CheckerDefinition{ + ID: "com.example.dummy", + Name: "Dummy checker", + Version: "0.1.0", + ObservationKeys: []sdk.ObservationKey{"dummy.observation"}, + // Add Rules / Aggregator / Options here in a real plugin. + } + return def, dummyProvider{}, nil +} +``` + +Build and deploy: + +```bash +go build -buildmode=plugin -o checker-dummy.so ./plugin +sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/ +sudo systemctl restart happydomain +``` + +happyDomain will log: + +``` +Plugin com.example.dummy (.../checker-dummy.so) loaded +``` + +--- + +## Build constraints and platform support + +Go's `plugin` package is unforgiving: + +- The plugin **must be built with the same Go version** as happyDomain + itself, including the same toolchain patch level. +- It **must use the same versions of every shared dependency**. Vendor the + exact module versions happyDomain ships, or pin them in your `go.mod` + with `replace` directives. +- `CGO_ENABLED=1` is required. +- `GOOS`/`GOARCH` must match the host binary. + +If any of these don't match, `plugin.Open` will fail with a (sometimes +cryptic) error like *"plugin was built with a different version of package +…"*. The host will log it and skip the file. + +Go's `plugin` package only works on **linux**, **darwin** and **freebsd**. +On other platforms (Windows, plan9, …) happyDomain is built without plugin +support and `--plugins-directory` is silently ignored apart from a warning +log line at startup. + +--- + +## Licensing + +Checker plugins import only `git.happydns.org/checker-sdk-go/checker`, +which is licensed under **Apache-2.0**. This is intentional: the +checker SDK is a small, stable public API for third-party checkers, +deliberately split out of the AGPL-3.0 happyDomain core so that +permissively-licensed checker plugins are possible. + +You may therefore distribute your checker `.so` under any license compatible +with Apache-2.0. Note that this only covers checker plugins; provider and +service plugins still link against AGPL code and remain subject to the +AGPL-3.0 reciprocity rules described in their respective documentation +([provider](provider-plugin.md), [service](service-plugin.md)). diff --git a/internal/app/app.go b/internal/app/app.go index 178a6b9e..ed809541 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -93,6 +93,9 @@ func NewApp(cfg *happydns.Options) *App { app.initStorageEngine() app.initNewsletter() app.initInsights() + if err := app.initPlugins(); err != nil { + log.Fatalf("Plugin initialization error: %s", err) + } app.initUsecases() app.initCaptcha() app.setupRouter() @@ -108,6 +111,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App { app.initMailer() app.initNewsletter() + if err := app.initPlugins(); err != nil { + log.Fatalf("Plugin initialization error: %s", err) + } app.initUsecases() app.initCaptcha() app.setupRouter() diff --git a/internal/app/plugins.go b/internal/app/plugins.go new file mode 100644 index 00000000..61f043d1 --- /dev/null +++ b/internal/app/plugins.go @@ -0,0 +1,238 @@ +// 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 . +// +// 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 . + +//go:build linux || darwin || freebsd + +package app + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "plugin" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/internal/checker" +) + +// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders. +// It exists so that loaders can be unit-tested with a fake instead of +// requiring a real .so file built via `go build -buildmode=plugin`. +type pluginSymbols interface { + Lookup(symName string) (plugin.Symbol, error) +} + +// pluginLoader attempts to find and register one specific kind of plugin +// symbol from an already-opened .so file. +// +// It returns (true, nil) when the symbol was found and registration +// succeeded, (true, err) when the symbol was found but something went wrong, +// and (false, nil) when the symbol simply isn't present in that file (which +// is not considered an error: a single .so may implement only a subset of +// the known plugin types). +type pluginLoader func(p pluginSymbols, fname string) (found bool, err error) + +// safeCall invokes fn while recovering from any panic raised by plugin code. +// A panicking factory must not take the whole server down at startup; the +// recovered value is converted to an error so the caller can log/skip the +// offending plugin like any other failure. +func safeCall(symbol string, fname string, fn func() error) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("plugin %q panicked in %s: %v", fname, symbol, r) + } + }() + return fn() +} + +// pluginLoaders is the authoritative list of plugin types that happyDomain +// knows about. To support a new plugin type, add a single entry here. +var pluginLoaders = []pluginLoader{ + loadCheckerPlugin, +} + +// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers +// built against checker-sdk-go (see ../../checker-dummy/README.md). +func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) { + sym, err := p.Lookup("NewCheckerPlugin") + if err != nil { + // Symbol not present in this .so, not an error. + return false, nil + } + + factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error)) + if !ok { + return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym) + } + + var ( + def *sdk.CheckerDefinition + provider sdk.ObservationProvider + ) + if err := safeCall("NewCheckerPlugin", fname, func() error { + var ferr error + def, provider, ferr = factory() + return ferr + }); err != nil { + return true, err + } + if def == nil { + return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition") + } + if provider == nil { + return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider") + } + + checker.RegisterObservationProvider(provider) + checker.RegisterExternalizableChecker(def) + log.Printf("Plugin %s (%s) loaded", def.ID, fname) + return true, nil +} + +// checkPluginDirectoryPermissions refuses to load plugins from a directory +// that any non-owner can write to. Loading a .so file is arbitrary code +// execution as the happyDomain process, so a world- or group-writable +// plugin directory is treated as a fatal misconfiguration: any local user +// (or any process sharing the group) able to drop a file there could take +// over the server. Operators who genuinely need shared deployment should +// stage plugins elsewhere and rsync them into a directory owned and +// writable only by the happyDomain user. +func checkPluginDirectoryPermissions(directory string) error { + // Use Lstat to detect symlinks: a symlink could be silently redirected + // to an attacker-controlled directory, bypassing the permission check + // on the original path. + linfo, err := os.Lstat(directory) + if err != nil { + return fmt.Errorf("unable to stat plugins directory %q: %s", directory, err) + } + if linfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("plugins directory %q is a symbolic link; refusing to follow it", directory) + } + if !linfo.IsDir() { + return fmt.Errorf("plugins path %q is not a directory", directory) + } + mode := linfo.Mode().Perm() + if mode&0o022 != 0 { + return fmt.Errorf("plugins directory %q is group- or world-writable (mode %#o); refusing to load plugins from it", directory, mode) + } + return nil +} + +// checkPluginFilePermissions refuses to load a .so file that is group- or +// world-writable. Even inside a properly locked-down directory, a writable +// plugin binary could be replaced by a malicious actor sharing the group. +// Symlinks are followed: the permission check applies to the resolved target, +// which allows the common pattern of symlinking to versioned binaries +// (e.g. checker-foo.so -> checker-foo-v1.2.so) for atomic upgrades. +// The directory-level symlink ban already prevents attackers from redirecting +// the scan root itself. +func checkPluginFilePermissions(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("unable to stat plugin file %q: %s", path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("plugin %q is not a regular file (or resolves to a non-regular file)", path) + } + mode := info.Mode().Perm() + if mode&0o022 != 0 { + return fmt.Errorf("plugin file %q is group- or world-writable (mode %#o)", path, mode) + } + return nil +} + +// initPlugins scans each directory listed in cfg.PluginsDirectories and loads +// every .so file found as a Go plugin. A directory that cannot be read is a +// fatal configuration error; individual plugin failures are logged and +// skipped so that one bad .so does not prevent the others from loading. +func (a *App) initPlugins() error { + for _, directory := range a.cfg.PluginsDirectories { + if err := checkPluginDirectoryPermissions(directory); err != nil { + return err + } + + files, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("unable to read plugins directory %q: %s", directory, err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + // Only attempt to load shared-object files. + if filepath.Ext(file.Name()) != ".so" { + continue + } + + fname := filepath.Join(directory, file.Name()) + + if err := checkPluginFilePermissions(fname); err != nil { + log.Printf("Skipping plugin %q: %s", fname, err) + continue + } + + if err := loadPlugin(fname); err != nil { + log.Printf("Unable to load plugin %q: %s", fname, err) + } + } + } + + return nil +} + +// loadPlugin opens the .so file at fname and runs every registered +// pluginLoader against it. A loader that does not find its symbol is silently +// skipped. If no loader recognises any symbol in the file a warning is +// logged, because the file might be a valid plugin for a future version of +// happyDomain. Loader errors for one plugin kind do not prevent the other +// kinds in the same .so from being attempted: a single .so is allowed to +// expose more than one plugin type, and a failure to register (e.g.) the +// service half should not silently drop the checker half. All loader errors +// encountered are joined and returned together. +func loadPlugin(fname string) error { + p, err := plugin.Open(fname) + if err != nil { + return err + } + + var ( + anyFound bool + errs []error + ) + for _, loader := range pluginLoaders { + found, err := loader(p, fname) + if found { + anyFound = true + } + if err != nil { + errs = append(errs, err) + } + } + + if !anyFound && len(errs) == 0 { + log.Printf("Warning: plugin %q exports no recognised symbols", fname) + } + return errors.Join(errs...) +} diff --git a/internal/app/plugins_checker_test.go b/internal/app/plugins_checker_test.go new file mode 100644 index 00000000..7f5054e6 --- /dev/null +++ b/internal/app/plugins_checker_test.go @@ -0,0 +1,143 @@ +// 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 . +// +// 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 . + +//go:build linux || darwin || freebsd + +package app + +import ( + "context" + "errors" + "plugin" + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// dummyCheckerProvider is a minimal ObservationProvider used by the tests +// below. It is intentionally trivial: the loader tests only care that +// registration succeeds, not what the provider actually collects. +type dummyCheckerProvider struct { + key happydns.ObservationKey +} + +func (d *dummyCheckerProvider) Key() happydns.ObservationKey { return d.key } +func (d *dummyCheckerProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) { + return nil, nil +} + +func newDummyCheckerFactory(id string) func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + return func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + def := &sdk.CheckerDefinition{ + ID: id, + Name: "Dummy checker", + } + return def, &dummyCheckerProvider{key: happydns.ObservationKey("dummy-" + id)}, nil + } +} + +func TestLoadCheckerPlugin_SymbolMissing(t *testing.T) { + found, err := loadCheckerPlugin(&fakeSymbols{}, "missing.so") + if found || err != nil { + t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err) + } +} + +func TestLoadCheckerPlugin_WrongSymbolType(t *testing.T) { + fs := &fakeSymbols{syms: map[string]plugin.Symbol{ + "NewCheckerPlugin": 42, // not a function + }} + found, err := loadCheckerPlugin(fs, "wrongtype.so") + if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") { + t.Fatalf("expected wrong-type error, got (%v, %v)", found, err) + } +} + +func TestLoadCheckerPlugin_FactoryError(t *testing.T) { + factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + return nil, nil, errors.New("boom") + } + fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}} + + found, err := loadCheckerPlugin(fs, "factoryerr.so") + if !found || err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err) + } +} + +func TestLoadCheckerPlugin_NilDefinition(t *testing.T) { + factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + return nil, &dummyCheckerProvider{key: "k"}, nil + } + fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}} + + found, err := loadCheckerPlugin(fs, "nildef.so") + if !found || err == nil || !strings.Contains(err.Error(), "nil CheckerDefinition") { + t.Fatalf("expected nil-definition error, got (%v, %v)", found, err) + } +} + +func TestLoadCheckerPlugin_NilProvider(t *testing.T) { + factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + return &sdk.CheckerDefinition{ID: "x"}, nil, nil + } + fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}} + + found, err := loadCheckerPlugin(fs, "nilprov.so") + if !found || err == nil || !strings.Contains(err.Error(), "nil ObservationProvider") { + t.Fatalf("expected nil-provider error, got (%v, %v)", found, err) + } +} + +func TestLoadCheckerPlugin_FactoryPanics(t *testing.T) { + factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + panic("kaboom") + } + fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}} + + found, err := loadCheckerPlugin(fs, "panic.so") + if !found || err == nil { + t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err) + } + if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") { + t.Errorf("expected wrapped panic error, got %v", err) + } +} + +func TestLoadCheckerPlugin_Success(t *testing.T) { + factory := newDummyCheckerFactory("dummy-success") + fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}} + + found, err := loadCheckerPlugin(fs, "first.so") + if !found || err != nil { + t.Fatalf("expected success, got (%v, %v)", found, err) + } + + if got := checker.FindChecker("dummy-success"); got == nil { + t.Errorf("expected checker %q to be registered", "dummy-success") + } + if got := sdk.FindObservationProvider(happydns.ObservationKey("dummy-dummy-success")); got == nil { + t.Errorf("expected observation provider %q to be registered", "dummy-dummy-success") + } +} diff --git a/internal/app/plugins_stub.go b/internal/app/plugins_stub.go new file mode 100644 index 00000000..7f84f8f9 --- /dev/null +++ b/internal/app/plugins_stub.go @@ -0,0 +1,37 @@ +// 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 . +// +// 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 . + +//go:build !linux && !darwin && !freebsd + +package app + +import "log" + +// initPlugins is a no-op on platforms where Go's plugin package is not +// supported (Windows, plan9, …). If the operator configured plugin +// directories anyway we log a clear warning rather than silently ignoring +// them, so the misconfiguration is visible at startup. +func (a *App) initPlugins() error { + if len(a.cfg.PluginsDirectories) > 0 { + log.Printf("Warning: plugin loading is not supported on this platform; ignoring %d configured plugin directories", len(a.cfg.PluginsDirectories)) + } + return nil +} diff --git a/internal/app/plugins_test.go b/internal/app/plugins_test.go new file mode 100644 index 00000000..b4d7d120 --- /dev/null +++ b/internal/app/plugins_test.go @@ -0,0 +1,172 @@ +// 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 . +// +// 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 . + +//go:build linux || darwin || freebsd + +package app + +import ( + "fmt" + "os" + "path/filepath" + "plugin" + "testing" +) + +// fakeSymbols is a pluginSymbols implementation backed by a static map. It +// lets the loader tests exercise their behaviour without having to compile a +// real .so file via `go build -buildmode=plugin`. +type fakeSymbols struct { + syms map[string]plugin.Symbol +} + +func (f *fakeSymbols) Lookup(name string) (plugin.Symbol, error) { + if s, ok := f.syms[name]; ok { + return s, nil + } + return nil, fmt.Errorf("symbol %q not found", name) +} + +// TestLoadPlugin_NoRecognisedSymbols verifies that when a .so file exports +// none of the known plugin symbols, every loader returns (false, nil), i.e. +// the file is silently skipped rather than reported as an error. loadPlugin +// itself logs a warning in that situation; we exercise the inner loop here +// because the outer call requires plugin.Open and a real .so file. +func TestLoadPlugin_NoRecognisedSymbols(t *testing.T) { + fs := &fakeSymbols{} + for _, loader := range pluginLoaders { + found, err := loader(fs, "empty.so") + if found || err != nil { + t.Fatalf("loader returned (%v, %v) for empty symbol set, expected (false, nil)", found, err) + } + } +} + +func TestCheckPluginDirectoryPermissions(t *testing.T) { + dir := t.TempDir() + + // A freshly-created TempDir is owner-only on every platform we run on, + // so this must be accepted. + if err := os.Chmod(dir, 0o750); err != nil { + t.Fatalf("chmod 0750: %v", err) + } + if err := checkPluginDirectoryPermissions(dir); err != nil { + t.Errorf("expected 0750 directory to be accepted, got %v", err) + } + + // World-writable: must be refused. + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatalf("chmod 0777: %v", err) + } + if err := checkPluginDirectoryPermissions(dir); err == nil { + t.Errorf("expected 0777 directory to be refused") + } + + // Group-writable: must also be refused. + if err := os.Chmod(dir, 0o770); err != nil { + t.Fatalf("chmod 0770: %v", err) + } + if err := checkPluginDirectoryPermissions(dir); err == nil { + t.Errorf("expected 0770 directory to be refused") + } + + // Restore permissions so t.TempDir cleanup can remove the directory. + _ = os.Chmod(dir, 0o700) + + // Non-existent path: must be refused. + if err := checkPluginDirectoryPermissions(filepath.Join(dir, "does-not-exist")); err == nil { + t.Errorf("expected missing directory to be refused") + } + + // Symlink to a valid directory: must be refused. + target := t.TempDir() + link := filepath.Join(dir, "symlink-plugins") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := checkPluginDirectoryPermissions(link); err == nil { + t.Errorf("expected symlink directory to be refused") + } +} + +func TestCheckPluginFilePermissions(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "test.so") + if err := os.WriteFile(f, []byte("fake"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // Owner-writable, not group/world-writable: accepted. + if err := checkPluginFilePermissions(f); err != nil { + t.Errorf("expected 0644 file to be accepted, got %v", err) + } + + // Group-writable: refused. + if err := os.Chmod(f, 0o664); err != nil { + t.Fatalf("chmod: %v", err) + } + if err := checkPluginFilePermissions(f); err == nil { + t.Errorf("expected 0664 file to be refused") + } + + // World-writable: refused. + if err := os.Chmod(f, 0o646); err != nil { + t.Fatalf("chmod: %v", err) + } + if err := checkPluginFilePermissions(f); err == nil { + t.Errorf("expected 0646 file to be refused") + } + + // Non-existent: refused. + if err := checkPluginFilePermissions(filepath.Join(dir, "nope.so")); err == nil { + t.Errorf("expected missing file to be refused") + } + + // Symlink to a safe regular file: accepted (we follow the link and + // check the target's permissions, not the link itself). + regular := filepath.Join(dir, "real.so") + if err := os.WriteFile(regular, []byte("real"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + link := filepath.Join(dir, "link.so") + if err := os.Symlink(regular, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := checkPluginFilePermissions(link); err != nil { + t.Errorf("expected symlink to safe file to be accepted, got %v", err) + } + + // Symlink to a writable target: refused. + writable := filepath.Join(dir, "writable.so") + if err := os.WriteFile(writable, []byte("bad"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(writable, 0o666); err != nil { + t.Fatalf("chmod: %v", err) + } + linkBad := filepath.Join(dir, "link-bad.so") + if err := os.Symlink(writable, linkBad); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := checkPluginFilePermissions(linkBad); err == nil { + t.Errorf("expected symlink to writable file to be refused") + } +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 01de6850..502fa1ed 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) { flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)") flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)") + flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated") + // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/custom.go b/internal/config/custom.go index f69cac42..775f9026 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -25,8 +25,27 @@ import ( "encoding/base64" "net/mail" "net/url" + "strings" ) +// stringSlice is a flag.Value that accumulates string values across repeated +// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b). +type stringSlice struct { + Values *[]string +} + +func (s *stringSlice) String() string { + if s.Values == nil { + return "" + } + return strings.Join(*s.Values, ",") +} + +func (s *stringSlice) Set(value string) error { + *s.Values = append(*s.Values, value) + return nil +} + type JWTSecretKey struct { Secret *[]byte } diff --git a/model/config.go b/model/config.go index f1187985..9d6b933c 100644 --- a/model/config.go +++ b/model/config.go @@ -99,6 +99,10 @@ type Options struct { // CaptchaLoginThreshold is the number of consecutive login failures before captcha is required. // 0 means always require captcha at login (when provider is configured). CaptchaLoginThreshold int + + // PluginsDirectories lists filesystem paths scanned at startup for + // checker plugins (.so files). + PluginsDirectories []string } // GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL. From 6070931025e199bff287733804514b2c5393028e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:49:36 +0700 Subject: [PATCH 30/54] checkers: add map-based option validation for checker fields Add ValidateMapValues() to the forms package for validating checker option maps against field documentation (required fields, allowed choices, type checking). --- internal/forms/field.go | 81 +++++++++++++++++++++++++ internal/forms/field_test.go | 113 +++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 internal/forms/field_test.go diff --git a/internal/forms/field.go b/internal/forms/field.go index e41f3723..55531337 100644 --- a/internal/forms/field.go +++ b/internal/forms/field.go @@ -127,6 +127,87 @@ func ValidateStructValues(data any) error { return nil } +// ValidateMapValues validates a map[string]any against a slice of Field definitions. +// It checks required fields, choices constraints, basic type compatibility, +// and rejects unknown keys not declared in any field definition. +func ValidateMapValues(opts map[string]any, fields []happydns.Field) error { + known := make(map[string]*happydns.Field, len(fields)) + for i := range fields { + known[fields[i].Id] = &fields[i] + } + + // Reject unknown keys. + for k := range opts { + if _, ok := known[k]; !ok { + return fmt.Errorf("unknown option %q", k) + } + } + + for _, f := range fields { + v, exists := opts[f.Id] + + label := f.Label + if label == "" { + label = f.Id + } + + // Required check. + if f.Required { + if !exists || v == nil { + return fmt.Errorf("field %q is required", label) + } + if s, ok := v.(string); ok && s == "" { + return fmt.Errorf("field %q is required", label) + } + } + + if !exists || v == nil { + continue + } + + // Choices check. + if len(f.Choices) > 0 { + s, ok := v.(string) + if !ok { + return fmt.Errorf("field %q: expected a string value for choices field", label) + } + if s != "" && !slices.Contains(f.Choices, s) { + return fmt.Errorf("field %q: value %q is not a valid choice (valid: %v)", label, s, f.Choices) + } + } + + // Basic type check. + if f.Type != "" { + if err := checkMapValueType(f.Type, v, label); err != nil { + return err + } + } + } + + return nil +} + +// checkMapValueType performs a basic type compatibility check between a Field.Type +// string and the actual value from a map[string]any (JSON-decoded). +func checkMapValueType(fieldType string, value any, label string) error { + switch { + case strings.HasPrefix(fieldType, "string"): + if _, ok := value.(string); !ok { + return fmt.Errorf("field %q: expected string, got %T", label, value) + } + case strings.HasPrefix(fieldType, "int") || strings.HasPrefix(fieldType, "uint") || strings.HasPrefix(fieldType, "float"): + // JSON numbers decode as float64. + if _, ok := value.(float64); !ok { + return fmt.Errorf("field %q: expected number, got %T", label, value) + } + case fieldType == "bool": + if _, ok := value.(bool); !ok { + return fmt.Errorf("field %q: expected bool, got %T", label, value) + } + } + return nil +} + // GenStructFields generates corresponding SourceFields of the given Source. func GenStructFields(data any) (fields []*happydns.Field) { if data != nil { diff --git a/internal/forms/field_test.go b/internal/forms/field_test.go new file mode 100644 index 00000000..57a9cded --- /dev/null +++ b/internal/forms/field_test.go @@ -0,0 +1,113 @@ +package forms + +import ( + "testing" + + happydns "git.happydns.org/happyDomain/model" +) + +func TestValidateMapValues_Required(t *testing.T) { + fields := []happydns.Field{ + {Id: "name", Type: "string", Required: true, Label: "Name"}, + } + + // Missing required field. + if err := ValidateMapValues(map[string]any{}, fields); err == nil { + t.Fatal("expected error for missing required field") + } + + // Nil value. + if err := ValidateMapValues(map[string]any{"name": nil}, fields); err == nil { + t.Fatal("expected error for nil required field") + } + + // Empty string value. + if err := ValidateMapValues(map[string]any{"name": ""}, fields); err == nil { + t.Fatal("expected error for empty string required field") + } + + // Valid value. + if err := ValidateMapValues(map[string]any{"name": "hello"}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateMapValues_Choices(t *testing.T) { + fields := []happydns.Field{ + {Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}}, + } + + if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil { + t.Fatal("expected error for invalid choice") + } + + // Empty string is allowed (field not required). + if err := ValidateMapValues(map[string]any{"color": ""}, fields); err != nil { + t.Fatalf("unexpected error for empty choice: %v", err) + } +} + +func TestValidateMapValues_TypeCheck(t *testing.T) { + fields := []happydns.Field{ + {Id: "count", Type: "int"}, + {Id: "label", Type: "string"}, + {Id: "enabled", Type: "bool"}, + } + + // Valid types. + if err := ValidateMapValues(map[string]any{"count": float64(5), "label": "test", "enabled": true}, fields); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wrong type for int field. + if err := ValidateMapValues(map[string]any{"count": "notanumber"}, fields); err == nil { + t.Fatal("expected error for wrong type on int field") + } + + // Wrong type for string field. + if err := ValidateMapValues(map[string]any{"label": float64(42)}, fields); err == nil { + t.Fatal("expected error for wrong type on string field") + } + + // Wrong type for bool field. + if err := ValidateMapValues(map[string]any{"enabled": "yes"}, fields); err == nil { + t.Fatal("expected error for wrong type on bool field") + } +} + +func TestValidateMapValues_UnknownKeys(t *testing.T) { + fields := []happydns.Field{ + {Id: "name", Type: "string"}, + } + + if err := ValidateMapValues(map[string]any{"name": "ok", "unknown": "bad"}, fields); err == nil { + t.Fatal("expected error for unknown key") + } +} + +func TestValidateMapValues_EmptyFieldsAndOpts(t *testing.T) { + // No fields defined, empty options: valid. + if err := ValidateMapValues(map[string]any{}, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // No fields defined, but has options: rejected as unknown. + if err := ValidateMapValues(map[string]any{"x": 1}, nil); err == nil { + t.Fatal("expected error for unknown key with no fields") + } +} + +func TestValidateMapValues_ChoicesNonString(t *testing.T) { + fields := []happydns.Field{ + {Id: "mode", Type: "string", Choices: []string{"a", "b"}}, + } + + // Non-string value on a choices field. + if err := ValidateMapValues(map[string]any{"mode": float64(1)}, fields); err == nil { + t.Fatal("expected error for non-string choices value") + } +} From 3016a8ee88c4bc47a02615a8a721667deb49e351 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:52:48 +0700 Subject: [PATCH 31/54] checkers: add storage interfaces and implementations Add the persistence layer for the checker system: - Storage interfaces (CheckPlanStorage, CheckerOptionsStorage, CheckEvaluationStorage, ExecutionStorage, ObservationSnapshotStorage, SchedulerStateStorage) in the usecase/checker package - KV-based implementations for LevelDB/Oracle NoSQL backends - In-memory implementation for testing - Integrate checker storage into the main Storage interface --- internal/storage/inmemory/checker.go | 579 ++++++++++++++++++ internal/storage/inmemory/database.go | 12 + internal/storage/interface.go | 7 + internal/storage/kvtpl/check_evaluation.go | 189 ++++++ internal/storage/kvtpl/check_plan.go | 126 ++++ internal/storage/kvtpl/checker_options.go | 119 ++++ internal/storage/kvtpl/execution.go | 188 ++++++ .../storage/kvtpl/observation_snapshot.go | 63 ++ internal/storage/kvtpl/scheduler_state.go | 44 ++ internal/usecase/checker/doc.go | 23 + internal/usecase/checker/storage.go | 108 ++++ model/errors.go | 23 +- 12 files changed, 1472 insertions(+), 9 deletions(-) create mode 100644 internal/storage/inmemory/checker.go create mode 100644 internal/storage/kvtpl/check_evaluation.go create mode 100644 internal/storage/kvtpl/check_plan.go create mode 100644 internal/storage/kvtpl/checker_options.go create mode 100644 internal/storage/kvtpl/execution.go create mode 100644 internal/storage/kvtpl/observation_snapshot.go create mode 100644 internal/storage/kvtpl/scheduler_state.go create mode 100644 internal/usecase/checker/doc.go create mode 100644 internal/usecase/checker/storage.go diff --git a/internal/storage/inmemory/checker.go b/internal/storage/inmemory/checker.go new file mode 100644 index 00000000..44ade511 --- /dev/null +++ b/internal/storage/inmemory/checker.go @@ -0,0 +1,579 @@ +// 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 . +// +// 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 . + +package inmemory + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.happydns.org/happyDomain/model" +) + +// sliceIterator implements happydns.Iterator[T] over an in-memory slice. +type sliceIterator[T any] struct { + keys []string + items []*T + index int + deleteFn func(key string) error +} + +func newSliceIterator[T any](keys []string, items []*T, deleteFn func(key string) error) *sliceIterator[T] { + return &sliceIterator[T]{keys: keys, items: items, index: -1, deleteFn: deleteFn} +} + +func (it *sliceIterator[T]) Next() bool { + it.index++ + return it.index < len(it.items) +} + +func (it *sliceIterator[T]) NextWithError() bool { return it.Next() } + +func (it *sliceIterator[T]) Item() *T { + if it.index < 0 || it.index >= len(it.items) { + return nil + } + return it.items[it.index] +} + +func (it *sliceIterator[T]) DropItem() error { + if it.index < 0 || it.index >= len(it.keys) { + return fmt.Errorf("DropItem: iterator is not valid") + } + if it.deleteFn != nil { + return it.deleteFn(it.keys[it.index]) + } + return nil +} + +func (it *sliceIterator[T]) Key() string { + if it.index < 0 || it.index >= len(it.keys) { + return "" + } + return it.keys[it.index] +} + +func (it *sliceIterator[T]) Raw() any { return it.Item() } +func (it *sliceIterator[T]) Err() error { return nil } +func (it *sliceIterator[T]) Close() {} + + +// --- CheckPlanStorage --- + +func (s *InMemoryStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + s.mu.Lock() + defer s.mu.Unlock() + + keys := make([]string, 0, len(s.checkPlans)) + items := make([]*happydns.CheckPlan, 0, len(s.checkPlans)) + for k, p := range s.checkPlans { + keys = append(keys, k) + cp := *p + items = append(items, &cp) + } + return newSliceIterator(keys, items, func(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkPlans, key) + return nil + }), nil +} + +func (s *InMemoryStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.Target.String() == target.String() { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.CheckerID == checkerID { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var plans []*happydns.CheckPlan + for _, p := range s.checkPlans { + if p.Target.UserId == userId.String() { + cp := *p + plans = append(plans, &cp) + } + } + return plans, nil +} + +func (s *InMemoryStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.checkPlans[planID.String()] + if !ok { + return nil, happydns.ErrCheckPlanNotFound + } + cp := *p + return &cp, nil +} + +func (s *InMemoryStorage) CreateCheckPlan(plan *happydns.CheckPlan) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + plan.Id = id + cp := *plan + s.checkPlans[id.String()] = &cp + return nil +} + +func (s *InMemoryStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error { + s.mu.Lock() + defer s.mu.Unlock() + + cp := *plan + s.checkPlans[plan.Id.String()] = &cp + return nil +} + +func (s *InMemoryStorage) DeleteCheckPlan(planID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkPlans, planID.String()) + return nil +} + +func (s *InMemoryStorage) ClearCheckPlans() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.checkPlans = make(map[string]*happydns.CheckPlan) + return nil +} + +// --- CheckerOptionsStorage --- + +func (s *InMemoryStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + s.mu.Lock() + defer s.mu.Unlock() + + keys := make([]string, 0, len(s.checkerOptions)) + items := make([]*happydns.CheckerOptions, 0, len(s.checkerOptions)) + for k, opts := range s.checkerOptions { + keys = append(keys, k) + co := opts + items = append(items, &co) + } + return newSliceIterator(keys, items, func(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.checkerOptions, key) + return nil + }), nil +} + +func (s *InMemoryStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + prefix := fmt.Sprintf("chckrcfg-%s/", checkerName) + + s.mu.Lock() + defer s.mu.Unlock() + + var results []*happydns.CheckerOptionsPositional + for k, opts := range s.checkerOptions { + if !strings.HasPrefix(k, prefix) { + continue + } + cn, uid, did, sid := happydns.ParseCheckerOptionsKey(k) + co := opts + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: co, + }) + } + return results, nil +} + +func (s *InMemoryStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var results []*happydns.CheckerOptionsPositional + + tryGet := func(cn string, uid, did, sid *happydns.Identifier) { + key := happydns.CheckerOptionsKey(cn, uid, did, sid) + if opts, ok := s.checkerOptions[key]; ok { + co := opts + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: co, + }) + } + } + + tryGet(checkerName, nil, nil, nil) + if userId != nil { + tryGet(checkerName, userId, nil, nil) + } + if userId != nil && domainId != nil { + tryGet(checkerName, userId, domainId, nil) + } + if userId != nil && domainId != nil && serviceId != nil { + tryGet(checkerName, userId, domainId, serviceId) + } + return results, nil +} + +func (s *InMemoryStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + + s.mu.Lock() + defer s.mu.Unlock() + + s.checkerOptions[key] = opts + return nil +} + +func (s *InMemoryStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.checkerOptions, key) + return nil +} + +func (s *InMemoryStorage) ClearCheckerConfigurations() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.checkerOptions = make(map[string]happydns.CheckerOptions) + return nil +} + +// --- CheckEvaluationStorage --- + +func (s *InMemoryStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var evals []*happydns.CheckEvaluation + for _, e := range s.evaluations { + if e.PlanID != nil && e.PlanID.String() == planID.String() { + ce := *e + evals = append(evals, &ce) + } + } + return evals, nil +} + +func (s *InMemoryStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + var evals []*happydns.CheckEvaluation + for _, e := range s.evaluations { + if e.CheckerID == checkerID && e.Target.String() == tStr { + ce := *e + evals = append(evals, &ce) + } + } + + sort.Slice(evals, func(i, j int) bool { + return evals[i].EvaluatedAt.After(evals[j].EvaluatedAt) + }) + if limit > 0 && len(evals) > limit { + evals = evals[:limit] + } + return evals, nil +} + +func (s *InMemoryStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.evaluations[evalID.String()] + if !ok { + return nil, happydns.ErrCheckEvaluationNotFound + } + ce := *e + return &ce, nil +} + +func (s *InMemoryStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) { + evals, err := s.ListEvaluationsByPlan(planID) + if err != nil { + return nil, err + } + if len(evals) == 0 { + return nil, happydns.ErrCheckEvaluationNotFound + } + latest := evals[0] + for _, e := range evals[1:] { + if e.EvaluatedAt.After(latest.EvaluatedAt) { + latest = e + } + } + return latest, nil +} + +func (s *InMemoryStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + eval.Id = id + ce := *eval + s.evaluations[id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) DeleteEvaluation(evalID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.evaluations, evalID.String()) + return nil +} + +func (s *InMemoryStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + for k, e := range s.evaluations { + if e.CheckerID == checkerID && e.Target.String() == tStr { + delete(s.evaluations, k) + } + } + return nil +} + +func (s *InMemoryStorage) ClearEvaluations() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.evaluations = make(map[string]*happydns.CheckEvaluation) + return nil +} + +// --- ExecutionStorage --- + +func (s *InMemoryStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + var execs []*happydns.Execution + for _, e := range s.executions { + if e.PlanID != nil && e.PlanID.String() == planID.String() { + ce := *e + execs = append(execs, &ce) + } + } + return execs, nil +} + +func (s *InMemoryStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + var execs []*happydns.Execution + for _, e := range s.executions { + if e.CheckerID == checkerID && e.Target.String() == tStr { + ce := *e + execs = append(execs, &ce) + } + } + + sort.Slice(execs, func(i, j int) bool { + return execs[i].StartedAt.After(execs[j].StartedAt) + }) + if limit > 0 && len(execs) > limit { + execs = execs[:limit] + } + return execs, nil +} + +func (s *InMemoryStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.executions[execID.String()] + if !ok { + return nil, happydns.ErrExecutionNotFound + } + ce := *e + return &ce, nil +} + +func (s *InMemoryStorage) CreateExecution(exec *happydns.Execution) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + exec.Id = id + ce := *exec + s.executions[id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) UpdateExecution(exec *happydns.Execution) error { + s.mu.Lock() + defer s.mu.Unlock() + + ce := *exec + s.executions[exec.Id.String()] = &ce + return nil +} + +func (s *InMemoryStorage) DeleteExecution(execID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.executions, execID.String()) + return nil +} + +func (s *InMemoryStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + s.mu.Lock() + defer s.mu.Unlock() + + tStr := target.String() + for k, e := range s.executions { + if e.CheckerID == checkerID && e.Target.String() == tStr { + delete(s.executions, k) + } + } + return nil +} + +func (s *InMemoryStorage) ClearExecutions() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.executions = make(map[string]*happydns.Execution) + return nil +} + +// --- ObservationSnapshotStorage --- + +func (s *InMemoryStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + + snap, ok := s.snapshots[snapID.String()] + if !ok { + return nil, happydns.ErrSnapshotNotFound + } + cs := *snap + return &cs, nil +} + +func (s *InMemoryStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error { + id, err := happydns.NewRandomIdentifier() + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + snap.Id = id + cs := *snap + s.snapshots[id.String()] = &cs + return nil +} + +func (s *InMemoryStorage) DeleteSnapshot(snapID happydns.Identifier) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshots, snapID.String()) + return nil +} + +func (s *InMemoryStorage) ClearSnapshots() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.snapshots = make(map[string]*happydns.ObservationSnapshot) + return nil +} + +// --- SchedulerStateStorage --- + +func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.schedulerLastRun == nil { + return time.Time{}, nil + } + return *s.schedulerLastRun, nil +} + +func (s *InMemoryStorage) SetLastSchedulerRun(t time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.schedulerLastRun = &t + return nil +} diff --git a/internal/storage/inmemory/database.go b/internal/storage/inmemory/database.go index a0bd9e4e..a85278b2 100644 --- a/internal/storage/inmemory/database.go +++ b/internal/storage/inmemory/database.go @@ -50,6 +50,13 @@ type InMemoryStorage struct { zones map[string]*happydns.ZoneMessage lastInsightsRun *time.Time lastInsightsID happydns.Identifier + // Checker-related storage + checkPlans map[string]*happydns.CheckPlan + checkerOptions map[string]happydns.CheckerOptions + evaluations map[string]*happydns.CheckEvaluation + executions map[string]*happydns.Execution + snapshots map[string]*happydns.ObservationSnapshot + schedulerLastRun *time.Time } // NewInMemoryStorage creates a new instance of InMemoryStorage. @@ -66,6 +73,11 @@ func NewInMemoryStorage() (*InMemoryStorage, error) { users: make(map[string]*happydns.User), usersByEmail: make(map[string]*happydns.User), zones: make(map[string]*happydns.ZoneMessage), + checkPlans: make(map[string]*happydns.CheckPlan), + checkerOptions: make(map[string]happydns.CheckerOptions), + evaluations: make(map[string]*happydns.CheckEvaluation), + executions: make(map[string]*happydns.Execution), + snapshots: make(map[string]*happydns.ObservationSnapshot), }, nil } diff --git a/internal/storage/interface.go b/internal/storage/interface.go index bc2da7bc..fbcb9245 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage" import ( "git.happydns.org/happyDomain/internal/usecase/authuser" + "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/internal/usecase/domain" "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/insight" @@ -40,6 +41,12 @@ type ProviderAndDomainStorage interface { type Storage interface { authuser.AuthUserStorage + checker.CheckPlanStorage + checker.CheckerOptionsStorage + checker.CheckEvaluationStorage + checker.ExecutionStorage + checker.ObservationSnapshotStorage + checker.SchedulerStateStorage domain.DomainStorage domainlog.DomainLogStorage insight.InsightStorage diff --git a/internal/storage/kvtpl/check_evaluation.go b/internal/storage/kvtpl/check_evaluation.go new file mode 100644 index 00000000..17675f6a --- /dev/null +++ b/internal/storage/kvtpl/check_evaluation.go @@ -0,0 +1,189 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + "sort" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) { + prefix := fmt.Sprintf("chckeval-plan|%s|", planID.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var evals []*happydns.CheckEvaluation + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + evals = append(evals, &eval) + } + return evals, nil +} + +func (s *KVStorage) GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) { + eval := &happydns.CheckEvaluation{} + err := s.db.Get(fmt.Sprintf("chckeval-%s", evalID.String()), eval) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckEvaluationNotFound + } + return eval, err +} + +func (s *KVStorage) GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) { + evals, err := s.ListEvaluationsByPlan(planID) + if err != nil { + return nil, err + } + if len(evals) == 0 { + return nil, happydns.ErrCheckEvaluationNotFound + } + + latest := evals[0] + for _, e := range evals[1:] { + if e.EvaluatedAt.After(latest.EvaluatedAt) { + latest = e + } + } + return latest, nil +} + +func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) { + prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var evals []*happydns.CheckEvaluation + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + evals = append(evals, &eval) + } + + // Sort by EvaluatedAt descending (most recent first). + sort.Slice(evals, func(i, j int) bool { + return evals[i].EvaluatedAt.After(evals[j].EvaluatedAt) + }) + + if limit > 0 && len(evals) > limit { + evals = evals[:limit] + } + return evals, nil +} + +func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error { + key, id, err := s.db.FindIdentifierKey("chckeval-") + if err != nil { + return err + } + eval.Id = id + + // Store the primary record. + if err := s.db.Put(key, eval); err != nil { + return err + } + + // Store secondary index by plan if applicable. + if eval.PlanID != nil { + indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + if err := s.db.Put(indexKey, eval); err != nil { + return err + } + } + + // Store secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String()) + if err := s.db.Put(checkerIndexKey, eval); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error { + // Load first to find plan ID for index cleanup. + eval, err := s.GetEvaluation(evalID) + if err != nil { + return err + } + + if eval.PlanID != nil { + indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + _ = s.db.Delete(indexKey) + } + + // Clean up checker+target index. + checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String()) + _ = s.db.Delete(checkerIndexKey) + + return s.db.Delete(fmt.Sprintf("chckeval-%s", evalID.String())) +} + +func (s *KVStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error { + prefix := fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + for iter.Next() { + var eval happydns.CheckEvaluation + if err := s.db.DecodeData(iter.Value(), &eval); err != nil { + continue + } + + // Delete primary record. + _ = s.db.Delete(fmt.Sprintf("chckeval-%s", eval.Id.String())) + + // Delete plan index if applicable. + if eval.PlanID != nil { + planIndexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String()) + _ = s.db.Delete(planIndexKey) + } + + // Delete this checker index entry. + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} + +func (s *KVStorage) ClearEvaluations() error { + // A single prefix scan covers primary records and all secondary indexes + // (chckeval-plan|... and chckeval-chkr|...) in one pass, avoiding + // double-delete errors that occurred when the indexes were deleted first + // and then matched again by the broader "chckeval-" prefix. + iter := s.db.Search("chckeval-") + defer iter.Release() + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/check_plan.go b/internal/storage/kvtpl/check_plan.go new file mode 100644 index 00000000..2a31e864 --- /dev/null +++ b/internal/storage/kvtpl/check_plan.go @@ -0,0 +1,126 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + iter := s.db.Search("chckpln-") + return NewKVIterator[happydns.CheckPlan](s.db, iter), nil +} + +func (s *KVStorage) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.Target.String() == target.String() { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.CheckerID == checkerID { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + iter, err := s.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plan := iter.Item() + if plan.Target.UserId == userId.String() { + plans = append(plans, plan) + } + } + return plans, nil +} + +func (s *KVStorage) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + plan := &happydns.CheckPlan{} + err := s.db.Get(fmt.Sprintf("chckpln-%s", planID.String()), plan) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrCheckPlanNotFound + } + return plan, err +} + +func (s *KVStorage) CreateCheckPlan(plan *happydns.CheckPlan) error { + key, id, err := s.db.FindIdentifierKey("chckpln-") + if err != nil { + return err + } + plan.Id = id + return s.db.Put(key, plan) +} + +func (s *KVStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error { + return s.db.Put(fmt.Sprintf("chckpln-%s", plan.Id.String()), plan) +} + +func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("chckpln-%s", planID.String())) +} + +func (s *KVStorage) ClearCheckPlans() error { + iter, err := s.ListAllCheckPlans() + if err != nil { + return err + } + defer iter.Close() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/checker_options.go b/internal/storage/kvtpl/checker_options.go new file mode 100644 index 00000000..0dabe99f --- /dev/null +++ b/internal/storage/kvtpl/checker_options.go @@ -0,0 +1,119 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "fmt" + + "git.happydns.org/happyDomain/model" +) + + + +func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + iter := s.db.Search("chckrcfg-") + return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil +} + +func (s *KVStorage) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + prefix := fmt.Sprintf("chckrcfg-%s/", checkerName) + iter := s.db.Search(prefix) + defer iter.Release() + + var results []*happydns.CheckerOptionsPositional + for iter.Next() { + var opts happydns.CheckerOptions + if err := s.db.DecodeData(iter.Value(), &opts); err != nil { + continue + } + + cn, uid, did, sid := happydns.ParseCheckerOptionsKey(iter.Key()) + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: cn, + UserId: uid, + DomainId: did, + ServiceId: sid, + Options: opts, + }) + } + return results, nil +} + +func (s *KVStorage) GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + var results []*happydns.CheckerOptionsPositional + + // Try each scope level from admin up to the requested specificity. + scopes := []struct { + uid, did, sid *happydns.Identifier + }{ + {nil, nil, nil}, + {userId, nil, nil}, + {userId, domainId, nil}, + {userId, domainId, serviceId}, + } + + for _, sc := range scopes { + // Skip levels that require identifiers not provided. + if (sc.uid != nil && userId == nil) || (sc.did != nil && domainId == nil) || (sc.sid != nil && serviceId == nil) { + continue + } + + key := happydns.CheckerOptionsKey(checkerName, sc.uid, sc.did, sc.sid) + var opts happydns.CheckerOptions + if err := s.db.Get(key, &opts); err == nil { + results = append(results, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, + UserId: sc.uid, + DomainId: sc.did, + ServiceId: sc.sid, + Options: opts, + }) + } + } + + return results, nil +} + +func (s *KVStorage) UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + return s.db.Put(key, opts) +} + +func (s *KVStorage) DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error { + key := happydns.CheckerOptionsKey(checkerName, userId, domainId, serviceId) + return s.db.Delete(key) +} + +func (s *KVStorage) ClearCheckerConfigurations() error { + iter, err := s.ListAllCheckerConfigurations() + if err != nil { + return err + } + defer iter.Close() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/execution.go b/internal/storage/kvtpl/execution.go new file mode 100644 index 00000000..e4c2d58e --- /dev/null +++ b/internal/storage/kvtpl/execution.go @@ -0,0 +1,188 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + "sort" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) { + prefix := fmt.Sprintf("chckexec-plan|%s|", planID.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var execs []*happydns.Execution + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + execs = append(execs, &exec) + } + return execs, nil +} + +func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + var execs []*happydns.Execution + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + execs = append(execs, &exec) + } + + sort.Slice(execs, func(i, j int) bool { + return execs[i].StartedAt.After(execs[j].StartedAt) + }) + + if limit > 0 && len(execs) > limit { + execs = execs[:limit] + } + return execs, nil +} + +func (s *KVStorage) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + exec := &happydns.Execution{} + err := s.db.Get(fmt.Sprintf("chckexec-%s", execID.String()), exec) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrExecutionNotFound + } + return exec, err +} + +func (s *KVStorage) CreateExecution(exec *happydns.Execution) error { + key, id, err := s.db.FindIdentifierKey("chckexec-") + if err != nil { + return err + } + exec.Id = id + + if err := s.db.Put(key, exec); err != nil { + return err + } + + // Secondary index by plan. + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + if err := s.db.Put(indexKey, exec); err != nil { + return err + } + } + + // Secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String()) + if err := s.db.Put(checkerIndexKey, exec); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error { + if err := s.db.Put(fmt.Sprintf("chckexec-%s", exec.Id.String()), exec); err != nil { + return err + } + + // Update secondary index by plan if applicable. + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + if err := s.db.Put(indexKey, exec); err != nil { + return err + } + } + + // Update secondary index by checker+target. + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String()) + if err := s.db.Put(checkerIndexKey, exec); err != nil { + return err + } + + return nil +} + +func (s *KVStorage) DeleteExecution(execID happydns.Identifier) error { + exec, err := s.GetExecution(execID) + if err != nil { + return err + } + + if exec.PlanID != nil { + indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), execID.String()) + if err := s.db.Delete(indexKey); err != nil { + return err + } + } + + checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), execID.String()) + if err := s.db.Delete(checkerIndexKey); err != nil { + return err + } + + return s.db.Delete(fmt.Sprintf("chckexec-%s", execID.String())) +} + +func (s *KVStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + prefix := fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()) + iter := s.db.Search(prefix) + defer iter.Release() + + for iter.Next() { + var exec happydns.Execution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + + _ = s.db.Delete(fmt.Sprintf("chckexec-%s", exec.Id.String())) + + if exec.PlanID != nil { + planIndexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String()) + _ = s.db.Delete(planIndexKey) + } + + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} + +func (s *KVStorage) ClearExecutions() error { + // A single prefix scan covers primary records and all secondary indexes + // (chckexec-plan|... and chckexec-chkr|...) in one pass. + iter := s.db.Search("chckexec-") + defer iter.Release() + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/observation_snapshot.go b/internal/storage/kvtpl/observation_snapshot.go new file mode 100644 index 00000000..1768cadd --- /dev/null +++ b/internal/storage/kvtpl/observation_snapshot.go @@ -0,0 +1,63 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + snap := &happydns.ObservationSnapshot{} + err := s.db.Get(fmt.Sprintf("chcksnap-%s", snapID.String()), snap) + if errors.Is(err, happydns.ErrNotFound) { + return nil, happydns.ErrSnapshotNotFound + } + return snap, err +} + +func (s *KVStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) error { + key, id, err := s.db.FindIdentifierKey("chcksnap-") + if err != nil { + return err + } + snap.Id = id + return s.db.Put(key, snap) +} + +func (s *KVStorage) DeleteSnapshot(snapID happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("chcksnap-%s", snapID.String())) +} + +func (s *KVStorage) ClearSnapshots() error { + iter := s.db.Search("chcksnap-") + defer iter.Release() + + for iter.Next() { + if err := s.db.Delete(iter.Key()); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/kvtpl/scheduler_state.go b/internal/storage/kvtpl/scheduler_state.go new file mode 100644 index 00000000..8e09f87d --- /dev/null +++ b/internal/storage/kvtpl/scheduler_state.go @@ -0,0 +1,44 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package database + +import ( + "errors" + "time" + + "git.happydns.org/happyDomain/model" +) + +const schedulerLastRunKey = "scheduler-lastrun" + +func (s *KVStorage) GetLastSchedulerRun() (time.Time, error) { + var t time.Time + err := s.db.Get(schedulerLastRunKey, &t) + if errors.Is(err, happydns.ErrNotFound) { + return time.Time{}, nil + } + return t, err +} + +func (s *KVStorage) SetLastSchedulerRun(t time.Time) error { + return s.db.Put(schedulerLastRunKey, t) +} diff --git a/internal/usecase/checker/doc.go b/internal/usecase/checker/doc.go new file mode 100644 index 00000000..caf59737 --- /dev/null +++ b/internal/usecase/checker/doc.go @@ -0,0 +1,23 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +// Package checker provides the usecase layer for the checker/monitoring system. +package checker // import "git.happydns.org/happyDomain/internal/usecase/checker" diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go new file mode 100644 index 00000000..26dc1ff3 --- /dev/null +++ b/internal/usecase/checker/storage.go @@ -0,0 +1,108 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "time" + + "git.happydns.org/happyDomain/model" +) + +// SchedulerStateStorage provides persistence for scheduler state (e.g. last run time). +type SchedulerStateStorage interface { + GetLastSchedulerRun() (time.Time, error) + SetLastSchedulerRun(t time.Time) error +} + +// DomainLister is the minimal interface needed by the scheduler to enumerate domains. +type DomainLister interface { + ListAllDomains() (happydns.Iterator[happydns.Domain], error) +} + +// CheckAutoFillStorage provides access to domain, zone and user data +// needed to resolve auto-fill field values at execution time. +type CheckAutoFillStorage interface { + GetDomain(id happydns.Identifier) (*happydns.Domain, error) + GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) + ListDomains(u *happydns.User) ([]*happydns.Domain, error) + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// CheckPlanStorage provides persistence for CheckPlan entities. +type CheckPlanStorage interface { + ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) + ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) + ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) + ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) + GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) + CreateCheckPlan(plan *happydns.CheckPlan) error + UpdateCheckPlan(plan *happydns.CheckPlan) error + DeleteCheckPlan(planID happydns.Identifier) error + ClearCheckPlans() error +} + +// CheckerOptionsStorage provides persistence for checker options at different levels. +type CheckerOptionsStorage interface { + ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) + ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) + GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) + UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error + DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error + ClearCheckerConfigurations() error +} + +// CheckEvaluationStorage provides persistence for check evaluation results. +type CheckEvaluationStorage interface { + ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) + ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error) + GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error) + GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error) + CreateEvaluation(eval *happydns.CheckEvaluation) error + DeleteEvaluation(evalID happydns.Identifier) error + DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error + ClearEvaluations() error +} + +// ExecutionStorage provides persistence for execution records. +type ExecutionStorage interface { + ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) + ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) + GetExecution(execID happydns.Identifier) (*happydns.Execution, error) + CreateExecution(exec *happydns.Execution) error + UpdateExecution(exec *happydns.Execution) error + DeleteExecution(execID happydns.Identifier) error + DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error + ClearExecutions() error +} + +// PlannedJobProvider exposes upcoming scheduler jobs from the in-memory queue. +type PlannedJobProvider interface { + GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob +} + +// ObservationSnapshotStorage provides persistence for observation snapshots. +type ObservationSnapshotStorage interface { + GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error) + CreateSnapshot(snap *happydns.ObservationSnapshot) error + DeleteSnapshot(snapID happydns.Identifier) error + ClearSnapshots() error +} diff --git a/model/errors.go b/model/errors.go index d3bb697b..07c760c7 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,15 +27,20 @@ import ( ) var ( - ErrAuthUserNotFound = errors.New("user not found") - ErrDomainNotFound = errors.New("domain not found") - ErrDomainLogNotFound = errors.New("domain log not found") - ErrProviderNotFound = errors.New("provider not found") - ErrSessionNotFound = errors.New("session not found") - ErrUserNotFound = errors.New("user not found") - ErrUserAlreadyExist = errors.New("user already exists") - ErrZoneNotFound = errors.New("zone not found") - ErrNotFound = errors.New("not found") + ErrAuthUserNotFound = errors.New("user not found") + ErrDomainNotFound = errors.New("domain not found") + ErrDomainLogNotFound = errors.New("domain log not found") + ErrProviderNotFound = errors.New("provider not found") + ErrSessionNotFound = errors.New("session not found") + ErrUserNotFound = errors.New("user not found") + ErrUserAlreadyExist = errors.New("user already exists") + ErrZoneNotFound = errors.New("zone not found") + ErrCheckerNotFound = errors.New("checker not found") + ErrCheckPlanNotFound = errors.New("check plan not found") + ErrCheckEvaluationNotFound = errors.New("check evaluation not found") + ErrExecutionNotFound = errors.New("execution not found") + ErrSnapshotNotFound = errors.New("snapshot not found") + ErrNotFound = errors.New("not found") ) const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later." From 39f26348c51f2efecb50642914c42924a371c610 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:53:05 +0700 Subject: [PATCH 32/54] checkers: add usecases, engine, and scheduler Implement the checker business logic: - CheckerOptionsUsecase: scope-based option resolution, validation, auto-fill from execution context (domain, zone, service) - CheckPlanUsecase: CRUD for user scheduling configurations - CheckStatusUsecase: aggregated status queries, execution history - CheckerEngine: full execution pipeline (observe, evaluate, aggregate) - Scheduler: background job executor with auto-discovery, min-heap queue, worker pool, and jitter-based scheduling --- .../usecase/checker/check_plan_usecase.go | 79 + .../checker/check_plan_usecase_test.go | 264 +++ .../usecase/checker/check_status_usecase.go | 219 +++ .../checker/check_status_usecase_test.go | 188 +++ internal/usecase/checker/checker_engine.go | 191 +++ .../usecase/checker/checker_engine_test.go | 290 ++++ .../checker/checker_options_usecase.go | 628 +++++++ .../checker/checker_options_usecase_test.go | 1442 +++++++++++++++++ internal/usecase/checker/scheduler.go | 579 +++++++ internal/usecase/checker/scheduler_test.go | 76 + internal/usecase/checker/storage.go | 5 + 11 files changed, 3961 insertions(+) create mode 100644 internal/usecase/checker/check_plan_usecase.go create mode 100644 internal/usecase/checker/check_plan_usecase_test.go create mode 100644 internal/usecase/checker/check_status_usecase.go create mode 100644 internal/usecase/checker/check_status_usecase_test.go create mode 100644 internal/usecase/checker/checker_engine.go create mode 100644 internal/usecase/checker/checker_engine_test.go create mode 100644 internal/usecase/checker/checker_options_usecase.go create mode 100644 internal/usecase/checker/checker_options_usecase_test.go create mode 100644 internal/usecase/checker/scheduler.go create mode 100644 internal/usecase/checker/scheduler_test.go diff --git a/internal/usecase/checker/check_plan_usecase.go b/internal/usecase/checker/check_plan_usecase.go new file mode 100644 index 00000000..7ff6f1f6 --- /dev/null +++ b/internal/usecase/checker/check_plan_usecase.go @@ -0,0 +1,79 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "fmt" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckPlanUsecase handles business logic for check plans. +type CheckPlanUsecase struct { + store CheckPlanStorage +} + +// NewCheckPlanUsecase creates a new CheckPlanUsecase. +func NewCheckPlanUsecase(store CheckPlanStorage) *CheckPlanUsecase { + return &CheckPlanUsecase{store: store} +} + +// ListCheckPlansByTarget returns all check plans matching the given target. +func (u *CheckPlanUsecase) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + return u.store.ListCheckPlansByTarget(target) +} + +// CreateCheckPlan validates that the checker exists and persists the plan. +func (u *CheckPlanUsecase) CreateCheckPlan(plan *happydns.CheckPlan) error { + if checkerPkg.FindChecker(plan.CheckerID) == nil { + return fmt.Errorf("checker %q not found", plan.CheckerID) + } + return u.store.CreateCheckPlan(plan) +} + +// GetCheckPlan retrieves a check plan by ID. +func (u *CheckPlanUsecase) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + return u.store.GetCheckPlan(planID) +} + +// UpdateCheckPlan fetches the existing plan, preserves Id and Target (immutable), +// and persists the merged result. +func (u *CheckPlanUsecase) UpdateCheckPlan(planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) { + existing, err := u.store.GetCheckPlan(planID) + if err != nil { + return nil, err + } + + updated.Id = existing.Id + updated.Target = existing.Target + + if err := u.store.UpdateCheckPlan(updated); err != nil { + return nil, err + } + return updated, nil +} + +// DeleteCheckPlan deletes a check plan by ID. +func (u *CheckPlanUsecase) DeleteCheckPlan(planID happydns.Identifier) error { + return u.store.DeleteCheckPlan(planID) +} diff --git a/internal/usecase/checker/check_plan_usecase_test.go b/internal/usecase/checker/check_plan_usecase_test.go new file mode 100644 index 00000000..d2b1b356 --- /dev/null +++ b/internal/usecase/checker/check_plan_usecase_test.go @@ -0,0 +1,264 @@ +// 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 . +// +// 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 . + +package checker_test + +import ( + "testing" + + "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +func setupPlanUC(t *testing.T) (*checkerUC.CheckPlanUsecase, *planStore) { + t.Helper() + // Register a checker so CreateCheckPlan validation passes. + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "plan_test_checker", + Name: "Plan Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_a", status: happydns.StatusOK}, + }, + }) + + store := newPlanStore() + uc := checkerUC.NewCheckPlanUsecase(store) + return uc, store +} + +func TestCheckPlanUsecase_CreateAndGet(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + if plan.Id.IsEmpty() { + t.Fatal("expected plan to get an ID assigned") + } + + got, err := uc.GetCheckPlan(plan.Id) + if err != nil { + t.Fatalf("GetCheckPlan() error: %v", err) + } + if got.CheckerID != "plan_test_checker" { + t.Errorf("expected CheckerID plan_test_checker, got %s", got.CheckerID) + } +} + +func TestCheckPlanUsecase_CreateUnknownChecker(t *testing.T) { + uc, _ := setupPlanUC(t) + + plan := &happydns.CheckPlan{ + CheckerID: "nonexistent_checker", + } + + if err := uc.CreateCheckPlan(plan); err == nil { + t.Fatal("expected error for unknown checker") + } +} + +func TestCheckPlanUsecase_ListByTarget(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + plans, err := uc.ListCheckPlansByTarget(target) + if err != nil { + t.Fatalf("ListCheckPlansByTarget() error: %v", err) + } + if len(plans) != 1 { + t.Errorf("expected 1 plan, got %d", len(plans)) + } + + // Different target should return empty. + uid2, _ := happydns.NewRandomIdentifier() + other := happydns.CheckTarget{UserId: uid2.String()} + plans2, err := uc.ListCheckPlansByTarget(other) + if err != nil { + t.Fatalf("ListCheckPlansByTarget() error: %v", err) + } + if len(plans2) != 0 { + t.Errorf("expected 0 plans for different target, got %d", len(plans2)) + } +} + +func TestCheckPlanUsecase_UpdatePreservesIdAndTarget(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + origID := plan.Id + + // Update with different target and ID — they should be preserved. + uid2, _ := happydns.NewRandomIdentifier() + fakeID, _ := happydns.NewRandomIdentifier() + updated := &happydns.CheckPlan{ + Id: fakeID, + CheckerID: "plan_test_checker", + Target: happydns.CheckTarget{UserId: uid2.String()}, + Enabled: map[string]bool{"rule_a": false}, + } + + result, err := uc.UpdateCheckPlan(origID, updated) + if err != nil { + t.Fatalf("UpdateCheckPlan() error: %v", err) + } + + if !result.Id.Equals(origID) { + t.Errorf("expected Id to be preserved as %s, got %s", origID, result.Id) + } + if result.Target.String() != target.String() { + t.Errorf("expected Target to be preserved") + } + if result.Enabled["rule_a"] != false { + t.Errorf("expected Enabled to be updated") + } +} + +func TestCheckPlanUsecase_UpdateNotFound(t *testing.T) { + uc, _ := setupPlanUC(t) + + fakeID, _ := happydns.NewRandomIdentifier() + _, err := uc.UpdateCheckPlan(fakeID, &happydns.CheckPlan{}) + if err == nil { + t.Fatal("expected error for nonexistent plan") + } +} + +func TestCheckPlanUsecase_Delete(t *testing.T) { + uc, _ := setupPlanUC(t) + + uid, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String()} + + plan := &happydns.CheckPlan{ + CheckerID: "plan_test_checker", + Target: target, + } + if err := uc.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + if err := uc.DeleteCheckPlan(plan.Id); err != nil { + t.Fatalf("DeleteCheckPlan() error: %v", err) + } + + _, err := uc.GetCheckPlan(plan.Id) + if err == nil { + t.Fatal("expected error after deletion") + } +} + +// --- planStore: minimal in-memory CheckPlanStorage --- + +type planStore struct { + plans map[string]*happydns.CheckPlan +} + +func newPlanStore() *planStore { + return &planStore{plans: make(map[string]*happydns.CheckPlan)} +} + +func (s *planStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) { + return nil, nil +} + +func (s *planStore) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) { + var result []*happydns.CheckPlan + for _, p := range s.plans { + if p.Target.String() == target.String() { + result = append(result, p) + } + } + return result, nil +} + +func (s *planStore) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) { + return nil, nil +} + +func (s *planStore) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) { + return nil, nil +} + +func (s *planStore) GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error) { + p, ok := s.plans[planID.String()] + if !ok { + return nil, happydns.ErrCheckPlanNotFound + } + return p, nil +} + +func (s *planStore) CreateCheckPlan(plan *happydns.CheckPlan) error { + id, _ := happydns.NewRandomIdentifier() + plan.Id = id + s.plans[plan.Id.String()] = plan + return nil +} + +func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { + s.plans[plan.Id.String()] = plan + return nil +} + +func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error { + delete(s.plans, planID.String()) + return nil +} + +func (s *planStore) ClearCheckPlans() error { + s.plans = make(map[string]*happydns.CheckPlan) + return nil +} diff --git a/internal/usecase/checker/check_status_usecase.go b/internal/usecase/checker/check_status_usecase.go new file mode 100644 index 00000000..b8362b3a --- /dev/null +++ b/internal/usecase/checker/check_status_usecase.go @@ -0,0 +1,219 @@ +// 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 . +// +// 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 . + +package checker + +import ( + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckStatusUsecase handles aggregation of checker statuses and evaluation/execution queries. +type CheckStatusUsecase struct { + planStore CheckPlanStorage + evalStore CheckEvaluationStorage + execStore ExecutionStorage + snapStore ObservationSnapshotStorage + plannedProvider PlannedJobProvider +} + +// NewCheckStatusUsecase creates a new CheckStatusUsecase. +func NewCheckStatusUsecase(planStore CheckPlanStorage, evalStore CheckEvaluationStorage, execStore ExecutionStorage, snapStore ObservationSnapshotStorage) *CheckStatusUsecase { + return &CheckStatusUsecase{ + planStore: planStore, + evalStore: evalStore, + execStore: execStore, + snapStore: snapStore, + } +} + +// SetPlannedJobProvider attaches an optional scheduler for planned execution queries. +func (u *CheckStatusUsecase) SetPlannedJobProvider(p PlannedJobProvider) { + u.plannedProvider = p +} + +// ListPlannedExecutions returns synthetic Execution records for upcoming scheduled jobs. +// Returns nil if no PlannedJobProvider is configured. +func (u *CheckStatusUsecase) ListPlannedExecutions(checkerID string, target happydns.CheckTarget) []*happydns.Execution { + if u.plannedProvider == nil { + return nil + } + jobs := u.plannedProvider.GetPlannedJobsForChecker(checkerID, target) + result := make([]*happydns.Execution, 0, len(jobs)) + for _, job := range jobs { + exec := &happydns.Execution{ + CheckerID: job.CheckerID, + PlanID: job.PlanID, + Target: job.Target, + Trigger: happydns.TriggerInfo{Type: happydns.TriggerSchedule}, + StartedAt: job.NextRun, + Status: happydns.ExecutionPending, + } + result = append(result, exec) + } + return result +} + +// ListCheckerStatuses aggregates checkers, plans, and latest evaluations into a status list. +func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([]happydns.CheckerStatus, error) { + checkers := checkerPkg.GetCheckers() + plans, err := u.planStore.ListCheckPlansByTarget(target) + if err != nil { + return nil, err + } + + planByChecker := make(map[string]*happydns.CheckPlan) + for _, p := range plans { + planByChecker[p.CheckerID] = p + } + + var result []happydns.CheckerStatus + for _, def := range checkers { + switch target.Scope() { + case happydns.CheckScopeDomain: + if !def.Availability.ApplyToDomain { + continue + } + case happydns.CheckScopeService: + if !def.Availability.ApplyToService { + continue + } + } + + status := happydns.CheckerStatus{ + CheckerDefinition: def, + Plan: planByChecker[def.ID], + Enabled: true, + } + + enabledRules := make(map[string]bool, len(def.Rules)) + for _, rule := range def.Rules { + enabledRules[rule.Name()] = true + } + if status.Plan != nil { + status.Enabled = !status.Plan.IsFullyDisabled() + for ruleName := range enabledRules { + enabledRules[ruleName] = status.Plan.IsRuleEnabled(ruleName) + } + } + status.EnabledRules = enabledRules + + execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1) + if err == nil && len(execs) > 0 { + status.LatestExecution = execs[0] + } + + result = append(result, status) + } + + if result == nil { + result = []happydns.CheckerStatus{} + } + return result, nil +} + +// GetExecution returns a specific execution by ID. +func (u *CheckStatusUsecase) GetExecution(execID happydns.Identifier) (*happydns.Execution, error) { + return u.execStore.GetExecution(execID) +} + +// ListExecutionsByChecker returns executions for a checker on a target, up to limit. +func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) { + return u.execStore.ListExecutionsByChecker(checkerID, target, limit) +} + +// GetObservationsByExecution returns the observation snapshot for an execution. +func (u *CheckStatusUsecase) GetObservationsByExecution(execID happydns.Identifier) (*happydns.ObservationSnapshot, error) { + exec, err := u.execStore.GetExecution(execID) + if err != nil { + return nil, err + } + if exec.EvaluationID == nil { + return nil, happydns.ErrCheckEvaluationNotFound + } + eval, err := u.evalStore.GetEvaluation(*exec.EvaluationID) + if err != nil { + return nil, err + } + return u.snapStore.GetSnapshot(eval.SnapshotID) +} + +// DeleteExecution deletes an execution record by ID. +func (u *CheckStatusUsecase) DeleteExecution(execID happydns.Identifier) error { + return u.execStore.DeleteExecution(execID) +} + +// DeleteExecutionsByChecker deletes all executions for a checker on a target. +func (u *CheckStatusUsecase) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error { + return u.execStore.DeleteExecutionsByChecker(checkerID, target) +} + +// GetWorstServiceStatuses returns the worst check status for each service in the zone. +// It iterates all services and all registered checkers, fetching the latest execution +// for each (service, checker) pair, and returns the worst status per service. +func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier, zone *happydns.Zone) (map[string]*happydns.Status, error) { + checkers := checkerPkg.GetCheckers() + if len(checkers) == 0 { + return nil, nil + } + + result := make(map[string]*happydns.Status) + for subdomain := range zone.Services { + for _, svc := range zone.Services[subdomain] { + target := happydns.CheckTarget{ + UserId: &userId, + DomainId: &domainId, + ServiceId: &svc.Id, + } + var worst *happydns.Status + for _, def := range checkers { + execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1) + if err != nil || len(execs) == 0 { + continue + } + s := execs[0].Result.Status + if worst == nil || s > *worst { + worst = &s + } + } + if worst != nil { + result[svc.Id.String()] = worst + } + } + } + + if len(result) == 0 { + return nil, nil + } + return result, nil +} + +// GetResultsByExecution returns the evaluation (with per-rule states) for an execution. +func (u *CheckStatusUsecase) GetResultsByExecution(execID happydns.Identifier) (*happydns.CheckEvaluation, error) { + exec, err := u.execStore.GetExecution(execID) + if err != nil { + return nil, err + } + if exec.EvaluationID == nil { + return nil, happydns.ErrCheckEvaluationNotFound + } + return u.evalStore.GetEvaluation(*exec.EvaluationID) +} diff --git a/internal/usecase/checker/check_status_usecase_test.go b/internal/usecase/checker/check_status_usecase_test.go new file mode 100644 index 00000000..16a17030 --- /dev/null +++ b/internal/usecase/checker/check_status_usecase_test.go @@ -0,0 +1,188 @@ +// 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 . +// +// 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 . + +package checker_test + +import ( + "testing" + "time" + + "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/storage/inmemory" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +func setupStatusUC(t *testing.T) (*checkerUC.CheckStatusUsecase, *planStore, *inmemory.InMemoryStorage) { + t.Helper() + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "status_test_checker", + Name: "Status Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_x", status: happydns.StatusOK}, + &testCheckRule{name: "rule_y", status: happydns.StatusWarn}, + }, + }) + + ps := newPlanStore() + ms, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + uc := checkerUC.NewCheckStatusUsecase(ps, ms, ms, ms) + return uc, ps, ms +} + +func TestCheckStatusUsecase_ListCheckerStatuses(t *testing.T) { + uc, _, _ := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + if len(statuses) == 0 { + t.Fatal("expected at least one checker status") + } + + // All should be enabled by default (no plans). + for _, s := range statuses { + if !s.Enabled { + t.Errorf("expected checker %s to be enabled by default", s.ID) + } + } +} + +func TestCheckStatusUsecase_ListCheckerStatuses_WithPlan(t *testing.T) { + uc, ps, _ := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Create a plan that fully disables the checker. + plan := &happydns.CheckPlan{ + CheckerID: "status_test_checker", + Target: target, + Enabled: map[string]bool{"rule_x": false, "rule_y": false}, + } + if err := ps.CreateCheckPlan(plan); err != nil { + t.Fatalf("CreateCheckPlan() error: %v", err) + } + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + found := false + for _, s := range statuses { + if s.ID == "status_test_checker" { + found = true + if s.Enabled { + t.Error("expected status_test_checker to be disabled when all rules are off") + } + if s.Plan == nil { + t.Error("expected Plan to be set") + } + if s.EnabledRules["rule_x"] { + t.Error("expected rule_x to be disabled") + } + if s.EnabledRules["rule_y"] { + t.Error("expected rule_y to be disabled") + } + } + } + if !found { + t.Error("status_test_checker not found in statuses") + } +} + +func TestCheckStatusUsecase_ListCheckerStatuses_WithEvaluation(t *testing.T) { + uc, _, ms := setupStatusUC(t) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Create an execution for the checker. + exec := &happydns.Execution{ + CheckerID: "status_test_checker", + Target: target, + StartedAt: time.Now(), + Status: happydns.ExecutionDone, + Result: happydns.CheckState{Status: happydns.StatusOK, Message: "all good"}, + } + if err := ms.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution() error: %v", err) + } + + statuses, err := uc.ListCheckerStatuses(target) + if err != nil { + t.Fatalf("ListCheckerStatuses() error: %v", err) + } + + for _, s := range statuses { + if s.ID == "status_test_checker" { + if s.LatestExecution == nil { + t.Error("expected LatestExecution to be set") + } else if s.LatestExecution.Result.Status != happydns.StatusOK { + t.Errorf("expected latest execution result status OK, got %s", s.LatestExecution.Result.Status) + } + } + } +} + +func TestCheckStatusUsecase_GetExecution(t *testing.T) { + uc, _, ms := setupStatusUC(t) + + exec := &happydns.Execution{ + Status: happydns.ExecutionDone, + } + if err := ms.CreateExecution(exec); err != nil { + t.Fatalf("CreateExecution() error: %v", err) + } + + got, err := uc.GetExecution(exec.Id) + if err != nil { + t.Fatalf("GetExecution() error: %v", err) + } + if got.Status != happydns.ExecutionDone { + t.Errorf("expected status Done, got %d", got.Status) + } +} + +func TestCheckStatusUsecase_GetExecutionNotFound(t *testing.T) { + uc, _, _ := setupStatusUC(t) + + fakeID, _ := happydns.NewRandomIdentifier() + _, err := uc.GetExecution(fakeID) + if err == nil { + t.Fatal("expected error for nonexistent execution") + } +} diff --git a/internal/usecase/checker/checker_engine.go b/internal/usecase/checker/checker_engine.go new file mode 100644 index 00000000..e69a2a61 --- /dev/null +++ b/internal/usecase/checker/checker_engine.go @@ -0,0 +1,191 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "context" + "fmt" + "log" + "time" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +// checkerEngine implements the happydns.CheckerEngine interface. +type checkerEngine struct { + optionsUC *CheckerOptionsUsecase + evalStore CheckEvaluationStorage + execStore ExecutionStorage + snapStore ObservationSnapshotStorage +} + +// NewCheckerEngine creates a new CheckerEngine implementation. +func NewCheckerEngine( + optionsUC *CheckerOptionsUsecase, + evalStore CheckEvaluationStorage, + execStore ExecutionStorage, + snapStore ObservationSnapshotStorage, +) happydns.CheckerEngine { + return &checkerEngine{ + optionsUC: optionsUC, + evalStore: evalStore, + execStore: execStore, + snapStore: snapStore, + } +} + +// CreateExecution validates the checker and creates a pending Execution record. +func (e *checkerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) { + if checkerPkg.FindChecker(checkerID) == nil { + return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, checkerID) + } + + // Determine trigger info. + trigger := happydns.TriggerInfo{Type: happydns.TriggerManual} + var planID *happydns.Identifier + if plan != nil { + planID = &plan.Id + trigger.PlanID = planID + trigger.Type = happydns.TriggerSchedule + } + + // Create execution record. + exec := &happydns.Execution{ + CheckerID: checkerID, + PlanID: planID, + Target: target, + Trigger: trigger, + StartedAt: time.Now(), + Status: happydns.ExecutionPending, + } + if err := e.execStore.CreateExecution(exec); err != nil { + return nil, fmt.Errorf("creating execution: %w", err) + } + + return exec, nil +} + +// RunExecution takes an existing execution and runs the checker pipeline. +func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) { + log.Printf("CheckerEngine: running checker %s on %s", exec.CheckerID, exec.Target.String()) + + def := checkerPkg.FindChecker(exec.CheckerID) + if def == nil { + endTime := time.Now() + exec.Status = happydns.ExecutionFailed + exec.EndedAt = &endTime + exec.Error = fmt.Sprintf("checker not found: %s", exec.CheckerID) + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, exec.CheckerID) + } + + // Mark as running. + exec.Status = happydns.ExecutionRunning + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + + // Run the pipeline and handle failure. + result, eval, err := e.runPipeline(ctx, def, exec.Target, plan, exec.PlanID, runOpts) + if err != nil { + log.Printf("CheckerEngine: checker %s on %s failed: %v", exec.CheckerID, exec.Target.String(), err) + endTime := time.Now() + exec.Status = happydns.ExecutionFailed + exec.EndedAt = &endTime + exec.Error = err.Error() + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + return nil, err + } + + // Mark as done. + endTime := time.Now() + exec.Status = happydns.ExecutionDone + exec.EndedAt = &endTime + exec.Result = result + exec.EvaluationID = &eval.Id + if err := e.execStore.UpdateExecution(exec); err != nil { + log.Printf("CheckerEngine: failed to update execution: %v", err) + } + + return eval, nil +} + +func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDefinition, target happydns.CheckTarget, plan *happydns.CheckPlan, planID *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckState, *happydns.CheckEvaluation, error) { + // Resolve options (stored + run + auto-fill). + mergedOpts, err := e.optionsUC.BuildMergedCheckerOptionsWithAutoFill(def.ID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), runOpts) + if err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err) + } + + // Create observation context for lazy data collection. + obsCtx := checkerPkg.NewObservationContext(target, mergedOpts) + + // Evaluate all rules, skipping disabled ones. + states := make([]happydns.CheckState, 0, len(def.Rules)) + for _, rule := range def.Rules { + if plan != nil && !plan.IsRuleEnabled(rule.Name()) { + continue + } + state := rule.Evaluate(ctx, obsCtx, mergedOpts) + if state.Code == "" { + state.Code = rule.Name() + } + states = append(states, state) + } + + // Aggregate results. + aggregator := def.Aggregator + if aggregator == nil { + aggregator = checkerPkg.WorstStatusAggregator{} + } + result := aggregator.Aggregate(states) + + // Persist observation snapshot. + snap := &happydns.ObservationSnapshot{ + Target: target, + CollectedAt: time.Now(), + Data: obsCtx.Data(), + } + if err := e.snapStore.CreateSnapshot(snap); err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err) + } + + // Persist evaluation. + eval := &happydns.CheckEvaluation{ + PlanID: planID, + CheckerID: def.ID, + Target: target, + SnapshotID: snap.Id, + EvaluatedAt: time.Now(), + States: states, + } + if err := e.evalStore.CreateEvaluation(eval); err != nil { + return happydns.CheckState{}, nil, fmt.Errorf("creating evaluation: %w", err) + } + + return result, eval, nil +} diff --git a/internal/usecase/checker/checker_engine_test.go b/internal/usecase/checker/checker_engine_test.go new file mode 100644 index 00000000..777614ac --- /dev/null +++ b/internal/usecase/checker/checker_engine_test.go @@ -0,0 +1,290 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker_test + +import ( + "context" + "testing" + + "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/storage/inmemory" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// testObservationProvider returns static test data. +type testObservationProvider struct{} + +func (p *testObservationProvider) Key() happydns.ObservationKey { + return "test_obs" +} + +func (p *testObservationProvider) Collect(ctx context.Context, target happydns.CheckTarget, opts happydns.CheckerOptions) (any, error) { + return map[string]any{"value": 42}, nil +} + +// testCheckRule produces a state based on observations. +type testCheckRule struct { + name string + status happydns.Status +} + +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 { + return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()} + } + return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name} +} + +func TestCheckerEngine_RunOK(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + // Register test provider and checker. + checker.RegisterObservationProvider(&testObservationProvider{}) + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker", + Name: "Test Checker", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_ok", status: happydns.StatusOK}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + exec, err := engine.CreateExecution("test_checker", target, nil) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + + eval, err := engine.RunExecution(context.Background(), exec, nil, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + if eval == nil { + t.Fatal("RunExecution() returned nil evaluation") + } + + if exec.Result.Status != happydns.StatusOK { + t.Errorf("expected status OK, got %s", exec.Result.Status) + } + + if len(eval.States) != 1 { + t.Errorf("expected 1 state, got %d", len(eval.States)) + } + + // Verify execution was persisted. + execs, err := store.ListExecutionsByChecker("test_checker", target, 0) + if err != nil { + t.Fatalf("ListExecutionsByChecker() returned error: %v", err) + } + if len(execs) != 1 { + t.Errorf("expected 1 execution, got %d", len(execs)) + } + + // Verify the execution ended as Done. + for _, ex := range execs { + if ex.Status != happydns.ExecutionDone { + t.Errorf("expected execution status Done, got %d", ex.Status) + } + } +} + +func TestCheckerEngine_RunWarn(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker_warn", + Name: "Test Checker Warn", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_ok", status: happydns.StatusOK}, + &testCheckRule{name: "rule_warn", status: happydns.StatusWarn}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + exec, err := engine.CreateExecution("test_checker_warn", target, nil) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + eval, err := engine.RunExecution(context.Background(), exec, nil, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + // Worst status aggregation: WARN should win over OK. + if exec.Result.Status != happydns.StatusWarn { + t.Errorf("expected aggregated status WARN, got %s", exec.Result.Status) + } + + if len(eval.States) != 2 { + t.Errorf("expected 2 states, got %d", len(eval.States)) + } +} + +func TestCheckerEngine_RunPerRuleDisable(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + + checker.RegisterChecker(&happydns.CheckerDefinition{ + ID: "test_checker_per_rule", + Name: "Test Checker Per Rule", + Availability: happydns.CheckerAvailability{ + ApplyToDomain: true, + }, + Rules: []happydns.CheckRule{ + &testCheckRule{name: "rule_a", status: happydns.StatusOK}, + &testCheckRule{name: "rule_b", status: happydns.StatusWarn}, + &testCheckRule{name: "rule_c", status: happydns.StatusCrit}, + }, + }) + + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + did, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + // Disable rule_b and rule_c, only rule_a should run. + plan := &happydns.CheckPlan{ + CheckerID: "test_checker_per_rule", + Target: target, + Enabled: map[string]bool{ + "rule_a": true, + "rule_b": false, + "rule_c": false, + }, + } + + exec, err := engine.CreateExecution("test_checker_per_rule", target, plan) + if err != nil { + t.Fatalf("CreateExecution() returned error: %v", err) + } + eval, err := engine.RunExecution(context.Background(), exec, plan, nil) + if err != nil { + t.Fatalf("RunExecution() returned error: %v", err) + } + + if len(eval.States) != 1 { + t.Fatalf("expected 1 state (only rule_a), got %d", len(eval.States)) + } + + if exec.Result.Status != happydns.StatusOK { + t.Errorf("expected status OK (only rule_a active), got %s", exec.Result.Status) + } + + if eval.States[0].Code != "rule_a" { + t.Errorf("expected rule_a state, got code %s", eval.States[0].Code) + } +} + +func TestCheckPlan_IsFullyDisabled(t *testing.T) { + // Nil map = not disabled. + p := &happydns.CheckPlan{} + if p.IsFullyDisabled() { + t.Error("nil map should not be fully disabled") + } + + // All false = disabled. + p.Enabled = map[string]bool{"a": false, "b": false} + if !p.IsFullyDisabled() { + t.Error("all-false map should be fully disabled") + } + + // Mixed = not disabled. + p.Enabled = map[string]bool{"a": true, "b": false} + if p.IsFullyDisabled() { + t.Error("mixed map should not be fully disabled") + } +} + +func TestCheckPlan_IsRuleEnabled(t *testing.T) { + // Nil map = all enabled. + p := &happydns.CheckPlan{} + if !p.IsRuleEnabled("any") { + t.Error("nil map should enable all rules") + } + + // Missing key = enabled. + p.Enabled = map[string]bool{"a": false} + if !p.IsRuleEnabled("b") { + t.Error("missing key should be enabled") + } + + // Explicit false = disabled. + if p.IsRuleEnabled("a") { + t.Error("explicit false should be disabled") + } + + // Explicit true = enabled. + p.Enabled["c"] = true + if !p.IsRuleEnabled("c") { + t.Error("explicit true should be enabled") + } +} + +func TestCheckerEngine_RunNotFound(t *testing.T) { + store, err := inmemory.NewInMemoryStorage() + if err != nil { + t.Fatalf("NewInMemoryStorage() returned error: %v", err) + } + optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil) + engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store) + + uid, _ := happydns.NewRandomIdentifier() + target := happydns.CheckTarget{UserId: uid.String()} + + _, err = engine.CreateExecution("nonexistent_checker", target, nil) + if err == nil { + t.Fatal("expected error for nonexistent checker") + } +} diff --git a/internal/usecase/checker/checker_options_usecase.go b/internal/usecase/checker/checker_options_usecase.go new file mode 100644 index 00000000..68ce569c --- /dev/null +++ b/internal/usecase/checker/checker_options_usecase.go @@ -0,0 +1,628 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "fmt" + "maps" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/internal/forms" + "git.happydns.org/happyDomain/model" +) + +// isEmptyValue returns true if v is nil or an empty string. +func isEmptyValue(v any) bool { + if v == nil { + return true + } + if s, ok := v.(string); ok && s == "" { + return true + } + return false +} + +// identifiersEqual returns true when both identifiers are nil or point to the same value. +func identifiersEqual(a, b *happydns.Identifier) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Equals(*b) +} + +// getScopedOptions returns options stored exactly at the requested scope level, +// without merging parent scopes. +func (u *CheckerOptionsUsecase) getScopedOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) (happydns.CheckerOptions, error) { + positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) + if err != nil { + return make(happydns.CheckerOptions), err + } + for _, p := range positionals { + if identifiersEqual(p.UserId, userId) && identifiersEqual(p.DomainId, domainId) && identifiersEqual(p.ServiceId, serviceId) { + if p.Options != nil { + return p.Options, nil + } + return make(happydns.CheckerOptions), nil + } + } + return make(happydns.CheckerOptions), nil +} + +// CheckerOptionsUsecase handles the resolution and persistence of checker options. +type CheckerOptionsUsecase struct { + store CheckerOptionsStorage + autoFillStore CheckAutoFillStorage +} + +// NewCheckerOptionsUsecase creates a new CheckerOptionsUsecase. +func NewCheckerOptionsUsecase(store CheckerOptionsStorage, autoFillStore CheckAutoFillStorage) *CheckerOptionsUsecase { + return &CheckerOptionsUsecase{store: store, autoFillStore: autoFillStore} +} + +// GetCheckerOptionsPositional returns the raw positional options from all scope levels, +// ordered from least to most specific (admin < user < domain < service). +func (u *CheckerOptionsUsecase) GetCheckerOptionsPositional( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) ([]*happydns.CheckerOptionsPositional, error) { + return u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) +} + +// GetAutoFillOptions resolves auto-fill values for a checker and target, +// returning only the auto-filled key/value pairs. +func (u *CheckerOptionsUsecase) GetAutoFillOptions( + checkerName string, + target happydns.CheckTarget, +) (happydns.CheckerOptions, error) { + result, err := u.resolveAutoFill(checkerName, target) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, nil + } + return result, nil +} + +// GetCheckerOptions retrieves and merges options from all applicable levels +// (admin < user < domain < service), returning the merged result. +func (u *CheckerOptionsUsecase) GetCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, +) (happydns.CheckerOptions, error) { + positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId) + if err != nil { + 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 { + 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 +} + +// BuildMergedCheckerOptions merges stored options with runtime overrides. +// RunOpts are applied last and win over all stored levels. +func BuildMergedCheckerOptions(storedOpts happydns.CheckerOptions, runOpts happydns.CheckerOptions) happydns.CheckerOptions { + result := make(happydns.CheckerOptions) + maps.Copy(result, storedOpts) + maps.Copy(result, runOpts) + return result +} + +// SetCheckerOptions persists options at the given positional level (full replace). +// Keys with nil or empty-string values are excluded from the stored map. +// Auto-fill keys are also stripped since they are system-provided at runtime. +func (u *CheckerOptionsUsecase) SetCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + opts happydns.CheckerOptions, +) error { + // 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] != "" { + 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) +} + +// AddCheckerOptions merges new options into existing ones at the given scope level. +// Keys with nil or empty-string values are deleted from the scope rather than stored. +func (u *CheckerOptionsUsecase) AddCheckerOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + newOpts happydns.CheckerOptions, +) (happydns.CheckerOptions, error) { + existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId) + 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 { + existing[k] = v + } + } + if err := u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing); err != nil { + return nil, err + } + return existing, nil +} + +// GetCheckerOption returns a single option value from the merged options. +func (u *CheckerOptionsUsecase) GetCheckerOption( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + optName string, +) (any, error) { + opts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId) + if err != nil { + return nil, err + } + return opts[optName], nil +} + +// scopeFromIdentifiers determines the CheckScopeType based on which identifiers are set. +func scopeFromIdentifiers(userId, domainId, serviceId *happydns.Identifier) happydns.CheckScopeType { + if serviceId != nil { + return happydns.CheckScopeService + } + if domainId != nil { + return happydns.CheckScopeDomain + } + if userId != nil { + return happydns.CheckScopeUser + } + return happydns.CheckScopeAdmin +} + +// collectFieldsForScope returns the fields from a CheckerOptionsDocumentation +// that are valid at the given scope level. RunOpts are never included for +// persisted scopes. +func collectFieldsForScope(doc happydns.CheckerOptionsDocumentation, scope happydns.CheckScopeType) []happydns.CheckerOptionDocumentation { + var fields []happydns.CheckerOptionDocumentation + switch scope { + case happydns.CheckScopeAdmin: + fields = append(fields, doc.AdminOpts...) + case happydns.CheckScopeUser: + fields = append(fields, doc.UserOpts...) + case happydns.CheckScopeDomain, happydns.CheckScopeZone: + fields = append(fields, doc.DomainOpts...) + case happydns.CheckScopeService: + fields = append(fields, doc.ServiceOpts...) + } + return fields +} + +// ValidateOptions validates checker options against the checker's field definitions +// for the given scope level, and any OptionsValidator interface implemented by rules. +// When withRunOpts is true, RunOpts fields are also included so that required run-time +// options are enforced (used at trigger time). For persisted scopes, pass false. +func (u *CheckerOptionsUsecase) ValidateOptions( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + opts happydns.CheckerOptions, + withRunOpts bool, +) error { + def := checkerPkg.FindChecker(checkerName) + if def == nil { + return fmt.Errorf("checker %q not found", checkerName) + } + + scope := scopeFromIdentifiers(userId, domainId, serviceId) + + // Collect fields for this scope from the checker definition. + // When withRunOpts is true (trigger time), also include all persisted-scope + // fields so that options already stored at a different scope level (e.g. + // admin-level options merged into the final opts map) are not rejected as + // unknown. + var allFields []happydns.CheckerOptionDocumentation + if withRunOpts { + allFields = append(allFields, def.Options.AdminOpts...) + allFields = append(allFields, def.Options.UserOpts...) + allFields = append(allFields, def.Options.DomainOpts...) + allFields = append(allFields, def.Options.ServiceOpts...) + allFields = append(allFields, def.Options.RunOpts...) + } else { + allFields = collectFieldsForScope(def.Options, scope) + } + + // Collect fields from rules that declare their own options at this scope. + for _, rule := range def.Rules { + if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok { + ruleDoc := rwo.Options() + if withRunOpts { + allFields = append(allFields, ruleDoc.AdminOpts...) + allFields = append(allFields, ruleDoc.UserOpts...) + allFields = append(allFields, ruleDoc.DomainOpts...) + allFields = append(allFields, ruleDoc.ServiceOpts...) + allFields = append(allFields, ruleDoc.RunOpts...) + } else { + allFields = append(allFields, collectFieldsForScope(ruleDoc, scope)...) + } + } + } + + // Filter out auto-fill fields — they are system-provided at runtime + // and should not be validated against user input. + autoFillIds := getAutoFillFieldIds(def) + var validatableFields []happydns.CheckerOptionDocumentation + for _, f := range allFields { + if _, isAutoFill := autoFillIds[f.Id]; !isAutoFill { + validatableFields = append(validatableFields, f) + } + } + + // Validate against field definitions. ValidateMapValues lives in the + // forms package and works with happydns.Field; CheckerOptionDocumentation + // is structurally identical so an element-wise conversion is enough. + if len(validatableFields) > 0 { + asFields := make([]happydns.Field, len(validatableFields)) + for i, opt := range validatableFields { + asFields[i] = happydns.FieldFromCheckerOption(opt) + } + if err := forms.ValidateMapValues(opts, asFields); err != nil { + return err + } + } + + // Check if any rule implements OptionsValidator. + for _, rule := range def.Rules { + if v, ok := rule.(happydns.OptionsValidator); ok { + if err := v.ValidateOptions(opts); err != nil { + return err + } + } + } + + return nil +} + +// SetCheckerOption sets a single option value at the given scope level. +// If value is nil or empty string, the key is deleted from the scope. +func (u *CheckerOptionsUsecase) SetCheckerOption( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + 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 + } + if isEmptyValue(value) { + delete(existing, optName) + } else { + existing[optName] = value + } + return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, existing) +} + +// collectAutoFillFromDoc scans all option groups in a CheckerOptionsDocumentation +// and adds any fields with AutoFill set to the result map. +func collectAutoFillFromDoc(doc happydns.CheckerOptionsDocumentation, result map[string]string) { + for _, group := range [][]happydns.CheckerOptionDocumentation{ + doc.AdminOpts, + doc.UserOpts, + doc.DomainOpts, + doc.ServiceOpts, + doc.RunOpts, + } { + for _, f := range group { + if f.AutoFill != "" { + result[f.Id] = f.AutoFill + } + } + } +} + +// getAutoFillFieldIds returns a set of field IDs that have AutoFill set +// for the given checker definition across all option groups. +func getAutoFillFieldIds(def *happydns.CheckerDefinition) map[string]string { + result := make(map[string]string) + collectAutoFillFromDoc(def.Options, result) + for _, rule := range def.Rules { + if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok { + collectAutoFillFromDoc(rwo.Options(), result) + } + } + 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.CheckerOptionDocumentation{ + 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( + target happydns.CheckTarget, +) (map[string]any, error) { + ctx := make(map[string]any) + if u.autoFillStore == nil { + return ctx, nil + } + + domainId := happydns.TargetIdentifier(target.DomainId) + if domainId == nil { + return ctx, nil + } + + domain, err := u.autoFillStore.GetDomain(*domainId) + if err != nil { + return ctx, fmt.Errorf("loading domain for auto-fill: %w", err) + } + + ctx[happydns.AutoFillDomainName] = domain.DomainName + + // Load the latest zone from domain history. + if len(domain.ZoneHistory) == 0 { + return ctx, nil + } + + latestZoneId := domain.ZoneHistory[len(domain.ZoneHistory)-1] + zone, err := u.autoFillStore.GetZone(latestZoneId) + if err != nil { + return ctx, fmt.Errorf("loading zone for auto-fill: %w", err) + } + ctx[happydns.AutoFillZone] = zone + + // Resolve service if target has a ServiceId. + // Search from the most recent zone backwards through history, + // since the service may not exist in the latest zone if it was + // updated or reimported. + if serviceId := happydns.TargetIdentifier(target.ServiceId); serviceId != nil { + for i := len(domain.ZoneHistory) - 1; i >= 0; i-- { + z := zone + if i < len(domain.ZoneHistory)-1 { + z, err = u.autoFillStore.GetZone(domain.ZoneHistory[i]) + if err != nil { + continue + } + } + for subdomain, services := range z.Services { + for _, svc := range services { + if svc.Id.Equals(*serviceId) { + ctx[happydns.AutoFillSubdomain] = string(subdomain) + ctx[happydns.AutoFillServiceType] = svc.Type + ctx[happydns.AutoFillService] = svc + return ctx, nil + } + } + } + } + } + + return ctx, nil +} + +// resolveAutoFill looks up the checker definition, scans its fields for AutoFill +// attributes, builds the execution context from storage, and returns a map of +// field ID to resolved value. Returns an empty map (not nil) when there is +// nothing to fill. +func (u *CheckerOptionsUsecase) resolveAutoFill( + checkerName string, + target happydns.CheckTarget, +) (happydns.CheckerOptions, error) { + def := checkerPkg.FindChecker(checkerName) + if def == nil { + return make(happydns.CheckerOptions), nil + } + + autoFillFields := getAutoFillFieldIds(def) + if len(autoFillFields) == 0 { + return make(happydns.CheckerOptions), nil + } + + ctx, err := u.buildAutoFillContext(target) + if err != nil { + return nil, err + } + + result := make(happydns.CheckerOptions, len(autoFillFields)) + for fieldId, autoFillKey := range autoFillFields { + if val, ok := ctx[autoFillKey]; ok { + result[fieldId] = val + } + } + return result, nil +} + +// BuildMergedCheckerOptionsWithAutoFill merges stored options, runtime overrides, +// and auto-fill values. Auto-fill values are applied last and always win. +func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill( + checkerName string, + userId *happydns.Identifier, + domainId *happydns.Identifier, + serviceId *happydns.Identifier, + runOpts happydns.CheckerOptions, +) (happydns.CheckerOptions, error) { + storedOpts, err := u.GetCheckerOptions(checkerName, userId, domainId, serviceId) + if err != nil { + return nil, err + } + + 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: happydns.FormatIdentifier(userId), + DomainId: happydns.FormatIdentifier(domainId), + ServiceId: happydns.FormatIdentifier(serviceId), + } + + autoFilled, err := u.resolveAutoFill(checkerName, target) + if err != nil { + return nil, err + } + maps.Copy(merged, autoFilled) + + return merged, nil +} diff --git a/internal/usecase/checker/checker_options_usecase_test.go b/internal/usecase/checker/checker_options_usecase_test.go new file mode 100644 index 00000000..5b48cd7f --- /dev/null +++ b/internal/usecase/checker/checker_options_usecase_test.go @@ -0,0 +1,1442 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// --- helpers --- + +func idPtr() *happydns.Identifier { + id, _ := happydns.NewRandomIdentifier() + return &id +} + +// optionsStore is a minimal in-memory CheckerOptionsStorage that supports +// multi-scope positional lookup and update. +type optionsStore struct { + // key: "checker|userId|domainId|serviceId" + data map[string]happydns.CheckerOptions +} + +func newOptionsStore() *optionsStore { + return &optionsStore{data: make(map[string]happydns.CheckerOptions)} +} + +func posKey(checkerName string, userId, domainId, serviceId *happydns.Identifier) string { + f := func(id *happydns.Identifier) string { + if id == nil { + return "" + } + return id.String() + } + return checkerName + "|" + f(userId) + "|" + f(domainId) + "|" + f(serviceId) +} + +func (s *optionsStore) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) { + return nil, nil +} +func (s *optionsStore) ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error) { + return nil, nil +} + +// GetCheckerConfiguration returns positionals from least to most specific. +// It constructs the hierarchy: admin -> user -> domain -> service. +func (s *optionsStore) GetCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error) { + var result []*happydns.CheckerOptionsPositional + + // admin level + if opts, ok := s.data[posKey(checkerName, nil, nil, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, Options: opts, + }) + } + // user level + if userId != nil { + if opts, ok := s.data[posKey(checkerName, userId, nil, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, Options: opts, + }) + } + } + // domain level + if domainId != nil { + if opts, ok := s.data[posKey(checkerName, userId, domainId, nil)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, DomainId: domainId, Options: opts, + }) + } + } + // service level + if serviceId != nil { + if opts, ok := s.data[posKey(checkerName, userId, domainId, serviceId)]; ok { + result = append(result, &happydns.CheckerOptionsPositional{ + CheckName: checkerName, UserId: userId, DomainId: domainId, ServiceId: serviceId, Options: opts, + }) + } + } + + return result, nil +} + +func (s *optionsStore) UpdateCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error { + s.data[posKey(checkerName, userId, domainId, serviceId)] = opts + return nil +} + +func (s *optionsStore) DeleteCheckerConfiguration(checkerName string, userId, domainId, serviceId *happydns.Identifier) error { + delete(s.data, posKey(checkerName, userId, domainId, serviceId)) + return nil +} + +func (s *optionsStore) ClearCheckerConfigurations() error { + s.data = make(map[string]happydns.CheckerOptions) + return nil +} + +// --- test rule/checker types --- + +// validatingRule is a CheckRule that also implements OptionsValidator. +type validatingRule struct { + name string + validateErr error +} + +func (r *validatingRule) Name() string { return r.name } +func (r *validatingRule) Description() string { return "validating rule" } +func (r *validatingRule) Evaluate(_ context.Context, _ happydns.ObservationGetter, _ happydns.CheckerOptions) happydns.CheckState { + return happydns.CheckState{Status: happydns.StatusOK} +} +func (r *validatingRule) ValidateOptions(_ happydns.CheckerOptions) error { + return r.validateErr +} + +// ruleWithOptions is a CheckRule that implements CheckRuleWithOptions. +type ruleWithOptions struct { + name string + opts happydns.CheckerOptionsDocumentation +} + +func (r *ruleWithOptions) Name() string { return r.name } +func (r *ruleWithOptions) Description() string { return "rule with options" } +func (r *ruleWithOptions) Evaluate(_ context.Context, _ happydns.ObservationGetter, _ happydns.CheckerOptions) happydns.CheckState { + return happydns.CheckState{Status: happydns.StatusOK} +} +func (r *ruleWithOptions) Options() happydns.CheckerOptionsDocumentation { + return r.opts +} + +// --- CRUD tests --- + +func TestSetAndGetCheckerOptions(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + opts := happydns.CheckerOptions{"key1": "value1", "key2": float64(42)} + + if err := uc.SetCheckerOptions("c1", uid, nil, nil, opts); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if got["key1"] != "value1" || got["key2"] != float64(42) { + t.Errorf("unexpected options: %v", got) + } +} + +func TestSetCheckerOptions_FiltersEmptyValues(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + opts := happydns.CheckerOptions{"keep": "yes", "drop_nil": nil, "drop_empty": ""} + if err := uc.SetCheckerOptions("c1", nil, nil, nil, opts); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["drop_nil"]; ok { + t.Error("nil value should have been filtered") + } + if _, ok := got["drop_empty"]; ok { + t.Error("empty string value should have been filtered") + } + if got["keep"] != "yes" { + t.Error("non-empty value should be kept") + } +} + +func TestAddCheckerOptions_MergesIntoExisting(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "updated", "c": "3"}) + if err != nil { + t.Fatal(err) + } + if merged["a"] != "1" { + t.Errorf("existing key 'a' should be preserved, got %v", merged["a"]) + } + if merged["b"] != "updated" { + t.Errorf("key 'b' should be updated, got %v", merged["b"]) + } + if merged["c"] != "3" { + t.Errorf("key 'c' should be added, got %v", merged["c"]) + } +} + +func TestAddCheckerOptions_DeletesEmptyValues(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": nil, "b": ""}) + if err != nil { + t.Fatal(err) + } + if _, ok := merged["a"]; ok { + t.Error("nil value should delete the key") + } + if _, ok := merged["b"]; ok { + t.Error("empty string value should delete the key") + } +} + +func TestGetCheckerOption_SingleKey(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"x": "hello"}) + + val, err := uc.GetCheckerOption("c1", uid, nil, nil, "x") + if err != nil { + t.Fatal(err) + } + if val != "hello" { + t.Errorf("expected 'hello', got %v", val) + } + + val, err = uc.GetCheckerOption("c1", uid, nil, nil, "missing") + if err != nil { + t.Fatal(err) + } + if val != nil { + t.Errorf("expected nil for missing key, got %v", val) + } +} + +func TestSetCheckerOption_SingleKey(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1"}) + + if err := uc.SetCheckerOption("c1", uid, nil, nil, "b", "2"); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if got["a"] != "1" || got["b"] != "2" { + t.Errorf("unexpected options: %v", got) + } +} + +func TestSetCheckerOption_DeletesEmpty(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"a": "1", "b": "2"}) + + if err := uc.SetCheckerOption("c1", uid, nil, nil, "a", nil); err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("c1", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["a"]; ok { + t.Error("key 'a' should have been deleted") + } + if got["b"] != "2" { + t.Error("key 'b' should be preserved") + } +} + +// --- Scope merging tests --- + +func TestGetCheckerOptions_MergesScopes(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Admin sets defaults. + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"a": "admin", "shared": "admin"}) + // User overrides shared. + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "user", "shared": "user"}) + // Domain overrides shared again. + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"c": "domain", "shared": "domain"}) + + got, err := uc.GetCheckerOptions("c1", uid, did, nil) + if err != nil { + t.Fatal(err) + } + + if got["a"] != "admin" { + t.Errorf("admin key 'a' should be visible, got %v", got["a"]) + } + if got["b"] != "user" { + t.Errorf("user key 'b' should be visible, got %v", got["b"]) + } + if got["c"] != "domain" { + t.Errorf("domain key 'c' should be visible, got %v", got["c"]) + } + if got["shared"] != "domain" { + t.Errorf("'shared' should be overridden to 'domain', got %v", got["shared"]) + } +} + +func TestGetCheckerOptions_ServiceScopeOverridesAll(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"key": "admin"}) + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"key": "user"}) + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"key": "domain"}) + _ = uc.SetCheckerOptions("c1", uid, did, sid, happydns.CheckerOptions{"key": "service"}) + + got, err := uc.GetCheckerOptions("c1", uid, did, sid) + if err != nil { + t.Fatal(err) + } + if got["key"] != "service" { + t.Errorf("service scope should win, got %v", got["key"]) + } +} + +func TestGetCheckerOptionsPositional_ReturnsAllLevels(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + _ = uc.SetCheckerOptions("c1", nil, nil, nil, happydns.CheckerOptions{"a": "1"}) + _ = uc.SetCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"b": "2"}) + _ = uc.SetCheckerOptions("c1", uid, did, nil, happydns.CheckerOptions{"c": "3"}) + + positionals, err := uc.GetCheckerOptionsPositional("c1", uid, did, nil) + if err != nil { + t.Fatal(err) + } + if len(positionals) != 3 { + t.Fatalf("expected 3 positional levels, got %d", len(positionals)) + } + // Least specific first. + if positionals[0].UserId != nil { + t.Error("first positional should be admin (no userId)") + } + if positionals[1].DomainId != nil { + t.Error("second positional should be user (no domainId)") + } + if positionals[2].DomainId == nil { + t.Error("third positional should be domain level") + } +} + +func TestGetCheckerOptions_EmptyStore(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + got, err := uc.GetCheckerOptions("nonexistent", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("expected empty options, got %v", got) + } +} + +func TestAddCheckerOptions_CreatesNewScope(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + merged, err := uc.AddCheckerOptions("c1", uid, nil, nil, happydns.CheckerOptions{"new": "value"}) + if err != nil { + t.Fatal(err) + } + if merged["new"] != "value" { + t.Errorf("expected 'value', got %v", merged["new"]) + } +} + +// --- BuildMergedCheckerOptions tests --- + +func TestBuildMergedCheckerOptions(t *testing.T) { + stored := happydns.CheckerOptions{"a": "stored", "shared": "stored"} + run := happydns.CheckerOptions{"b": "run", "shared": "run"} + + result := checkerUC.BuildMergedCheckerOptions(stored, run) + if result["a"] != "stored" { + t.Errorf("stored key should be preserved") + } + if result["b"] != "run" { + t.Errorf("run key should be added") + } + if result["shared"] != "run" { + t.Errorf("run should override stored, got %v", result["shared"]) + } +} + +func TestBuildMergedCheckerOptions_NilInputs(t *testing.T) { + result := checkerUC.BuildMergedCheckerOptions(nil, nil) + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + + result = checkerUC.BuildMergedCheckerOptions(happydns.CheckerOptions{"a": "1"}, nil) + if result["a"] != "1" { + t.Errorf("stored key should be preserved with nil runOpts") + } + + result = checkerUC.BuildMergedCheckerOptions(nil, happydns.CheckerOptions{"b": "2"}) + if result["b"] != "2" { + t.Errorf("run key should be present with nil storedOpts") + } +} + +// --- Validation tests --- + +// registerTestChecker is a helper that registers a checker in the global +// registry and returns its ID. Each call should use a unique ID. +func registerTestChecker(id string, def *happydns.CheckerDefinition) { + def.ID = id + def.Name = id + checker.RegisterChecker(def) +} + +func TestValidateOptions_UnknownChecker(t *testing.T) { + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("no_such_checker", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error for unknown checker") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateOptions_AdminScope_AcceptsAdminOpts(t *testing.T) { + registerTestChecker("val_admin_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Admin scope (all nil) — admin_key is valid. + err := uc.ValidateOptions("val_admin_ok", nil, nil, nil, happydns.CheckerOptions{"admin_key": "hello"}, false) + if err != nil { + t.Fatalf("expected no error for valid admin opt, got: %v", err) + } +} + +func TestValidateOptions_AdminScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_admin_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Admin scope — domain_key should be rejected as unknown. + err := uc.ValidateOptions("val_admin_reject_domain", nil, nil, nil, happydns.CheckerOptions{"domain_key": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at admin scope") + } + if !strings.Contains(err.Error(), "unknown") { + t.Errorf("expected 'unknown' error, got: %v", err) + } +} + +func TestValidateOptions_DomainScope_AcceptsDomainOpts(t *testing.T) { + registerTestChecker("val_domain_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("val_domain_ok", uid, did, nil, happydns.CheckerOptions{"domain_key": "hello"}, false) + if err != nil { + t.Fatalf("expected no error for valid domain opt, got: %v", err) + } +} + +func TestValidateOptions_DomainScope_RejectsAdminOpt(t *testing.T) { + registerTestChecker("val_domain_reject_admin", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_key", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("val_domain_reject_admin", uid, did, nil, happydns.CheckerOptions{"admin_key": "x"}, false) + if err == nil { + t.Fatal("expected error for admin opt at domain scope") + } +} + +func TestValidateOptions_UserScope_AcceptsUserOpts(t *testing.T) { + registerTestChecker("val_user_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + err := uc.ValidateOptions("val_user_ok", uid, nil, nil, happydns.CheckerOptions{"user_key": "val"}, false) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_ServiceScope_AcceptsServiceOpts(t *testing.T) { + registerTestChecker("val_service_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_service_ok", uid, did, sid, happydns.CheckerOptions{"svc_key": "val"}, false) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_ServiceScope_RejectsRunOpts(t *testing.T) { + registerTestChecker("val_service_reject_run", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_service_reject_run", uid, did, sid, happydns.CheckerOptions{"run_key": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt at service scope") + } +} + +func TestValidateOptions_DomainScope_RejectsRunOpts(t *testing.T) { + registerTestChecker("val_domain_reject_run", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + err := uc.ValidateOptions("val_domain_reject_run", uid, did, nil, happydns.CheckerOptions{"run_key": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt at domain scope") + } +} + +func TestValidateOptions_RequiredField(t *testing.T) { + registerTestChecker("val_required", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_have", Type: "string", Required: true, Label: "Must Have"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Missing required field. + err := uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error for missing required field") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + // Present but empty. + err = uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{"must_have": ""}, false) + if err == nil { + t.Fatal("expected error for empty required field") + } + + // Valid. + err = uc.ValidateOptions("val_required", uid, did, nil, happydns.CheckerOptions{"must_have": "ok"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_ChoicesField(t *testing.T) { + registerTestChecker("val_choices", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "mode", Type: "string", Choices: []string{"fast", "slow", "auto"}}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + + err := uc.ValidateOptions("val_choices", uid, nil, nil, happydns.CheckerOptions{"mode": "fast"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = uc.ValidateOptions("val_choices", uid, nil, nil, happydns.CheckerOptions{"mode": "invalid"}, false) + if err == nil { + t.Fatal("expected error for invalid choice") + } +} + +func TestValidateOptions_TypeCheckNumber(t *testing.T) { + registerTestChecker("val_type_num", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "count", Type: "int"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // float64 is fine (JSON numbers are float64). + err := uc.ValidateOptions("val_type_num", uid, did, nil, happydns.CheckerOptions{"count": float64(10)}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // String is not a number. + err = uc.ValidateOptions("val_type_num", uid, did, nil, happydns.CheckerOptions{"count": "ten"}, false) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestValidateOptions_TypeCheckBool(t *testing.T) { + registerTestChecker("val_type_bool", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "enabled", Type: "bool"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_type_bool", nil, nil, nil, happydns.CheckerOptions{"enabled": true}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = uc.ValidateOptions("val_type_bool", nil, nil, nil, happydns.CheckerOptions{"enabled": "true"}, false) + if err == nil { + t.Fatal("expected error for string instead of bool") + } +} + +func TestValidateOptions_EmptyOptionsValid(t *testing.T) { + registerTestChecker("val_empty_ok", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "optional_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + err := uc.ValidateOptions("val_empty_ok", uid, did, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("empty options should be valid when no required fields, got: %v", err) + } +} + +func TestValidateOptions_NoFieldsAtScope_AcceptsEmpty(t *testing.T) { + registerTestChecker("val_no_fields_scope", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // At admin scope there are no fields defined — empty opts should pass. + err := uc.ValidateOptions("val_no_fields_scope", nil, nil, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("empty options at scope with no fields should be valid, got: %v", err) + } +} + +func TestValidateOptions_NoFieldsAtScope_AcceptsAnything(t *testing.T) { + // When no fields are defined at the target scope, validation is skipped + // (the OptionsValidator may still reject), so unknown keys at a scope + // without field definitions pass through. + registerTestChecker("val_no_fields_pass", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // At user scope, no fields are declared, so any key is accepted. + uid := idPtr() + err := uc.ValidateOptions("val_no_fields_pass", uid, nil, nil, happydns.CheckerOptions{"anything": "value"}, false) + if err != nil { + t.Fatalf("scope with no fields should skip validation, got: %v", err) + } +} + +// --- OptionsValidator interface tests --- + +func TestValidateOptions_OptionsValidatorCalled(t *testing.T) { + registerTestChecker("val_validator_err", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: fmt.Errorf("custom validation failed")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_validator_err", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error from OptionsValidator") + } + if !strings.Contains(err.Error(), "custom validation failed") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateOptions_OptionsValidatorPasses(t *testing.T) { + registerTestChecker("val_validator_ok", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: nil}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_validator_ok", nil, nil, nil, happydns.CheckerOptions{}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_MultipleValidators_StopsAtFirst(t *testing.T) { + registerTestChecker("val_multi_validators", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: nil}, + &validatingRule{name: "r2", validateErr: fmt.Errorf("r2 failed")}, + &validatingRule{name: "r3", validateErr: fmt.Errorf("r3 failed")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_multi_validators", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error from second validator") + } + if !strings.Contains(err.Error(), "r2 failed") { + t.Errorf("expected r2 error, got: %v", err) + } +} + +// --- Rule-level options tests --- + +func TestValidateOptions_RuleOptionsAtCorrectScope(t *testing.T) { + registerTestChecker("val_rule_opts", &happydns.CheckerDefinition{ + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_with_domain_opt", + opts: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_domain_opt", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_run_opt", Type: "string"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Domain scope should accept rule's domain opt. + err := uc.ValidateOptions("val_rule_opts", uid, did, nil, happydns.CheckerOptions{"rule_domain_opt": "val"}, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Domain scope should reject rule's run opt. + err = uc.ValidateOptions("val_rule_opts", uid, did, nil, happydns.CheckerOptions{"rule_run_opt": "val"}, false) + if err == nil { + t.Fatal("expected error for run opt from rule at domain scope") + } +} + +func TestValidateOptions_CombinesDefAndRuleFields(t *testing.T) { + registerTestChecker("val_combined", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "def_opt", Type: "string"}, + }, + }, + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_extra", + opts: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_opt", Type: "string"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Both def and rule opts should be accepted. + err := uc.ValidateOptions("val_combined", uid, did, nil, happydns.CheckerOptions{ + "def_opt": "a", + "rule_opt": "b", + }, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Unknown key should be rejected. + err = uc.ValidateOptions("val_combined", uid, did, nil, happydns.CheckerOptions{"unknown": "x"}, false) + if err == nil { + t.Fatal("expected error for unknown key") + } +} + +// --- Validation + OptionsValidator combined --- + +func TestValidateOptions_FieldValidationRunsBeforeOptionsValidator(t *testing.T) { + registerTestChecker("val_order", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "a", Type: "string", Required: true, Label: "A"}, + }, + }, + Rules: []happydns.CheckRule{ + &validatingRule{name: "r1", validateErr: fmt.Errorf("should not reach here")}, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + // Field validation should fail before reaching OptionsValidator. + err := uc.ValidateOptions("val_order", nil, nil, nil, happydns.CheckerOptions{}, false) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "should not reach here") { + t.Error("OptionsValidator should not have been called — field validation should fail first") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } +} + +// --- Scope isolation tests --- + +func TestValidateOptions_DomainScope_DoesNotEnforceUserRequired(t *testing.T) { + registerTestChecker("val_scope_isolation", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_required", Type: "string", Required: true}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Domain scope should not enforce user-level required field. + err := uc.ValidateOptions("val_scope_isolation", uid, did, nil, happydns.CheckerOptions{"domain_opt": "val"}, false) + if err != nil { + t.Fatalf("domain scope should not enforce user required field, got: %v", err) + } +} + +func TestValidateOptions_AdminScope_DoesNotEnforceServiceRequired(t *testing.T) { + registerTestChecker("val_admin_no_svc", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + AdminOpts: []happydns.CheckerOptionDocumentation{ + {Id: "admin_opt", Type: "string"}, + }, + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_required", Type: "string", Required: true}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + err := uc.ValidateOptions("val_admin_no_svc", nil, nil, nil, happydns.CheckerOptions{"admin_opt": "val"}, false) + if err != nil { + t.Fatalf("admin scope should not enforce service required field, got: %v", err) + } +} + +func TestValidateOptions_UserScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_user_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + UserOpts: []happydns.CheckerOptionDocumentation{ + {Id: "user_opt", Type: "string"}, + }, + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + err := uc.ValidateOptions("val_user_reject_domain", uid, nil, nil, happydns.CheckerOptions{"domain_opt": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at user scope") + } +} + +func TestValidateOptions_ServiceScope_RejectsDomainOpt(t *testing.T) { + registerTestChecker("val_svc_reject_domain", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_opt", Type: "string"}, + }, + ServiceOpts: []happydns.CheckerOptionDocumentation{ + {Id: "svc_opt", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + sid := idPtr() + err := uc.ValidateOptions("val_svc_reject_domain", uid, did, sid, happydns.CheckerOptions{"domain_opt": "x"}, false) + if err == nil { + t.Fatal("expected error for domain opt at service scope") + } +} + +// --- withRunOpts=true tests --- + +func TestValidateOptions_WithRunOpts_AcceptsRunOptKeys(t *testing.T) { + registerTestChecker("trig_run_accept", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // With withRunOpts=true, run_key should be accepted alongside domain_key. + err := uc.ValidateOptions("trig_run_accept", uid, did, nil, happydns.CheckerOptions{ + "domain_key": "foo", + "run_key": "bar", + }, true) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_EnforcesRequiredRunOpt(t *testing.T) { + registerTestChecker("trig_run_required", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_run", Type: "string", Required: true, Label: "Must Run"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // Missing required run opt. + err := uc.ValidateOptions("trig_run_required", uid, did, nil, happydns.CheckerOptions{}, true) + if err == nil { + t.Fatal("expected error for missing required run opt") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + // Present and non-empty. + err = uc.ValidateOptions("trig_run_required", uid, did, nil, happydns.CheckerOptions{"must_run": "ok"}, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_StillRejectsUnknownKeys(t *testing.T) { + registerTestChecker("trig_run_unknown", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "run_key", Type: "string"}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + err := uc.ValidateOptions("trig_run_unknown", uid, did, nil, happydns.CheckerOptions{"totally_unknown": "x"}, true) + if err == nil { + t.Fatal("expected error for unknown key even with withRunOpts=true") + } +} + +func TestValidateOptions_WithRunOpts_RequiredRunOptNotEnforcedWhenFalse(t *testing.T) { + registerTestChecker("trig_run_not_enforced", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_key", Type: "string"}, + }, + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "must_run", Type: "string", Required: true}, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // withRunOpts=false: required run opt is not enforced, run_key is not known. + err := uc.ValidateOptions("trig_run_not_enforced", uid, did, nil, happydns.CheckerOptions{"domain_key": "val"}, false) + if err != nil { + t.Fatalf("persisted scope should not enforce run opt required field, got: %v", err) + } +} + +func TestValidateOptions_WithRunOpts_RuleRunOptsAccepted(t *testing.T) { + registerTestChecker("trig_rule_run_accept", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "def_domain_opt", Type: "string"}, + }, + }, + Rules: []happydns.CheckRule{ + &ruleWithOptions{ + name: "rule_with_run", + opts: happydns.CheckerOptionsDocumentation{ + RunOpts: []happydns.CheckerOptionDocumentation{ + {Id: "rule_run_opt", Type: "string", Required: true, Label: "Rule Run Opt"}, + }, + }, + }, + }, + }) + + store := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(store, nil) + + uid := idPtr() + did := idPtr() + + // withRunOpts=true: rule run opt is accepted and required. + err := uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "def_domain_opt": "x", + }, true) + if err == nil { + t.Fatal("expected error: rule's required run opt is missing") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("expected 'required' error, got: %v", err) + } + + err = uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "def_domain_opt": "x", + "rule_run_opt": "y", + }, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // withRunOpts=false: rule run opt is unknown (rejected). + err = uc.ValidateOptions("trig_rule_run_accept", uid, did, nil, happydns.CheckerOptions{ + "rule_run_opt": "y", + }, false) + if err == nil { + t.Fatal("expected error: rule run opt should be unknown at domain scope without withRunOpts") + } +} + +// --- Auto-fill tests --- + +// autoFillStore is a minimal in-memory store satisfying CheckAutoFillStorage. +type autoFillStore struct { + domains map[string]*happydns.Domain + zones map[string]*happydns.ZoneMessage + users map[string]*happydns.User +} + +func newAutoFillStore() *autoFillStore { + return &autoFillStore{ + domains: make(map[string]*happydns.Domain), + zones: make(map[string]*happydns.ZoneMessage), + users: make(map[string]*happydns.User), + } +} + +func (s *autoFillStore) GetDomain(id happydns.Identifier) (*happydns.Domain, error) { + if d, ok := s.domains[id.String()]; ok { + return d, nil + } + return nil, fmt.Errorf("domain %s not found", id) +} + +func (s *autoFillStore) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) { + if z, ok := s.zones[id.String()]; ok { + return z, nil + } + return nil, fmt.Errorf("zone %s not found", id) +} + +func (s *autoFillStore) ListDomains(u *happydns.User) ([]*happydns.Domain, error) { + return nil, nil +} + +func (s *autoFillStore) GetUser(id happydns.Identifier) (*happydns.User, error) { + if u, ok := s.users[id.String()]; ok { + return u, nil + } + return nil, fmt.Errorf("user %s not found", id) +} + +func TestBuildMergedCheckerOptionsWithAutoFill_InjectsValues(t *testing.T) { + registerTestChecker("af_inject", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "domain_name_field", Type: "string", AutoFill: happydns.AutoFillDomainName}, + {Id: "user_opt", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + afStore := newAutoFillStore() + + uid := idPtr() + did := idPtr() + + // Set up domain in auto-fill store. + zoneId, _ := happydns.NewRandomIdentifier() + afStore.domains[did.String()] = &happydns.Domain{ + Id: *did, + Owner: *uid, + DomainName: "example.com.", + ZoneHistory: []happydns.Identifier{zoneId}, + } + afStore.zones[zoneId.String()] = &happydns.ZoneMessage{} + + uc := checkerUC.NewCheckerOptionsUsecase(optStore, afStore) + _ = uc.SetCheckerOptions("af_inject", uid, nil, nil, happydns.CheckerOptions{"user_opt": "hello"}) + + merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("af_inject", uid, did, nil, nil) + if err != nil { + t.Fatal(err) + } + + if merged["domain_name_field"] != "example.com." { + t.Errorf("expected auto-filled domain name, got %v", merged["domain_name_field"]) + } + if merged["user_opt"] != "hello" { + t.Errorf("expected stored opt to be preserved, got %v", merged["user_opt"]) + } +} + +func TestBuildMergedCheckerOptionsWithAutoFill_OverridesRunOpts(t *testing.T) { + registerTestChecker("af_override", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName}, + }, + }, + }) + + optStore := newOptionsStore() + afStore := newAutoFillStore() + + uid := idPtr() + did := idPtr() + zoneId, _ := happydns.NewRandomIdentifier() + afStore.domains[did.String()] = &happydns.Domain{ + Id: *did, + Owner: *uid, + DomainName: "real.example.com.", + ZoneHistory: []happydns.Identifier{zoneId}, + } + afStore.zones[zoneId.String()] = &happydns.ZoneMessage{} + + uc := checkerUC.NewCheckerOptionsUsecase(optStore, afStore) + + // Even if runOpts tries to set the auto-fill field, auto-fill wins. + merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("af_override", uid, did, nil, + happydns.CheckerOptions{"dn": "user-provided.com."}) + if err != nil { + t.Fatal(err) + } + + if merged["dn"] != "real.example.com." { + t.Errorf("auto-fill should override run opts, got %v", merged["dn"]) + } +} + +func TestSetCheckerOptions_StripsAutoFillKeys(t *testing.T) { + registerTestChecker("af_strip", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName}, + {Id: "normal", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(optStore, nil) + + uid := idPtr() + err := uc.SetCheckerOptions("af_strip", uid, nil, nil, happydns.CheckerOptions{ + "dn": "should-be-stripped", + "normal": "kept", + }) + if err != nil { + t.Fatal(err) + } + + got, err := uc.GetCheckerOptions("af_strip", uid, nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := got["dn"]; ok { + t.Error("auto-fill key should have been stripped from persisted options") + } + if got["normal"] != "kept" { + t.Errorf("normal key should be preserved, got %v", got["normal"]) + } +} + +func TestValidateOptions_SkipsAutoFillFields(t *testing.T) { + registerTestChecker("af_validate_skip", &happydns.CheckerDefinition{ + Options: happydns.CheckerOptionsDocumentation{ + DomainOpts: []happydns.CheckerOptionDocumentation{ + {Id: "dn", Type: "string", AutoFill: happydns.AutoFillDomainName, Required: true}, + {Id: "normal", Type: "string"}, + }, + }, + }) + + optStore := newOptionsStore() + uc := checkerUC.NewCheckerOptionsUsecase(optStore, nil) + + uid := idPtr() + did := idPtr() + + // The auto-fill field "dn" is required, but since it's auto-filled, + // validation should not enforce it as a user-provided requirement. + err := uc.ValidateOptions("af_validate_skip", uid, did, nil, happydns.CheckerOptions{ + "normal": "val", + }, false) + if err != nil { + t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err) + } +} diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go new file mode 100644 index 00000000..c72f697a --- /dev/null +++ b/internal/usecase/checker/scheduler.go @@ -0,0 +1,579 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "container/heap" + "context" + "hash/fnv" + "log" + "slices" + "sort" + "sync" + "time" + + checkerPkg "git.happydns.org/happyDomain/internal/checker" + "git.happydns.org/happyDomain/model" +) + +const ( + minSpacing = 2 * time.Second + maxCatchUpWindow = 10 * time.Minute + defaultInterval = 24 * time.Hour +) + +// SchedulerJob represents a single scheduled checker execution. +type SchedulerJob struct { + CheckerID string `json:"checkerID"` + Target happydns.CheckTarget `json:"target"` + PlanID *happydns.Identifier `json:"planID" swaggertype:"string"` + Interval time.Duration `json:"interval" swaggertype:"integer"` + NextRun time.Time `json:"nextRun"` + index int // heap index +} + +// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun. +type SchedulerQueue []*SchedulerJob + +func (q SchedulerQueue) Len() int { return len(q) } +func (q SchedulerQueue) Less(i, j int) bool { return q[i].NextRun.Before(q[j].NextRun) } +func (q SchedulerQueue) Swap(i, j int) { + q[i], q[j] = q[j], q[i] + q[i].index = i + q[j].index = j +} + +func (q *SchedulerQueue) Push(x any) { + n := len(*q) + job := x.(*SchedulerJob) + job.index = n + *q = append(*q, job) +} + +func (q *SchedulerQueue) Pop() any { + old := *q + n := len(old) + job := old[n-1] + old[n-1] = nil + job.index = -1 + *q = old[:n-1] + return job +} + +func (q *SchedulerQueue) Peek() *SchedulerJob { + if len(*q) == 0 { + return nil + } + return (*q)[0] +} + +// SchedulerStatus holds a snapshot of the scheduler's current state. +type SchedulerStatus struct { + Running bool `json:"running"` + JobCount int `json:"job_count"` + NextJobs []*SchedulerJob `json:"next_jobs,omitempty"` +} + +// Scheduler manages periodic execution of checkers. +type Scheduler struct { + queue SchedulerQueue + engine happydns.CheckerEngine + planStore CheckPlanStorage + domainStore DomainLister + zoneStore ZoneGetter + stateStore SchedulerStateStorage + cancel context.CancelFunc + mu sync.RWMutex + running bool + ctx context.Context + maxConcurrency int +} + +// NewScheduler creates a new Scheduler. +func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, zoneStore ZoneGetter, stateStore SchedulerStateStorage) *Scheduler { + if maxConcurrency <= 0 { + maxConcurrency = 1 + } + return &Scheduler{ + engine: engine, + planStore: planStore, + domainStore: domainStore, + zoneStore: zoneStore, + stateStore: stateStore, + maxConcurrency: maxConcurrency, + } +} + +// Start begins the scheduler loop in a goroutine. +func (s *Scheduler) Start(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + s.mu.Lock() + s.ctx = ctx + s.cancel = cancel + s.running = true + s.buildQueue() + s.spreadOverdueJobs() + s.mu.Unlock() + go s.run(ctx) +} + +// Stop halts the scheduler. +func (s *Scheduler) Stop() { + s.mu.Lock() + s.running = false + cancel := s.cancel + s.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// GetStatus returns a snapshot of the scheduler's current state. +func (s *Scheduler) GetStatus() SchedulerStatus { + s.mu.RLock() + defer s.mu.RUnlock() + + status := SchedulerStatus{ + Running: s.running, + JobCount: s.queue.Len(), + } + + n := min(20, s.queue.Len()) + if n > 0 { + all := make([]*SchedulerJob, s.queue.Len()) + copy(all, s.queue) + sort.Slice(all, func(i, j int) bool { + return all[i].NextRun.Before(all[j].NextRun) + }) + status.NextJobs = all[:n] + } + + return status +} + +// SetEnabled starts or stops the scheduler. +func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error { + s.Stop() + if enabled { + s.mu.Lock() + parentCtx := s.ctx + s.mu.Unlock() + if parentCtx == nil { + parentCtx = ctx + } + s.Start(parentCtx) + } + return nil +} + +// RebuildQueue rebuilds the scheduler queue and returns the new job count. +func (s *Scheduler) RebuildQueue() int { + s.mu.Lock() + defer s.mu.Unlock() + s.buildQueue() + s.spreadOverdueJobs() + return s.queue.Len() +} + +func (s *Scheduler) run(ctx context.Context) { + sem := make(chan struct{}, s.maxConcurrency) + + for { + s.mu.RLock() + qLen := s.queue.Len() + s.mu.RUnlock() + + if qLen == 0 { + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Minute): + s.mu.Lock() + s.buildQueue() + s.mu.Unlock() + continue + } + } + + s.mu.RLock() + next := s.queue.Peek() + var delay time.Duration + if next != nil { + delay = time.Until(next.NextRun) + } + s.mu.RUnlock() + + if delay > 0 { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + + s.mu.Lock() + if s.queue.Len() == 0 { + s.mu.Unlock() + continue + } + job := heap.Pop(&s.queue).(*SchedulerJob) + s.mu.Unlock() + + // Find plan if applicable. + var plan *happydns.CheckPlan + if job.PlanID != nil { + p, err := s.planStore.GetCheckPlan(*job.PlanID) + if err == nil { + plan = p + } + } + + // Acquire a concurrency slot, but stay responsive to cancellation. + select { + case sem <- struct{}{}: + default: + log.Printf("Scheduler: all %d workers busy, waiting for a slot (checker %s on %s)", s.maxConcurrency, job.CheckerID, job.Target.String()) + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + } + + go func(j *SchedulerJob, p *happydns.CheckPlan) { + defer func() { <-sem }() + log.Printf("Scheduler: running checker %s on %s", j.CheckerID, j.Target.String()) + exec, err := s.engine.CreateExecution(j.CheckerID, j.Target, p) + if err != nil { + log.Printf("Scheduler: checker %s on %s failed to create execution: %v", j.CheckerID, j.Target.String(), err) + return + } + _, err = s.engine.RunExecution(ctx, exec, p, nil) + if err != nil { + log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err) + } + if s.stateStore != nil { + if err := s.stateStore.SetLastSchedulerRun(time.Now()); err != nil { + log.Printf("Scheduler: failed to persist last run time: %v", err) + } + } + }(job, plan) + + // Advance to next cycle, skipping past cycles. + now := time.Now() + for job.NextRun.Before(now) { + job.NextRun = job.NextRun.Add(job.Interval) + } + // Add jitter for next cycle. + job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval)) + s.mu.Lock() + heap.Push(&s.queue, job) + s.mu.Unlock() + } +} + +func (s *Scheduler) buildQueue() { + s.queue = s.queue[:0] + + var lastRun time.Time + if s.stateStore != nil { + if t, err := s.stateStore.GetLastSchedulerRun(); err != nil { + log.Printf("Scheduler: failed to read last run time: %v", err) + } else { + lastRun = t + } + } + + checkers := checkerPkg.GetCheckers() + plans, err := s.loadAllPlans() + if err != nil { + log.Printf("Scheduler: failed to load plans: %v", err) + } + + // Build a set of disabled (checker, target) pairs. + 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 + } + } + + // 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 + domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()} + + for _, c := range domainCheckers { + key := c.id + "|" + domainTarget.String() + if disabledSet[key] { + continue + } + plan := planMap[key] + + interval := s.effectiveInterval(c.def, plan) + offset := computeOffset(c.id, domainTarget.String(), interval) + nextRun := computeNextRun(interval, offset, lastRun) + + job := &SchedulerJob{ + CheckerID: c.id, + Target: domainTarget, + Interval: interval, + NextRun: nextRun, + } + if plan != nil { + job.PlanID = &plan.Id + } + 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.String(), DomainId: did.String(), ServiceId: sid.String(), 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) + } + } + } + } +} + +func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) { + iter, err := s.planStore.ListAllCheckPlans() + if err != nil { + return nil, err + } + defer iter.Close() + + var plans []*happydns.CheckPlan + for iter.Next() { + plans = append(plans, iter.Item()) + } + return plans, nil +} + +func (s *Scheduler) loadAllDomains() []*happydns.Domain { + if s.domainStore == nil { + return nil + } + iter, err := s.domainStore.ListAllDomains() + if err != nil { + log.Printf("Scheduler: failed to list domains for auto-discovery: %v", err) + return nil + } + defer iter.Close() + + var domains []*happydns.Domain + for iter.Next() { + d := iter.Item() + domains = append(domains, d) + } + 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 { + interval = def.Interval.Default + } + + if plan != nil && plan.Interval != nil { + interval = *plan.Interval + } + + // Clamp to bounds. + if def.Interval != nil { + if interval < def.Interval.Min { + interval = def.Interval.Min + } + if interval > def.Interval.Max { + interval = def.Interval.Max + } + } + + return interval +} + +func (s *Scheduler) spreadOverdueJobs() { + now := time.Now() + var overdue []*SchedulerJob + + for s.queue.Len() > 0 && s.queue.Peek().NextRun.Before(now) { + overdue = append(overdue, heap.Pop(&s.queue).(*SchedulerJob)) + } + + if len(overdue) == 0 { + return + } + + window := time.Duration(len(overdue)) * minSpacing + window = min(window, maxCatchUpWindow) + + for i, job := range overdue { + delay := window * time.Duration(i) / time.Duration(len(overdue)) + job.NextRun = now.Add(delay) + heap.Push(&s.queue, job) + } +} + +// GetPlannedJobsForChecker returns a snapshot of scheduled jobs for the given checker and target. +func (s *Scheduler) GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob { + s.mu.RLock() + defer s.mu.RUnlock() + tStr := target.String() + var result []*SchedulerJob + for _, job := range s.queue { + if job.CheckerID == checkerID && job.Target.String() == tStr { + cp := *job + result = append(result, &cp) + } + } + return result +} + +// computeOffset returns a deterministic offset within the interval. +func computeOffset(checkerID, targetStr string, interval time.Duration) time.Duration { + h := fnv.New64a() + h.Write([]byte(checkerID + targetStr)) + return time.Duration(h.Sum64()%uint64(interval.Nanoseconds())) * time.Nanosecond +} + +// computeJitter returns a small deterministic jitter (~5% of interval). +func computeJitter(checkerID, targetStr string, cycleTime time.Time, interval time.Duration) time.Duration { + h := fnv.New64a() + h.Write([]byte(checkerID + targetStr + cycleTime.Format(time.RFC3339))) + maxJitter := interval / 20 // 5% + if maxJitter <= 0 { + return 0 + } + return time.Duration(h.Sum64()%uint64(maxJitter.Nanoseconds())) * time.Nanosecond +} + +// computeNextRun calculates the next run time based on interval, offset, and +// the last time the scheduler was known to be active. When lastActive is zero +// (first execution), it behaves as before. Otherwise it detects jobs that were +// missed during downtime (slot in (lastActive, now]) and schedules them +// immediately so spreadOverdueJobs can stagger them, while skipping jobs that +// already ran (slot <= lastActive). +func computeNextRun(interval, offset time.Duration, lastActive time.Time) time.Time { + now := time.Now() + + // Use Unix nanoseconds to avoid time.Duration overflow with ancient epochs. + nowNano := now.UnixNano() + intervalNano := int64(interval) + offsetNano := int64(offset) % intervalNano + + // Find the most recent grid slot <= now. + cycleN := (nowNano - offsetNano) / intervalNano + slotNano := cycleN*intervalNano + offsetNano + if slotNano > nowNano { + slotNano -= intervalNano + } + slot := time.Unix(0, slotNano) + + if lastActive.IsZero() { + // First execution: schedule at the next future slot. + if !slot.After(now) { + return slot.Add(interval) + } + return slot + } + + // Slot was missed during downtime — schedule now for catch-up. + if slot.After(lastActive) && !slot.After(now) { + return now + } + + // Slot already executed before shutdown — advance to next cycle. + return slot.Add(interval) +} diff --git a/internal/usecase/checker/scheduler_test.go b/internal/usecase/checker/scheduler_test.go new file mode 100644 index 00000000..f9ceaf68 --- /dev/null +++ b/internal/usecase/checker/scheduler_test.go @@ -0,0 +1,76 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package checker + +import ( + "testing" + "time" +) + +func TestComputeNextRun_ZeroLastActive(t *testing.T) { + interval := 1 * time.Hour + offset := 10 * time.Minute + + nextRun := computeNextRun(interval, offset, time.Time{}) + now := time.Now() + + if !nextRun.After(now) { + t.Errorf("expected nextRun (%v) to be in the future (now=%v)", nextRun, now) + } + if nextRun.After(now.Add(interval)) { + t.Errorf("expected nextRun (%v) to be within one interval from now (%v)", nextRun, now.Add(interval)) + } +} + +func TestComputeNextRun_RecentLastActive_NoRerun(t *testing.T) { + interval := 1 * time.Hour + offset := computeOffset("test-checker", "test-target", interval) + now := time.Now() + + // lastActive is very recent — the current slot was already executed. + lastActive := now.Add(-1 * time.Minute) + + nextRun := computeNextRun(interval, offset, lastActive) + + if !nextRun.After(now) { + t.Errorf("expected nextRun (%v) to be in the future when lastActive is recent (now=%v)", nextRun, now) + } +} + +func TestComputeNextRun_OldLastActive_CatchUp(t *testing.T) { + interval := 1 * time.Hour + offset := 0 * time.Minute + now := time.Now() + + // lastActive is several hours ago — there should be a missed slot. + lastActive := now.Add(-3 * time.Hour) + + nextRun := computeNextRun(interval, offset, lastActive) + + // The missed slot should be scheduled at now (catch-up). + if nextRun.After(now.Add(1 * time.Second)) { + t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now) + } + if nextRun.Before(now.Add(-1 * time.Second)) { + t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now) + } +} diff --git a/internal/usecase/checker/storage.go b/internal/usecase/checker/storage.go index 26dc1ff3..f1ba3a33 100644 --- a/internal/usecase/checker/storage.go +++ b/internal/usecase/checker/storage.go @@ -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 { From 7b187dd6b6289dc747971901762919cec9cd399c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:53:22 +0700 Subject: [PATCH 33/54] checkers: add API controllers, routes, and app wiring Wire up the checker system to the HTTP layer: - API controllers for checker operations, options, plans, and results - Scoped routes at domain and service level - Admin controllers for checker config and scheduler management - App initialization: create usecases, start/stop scheduler - Zone controller updated to include per-service check status --- .drone.yml | 4 + cmd/happyDomain/main.go | 1 + generate.go | 4 +- .../api-admin/controller/check_controller.go | 64 ++++ .../controller/scheduler_controller.go | 100 ++++++ .../api-admin/controller/zone_controller.go | 2 +- internal/api-admin/route/check.go | 51 +++ internal/api-admin/route/route.go | 5 + internal/api-admin/route/scheduler.go | 41 +++ internal/api/controller/checker.go | 273 +++++++++++++++ internal/api/controller/checker_options.go | 219 ++++++++++++ internal/api/controller/checker_plans.go | 225 ++++++++++++ internal/api/controller/checker_results.go | 329 ++++++++++++++++++ internal/api/controller/zone.go | 22 +- internal/api/route/checker.go | 99 ++++++ internal/api/route/domain.go | 7 + internal/api/route/route.go | 20 ++ internal/api/route/service.go | 6 + internal/api/route/zone.go | 9 + internal/app/admin.go | 6 + internal/app/app.go | 33 ++ internal/config/cli.go | 2 + .../usecase/checker/check_status_usecase.go | 7 + model/config.go | 4 + model/zone.go | 8 + 25 files changed, 1535 insertions(+), 6 deletions(-) create mode 100644 internal/api-admin/controller/check_controller.go create mode 100644 internal/api-admin/controller/scheduler_controller.go create mode 100644 internal/api-admin/route/check.go create mode 100644 internal/api-admin/route/scheduler.go create mode 100644 internal/api/controller/checker.go create mode 100644 internal/api/controller/checker_options.go create mode 100644 internal/api/controller/checker_plans.go create mode 100644 internal/api/controller/checker_results.go create mode 100644 internal/api/route/checker.go diff --git a/.drone.yml b/.drone.yml index 691a9cd5..8602760d 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,6 +14,8 @@ steps: - sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go - go install github.com/swaggo/swag/cmd/swag@latest - go generate ./... + environment: + CGO_ENABLED: 0 - name: update frontend version image: node:24-alpine @@ -220,6 +222,8 @@ steps: - sed -i '/npm run build/d;/npm run generate:api/d' web/assets.go web-admin/assets.go - go install github.com/swaggo/swag/cmd/swag@latest - go generate ./... + environment: + CGO_ENABLED: 0 - name: update frontend version image: node:24-alpine diff --git a/cmd/happyDomain/main.go b/cmd/happyDomain/main.go index 30cffc04..6d2a0522 100644 --- a/cmd/happyDomain/main.go +++ b/cmd/happyDomain/main.go @@ -38,6 +38,7 @@ import ( _ "git.happydns.org/happyDomain/internal/storage/oracle-nosql" _ "git.happydns.org/happyDomain/internal/storage/postgresql" "git.happydns.org/happyDomain/model" + _ "git.happydns.org/happyDomain/checkers" _ "git.happydns.org/happyDomain/services/abstract" _ "git.happydns.org/happyDomain/services/providers/google" ) diff --git a/generate.go b/generate.go index 86ee819a..6d636279 100644 --- a/generate.go +++ b/generate.go @@ -26,5 +26,5 @@ package main //go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts //go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts //go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go -//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go -//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go +//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go +//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go diff --git a/internal/api-admin/controller/check_controller.go b/internal/api-admin/controller/check_controller.go new file mode 100644 index 00000000..b439c552 --- /dev/null +++ b/internal/api-admin/controller/check_controller.go @@ -0,0 +1,64 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + apicontroller "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" +) + +// AdminCheckerController handles admin checker-related API endpoints. +// It embeds CheckerController and overrides GetCheckerOptions to return a flat +// (non-positional) map scoped to nil (global/admin) level. +type AdminCheckerController struct { + *apicontroller.CheckerController +} + +// NewAdminCheckerController creates a new AdminCheckerController. +func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController { + return &AdminCheckerController{ + CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil), + } +} + +// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map. +// +// @Summary Get admin-level checker options +// @Tags admin,checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [get] +func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) { + checkerID := c.Param("checkerId") + opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, opts) +} diff --git a/internal/api-admin/controller/scheduler_controller.go b/internal/api-admin/controller/scheduler_controller.go new file mode 100644 index 00000000..c58438b0 --- /dev/null +++ b/internal/api-admin/controller/scheduler_controller.go @@ -0,0 +1,100 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" +) + +// AdminSchedulerController handles admin scheduler API endpoints. +type AdminSchedulerController struct { + scheduler *checkerUC.Scheduler +} + +// NewAdminSchedulerController creates a new AdminSchedulerController. +func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController { + return &AdminSchedulerController{scheduler: scheduler} +} + +// GetSchedulerStatus returns the current scheduler status. +// +// @Summary Get scheduler status +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Router /scheduler [get] +func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) { + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// EnableScheduler starts the scheduler and returns updated status. +// +// @Summary Enable the scheduler +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Failure 500 {object} object +// @Router /scheduler/enable [post] +func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) { + if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// DisableScheduler stops the scheduler and returns updated status. +// +// @Summary Disable the scheduler +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} checkerUC.SchedulerStatus +// @Failure 500 {object} object +// @Router /scheduler/disable [post] +func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) { + if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, s.scheduler.GetStatus()) +} + +// RescheduleUpcoming rebuilds the job queue and returns the new count. +// +// @Summary Rebuild the scheduler queue +// @Tags admin-scheduler +// @Produce json +// @Security securitydefinitions.basic +// @Success 200 {object} map[string]int +// @Router /scheduler/reschedule-upcoming [post] +func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) { + n := s.scheduler.RebuildQueue() + c.JSON(http.StatusOK, gin.H{"rescheduled": n}) +} diff --git a/internal/api-admin/controller/zone_controller.go b/internal/api-admin/controller/zone_controller.go index 695c0bab..ae5fd176 100644 --- a/internal/api-admin/controller/zone_controller.go +++ b/internal/api-admin/controller/zone_controller.go @@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) { // @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get] // @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get] func (zc *ZoneController) GetZone(c *gin.Context) { - apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService) + apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil) apizc.GetZone(c) } diff --git a/internal/api-admin/route/check.go b/internal/api-admin/route/check.go new file mode 100644 index 00000000..a16831d3 --- /dev/null +++ b/internal/api-admin/route/check.go @@ -0,0 +1,51 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) { + if dep.CheckerOptionsUC == nil { + return + } + cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC) + + apiCheckersRoutes := router.Group("/checkers") + apiCheckersRoutes.GET("", cc.ListCheckers) + + apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId") + apiCheckerRoutes.Use(cc.CheckerHandler) + apiCheckerRoutes.GET("", cc.GetChecker) + + apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options") + apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions) + apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions) + apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions) + + apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname") + apiCheckerOptionRoutes.GET("", cc.GetCheckerOption) + apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption) +} diff --git a/internal/api-admin/route/route.go b/internal/api-admin/route/route.go index 3a328b2e..e70ab6a9 100644 --- a/internal/api-admin/route/route.go +++ b/internal/api-admin/route/route.go @@ -26,6 +26,7 @@ import ( api "git.happydns.org/happyDomain/internal/api/route" "git.happydns.org/happyDomain/internal/storage" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -41,14 +42,18 @@ type Dependencies struct { ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase ZoneImporter happydns.ZoneImporterUsecase ZoneService happydns.ZoneServiceUsecase + CheckerOptionsUC *checkerUC.CheckerOptionsUsecase + CheckScheduler *checkerUC.Scheduler } func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) { apiRoutes := router.Group("/api") declareBackupRoutes(cfg, apiRoutes, s) + declareChecksRoutes(apiRoutes, dep) declareDomainRoutes(apiRoutes, dep, s) declareProviderRoutes(apiRoutes, dep, s) + declareSchedulerRoutes(apiRoutes, dep) declareSessionsRoutes(cfg, apiRoutes, s) declareUserAuthsRoutes(apiRoutes, dep, s) declareUsersRoutes(apiRoutes, dep, s) diff --git a/internal/api-admin/route/scheduler.go b/internal/api-admin/route/scheduler.go new file mode 100644 index 00000000..a0580698 --- /dev/null +++ b/internal/api-admin/route/scheduler.go @@ -0,0 +1,41 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" +) + +func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) { + if dep.CheckScheduler == nil { + return + } + ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler) + + schedulerRoute := router.Group("/scheduler") + schedulerRoute.GET("", ctrl.GetSchedulerStatus) + schedulerRoute.POST("/enable", ctrl.EnableScheduler) + schedulerRoute.POST("/disable", ctrl.DisableScheduler) + schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming) +} diff --git a/internal/api/controller/checker.go b/internal/api/controller/checker.go new file mode 100644 index 00000000..5009a27f --- /dev/null +++ b/internal/api/controller/checker.go @@ -0,0 +1,273 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + checkerPkg "git.happydns.org/happyDomain/internal/checker" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// CheckerController handles checker-related API endpoints. +type CheckerController struct { + engine happydns.CheckerEngine + OptionsUC *checkerUC.CheckerOptionsUsecase + planUC *checkerUC.CheckPlanUsecase + statusUC *checkerUC.CheckStatusUsecase +} + +// NewCheckerController creates a new CheckerController. +func NewCheckerController( + engine happydns.CheckerEngine, + optionsUC *checkerUC.CheckerOptionsUsecase, + planUC *checkerUC.CheckPlanUsecase, + statusUC *checkerUC.CheckStatusUsecase, +) *CheckerController { + return &CheckerController{ + engine: engine, + OptionsUC: optionsUC, + planUC: planUC, + statusUC: statusUC, + } +} + +// StatusUC returns the CheckStatusUsecase for use by other controllers. +func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase { + return cc.statusUC +} + +// targetFromContext builds a CheckTarget from middleware context values. +func targetFromContext(c *gin.Context) happydns.CheckTarget { + user := middleware.MyUser(c) + target := happydns.CheckTarget{} + if user != nil { + target.UserId = user.Id.String() + } + if domain, exists := c.Get("domain"); exists { + d := domain.(*happydns.Domain) + target.DomainId = d.Id.String() + } + if sid, exists := c.Get("serviceid"); exists { + id := sid.(happydns.Identifier) + target.ServiceId = id.String() + if z, zExists := c.Get("zone"); zExists { + zone := z.(*happydns.Zone) + if _, svc := zone.FindService(id); svc != nil { + target.ServiceType = svc.Type + } + } + } + return target +} + +// targetMatchesContext verifies that every non-empty field in contextTarget +// matches the corresponding field in resourceTarget. Returns false if any +// context-specified scope does not match, indicating the resource belongs +// to a different user/domain/service than the request scope. +func targetMatchesContext(contextTarget, resourceTarget happydns.CheckTarget) bool { + if contextTarget.UserId != "" && contextTarget.UserId != resourceTarget.UserId { + return false + } + if contextTarget.DomainId != "" && contextTarget.DomainId != resourceTarget.DomainId { + return false + } + if contextTarget.ServiceId != "" && contextTarget.ServiceId != resourceTarget.ServiceId { + return false + } + return true +} + +// --- Global checker routes --- + +// ListCheckers returns all registered checker definitions. +// +// @Summary List available checkers +// @Tags checkers +// @Produce json +// @Success 200 {object} map[string]checker.CheckerDefinition +// @Router /checkers [get] +func (cc *CheckerController) ListCheckers(c *gin.Context) { + c.JSON(http.StatusOK, checkerPkg.GetCheckers()) +} + +// GetChecker returns a specific checker definition. +// +// @Summary Get a checker definition +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Success 200 {object} checker.CheckerDefinition +// @Failure 404 {object} happydns.ErrorResponse +// @Router /checkers/{checkerId} [get] +func (cc *CheckerController) GetChecker(c *gin.Context) { + checkerID := c.Param("checkerId") + def := checkerPkg.FindChecker(checkerID) + if def == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"}) + return + } + c.JSON(http.StatusOK, def) +} + +// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context. +func (cc *CheckerController) CheckerHandler(c *gin.Context) { + checkerID := c.Param("checkerId") + def := checkerPkg.FindChecker(checkerID) + if def == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"}) + return + } + c.Set("checker", def) + c.Next() +} + +// --- Scoped routes (domain/service) --- + +// ListAvailableChecks lists all checkers with their latest status for a target. +// +// @Summary List available checks with status +// @Tags checkers +// @Produce json +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckerStatus +// @Router /domains/{domain}/checkers [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get] +func (cc *CheckerController) ListAvailableChecks(c *gin.Context) { + target := targetFromContext(c) + + result, err := cc.statusUC.ListCheckerStatuses(target) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, result) +} + +// TriggerCheck manually triggers a checker execution. +// By default the check runs asynchronously and returns an Execution (HTTP 202). +// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200). +// +// @Summary Trigger a manual check +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param sync query bool false "Run synchronously" +// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckEvaluation +// @Success 202 {object} happydns.Execution +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post] +func (cc *CheckerController) TriggerCheck(c *gin.Context) { + cname := c.Param("checkerId") + if checkerPkg.FindChecker(cname) == nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Checker not found"}) + return + } + + var req happydns.CheckerRunRequest + _ = c.ShouldBindJSON(&req) + + target := targetFromContext(c) + if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + // Build a temporary plan from enabled rules if provided. + var plan *happydns.CheckPlan + if len(req.EnabledRules) > 0 { + plan = &happydns.CheckPlan{ + CheckerID: cname, + Target: target, + Enabled: req.EnabledRules, + } + } + + exec, err := cc.engine.CreateExecution(cname, target, plan) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + if c.Query("sync") == "true" { + eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, eval) + } else { + go cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options) + c.JSON(http.StatusAccepted, exec) + } +} + +// GetExecutionStatus returns the status of an execution. +// +// @Summary Get execution status +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.Execution +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get] +func (cc *CheckerController) GetExecutionStatus(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + c.JSON(http.StatusOK, exec) +} diff --git a/internal/api/controller/checker_options.go b/internal/api/controller/checker_options.go new file mode 100644 index 00000000..281a3a41 --- /dev/null +++ b/internal/api/controller/checker_options.go @@ -0,0 +1,219 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// GetCheckerOptions returns layered options for a checker, from least to most specific scope. +// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes). +// +// @Summary Get checker options by scope +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckerOptionsPositional +// @Router /checkers/{checkerId}/options [get] +// @Router /domains/{domain}/checkers/{checkerId}/options [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get] +func (cc *CheckerController) GetCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if positionals == nil { + positionals = []*happydns.CheckerOptionsPositional{} + } + + // Append auto-fill resolved values so the frontend can display them. + autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target) + if err == nil && autoFillOpts != nil { + positionals = append(positionals, &happydns.CheckerOptionsPositional{ + CheckName: checkerID, + UserId: happydns.TargetIdentifier(target.UserId), + DomainId: happydns.TargetIdentifier(target.DomainId), + ServiceId: happydns.TargetIdentifier(target.ServiceId), + Options: autoFillOpts, + }) + } + + c.JSON(http.StatusOK, positionals) +} + +// AddCheckerOptions partially merges options at the current scope. +// +// @Summary Merge checker options +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param options body checker.CheckerOptions true "Options to merge" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [post] +// @Router /domains/{domain}/checkers/{checkerId}/options [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post] +func (cc *CheckerController) AddCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + var opts happydns.CheckerOptions + if err := c.ShouldBindJSON(&opts); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + merged, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + c.JSON(http.StatusOK, merged) +} + +// ChangeCheckerOptions fully replaces options at the current scope. +// +// @Summary Replace checker options +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param options body checker.CheckerOptions true "Options to set" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckerOptions +// @Router /checkers/{checkerId}/options [put] +// @Router /domains/{domain}/checkers/{checkerId}/options [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put] +func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + var opts happydns.CheckerOptions + if err := c.ShouldBindJSON(&opts); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, opts) +} + +// GetCheckerOption returns a single option value at the current scope. +// +// @Summary Get a single checker option +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param optname path string true "Option name" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Router /checkers/{checkerId}/options/{optname} [get] +// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get] +func (cc *CheckerController) GetCheckerOption(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + optname := c.Param("optname") + val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if val == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"}) + return + } + c.JSON(http.StatusOK, val) +} + +// SetCheckerOption sets a single option value at the current scope. +// +// @Summary Set a single checker option +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param optname path string true "Option name" +// @Param value body any true "Option value" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Router /checkers/{checkerId}/options/{optname} [put] +// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put] +func (cc *CheckerController) SetCheckerOption(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + optname := c.Param("optname") + var value any + if err := c.ShouldBindJSON(&value); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + // Validate the full merged options after inserting the key. + existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId)) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + existing[optname] = value + if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + c.JSON(http.StatusOK, value) +} diff --git a/internal/api/controller/checker_plans.go b/internal/api/controller/checker_plans.go new file mode 100644 index 00000000..1583ea1d --- /dev/null +++ b/internal/api/controller/checker_plans.go @@ -0,0 +1,225 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// ListCheckPlans returns all check plans for a domain. +// +// @Summary List check plans for a domain +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.CheckPlan +// @Router /domains/{domain}/checkers/{checkerId}/plans [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get] +func (cc *CheckerController) ListCheckPlans(c *gin.Context) { + target := targetFromContext(c) + checkerID := c.Param("checkerId") + + plans, err := cc.planUC.ListCheckPlansByTarget(target) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + filtered := make([]*happydns.CheckPlan, 0, len(plans)) + for _, p := range plans { + if p.CheckerID == checkerID { + filtered = append(filtered, p) + } + } + c.JSON(http.StatusOK, filtered) +} + +// CreateCheckPlan creates a new check plan. +// +// @Summary Create a check plan +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param plan body happydns.CheckPlan true "Check plan to create" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 201 {object} happydns.CheckPlan +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans [post] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post] +func (cc *CheckerController) CreateCheckPlan(c *gin.Context) { + target := targetFromContext(c) + + var plan happydns.CheckPlan + if err := c.ShouldBindJSON(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + plan.Target = target + + if err := cc.planUC.CreateCheckPlan(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Cannot create check plan: %s", err.Error())}) + return + } + + c.JSON(http.StatusCreated, plan) +} + +// GetCheckPlan returns a specific check plan. +// +// @Summary Get a check plan +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckPlan +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [get] +func (cc *CheckerController) GetCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + plan, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), plan.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + c.JSON(http.StatusOK, plan) +} + +// UpdateCheckPlan updates an existing check plan. +// +// @Summary Update a check plan +// @Tags checkers +// @Accept json +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param plan body happydns.CheckPlan true "Updated check plan" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckPlan +// @Failure 400 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [put] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [put] +func (cc *CheckerController) UpdateCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + existing, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), existing.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + var plan happydns.CheckPlan + if err := c.ShouldBindJSON(&plan); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + updated, err := cc.planUC.UpdateCheckPlan(planID, &plan) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Cannot update check plan: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, updated) +} + +// DeleteCheckPlan deletes a check plan. +// +// @Summary Delete a check plan +// @Tags checkers +// @Param checkerId path string true "Checker ID" +// @Param planId path string true "Plan ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/plans/{planId} [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans/{planId} [delete] +func (cc *CheckerController) DeleteCheckPlan(c *gin.Context) { + planID, err := happydns.NewIdentifierFromString(c.Param("planId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"}) + return + } + + plan, err := cc.planUC.GetCheckPlan(planID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), plan.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + if err := cc.planUC.DeleteCheckPlan(planID); err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/controller/checker_results.go b/internal/api/controller/checker_results.go new file mode 100644 index 00000000..4644b1f6 --- /dev/null +++ b/internal/api/controller/checker_results.go @@ -0,0 +1,329 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// ListExecutions returns executions for a checker on a target. +// +// @Summary List executions for a checker +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param limit query int false "Maximum number of results" +// @Param include_planned query bool false "Include upcoming planned executions from the scheduler" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {array} happydns.Execution +// @Router /domains/{domain}/checkers/{checkerId}/executions [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get] +func (cc *CheckerController) ListExecutions(c *gin.Context) { + cname := c.Param("checkerId") + target := targetFromContext(c) + + limit := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + if execs == nil { + execs = []*happydns.Execution{} + } + + if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" { + planned := cc.statusUC.ListPlannedExecutions(cname, target) + execs = append(planned, execs...) + } + + c.JSON(http.StatusOK, execs) +} + +// DeleteExecution deletes an execution record. +// +// @Summary Delete an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete] +func (cc *CheckerController) DeleteExecution(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if err := cc.statusUC.DeleteExecution(execID); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +// DeleteCheckerExecutions deletes all executions for a checker on a target. +// +// @Summary Delete all executions for a checker +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 204 +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions [delete] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete] +func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) { + cname := c.Param("checkerId") + target := targetFromContext(c) + + if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} + +// GetExecutionObservations returns the observation snapshot for an execution. +// +// @Summary Get observations for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.ObservationSnapshot +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get] +func (cc *CheckerController) GetExecutionObservations(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + snap, err := cc.statusUC.GetObservationsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"}) + return + } + + c.JSON(http.StatusOK, snap) +} + +// GetExecutionObservation returns a specific observation key from an execution's snapshot. +// +// @Summary Get a specific observation for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param obsKey path string true "Observation key" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} any +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get] +func (cc *CheckerController) GetExecutionObservation(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + snap, err := cc.statusUC.GetObservationsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"}) + return + } + + obsKey := c.Param("obsKey") + val, ok := snap.Data[obsKey] + if !ok { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"}) + return + } + + c.JSON(http.StatusOK, val) +} + +// GetExecutionResults returns the evaluation (per-rule states) for an execution. +// +// @Summary Get results for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} happydns.CheckEvaluation +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get] +func (cc *CheckerController) GetExecutionResults(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + eval, err := cc.statusUC.GetResultsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"}) + return + } + + c.JSON(http.StatusOK, eval) +} + +// GetExecutionResult returns a specific rule's result from an execution. +// +// @Summary Get a specific rule result for an execution +// @Tags checkers +// @Produce json +// @Param checkerId path string true "Checker ID" +// @Param executionId path string true "Execution ID" +// @Param ruleName path string true "Rule name" +// @Param domain path string true "Domain identifier" +// @Param zoneid path string true "Zone identifier" +// @Param subdomain path string true "Subdomain" +// @Param serviceid path string true "Service identifier" +// @Success 200 {object} checker.CheckState +// @Failure 404 {object} happydns.ErrorResponse +// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get] +// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get] +func (cc *CheckerController) GetExecutionResult(c *gin.Context) { + execID, err := happydns.NewIdentifierFromString(c.Param("executionId")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"}) + return + } + + exec, err := cc.statusUC.GetExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + if !targetMatchesContext(targetFromContext(c), exec.Target) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"}) + return + } + + eval, err := cc.statusUC.GetResultsByExecution(execID) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"}) + return + } + + ruleName := c.Param("ruleName") + for _, state := range eval.States { + if state.Code == ruleName { + c.JSON(http.StatusOK, state) + return + } + } + + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"}) +} diff --git a/internal/api/controller/zone.go b/internal/api/controller/zone.go index 04798e06..48d4f189 100644 --- a/internal/api/controller/zone.go +++ b/internal/api/controller/zone.go @@ -31,6 +31,7 @@ import ( "git.happydns.org/happyDomain/internal/api/middleware" "git.happydns.org/happyDomain/internal/helpers" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" "git.happydns.org/happyDomain/model" ) @@ -38,13 +39,15 @@ type ZoneController struct { domainService happydns.DomainUsecase zoneCorrectionService happydns.ZoneCorrectionApplierUsecase zoneService happydns.ZoneUsecase + checkStatusUC *checkerUC.CheckStatusUsecase } -func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController { +func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *ZoneController { return &ZoneController{ domainService: domainService, zoneCorrectionService: zoneCorrectionService, zoneService: zoneService, + checkStatusUC: checkStatusUC, } } @@ -59,14 +62,27 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns. // @Security securitydefinitions.basic // @Param domainId path string true "Domain identifier" // @Param zoneId path string true "Zone identifier" -// @Success 200 {object} happydns.Zone +// @Success 200 {object} happydns.ZoneWithServicesCheckStatus // @Failure 401 {object} happydns.ErrorResponse "Authentication failure" // @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found" // @Router /domains/{domainId}/zone/{zoneId} [get] func (zc *ZoneController) GetZone(c *gin.Context) { zone := c.MustGet("zone").(*happydns.Zone) - c.JSON(http.StatusOK, zone) + result := &happydns.ZoneWithServicesCheckStatus{Zone: zone} + + if zc.checkStatusUC != nil { + user := c.MustGet("LoggedUser").(*happydns.User) + domain := c.MustGet("domain").(*happydns.Domain) + statusByService, err := zc.checkStatusUC.GetWorstServiceStatuses(user.Id, domain.Id, zone) + if err != nil { + log.Printf("GetWorstServiceStatuses: %s", err.Error()) + } else { + result.ServicesCheckStatus = statusByService + } + } + + c.JSON(http.StatusOK, result) } // GetZoneSubdomain returns the services associated with a given subdomain. diff --git a/internal/api/route/checker.go b/internal/api/route/checker.go new file mode 100644 index 00000000..9c94ce0f --- /dev/null +++ b/internal/api/route/checker.go @@ -0,0 +1,99 @@ +// 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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" + "git.happydns.org/happyDomain/model" +) + +// DeclareCheckerRoutes registers global checker routes under /api/checkers. +// Returns the controller so it can be reused for scoped routes. +func DeclareCheckerRoutes( + apiRoutes *gin.RouterGroup, + engine happydns.CheckerEngine, + optionsUC *checkerUC.CheckerOptionsUsecase, + planUC *checkerUC.CheckPlanUsecase, + statusUC *checkerUC.CheckStatusUsecase, +) *controller.CheckerController { + cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC) + + // Global: /api/checkers + checkers := apiRoutes.Group("/checkers") + checkers.GET("", cc.ListCheckers) + + checkerID := checkers.Group("/:checkerId") + checkerID.GET("", cc.GetChecker) + + // User-scoped options (scope determined by context — user-only here). + checkerID.GET("/options", cc.GetCheckerOptions) + checkerID.POST("/options", cc.AddCheckerOptions) + checkerID.PUT("/options", cc.ChangeCheckerOptions) + checkerID.GET("/options/:optname", cc.GetCheckerOption) + checkerID.PUT("/options/:optname", cc.SetCheckerOption) + + return cc +} + +// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service. +// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers. +func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) { + checkers := scopedRouter.Group("/checkers") + checkers.GET("", cc.ListAvailableChecks) + + checkerID := checkers.Group("/:checkerId") + + // Scoped options (scope determined by context — domain/service here). + checkerID.GET("/options", cc.GetCheckerOptions) + checkerID.POST("/options", cc.AddCheckerOptions) + checkerID.PUT("/options", cc.ChangeCheckerOptions) + checkerID.GET("/options/:optname", cc.GetCheckerOption) + checkerID.PUT("/options/:optname", cc.SetCheckerOption) + + // Plans (schedules). + checkerID.GET("/plans", cc.ListCheckPlans) + checkerID.POST("/plans", cc.CreateCheckPlan) + checkerID.GET("/plans/:planId", cc.GetCheckPlan) + checkerID.PUT("/plans/:planId", cc.UpdateCheckPlan) + checkerID.DELETE("/plans/:planId", cc.DeleteCheckPlan) + + // Executions. + executions := checkerID.Group("/executions") + executions.GET("", cc.ListExecutions) + executions.POST("", cc.TriggerCheck) + executions.DELETE("", cc.DeleteCheckerExecutions) + + executionID := executions.Group("/:executionId") + executionID.GET("", cc.GetExecutionStatus) + executionID.DELETE("", cc.DeleteExecution) + + // Observations (under execution). + executionID.GET("/observations", cc.GetExecutionObservations) + executionID.GET("/observations/:obsKey", cc.GetExecutionObservation) + + // Results (under execution). + executionID.GET("/results", cc.GetExecutionResults) + executionID.GET("/results/:ruleName", cc.GetExecutionResult) +} diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index 23af0db6..94eb6c18 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -39,6 +39,7 @@ func DeclareDomainRoutes( zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, + cc *controller.CheckerController, ) { dc := controller.NewDomainController( domainUC, @@ -61,6 +62,11 @@ func DeclareDomainRoutes( apiDomainsRoutes.POST("/zone", dc.ImportZone) apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone) + // Mount domain-scoped checker routes. + if cc != nil { + DeclareScopedCheckerRoutes(apiDomainsRoutes, cc) + } + DeclareZoneRoutes( apiDomainsRoutes, zoneUC, @@ -68,5 +74,6 @@ func DeclareDomainRoutes( zoneCorrApplier, zoneServiceUC, serviceUC, + cc, ) } diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 94478288..d2df1b9a 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -24,7 +24,9 @@ package route import ( "github.com/gin-gonic/gin" + "git.happydns.org/happyDomain/internal/api/controller" "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -50,6 +52,11 @@ type Dependencies struct { ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase ZoneImporter happydns.ZoneImporterUsecase ZoneService happydns.ZoneServiceUsecase + + CheckerEngine happydns.CheckerEngine + CheckerOptionsUC *checkerUC.CheckerOptionsUsecase + CheckPlanUC *checkerUC.CheckPlanUsecase + CheckStatusUC *checkerUC.CheckStatusUsecase } // @title happyDomain API @@ -105,6 +112,18 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc } apiAuthRoutes.Use(middleware.AuthRequired()) + // Initialize checker controller if checker engine is available. + var cc *controller.CheckerController + if dep.CheckerEngine != nil { + cc = DeclareCheckerRoutes( + apiAuthRoutes, + dep.CheckerEngine, + dep.CheckerOptionsUC, + dep.CheckPlanUC, + dep.CheckStatusUC, + ) + } + DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc) DeclareDomainRoutes( apiAuthRoutes, @@ -116,6 +135,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service, + cc, ) DeclareProviderRoutes(apiAuthRoutes, dep.Provider) DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings) diff --git a/internal/api/route/service.go b/internal/api/route/service.go index 906fbe88..ded089a8 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes( zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase, + cc *controller.CheckerController, ) { sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC) @@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes( apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC)) apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService) apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService) + + // Mount service-scoped checker routes. + if cc != nil { + DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc) + } } diff --git a/internal/api/route/zone.go b/internal/api/route/zone.go index f5ae8c84..bb6da44c 100644 --- a/internal/api/route/zone.go +++ b/internal/api/route/zone.go @@ -26,6 +26,7 @@ import ( "git.happydns.org/happyDomain/internal/api/controller" "git.happydns.org/happyDomain/internal/api/middleware" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" happydns "git.happydns.org/happyDomain/model" ) @@ -36,11 +37,18 @@ func DeclareZoneRoutes( zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, + cc *controller.CheckerController, ) { + var checkStatusUC *checkerUC.CheckStatusUsecase + if cc != nil { + checkStatusUC = cc.StatusUC() + } + zc := controller.NewZoneController( zoneUC, domainUC, zoneCorrApplier, + checkStatusUC, ) apiZonesRoutes := router.Group("/zone/:zoneid") @@ -65,6 +73,7 @@ func DeclareZoneRoutes( zoneServiceUC, serviceUC, zoneUC, + cc, ) apiZonesRoutes.POST("/records", zc.AddRecords) diff --git a/internal/app/admin.go b/internal/app/admin.go index 3544d376..60658399 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -33,6 +33,7 @@ import ( "github.com/gin-gonic/gin" admin "git.happydns.org/happyDomain/internal/api-admin/route" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" providerUC "git.happydns.org/happyDomain/internal/usecase/provider" "git.happydns.org/happyDomain/model" "git.happydns.org/happyDomain/web-admin" @@ -55,6 +56,9 @@ func NewAdmin(app *App) *Admin { // Prepare usecases (admin uses unrestricted provider access) app.usecases.providerAdmin = providerUC.NewService(app.store, nil) + if app.usecases.checkerOptionsUC == nil { + app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store) + } admin.DeclareRoutes( app.cfg, @@ -71,6 +75,8 @@ func NewAdmin(app *App) *Admin { ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier, ZoneImporter: app.usecases.orchestrator.ZoneImporter, ZoneService: app.usecases.zoneService, + CheckerOptionsUC: app.usecases.checkerOptionsUC, + CheckScheduler: app.usecases.checkerScheduler, }, ) web.DeclareRoutes(app.cfg, router) diff --git a/internal/app/app.go b/internal/app/app.go index ed809541..54761a0d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,6 +38,7 @@ import ( "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/internal/usecase" authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser" + checkerUC "git.happydns.org/happyDomain/internal/usecase/checker" domainUC "git.happydns.org/happyDomain/internal/usecase/domain" domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/orchestrator" @@ -69,6 +70,12 @@ type Usecases struct { zoneService happydns.ZoneServiceUsecase orchestrator *orchestrator.Orchestrator + + checkerEngine happydns.CheckerEngine + checkerOptionsUC *checkerUC.CheckerOptionsUsecase + checkerPlanUC *checkerUC.CheckPlanUsecase + checkerStatusUC *checkerUC.CheckStatusUsecase + checkerScheduler *checkerUC.Scheduler } type App struct { @@ -252,6 +259,19 @@ func (app *App) initUsecases() { providerAdminService, zoneService.UpdateZoneUC, ) + + // Checker system. + app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store) + app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store) + app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store) + app.usecases.checkerEngine = checkerUC.NewCheckerEngine( + app.usecases.checkerOptionsUC, + 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) } func (app *App) setupRouter() { @@ -297,6 +317,11 @@ func (app *App) setupRouter() { ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier, ZoneImporter: app.usecases.orchestrator.ZoneImporter, ZoneService: app.usecases.zoneService, + + CheckerEngine: app.usecases.checkerEngine, + CheckerOptionsUC: app.usecases.checkerOptionsUC, + CheckPlanUC: app.usecases.checkerPlanUC, + CheckStatusUC: app.usecases.checkerStatusUC, }, ) web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier) @@ -314,6 +339,10 @@ func (app *App) Start() { go app.insights.Run() } + if app.usecases.checkerScheduler != nil { + app.usecases.checkerScheduler.Start(context.Background()) + } + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -327,6 +356,10 @@ func (app *App) Stop() { log.Fatal("Server Shutdown:", err) } + if app.usecases.checkerScheduler != nil { + app.usecases.checkerScheduler.Stop() + } + // Close storage if app.store != nil { app.store.Close() diff --git a/internal/config/cli.go b/internal/config/cli.go index 502fa1ed..fecf0c7d 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -24,6 +24,7 @@ package config // import "git.happydns.org/happyDomain/config" import ( "flag" "fmt" + "runtime" "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/model" @@ -45,6 +46,7 @@ func declareFlags(o *happydns.Options) { flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)") flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)") flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.") + flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously") flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server") flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user") diff --git a/internal/usecase/checker/check_status_usecase.go b/internal/usecase/checker/check_status_usecase.go index b8362b3a..d201fa3e 100644 --- a/internal/usecase/checker/check_status_usecase.go +++ b/internal/usecase/checker/check_status_usecase.go @@ -22,6 +22,8 @@ package checker import ( + "slices" + checkerPkg "git.happydns.org/happyDomain/internal/checker" "git.happydns.org/happyDomain/model" ) @@ -96,6 +98,11 @@ func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([ if !def.Availability.ApplyToService { continue } + if len(def.Availability.LimitToServices) > 0 && target.ServiceType != "" { + if !slices.Contains(def.Availability.LimitToServices, target.ServiceType) { + continue + } + } } status := happydns.CheckerStatus{ diff --git a/model/config.go b/model/config.go index 9d6b933c..45048aa6 100644 --- a/model/config.go +++ b/model/config.go @@ -93,6 +93,10 @@ type Options struct { OIDCClients []OIDCSettings + // CheckerMaxConcurrency is the maximum number of checker jobs that can + // run simultaneously. Defaults to runtime.NumCPU(). + CheckerMaxConcurrency int + // CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or ""). CaptchaProvider string diff --git a/model/zone.go b/model/zone.go index 68d4308e..738c6ddb 100644 --- a/model/zone.go +++ b/model/zone.go @@ -154,6 +154,14 @@ type ZoneServices struct { Services []*Service `json:"services"` } +// ZoneWithServicesCheckStatus wraps a Zone with the worst check status for each service. +type ZoneWithServicesCheckStatus struct { + *Zone + // ServicesCheckStatus holds the worst check status for each service, + // keyed by service identifier string. Nil/absent if no results exist yet. + ServicesCheckStatus map[string]*Status `json:"services_check_status,omitempty"` +} + type ZoneUsecase interface { AddRecord(*Zone, string, Record) error CreateZone(*Zone) error From 8c0c3879699177c65ca25e6e61f529e3d950328c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:55:31 +0700 Subject: [PATCH 34/54] checkers: add frontend API client, stores, and utilities Add the frontend infrastructure for the checker UI: - API client with scoped helpers for domain/service-level operations - Svelte stores for checker state (currentExecution, currentCheckInfo) - Utility functions for status colors, icons, i18n keys, date formatting - Shared helpers: withInheritedPlaceholders, downloadBlob, collectAllOptionDocs - English translations for all checker UI strings - Zone model and form types extended for checker support --- web/src/lib/api/checkers.ts | 601 ++++++++++++++++++++++++ web/src/lib/locales/en.json | 244 ++++++++++ web/src/lib/model/custom_form.svelte.ts | 1 + web/src/lib/model/zone.ts | 3 +- web/src/lib/stores/checkers.ts | 42 ++ web/src/lib/translations.ts | 2 + web/src/lib/utils/checkers.ts | 134 ++++++ web/src/lib/utils/datetime.ts | 39 ++ web/src/lib/utils/index.ts | 3 +- 9 files changed, 1067 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/api/checkers.ts create mode 100644 web/src/lib/stores/checkers.ts create mode 100644 web/src/lib/utils/checkers.ts diff --git a/web/src/lib/api/checkers.ts b/web/src/lib/api/checkers.ts new file mode 100644 index 00000000..8c819271 --- /dev/null +++ b/web/src/lib/api/checkers.ts @@ -0,0 +1,601 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { + getCheckers, + getCheckersByCheckerId, + getCheckersByCheckerIdOptions, + putCheckersByCheckerIdOptions, + getDomainsByDomainCheckers, + getDomainsByDomainCheckersByCheckerIdExecutions, + postDomainsByDomainCheckersByCheckerIdExecutions, + deleteDomainsByDomainCheckersByCheckerIdExecutions, + deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults, + getDomainsByDomainCheckersByCheckerIdOptions, + putDomainsByDomainCheckersByCheckerIdOptions, + getDomainsByDomainCheckersByCheckerIdPlans, + postDomainsByDomainCheckersByCheckerIdPlans, + putDomainsByDomainCheckersByCheckerIdPlansByPlanId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions, + deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, + putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions, + getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans, + postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans, + putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId, +} from "$lib/api-base/sdk.gen"; +import type { + HappydnsCheckEvaluation, + HappydnsCheckPlan, + HappydnsCheckerDefinition, + HappydnsCheckerOptions, + HappydnsCheckerOptionsPositional, + HappydnsCheckerRunRequest, + HappydnsCheckerStatus, + HappydnsExecution, + HappydnsObservationSnapshot, +} from "$lib/api-base/types.gen"; +import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors"; + +export async function listCheckers(): Promise> { + return unwrapSdkResponse(await getCheckers()) as Record; +} + +export async function getCheckStatus(checkerId: string): Promise { + return unwrapSdkResponse( + await getCheckersByCheckerId({ path: { checkerId } }), + ) as HappydnsCheckerDefinition; +} + +export async function getCheckOptions(checkerId: string): Promise { + return (unwrapSdkResponse( + await getCheckersByCheckerIdOptions({ path: { checkerId } }), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateCheckOptions( + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as any }), + ) as HappydnsCheckerOptions; +} + +// Domain-scoped checker API functions + +export async function listDomainCheckers(domain: string): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckers({ path: { domain } } as any), + ) as HappydnsCheckerStatus[]) ?? []; +} + +export async function listDomainExecutions( + domain: string, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + query: { + ...(options?.includePlanned ? { include_planned: "true" } : {}), + ...(options?.limit ? { limit: String(options.limit) } : {}), + }, + } as any), + ) as HappydnsExecution[]) ?? []; +} + +export async function triggerDomainCheck( + domain: string, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + body: request, + } as any), + ) as HappydnsExecution; +} + +export async function getDomainExecution( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsExecution; +} + +export async function deleteDomainExecution( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, checkerId, executionId }, + } as any), + ); +} + +export async function deleteAllDomainExecutions( + domain: string, + checkerId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainCheckersByCheckerIdExecutions({ + path: { domain, checkerId }, + } as any), + ); +} + +export async function getDomainExecutionResults( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsCheckEvaluation; +} + +export async function getDomainExecutionObservations( + domain: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({ + path: { domain, checkerId, executionId }, + } as any), + ) as HappydnsObservationSnapshot; +} + +export async function getDomainCheckOptions( + domain: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdOptions({ + path: { domain, checkerId }, + } as any), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateDomainCheckOptions( + domain: string, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainCheckersByCheckerIdOptions({ + path: { domain, checkerId }, + body: options as any, + } as any), + ) as HappydnsCheckerOptions; +} + +export async function getDomainCheckPlans( + domain: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainCheckersByCheckerIdPlans({ + path: { domain, checkerId }, + } as any), + ) as HappydnsCheckPlan[]) ?? []; +} + +export async function createDomainCheckPlan( + domain: string, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainCheckersByCheckerIdPlans({ + path: { domain, checkerId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +export async function updateDomainCheckPlan( + domain: string, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({ + path: { domain, checkerId, planId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +// Service-scoped checker API functions + +export async function listServiceCheckers( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({ + path: { domain, zoneid, subdomain, serviceid }, + } as any), + ) as HappydnsCheckerStatus[]) ?? []; +} + +export async function listServiceExecutions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + query: { + ...(options?.includePlanned ? { include_planned: "true" } : {}), + ...(options?.limit ? { limit: String(options.limit) } : {}), + }, + } as any), + ) as HappydnsExecution[]) ?? []; +} + +export async function triggerServiceCheck( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: request, + } as any), + ) as HappydnsExecution; +} + +export async function getServiceExecution( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsExecution; +} + +export async function deleteServiceExecution( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ); +} + +export async function deleteAllServiceExecutions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return unwrapEmptyResponse( + await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ); +} + +export async function getServiceExecutionResults( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsCheckEvaluation; +} + +export async function getServiceExecutionObservations( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({ + path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, + } as any), + ) as HappydnsObservationSnapshot; +} + +export async function getServiceCheckOptions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ) as HappydnsCheckerOptionsPositional[]) ?? []; +} + +export async function updateServiceCheckOptions( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: options as any, + } as any), + ) as HappydnsCheckerOptions; +} + +export async function getServiceCheckPlans( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, +): Promise { + return (unwrapSdkResponse( + await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + } as any), + ) as HappydnsCheckPlan[]) ?? []; +} + +export async function createServiceCheckPlan( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ + path: { domain, zoneid, subdomain, serviceid, checkerId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +export async function updateServiceCheckPlan( + domain: string, + zoneid: string, + subdomain: string, + serviceid: string, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + return unwrapSdkResponse( + await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({ + path: { domain, zoneid, subdomain, serviceid, checkerId, planId }, + body: plan as any, + } as any), + ) as HappydnsCheckPlan; +} + +// Scope-aware helpers + +export interface CheckerScope { + domainId: string; + zoneId?: string; + subdomain?: string; + serviceId?: string; +} + +function isServiceScope(scope: CheckerScope): scope is CheckerScope & { zoneId: string; subdomain: string; serviceId: string } { + return !!(scope.zoneId && scope.subdomain !== undefined && scope.serviceId); +} + +export async function listScopedCheckers( + scope: CheckerScope, +): Promise { + if (isServiceScope(scope)) { + return listServiceCheckers(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId); + } + return listDomainCheckers(scope.domainId); +} + +export async function listScopedExecutions( + scope: CheckerScope, + checkerId: string, + options?: { includePlanned?: boolean; limit?: number }, +): Promise { + if (isServiceScope(scope)) { + return listServiceExecutions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, options); + } + return listDomainExecutions(scope.domainId, checkerId, options); +} + +export async function triggerScopedCheck( + scope: CheckerScope, + checkerId: string, + request?: HappydnsCheckerRunRequest, +): Promise { + if (isServiceScope(scope)) { + return triggerServiceCheck(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, request); + } + return triggerDomainCheck(scope.domainId, checkerId, request); +} + +export async function getScopedExecution( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecution(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecution(scope.domainId, checkerId, executionId); +} + +export async function deleteScopedExecution( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return deleteServiceExecution(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return deleteDomainExecution(scope.domainId, checkerId, executionId); +} + +export async function deleteAllScopedExecutions( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return deleteAllServiceExecutions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return deleteAllDomainExecutions(scope.domainId, checkerId); +} + +export async function getScopedExecutionResults( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecutionResults(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecutionResults(scope.domainId, checkerId, executionId); +} + +export async function getScopedExecutionObservations( + scope: CheckerScope, + checkerId: string, + executionId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceExecutionObservations(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, executionId); + } + return getDomainExecutionObservations(scope.domainId, checkerId, executionId); +} + +export async function getScopedCheckOptions( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceCheckOptions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return getDomainCheckOptions(scope.domainId, checkerId); +} + +export async function updateScopedCheckOptions( + scope: CheckerScope, + checkerId: string, + options: HappydnsCheckerOptions, +): Promise { + if (isServiceScope(scope)) { + return updateServiceCheckOptions(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, options); + } + return updateDomainCheckOptions(scope.domainId, checkerId, options); +} + +export async function getScopedCheckPlans( + scope: CheckerScope, + checkerId: string, +): Promise { + if (isServiceScope(scope)) { + return getServiceCheckPlans(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId); + } + return getDomainCheckPlans(scope.domainId, checkerId); +} + +export async function createScopedCheckPlan( + scope: CheckerScope, + checkerId: string, + plan: HappydnsCheckPlan, +): Promise { + if (isServiceScope(scope)) { + return createServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, plan); + } + return createDomainCheckPlan(scope.domainId, checkerId, plan); +} + +export async function updateScopedCheckPlan( + scope: CheckerScope, + checkerId: string, + planId: string, + plan: HappydnsCheckPlan, +): Promise { + if (isServiceScope(scope)) { + return updateServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, planId, plan); + } + return updateDomainCheckPlan(scope.domainId, checkerId, planId, plan); +} diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index f0ee39a2..8a98da9e 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -77,6 +77,7 @@ "subdomain": "subdomain", "actions": { "audit": "View changes logs", + "checks": "Domain checks", "do-migration": "Migrate now", "history": "View changes history", "propagate": "Publish my changes", @@ -255,6 +256,7 @@ "my-domains": "My domains", "my-providers": "My domain providers", "dns-resolver": "DNS resolver", + "checkers": "Configure Checkers", "my-account": "My account", "logout": "Sign out", "provider-features": "Supported providers", @@ -603,5 +605,247 @@ "import-text": "Import from text", "import-file": "Import from file", "return-to": "Go to the zone" + }, + "checkers": { + "run-check": { + "title": "Run Check", + "loading-options": "Loading checker options...", + "select-rule": "Rule to check", + "configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.", + "no-options": "This checker has no configurable options. Click \"Run Check\" to execute with default settings.", + "no-run-options": "This checker has no run-time options. You can still override advanced settings below.", + "error-loading-options": "Error loading checker options: {{error}}", + "run-button": "Run Check", + "triggered-success": "Check triggered successfully! Execution ID: {{id}}", + "trigger-failed": "Failed to trigger check: {{error}}", + "advanced-options": "Advanced options", + "rules": "Rules" + }, + "never": "Never", + "na": "N/A", + "relative": { + "in-less-than-a-minute": "in less than a minute", + "just-now": "just now", + "in": "in {{label}}", + "ago": "{{label}} ago" + }, + "status": { + "ok": "OK", + "info": "Info", + "warning": "Warning", + "critical": "Critical", + "error": "Error", + "unknown": "Unknown", + "pending": "Pending", + "planned": "Planned", + "running": "Running", + "not-run": "Not run" + }, + "list": { + "title": "Checks for ", + "title-service": "Checks for {{service}}", + "loading": "Loading checkers...", + "loading-checkers": "Loading checker information...", + "no-checks": "No checks available for this domain.", + "no-checks-service": "No checks available for this service.", + "run-check": "Run Check", + "view-results": "View Results", + "configure": "Configure", + "error-loading": "Error loading checkers: {{error}}", + "unknown-version": "Unknown", + "table": { + "checker": "Checker", + "status": "Status", + "last-run": "Last Run", + "schedule": "Schedule", + "actions": "Actions" + }, + "schedule": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "loading-checks": "Loading checker information..." + }, + "other-checkers": { + "title": "Other available checkers", + "description": "These checkers are not directly associated with this domain but can be configured with domain-specific options.", + "no-checkers": "No other checkers available.", + "configure": "Configure" + }, + "schedule": { + "title": "Schedule", + "card-title": "Automatic scheduling", + "auto-enabled": "Run automatically", + "auto-disabled": "Disabled (run manually only)", + "interval-label": "Check interval", + "hours": "hours", + "interval-hint": "Minimum 1 hour. The check will run once per interval.", + "interval-hint-bounded": "Between {{min}} and {{max}} hours.", + "next-run": "Next scheduled run", + "last-run": "Last run", + "no-schedule-yet": "No schedule created yet. Save to create one.", + "save": "Save", + "save-failed": "Failed to save schedule", + "saved": "Schedule saved successfully." + }, + "executions": { + "loading": "Loading check results...", + "no-results": "No check results yet. Click \"Run Check Now\" to execute the check.", + "title": "Check Executions ({{count}})", + "run-check-now": "Run Check Now", + "back-to-checks": "Back to checks", + "delete-all": "Delete All", + "delete-confirm": "Are you sure you want to delete this check result?", + "delete-all-confirm": "Are you sure you want to delete ALL check results for this checker? This cannot be undone.", + "deleted-all": "All check results have been deleted.", + "delete-failed": "Failed to delete result", + "delete-all-failed": "Failed to delete results", + "configure": "Configure", + "domain-level": "Domain-level", + "error-loading": "Error loading checker results: {{error}}", + "error-deleting": "Error deleting execution: {{error}}", + "table": { + "executed-at": "Executed At", + "status": "Status", + "message": "Message", + "duration": "Duration", + "type": "Type", + "actions": "Actions" + }, + "type": { + "scheduled": "Scheduled", + "manual": "Manual" + }, + "pending": { + "queued": "Queued", + "queued-description": "Queued, waiting to run\u2026", + "running": "Running", + "running-description": "Check is currently running\u2026" + }, + "view": "View" + }, + "execution": { + "title": "Check Execution Details", + "field": { + "ended-at": "Ended At:", + "trigger": "Trigger:", + "error": "Error:" + }, + "status": { + "pending": "Pending", + "running": "Running", + "done": "Done", + "failed": "Failed", + "unknown": "Unknown" + } + }, + "result": { + "title": "Check Result Details", + "loading": "Loading check result...", + "relaunch": "Relaunch Check", + "delete": "Delete Result", + "relaunch-failed": "Failed to relaunch check", + "delete-confirm": "Are you sure you want to delete this check?", + "delete-failed": "Failed to delete result", + "error-loading": "Error loading check: {{error}}", + "milliseconds": "milliseconds", + "seconds": "seconds", + "type": { + "scheduled": "Scheduled Check", + "manual": "Manual Check" + }, + "check-options": "Check Options", + "full-report": "Full Report", + "field": { + "domain": "Domain:", + "executed-at": "Executed At:", + "duration": "Duration:", + "status": "Status:", + "status-message": "Message:", + "error": "Error:" + }, + "view-metrics": "Metrics", + "view-html": "HTML Report", + "view-json": "Raw JSON", + "download-html": "Download HTML", + "download-json": "Download JSON" + }, + "title": "Checkers", + "description": "Configure automated checks for your domains", + "available-count": "Available: {{count}} checkers", + "search-placeholder": "Search checkers...", + "loading": "Loading checkers...", + "loading-info": "Loading checker information...", + "no-checkers": "No checkers available", + "service-checks": "Service Checks", + "view-all": "View all", + "no-checks": "No checks available", + "load-error": "Error loading checks", + "error-loading": "Error loading checkers: {{error}}", + "error-loading-checker": "Error loading checker: {{error}}", + "checker-info-not-found": "Error: Checker information not found", + "back-to-checkers": "Back to checkers", + "table": { + "name": "Checker Name", + "availability": "Availability", + "actions": "Actions", + "manage": "Manage" + }, + "availability": { + "domain": "Domain", + "zone": "Zone", + "provider-specific": "Provider-specific", + "service-specific": "Service-specific", + "general": "General", + "user-level": "User-level", + "domain-level": "Domain-level", + "zone-level": "Zone-level", + "service-level": "Service-level", + "providers": "Providers: {{providers}}", + "services": "Services: {{services}}" + }, + "actions": { + "configure": "Configure" + }, + "sidebar": { + "back-to-list": "Back to checkers list" + }, + "detail": { + "checker-information": "Checker Information", + "name": "Name:", + "availability": "Availability:", + "loading-options": "Loading options...", + "check-rules": "Check Individual Rules", + "admin-options": "Admin Options", + "configuration": "Configuration", + "save": "Save", + "save-changes": "Save Changes", + "no-configurable-options": "This checker has no configurable options.", + "error-loading-options": "Error loading options: {{error}}", + "orphaned-options": "Orphaned options detected: {{options}}", + "clean-up": "Clean Up", + "read-only": "Read-only" + }, + "option-groups": { + "global-settings": "Global Settings", + "domain-settings": "Domain-specific Settings", + "service-settings": "Service-specific Settings", + "checker-parameters": "Checker Parameters", + "type": "Type: {{type}}", + "required": "Required", + "auto-fill": "Auto-filled Fields" + }, + "auto-fill": { + "domain_name": "auto-filled: domain name", + "subdomain": "auto-filled: subdomain", + "service_type": "auto-filled: service type", + "generic": "auto-filled: {{key}}" + }, + "messages": { + "options-updated": "Checker options updated successfully", + "options-cleaned": "Orphaned options removed successfully", + "update-failed": "Failed to update options: {{error}}", + "clean-failed": "Failed to clean options: {{error}}" + } } } diff --git a/web/src/lib/model/custom_form.svelte.ts b/web/src/lib/model/custom_form.svelte.ts index 975474ad..56a595e5 100644 --- a/web/src/lib/model/custom_form.svelte.ts +++ b/web/src/lib/model/custom_form.svelte.ts @@ -31,6 +31,7 @@ export class Field { required? = $state(); secret? = $state(); textarea? = $state(); + autoFill? = $state(); } export class CustomForm { diff --git a/web/src/lib/model/zone.ts b/web/src/lib/model/zone.ts index 97530689..49157bfd 100644 --- a/web/src/lib/model/zone.ts +++ b/web/src/lib/model/zone.ts @@ -19,7 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { HappydnsZoneMeta } from "$lib/api-base/types.gen"; +import type { HappydnsStatus, HappydnsZoneMeta } from "$lib/api-base/types.gen"; import type { ServiceWithValue } from "$lib/model/service.svelte"; export interface ServiceRecord { @@ -34,4 +34,5 @@ export type ZoneMeta = HappydnsZoneMeta; export interface Zone extends ZoneMeta { services: Record>; + services_check_status?: Record; } diff --git a/web/src/lib/stores/checkers.ts b/web/src/lib/stores/checkers.ts new file mode 100644 index 00000000..b9b2bfab --- /dev/null +++ b/web/src/lib/stores/checkers.ts @@ -0,0 +1,42 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { writable, type Writable } from "svelte/store"; +import { listCheckers } from "$lib/api/checkers"; +import type { + HappydnsCheckerDefinition, + HappydnsExecution, + HappydnsObservationSnapshot, +} from "$lib/api-base/types.gen"; + +export const checkers: Writable | undefined> = + writable(undefined); + +export async function refreshCheckers() { + const data = await listCheckers(); + checkers.set(data); + return data; +} + +// Stores for the currently viewed execution detail page +export const currentExecution: Writable = writable(undefined); +export const currentCheckInfo: Writable = writable(undefined); +export const currentObservations: Writable = writable(undefined); diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index 4c34066d..cd034c59 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -44,6 +44,8 @@ interface Params { nbDiffs?: number; nbSelected?: number; countdown?: string; + error?: string; + options?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts new file mode 100644 index 00000000..5d8e25eb --- /dev/null +++ b/web/src/lib/utils/checkers.ts @@ -0,0 +1,134 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import type { HappydnsCheckerOptionDocumentation, HappydnsExecutionStatus, HappydnsStatus } from "$lib/api-base/types.gen"; + +// HappydnsStatus: 0=Unknown, 1=OK, 2=Info, 3=Warn, 4=Crit, 5=Error + +export function getStatusColor(status: HappydnsStatus | undefined): string { + switch (status) { + case 0: return "secondary"; + case 1: return "success"; + case 2: return "info"; + case 3: return "warning"; + case 4: return "danger"; + case 5: return "danger"; + default: return "secondary"; + } +} + +export function getStatusI18nKey(status: HappydnsStatus | undefined): string { + switch (status) { + case 0: return "checkers.status.unknown"; + case 1: return "checkers.status.ok"; + case 2: return "checkers.status.info"; + case 3: return "checkers.status.warning"; + case 4: return "checkers.status.critical"; + case 5: return "checkers.status.error"; + default: return "checkers.status.not-run"; + } +} + +export function getStatusIcon(status: HappydnsStatus | undefined): string { + switch (status) { + case 1: return "check-circle-fill"; + case 2: return "info-circle-fill"; + case 3: return "exclamation-triangle-fill"; + case 4: return "exclamation-octagon-fill"; + case 5: return "exclamation-octagon-fill"; + default: return "question-circle-fill"; + } +} + +// HappydnsExecutionStatus: 0=Pending, 1=Running, 2=Done, 3=Failed + +export function getExecutionStatusColor(status: HappydnsExecutionStatus | undefined): string { + switch (status) { + case 0: return "secondary"; + case 1: return "primary"; + case 2: return "success"; + case 3: return "danger"; + default: return "secondary"; + } +} + +export function getExecutionStatusI18nKey(status: HappydnsExecutionStatus | undefined): string { + switch (status) { + case 0: return "checkers.execution.status.pending"; + case 1: return "checkers.execution.status.running"; + case 2: return "checkers.execution.status.done"; + case 3: return "checkers.execution.status.failed"; + default: return "checkers.execution.status.unknown"; + } +} + +export function withInheritedPlaceholders( + opts: HappydnsCheckerOptionDocumentation[], + optionValues: Record, + inheritedValues: Record, +): HappydnsCheckerOptionDocumentation[] { + return opts.map((opt) => { + if ( + opt.id && + optionValues[opt.id] === undefined && + inheritedValues[opt.id] !== undefined + ) { + return { ...opt, placeholder: String(inheritedValues[opt.id]) }; + } + return opt; + }); +} + +export function collectAllOptionDocs( + status: { options?: { runOpts?: HappydnsCheckerOptionDocumentation[]; adminOpts?: HappydnsCheckerOptionDocumentation[]; userOpts?: HappydnsCheckerOptionDocumentation[]; domainOpts?: HappydnsCheckerOptionDocumentation[] }; rules?: { options?: { runOpts?: HappydnsCheckerOptionDocumentation[]; adminOpts?: HappydnsCheckerOptionDocumentation[]; userOpts?: HappydnsCheckerOptionDocumentation[]; domainOpts?: HappydnsCheckerOptionDocumentation[] } }[] }, +): HappydnsCheckerOptionDocumentation[] { + return [ + ...(status.options?.runOpts || []), + ...(status.options?.adminOpts || []), + ...(status.options?.userOpts || []), + ...(status.options?.domainOpts || []), + ...(status.rules || []).flatMap((r) => [ + ...(r.options?.runOpts || []), + ...(r.options?.adminOpts || []), + ...(r.options?.userOpts || []), + ...(r.options?.domainOpts || []), + ]), + ]; +} + +export function downloadBlob(content: string, filename: string, mime: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function formatCheckDate(date: string | undefined): string { + if (!date) return ""; + try { + return new Date(date).toLocaleString(); + } catch { + return date; + } +} diff --git a/web/src/lib/utils/datetime.ts b/web/src/lib/utils/datetime.ts index ee38c5be..ab587699 100644 --- a/web/src/lib/utils/datetime.ts +++ b/web/src/lib/utils/datetime.ts @@ -18,6 +18,45 @@ export function toDatetimeLocal(isoString: string | null | undefined): string { } } +/** + * Format a Go time.Duration (nanoseconds) into a human-readable string. + * @param ns Duration in nanoseconds + * @returns Human-readable string such as "30s", "5m", "2h", "3d" + */ +export function formatDuration(ns: number | undefined): string { + if (ns == null) return "—"; + const s = ns / 1e9; + if (s < 60) return `${s}s`; + const m = s / 60; + if (m < 60) return `${m}m`; + const h = m / 60; + if (h < 24) return `${h}h`; + return `${h / 24}d`; +} + +/** + * Format a date string to relative time (e.g. "in 5m", "3h ago"). + * @param dateStr ISO 8601 date string + * @returns Human-readable relative time string + */ +export function formatRelative(dateStr: string | undefined): string { + if (!dateStr) return "—"; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const absMs = Math.abs(diffMs); + const s = Math.round(absMs / 1000); + const m = Math.round(s / 60); + const h = Math.round(m / 60); + + let rel: string; + if (s < 60) rel = `${s}s`; + else if (m < 60) rel = `${m}m`; + else rel = `${h}h`; + + return diffMs >= 0 ? `in ${rel}` : `${rel} ago`; +} + /** * Convert datetime-local format back to ISO 8601 string * @param datetimeLocal Datetime-local format string (YYYY-MM-DDTHH:mm) diff --git a/web/src/lib/utils/index.ts b/web/src/lib/utils/index.ts index 8f586662..eec9a9ba 100644 --- a/web/src/lib/utils/index.ts +++ b/web/src/lib/utils/index.ts @@ -2,4 +2,5 @@ * Centralized utility exports */ -export { toDatetimeLocal, fromDatetimeLocal } from './datetime'; +export { toDatetimeLocal, fromDatetimeLocal, formatDuration } from './datetime'; +export { getStatusColor, getStatusIcon, getStatusI18nKey, getExecutionStatusColor, getExecutionStatusI18nKey, formatCheckDate, withInheritedPlaceholders, downloadBlob, collectAllOptionDocs } from './checkers'; From 28bed1cb463321243b78e71525e90748b33c3a21 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Apr 2026 11:55:59 +0700 Subject: [PATCH 35/54] checkers: add frontend UI components and routes Add all checker UI pages and components: - Checker list, config, schedule, and rules pages - Execution list, detail, results, and rules pages - Sidebar components for domain/service checker status - Run check modal with option overrides and rule selection - Domain-scoped and service-scoped check routes - Admin pages for checker configuration and scheduler management - Header navigation link for checkers section --- web-admin/src/routes/+layout.svelte | 6 + web-admin/src/routes/checkers/+page.svelte | 141 ++++++ .../routes/checkers/[checkerId]/+page.svelte | 452 ++++++++++++++++++ web-admin/src/routes/scheduler/+page.svelte | 262 ++++++++++ web/src/lib/api/checkers.ts | 87 ++-- web/src/lib/components/Header.svelte | 8 + .../checkers/CheckResultSidebar.svelte | 276 +++++++++++ .../checkers/CheckerConfigPage.svelte | 187 ++++++++ .../checkers/CheckerListPage.svelte | 181 +++++++ .../checkers/CheckerOptionsGroups.svelte | 70 +++ .../checkers/CheckerOptionsPanel.svelte | 197 ++++++++ .../checkers/CheckerRulesCard.svelte | 165 +++++++ .../checkers/CheckerScheduleCard.svelte | 147 ++++++ .../components/checkers/CheckerSidebar.svelte | 81 ++++ .../checkers/CheckersAvailabilityTable.svelte | 83 ++++ .../checkers/ChecksSidebarContent.svelte | 103 ++++ .../checkers/DomainCheckerSidebar.svelte | 152 ++++++ .../checkers/ExecutionDetailPage.svelte | 102 ++++ .../checkers/ExecutionListPage.svelte | 275 +++++++++++ .../checkers/ExecutionResultsCard.svelte | 63 +++ .../checkers/ExecutionRulesPage.svelte | 79 +++ .../checkers/ExecutionSidebarContent.svelte | 253 ++++++++++ .../checkers/ObservationReportCard.svelte | 43 ++ web/src/lib/components/inputs/Resource.svelte | 3 +- .../components/modals/RunCheckModal.svelte | 326 +++++++++++++ web/src/lib/translations.ts | 1 + web/src/lib/utils/checkers.ts | 5 +- web/src/routes/checkers/+layout.ts | 31 ++ web/src/routes/checkers/+page.svelte | 99 ++++ .../checkers/[checkerId]/+layout.svelte | 53 ++ .../routes/checkers/[checkerId]/+page.svelte | 239 +++++++++ web/src/routes/domains/[dn]/+layout.svelte | 69 ++- .../[dn]/ServiceDetailsOffcanvas.svelte | 70 +++ .../routes/domains/[dn]/ZoneSidebar.svelte | 3 + .../[dn]/[[historyid]]/ServiceCard.svelte | 8 +- .../[subdomain]/[serviceid]/+page.svelte | 8 + .../[subdomain]/[serviceid]/checks/+layout.ts | 19 + .../[serviceid]/checks/+page.svelte | 48 ++ .../checks/[checkerId]/+page.svelte | 74 +++ .../[checkerId]/executions/+page.svelte | 47 ++ .../executions/[execId]/+page.svelte | 42 ++ .../executions/[execId]/rules/+page.svelte | 44 ++ web/src/routes/domains/[dn]/checks/+layout.ts | 14 + .../routes/domains/[dn]/checks/+page.svelte | 42 ++ .../[dn]/checks/[checkerId]/+page.svelte | 68 +++ .../[checkerId]/executions/+page.svelte | 41 ++ .../executions/[execId]/+page.svelte | 39 ++ .../[execId]/ObservationReportCard.svelte | 35 ++ .../executions/[execId]/rules/+page.svelte | 40 ++ 49 files changed, 4821 insertions(+), 60 deletions(-) create mode 100644 web-admin/src/routes/checkers/+page.svelte create mode 100644 web-admin/src/routes/checkers/[checkerId]/+page.svelte create mode 100644 web-admin/src/routes/scheduler/+page.svelte create mode 100644 web/src/lib/components/checkers/CheckResultSidebar.svelte create mode 100644 web/src/lib/components/checkers/CheckerConfigPage.svelte create mode 100644 web/src/lib/components/checkers/CheckerListPage.svelte create mode 100644 web/src/lib/components/checkers/CheckerOptionsGroups.svelte create mode 100644 web/src/lib/components/checkers/CheckerOptionsPanel.svelte create mode 100644 web/src/lib/components/checkers/CheckerRulesCard.svelte create mode 100644 web/src/lib/components/checkers/CheckerScheduleCard.svelte create mode 100644 web/src/lib/components/checkers/CheckerSidebar.svelte create mode 100644 web/src/lib/components/checkers/CheckersAvailabilityTable.svelte create mode 100644 web/src/lib/components/checkers/ChecksSidebarContent.svelte create mode 100644 web/src/lib/components/checkers/DomainCheckerSidebar.svelte create mode 100644 web/src/lib/components/checkers/ExecutionDetailPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionListPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionResultsCard.svelte create mode 100644 web/src/lib/components/checkers/ExecutionRulesPage.svelte create mode 100644 web/src/lib/components/checkers/ExecutionSidebarContent.svelte create mode 100644 web/src/lib/components/checkers/ObservationReportCard.svelte create mode 100644 web/src/lib/components/modals/RunCheckModal.svelte create mode 100644 web/src/routes/checkers/+layout.ts create mode 100644 web/src/routes/checkers/+page.svelte create mode 100644 web/src/routes/checkers/[checkerId]/+layout.svelte create mode 100644 web/src/routes/checkers/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/+layout.ts create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/[execId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks/[checkerId]/executions/[execId]/rules/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/+layout.ts create mode 100644 web/src/routes/domains/[dn]/checks/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/+page.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/ObservationReportCard.svelte create mode 100644 web/src/routes/domains/[dn]/checks/[checkerId]/executions/[execId]/rules/+page.svelte diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index f28ed70e..b0bc7659 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -101,6 +101,12 @@ Sessions + + Checkers + + + Scheduler + diff --git a/web-admin/src/routes/checkers/+page.svelte b/web-admin/src/routes/checkers/+page.svelte new file mode 100644 index 00000000..5117cf04 --- /dev/null +++ b/web-admin/src/routes/checkers/+page.svelte @@ -0,0 +1,141 @@ + + + + + + + +

+ + Checkers +

+

+ Manage all checkers + {#await checkersQ then checkersR} + Total: {Object.keys(checkersR.data ?? {}).length} checkers + {/await} +

+ +
+ + + + + + + + + + + + + {#await checkersQ} + Please wait... + {:then checkersR} + {@const checkers = checkersR.data} +
+ + + + + + + + + + {#if !checkers || Object.keys(checkers).length == 0} + + + + {:else} + {#each Object.entries(checkers ?? {}).filter(([name, _info]) => name + .toLowerCase() + .indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]} + + + + + + {/each} + {/if} + +
Plugin NameAvailabilityActions
+ No checkers available +
{checkerInfo.name || checkerId} + {#if availabilityBadges(checkerInfo.availability).length > 0} + {#each availabilityBadges(checkerInfo.availability) as badge} + {badge.label} + {/each} + {:else} + General + {/if} + + + + Manage + +
+
+ {:catch error} + +

+ + Error loading checkers: {error.message} +

+
+ {/await} +
diff --git a/web-admin/src/routes/checkers/[checkerId]/+page.svelte b/web-admin/src/routes/checkers/[checkerId]/+page.svelte new file mode 100644 index 00000000..0c73776d --- /dev/null +++ b/web-admin/src/routes/checkers/[checkerId]/+page.svelte @@ -0,0 +1,452 @@ + + + + + + + + +

+ + {checkerId} +

+ +
+ + {#await checkerQ} + +

+ + Loading checker status... +

+
+ {:then checkerR} + {@const checker = checkerR.data} + {#if checker} + + + + + Checker Information + + +
+
Name:
+
{checker.name}
+ +
Availability:
+
+ {#if availabilityBadges(checker.availability).length > 0} +
+ {#each availabilityBadges(checker.availability) as badge} + {badge.label}-level + {/each} +
+ {:else} + General + {/if} + {#if checker.availability?.limitToProviders?.length} +
+ Providers: {checker.availability.limitToProviders.join( + ", ", + )} +
+ {/if} + {#if checker.availability?.limitToServices?.length} +
+ Services: {checker.availability.limitToServices.join( + ", ", + )} +
+ {/if} +
+ + {#if checker.interval} +
Interval:
+
+ default {formatDuration(checker.interval.default)} + + (min {formatDuration(checker.interval.min)} / max {formatDuration(checker.interval.max)}) + +
+ {/if} +
+
+
+ + {#if checker.rules && checker.rules.length > 0} + + +
+ Check Rules + + {checker.rules.length} + +
+ {#if checker.rules.reduce((acc, rule) => acc + rule.options?.adminOpts?.length, 0) > 0} + + {/if} +
+ + {#each checker.rules as rule, i} + {@const ruleOpts = rule.options?.adminOpts || []} + +
+ +
+ {rule.name} + {#if rule.description} +

+ {rule.description} +

+ {/if} +
+
+ {#if ruleOpts.length > 0} +
+
+ {#each ruleOpts as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+ {/if} +
+ {/each} +
+
+ {/if} + + + + {#await checkerOptionsQ} + + +

+ + Loading options... +

+
+
+ {:then _optionsR} + {@const adminOpts = checker.options?.adminOpts || []} + {@const readOnlyOptGroups = [ + { + key: "userOpts", + label: "User Options", + opts: checker.options?.userOpts || [], + }, + { + key: "domainOpts", + label: "Domain Options", + opts: checker.options?.domainOpts || [], + }, + { + key: "serviceOpts", + label: "Service Options", + opts: checker.options?.serviceOpts || [], + }, + { + key: "runOpts", + label: "Run Options", + opts: checker.options?.runOpts || [], + }, + ]} + {@const rulesAdminOpts = (checker.rules || []).flatMap( + (r) => r.options?.adminOpts || [], + )} + {@const allAdminOpts = [...adminOpts, ...rulesAdminOpts]} + {@const hasAnyOpts = + allAdminOpts.length > 0 || + readOnlyOptGroups.some((g) => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(allAdminOpts)} + + {#if orphanedOpts.length > 0} + +
+
+ + Orphaned options detected: + {orphanedOpts.join(", ")} +
+ +
+
+ {/if} + + {#if adminOpts.length > 0} + + + Admin Options + + + +
+ {#each adminOpts as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+
+ {/if} + + {#each readOnlyOptGroups.filter((g) => g.opts.length > 0) as group} + + + {group.label} + read-only + + +
+ {#each group.opts as opt} +
{opt.label || opt.id}
+
+ {opt.type || "string"} + {#if opt.description} +
{opt.description}
+ {/if} +
+ {/each} +
+
+
+ {/each} + + {#if !hasAnyOpts} + + + + + This checker has no configurable options. + + + + {/if} + {:catch error} + + + + + Error loading options: {error.message} + + + + {/await} + +
+ {:else} + + + Error: checker data not found + + {/if} + {:catch error} + + + Error loading checker: {error.message} + + {/await} +
diff --git a/web-admin/src/routes/scheduler/+page.svelte b/web-admin/src/routes/scheduler/+page.svelte new file mode 100644 index 00000000..d62df7f6 --- /dev/null +++ b/web-admin/src/routes/scheduler/+page.svelte @@ -0,0 +1,262 @@ + + + + + + + +

+ + Scheduler +

+

Monitor and control the checker scheduler

+ +
+ + {#if error} + + + {error} + + {/if} + + {#if loading} +
+ + Loading scheduler status... +
+ {:else if status} + + +
+ + + Scheduler Status + +
+ + + +
+
+
+ +
+
+ Status + {#if status.running} + Running + {:else} + Stopped + {/if} +
+
+ Jobs in queue + {status.job_count ?? 0} +
+
+
+
+ + + + + Next scheduled jobs + {status.next_jobs?.length ?? 0} + + +
+ + + + + + + + + + + {#if !status.next_jobs || status.next_jobs.length === 0} + + + + {:else} + {#each status.next_jobs as job} + + + + + + + {/each} + {/if} + +
CheckerTargetIntervalNext run
+ No jobs scheduled +
+ {job.checkerID ?? "—"} + + {#if job.target?.domainId} + + domain + + {/if} + {#if job.target?.serviceId} + + service + + {/if} + {#if job.target?.userId} + + user + + {/if} + {#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId} + + {/if} + {formatDuration(job.interval)} + {formatRelative(job.nextRun)} +
+
+
+
+ {/if} +
diff --git a/web/src/lib/api/checkers.ts b/web/src/lib/api/checkers.ts index 8c819271..aac15c4d 100644 --- a/web/src/lib/api/checkers.ts +++ b/web/src/lib/api/checkers.ts @@ -54,6 +54,7 @@ import { import type { HappydnsCheckEvaluation, HappydnsCheckPlan, + HappydnsCheckPlanWritable, HappydnsCheckerDefinition, HappydnsCheckerOptions, HappydnsCheckerOptionsPositional, @@ -85,7 +86,7 @@ export async function updateCheckOptions( options: HappydnsCheckerOptions, ): Promise { return unwrapSdkResponse( - await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as any }), + await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options }), ) as HappydnsCheckerOptions; } @@ -93,7 +94,7 @@ export async function updateCheckOptions( export async function listDomainCheckers(domain: string): Promise { return (unwrapSdkResponse( - await getDomainsByDomainCheckers({ path: { domain } } as any), + await getDomainsByDomainCheckers({ path: { domain } }), ) as HappydnsCheckerStatus[]) ?? []; } @@ -106,10 +107,10 @@ export async function listDomainExecutions( await getDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, query: { - ...(options?.includePlanned ? { include_planned: "true" } : {}), - ...(options?.limit ? { limit: String(options.limit) } : {}), + ...(options?.includePlanned ? { include_planned: true } : {}), + ...(options?.limit ? { limit: options.limit } : {}), }, - } as any), + }), ) as HappydnsExecution[]) ?? []; } @@ -122,7 +123,7 @@ export async function triggerDomainCheck( await postDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, body: request, - } as any), + }), ) as HappydnsExecution; } @@ -134,7 +135,7 @@ export async function getDomainExecution( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsExecution; } @@ -146,7 +147,7 @@ export async function deleteDomainExecution( return unwrapEmptyResponse( await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, checkerId, executionId }, - } as any), + }), ); } @@ -157,7 +158,7 @@ export async function deleteAllDomainExecutions( return unwrapEmptyResponse( await deleteDomainsByDomainCheckersByCheckerIdExecutions({ path: { domain, checkerId }, - } as any), + }), ); } @@ -169,7 +170,7 @@ export async function getDomainExecutionResults( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsCheckEvaluation; } @@ -181,7 +182,7 @@ export async function getDomainExecutionObservations( return unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({ path: { domain, checkerId, executionId }, - } as any), + }), ) as HappydnsObservationSnapshot; } @@ -192,7 +193,7 @@ export async function getDomainCheckOptions( return (unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdOptions({ path: { domain, checkerId }, - } as any), + }), ) as HappydnsCheckerOptionsPositional[]) ?? []; } @@ -204,8 +205,8 @@ export async function updateDomainCheckOptions( return unwrapSdkResponse( await putDomainsByDomainCheckersByCheckerIdOptions({ path: { domain, checkerId }, - body: options as any, - } as any), + body: options, + }), ) as HappydnsCheckerOptions; } @@ -216,20 +217,20 @@ export async function getDomainCheckPlans( return (unwrapSdkResponse( await getDomainsByDomainCheckersByCheckerIdPlans({ path: { domain, checkerId }, - } as any), + }), ) as HappydnsCheckPlan[]) ?? []; } export async function createDomainCheckPlan( domain: string, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await postDomainsByDomainCheckersByCheckerIdPlans({ path: { domain, checkerId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -237,13 +238,13 @@ export async function updateDomainCheckPlan( domain: string, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({ path: { domain, checkerId, planId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -258,7 +259,7 @@ export async function listServiceCheckers( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({ path: { domain, zoneid, subdomain, serviceid }, - } as any), + }), ) as HappydnsCheckerStatus[]) ?? []; } @@ -274,10 +275,10 @@ export async function listServiceExecutions( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, query: { - ...(options?.includePlanned ? { include_planned: "true" } : {}), - ...(options?.limit ? { limit: String(options.limit) } : {}), + ...(options?.includePlanned ? { include_planned: true } : {}), + ...(options?.limit ? { limit: options.limit } : {}), }, - } as any), + }), ) as HappydnsExecution[]) ?? []; } @@ -293,7 +294,7 @@ export async function triggerServiceCheck( await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, body: request, - } as any), + }), ) as HappydnsExecution; } @@ -308,7 +309,7 @@ export async function getServiceExecution( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsExecution; } @@ -323,7 +324,7 @@ export async function deleteServiceExecution( return unwrapEmptyResponse( await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ); } @@ -337,7 +338,7 @@ export async function deleteAllServiceExecutions( return unwrapEmptyResponse( await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ); } @@ -352,7 +353,7 @@ export async function getServiceExecutionResults( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsCheckEvaluation; } @@ -367,7 +368,7 @@ export async function getServiceExecutionObservations( return unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({ path: { domain, zoneid, subdomain, serviceid, checkerId, executionId }, - } as any), + }), ) as HappydnsObservationSnapshot; } @@ -381,7 +382,7 @@ export async function getServiceCheckOptions( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ) as HappydnsCheckerOptionsPositional[]) ?? []; } @@ -396,8 +397,8 @@ export async function updateServiceCheckOptions( return unwrapSdkResponse( await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - body: options as any, - } as any), + body: options, + }), ) as HappydnsCheckerOptions; } @@ -411,7 +412,7 @@ export async function getServiceCheckPlans( return (unwrapSdkResponse( await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - } as any), + }), ) as HappydnsCheckPlan[]) ?? []; } @@ -421,13 +422,13 @@ export async function createServiceCheckPlan( subdomain: string, serviceid: string, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({ path: { domain, zoneid, subdomain, serviceid, checkerId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -438,13 +439,13 @@ export async function updateServiceCheckPlan( serviceid: string, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { return unwrapSdkResponse( await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({ path: { domain, zoneid, subdomain, serviceid, checkerId, planId }, - body: plan as any, - } as any), + body: plan as HappydnsCheckPlanWritable, + }), ) as HappydnsCheckPlan; } @@ -580,7 +581,7 @@ export async function getScopedCheckPlans( export async function createScopedCheckPlan( scope: CheckerScope, checkerId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { if (isServiceScope(scope)) { return createServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, plan); @@ -592,7 +593,7 @@ export async function updateScopedCheckPlan( scope: CheckerScope, checkerId: string, planId: string, - plan: HappydnsCheckPlan, + plan: HappydnsCheckPlan | HappydnsCheckPlanWritable, ): Promise { if (isServiceScope(scope)) { return updateServiceCheckPlan(scope.domainId, scope.zoneId, scope.subdomain, scope.serviceId, checkerId, planId, plan); diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index a3ffa88b..68e09069 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -138,6 +138,14 @@ > {$t("menu.dns-resolver")}
+ + {$t("menu.checkers")} + {$t("menu.my-account")} diff --git a/web/src/lib/components/checkers/CheckResultSidebar.svelte b/web/src/lib/components/checkers/CheckResultSidebar.svelte new file mode 100644 index 00000000..12f7ba40 --- /dev/null +++ b/web/src/lib/components/checkers/CheckResultSidebar.svelte @@ -0,0 +1,276 @@ + + + + +{#if $currentExecution} + + +
+ {$currentCheckInfo?.name || checkerId} + {#if $currentExecution.planId} + + + {$t("checkers.result.type.scheduled")} + + {:else} + + + {$t("checkers.result.type.manual")} + + {/if} +
+
+
+ + + + + + + {#if $currentExecution.endedAt && $currentExecution.startedAt} + + + + + {/if} + {#if $currentExecution.result} + + + + + {#if $currentExecution.result.message} + + + + + {/if} + {/if} + {#if $currentExecution.error} + + + + + {/if} + +
+ {$t("checkers.result.field.executed-at")} + {formatCheckDate($currentExecution.startedAt)}
{$t("checkers.result.field.duration")}{formatDuration(($currentExecution.endedAt.getTime() - $currentExecution.startedAt.getTime()) * 1e6)}
{$t("checkers.result.field.status")} + + {$t(getStatusI18nKey($currentExecution.result.status))} + +
{$t("checkers.result.field.status-message")} + {$currentExecution.result.message} +
{$t("checkers.result.field.error")} + {$currentExecution.error} +
+
+
+ +
+ + {#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentExecution.result != null} + {#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report} + + {#if $currentCheckInfo?.has_metrics} + + {/if} + {#if $currentCheckInfo?.has_html_report} + + {/if} + + + {/if} + + {#if $currentCheckInfo?.has_html_report} + + {/if} + {#if $currentExecution.result != null} + + {/if} + + {/if} +{:else} +
+{/if} + +
+ + +
diff --git a/web/src/lib/components/checkers/CheckerConfigPage.svelte b/web/src/lib/components/checkers/CheckerConfigPage.svelte new file mode 100644 index 00000000..77407013 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerConfigPage.svelte @@ -0,0 +1,187 @@ + + + + + + {resolvedStatus?.name ?? checkerId} - {domainName} - happyDomain + + +
+ + {#if $checkers && (!$checkers[checkerId]?.availability || $checkers[checkerId].availability.applyToDomain || $checkers[checkerId].availability.applyToZone)} + + {/if} + + + {#await checkStatusPromise} + +

+ + {$t("checkers.loading-info")} +

+
+ {:then status} + {#if status} + {@const editable = editableGroups(status)} + {@const readOnly = readOnlyGroups(status)} + + + + + {#if status.rules && status.rules.length > 0} + + {/if} + + + + + + + {:else} + + + {$t("checkers.checker-info-not-found")} + + {/if} + {:catch error} + + + {$t("checkers.error-loading-checker", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/CheckerListPage.svelte b/web/src/lib/components/checkers/CheckerListPage.svelte new file mode 100644 index 00000000..b3014c67 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerListPage.svelte @@ -0,0 +1,181 @@ + + + + + + {$t("checkers.list.title")}{domainName} - happyDomain + + +
+ + + {#await checkersPromise} + +

+ + {$t("checkers.list.loading")} +

+
+ {:then checkerStatuses} + {#if checkerStatuses.length > 0} +
+ + + + + + + + + + + + {#each checkerStatuses as checker} + {@const status = checker.latestExecution?.result?.status} + + + + + + + + {/each} + +
{$t("checkers.list.table.checker")}{$t("checkers.list.table.status")}{$t("checkers.list.table.last-run")}{$t("checkers.list.table.schedule")}{$t("checkers.list.table.actions")}
+ {checker.name || checker.id} + + {#if checker.latestExecution} + + {$t(getStatusI18nKey(status))} + + {:else} + + {$t("checkers.status.not-run")} + + {/if} + + {#if checker.latestExecution?.startedAt} + {formatCheckDate(checker.latestExecution.startedAt)} + {:else} + {$t("checkers.never")} + {/if} + + {#if checker.enabled} + + {$t("checkers.list.schedule.enabled")} + + {:else} + + {$t("checkers.list.schedule.disabled")} + + {/if} + + +
+
+ {:else} + + + {$t("checkers.list.no-checks")} + + {/if} + + {@const configuredIds = getConfiguredCheckerIds(checkerStatuses)} + {@const unconfigured = getUnconfiguredCheckers(configuredIds)} + {#if unconfigured.length > 0} + + + {$t("checkers.other-checkers.title")} + + +

{$t("checkers.other-checkers.description")}

+ +
+
+ {/if} + {:catch error} + + + {$t("checkers.list.error-loading", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/CheckerOptionsGroups.svelte b/web/src/lib/components/checkers/CheckerOptionsGroups.svelte new file mode 100644 index 00000000..58717d56 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerOptionsGroups.svelte @@ -0,0 +1,70 @@ + + + + +{#each groups.filter((g) => g.opts.length > 0) as group} + + + {group.label} + {$t("checkers.detail.read-only")} + + +
+ {#each group.opts as opt} +
+ {opt.label || opt.id} + {#if opt.autoFill} + {autoFillLabel(opt.autoFill)} + {/if} +
+
+ {opt.type || "string"} + {#if opt.description} +
{opt.description}
+ {/if} +
+ {/each} +
+
+
+{/each} diff --git a/web/src/lib/components/checkers/CheckerOptionsPanel.svelte b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte new file mode 100644 index 00000000..1369588f --- /dev/null +++ b/web/src/lib/components/checkers/CheckerOptionsPanel.svelte @@ -0,0 +1,197 @@ + + + + +{#await checkOptionsPromise} + + +

+ + {$t("checkers.detail.loading-options")} +

+
+
+{:then _options} + {#if orphanedOpts.length > 0 && onclean} + +
+
+ + {$t("checkers.detail.orphaned-options", { + options: orphanedOpts.join(", "), + })} +
+ +
+
+ {/if} + + {#each filteredEditableGroups.filter((g) => g.opts.length > 0) as group, gid} + + + {group.label} + + + +
+ {#each withInheritedPlaceholders(group.opts, optionValues, inheritedValues) as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+
+ {/each} + + {#if autoFillOpts.length > 0} + + {/if} + + + + {#if !hasAnyOpts} + + + + + {$t("checkers.detail.no-configurable-options")} + + + + {/if} +{:catch error} + + + + + {$t("checkers.detail.error-loading-options", { + error: error.message, + })} + + + +{/await} diff --git a/web/src/lib/components/checkers/CheckerRulesCard.svelte b/web/src/lib/components/checkers/CheckerRulesCard.svelte new file mode 100644 index 00000000..1b6b7539 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerRulesCard.svelte @@ -0,0 +1,165 @@ + + + + + + +
+ {$t("checkers.detail.check-rules")} + {rules.length} +
+ {#if plan} +
+
+ + +
+
+ {:else if hasRuleOpts} + + {/if} +
+ + {#each rules as rule} + {@const ruleOpts = [ + ...(rule.options?.adminOpts || []), + ...(rule.options?.userOpts || []), + ]} + +
+ {#if plan} +
+ { + if (rule.name && plan) { + plan.enabled = { + ...plan.enabled, + [rule.name]: !(plan.enabled?.[rule.name] ?? false), + }; + } + }} + /> +
+ {:else} + + {/if} +
+ {rule.name} + {#if rule.description} +

{rule.description}

+ {/if} +
+
+ {#if ruleOpts.length > 0} +
+
+ {#each withInheritedPlaceholders(ruleOpts, optionValues, inheritedValues) as optDoc, index} + {#if optDoc.id} + + {/if} + {/each} + +
+ {/if} +
+ {/each} +
+
diff --git a/web/src/lib/components/checkers/CheckerScheduleCard.svelte b/web/src/lib/components/checkers/CheckerScheduleCard.svelte new file mode 100644 index 00000000..840f8e73 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerScheduleCard.svelte @@ -0,0 +1,147 @@ + + + + + + + {$t("checkers.schedule.card-title")} + + + +
+ + +
+ + setIntervalHours(parseInt((e.target as HTMLInputElement).value) || 1)} + style="width: 100px" + /> + {$t("checkers.schedule.hours")} +
+ {$t("checkers.schedule.interval-hint")} +
+
+ + {#if !existingPlanId} + + + {$t("checkers.schedule.no-schedule-yet")} + + {/if} +
+
diff --git a/web/src/lib/components/checkers/CheckerSidebar.svelte b/web/src/lib/components/checkers/CheckerSidebar.svelte new file mode 100644 index 00000000..ec7c0005 --- /dev/null +++ b/web/src/lib/components/checkers/CheckerSidebar.svelte @@ -0,0 +1,81 @@ + + + + +
+ + + {$t("checkers.title")} + + + {#if $checkers} + + {:else} +
+ +
+ {/if} +
+ + diff --git a/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte b/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte new file mode 100644 index 00000000..94d7d0d3 --- /dev/null +++ b/web/src/lib/components/checkers/CheckersAvailabilityTable.svelte @@ -0,0 +1,83 @@ + + + + +
+ + + + + + + + + + {#each checkers as [checkerId, checkerInfo]} + {@const badges = availabilityBadges(checkerInfo.availability)} + + + + + + {/each} + +
{$t("checkers.table.name")}{$t("checkers.table.availability")}{$t("checkers.table.actions")}
{checkerInfo.name || checkerId} + {#if badges.length > 0} +
+ {#each badges as badge} + + {badge.label} + + {/each} +
+ {:else} + {$t("checkers.availability.general")} + {/if} +
+ + {$t("checkers.table.manage")} + +
+
diff --git a/web/src/lib/components/checkers/ChecksSidebarContent.svelte b/web/src/lib/components/checkers/ChecksSidebarContent.svelte new file mode 100644 index 00000000..71910b49 --- /dev/null +++ b/web/src/lib/components/checkers/ChecksSidebarContent.svelte @@ -0,0 +1,103 @@ + + + + +{#if page.params.execId} + + + {$t("zones.return-to-results")} + + +{:else if page.params.checkerId} + + + {$t("checkers.title")} + + +
+{:else} + + + {$t("zones.return-to")} + +{/if} diff --git a/web/src/lib/components/checkers/DomainCheckerSidebar.svelte b/web/src/lib/components/checkers/DomainCheckerSidebar.svelte new file mode 100644 index 00000000..27276c1d --- /dev/null +++ b/web/src/lib/components/checkers/DomainCheckerSidebar.svelte @@ -0,0 +1,152 @@ + + + + + + + diff --git a/web/src/lib/components/checkers/ExecutionDetailPage.svelte b/web/src/lib/components/checkers/ExecutionDetailPage.svelte new file mode 100644 index 00000000..9c04c046 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionDetailPage.svelte @@ -0,0 +1,102 @@ + + + + + + {$t("checkers.execution.title")} - {checkerName || checkerId} - happyDomain + + +{#if loading} + + +

+ + {$t("checkers.result.loading")} +

+
+
+{:else if error} + + + + {$t("checkers.result.error-loading", { error })} + + +{:else if $currentObservations} + +{/if} diff --git a/web/src/lib/components/checkers/ExecutionListPage.svelte b/web/src/lib/components/checkers/ExecutionListPage.svelte new file mode 100644 index 00000000..28a90b9e --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionListPage.svelte @@ -0,0 +1,275 @@ + + + + + + + {$t("checkers.executions.title", { count: executions.length })} - {resolvedName || + checkerId} - happyDomain + + + +
+ +
+ + + +
+
+ + {#await executionsPromise} + +

+ + {$t("checkers.executions.loading")} +

+
+ {:then _executions} + {#if executions.length === 0} + + + {$t("checkers.executions.no-results")} + + {:else} + + + + + + + + + + + {#each executions.toSorted((a, b) => { + const aTime = a.startedAt ? new Date(a.startedAt).getTime() : Infinity; + const bTime = b.startedAt ? new Date(b.startedAt).getTime() : Infinity; + return bTime - aTime; + }) as execution} + {@const isPending = !execution.id} + {@const isRunning = + execution.id && execution.startedAt && !execution.endedAt} + {@const status = execution.status} + {@const duration = + execution.startedAt && execution.endedAt + ? Math.round( + (new Date(execution.endedAt).getTime() - + new Date(execution.startedAt).getTime()) / + 1000, + ) + : null} + + + + + + + {/each} + +
{$t("checkers.executions.table.executed-at")}{$t("checkers.executions.table.status")}{$t("checkers.executions.table.duration")}{$t("checkers.executions.table.actions")}
+ {#if !execution.startedAt} + + {$t("checkers.status.planned")} + + {:else if isPending} + + {formatCheckDate(execution.startedAt)} + + {:else} + {formatCheckDate(execution.startedAt)} + {/if} + + {#if isPending} + {$t("checkers.status.planned")} + {:else if status == 2 && execution.result} + + {$t(getStatusI18nKey(execution.result.status))} + + {:else} + + {$t(getExecutionStatusI18nKey(status))} + + {/if} + + {#if isRunning} + + {$t("checkers.status.running")} + + {:else if duration !== null} + {duration}s + {:else} + - + {/if} + +
+ + {$t("checkers.executions.view")} + + +
+
+ {/if} + {/await} +
+ + pollForNewExecution()} + bind:this={runCheckModal} +/> diff --git a/web/src/lib/components/checkers/ExecutionResultsCard.svelte b/web/src/lib/components/checkers/ExecutionResultsCard.svelte new file mode 100644 index 00000000..bec9fe40 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionResultsCard.svelte @@ -0,0 +1,63 @@ + + + + + + + {$t("checkers.detail.check-rules")} + + + {#if evaluation.states && evaluation.states.length > 0} + + + + + + + + + {#each evaluation.states as state} + + + + + {/each} + +
{$t("checkers.result.field.rule")}{$t("checkers.result.field.message")}
{state.code ?? ""}{state.message ?? ""}
+ {:else} +
{JSON.stringify(evaluation, null, 2)}
+ {/if} +
+
diff --git a/web/src/lib/components/checkers/ExecutionRulesPage.svelte b/web/src/lib/components/checkers/ExecutionRulesPage.svelte new file mode 100644 index 00000000..453879fa --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionRulesPage.svelte @@ -0,0 +1,79 @@ + + + + + + {$t("checkers.detail.check-rules")} - {checkerName || checkerId} - happyDomain + + +
+ + + {#await resultsPromise} +

+ + {$t("checkers.result.loading")} +

+ {:then evaluation} + {#if evaluation} + + {:else} + + + {$t("checkers.result.no-results")} + + {/if} + {:catch error} + + + {$t("checkers.result.error-loading", { error: error.message })} + + {/await} +
diff --git a/web/src/lib/components/checkers/ExecutionSidebarContent.svelte b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte new file mode 100644 index 00000000..d36253f2 --- /dev/null +++ b/web/src/lib/components/checkers/ExecutionSidebarContent.svelte @@ -0,0 +1,253 @@ + + + + +{#if $currentExecution} + + +
+ {$currentCheckInfo?.name || checkerId} + + {$t(getExecutionStatusI18nKey($currentExecution.status))} + +
+
+
+ + + + + + + {#if $currentExecution.endedAt} + + + + + {/if} + + + + + {#if $currentExecution.result?.message} + + + + + {/if} + {#if $currentExecution.error} + + + + + {/if} + {#if $currentExecution.trigger} + + + + + {/if} + +
+ {$t("checkers.result.field.executed-at")} + {formatCheckDate($currentExecution.startedAt)}
{$t("checkers.execution.field.ended-at")}{formatCheckDate($currentExecution.endedAt)}
{$t("checkers.result.field.status")} + + {$t(getStatusI18nKey($currentExecution.result?.status))} + + + {$t("checkers.detail.check-rules")} + +
{$t("checkers.result.field.status-message")} + {$currentExecution.result.message} +
{$t("checkers.result.field.error")} + {$currentExecution.error} +
{$t("checkers.execution.field.trigger")}{JSON.stringify($currentExecution.trigger)}
+
+
+ +
+ + + + + + + + + + + + +{:else} +
+{/if} + +
+ + +
diff --git a/web/src/lib/components/checkers/ObservationReportCard.svelte b/web/src/lib/components/checkers/ObservationReportCard.svelte new file mode 100644 index 00000000..6d7dd822 --- /dev/null +++ b/web/src/lib/components/checkers/ObservationReportCard.svelte @@ -0,0 +1,43 @@ + + + + +{#if observations?.data && Object.keys(observations.data).length > 0} +
+
{JSON.stringify(observations.data, null, 2)}
+
+{/if} diff --git a/web/src/lib/components/inputs/Resource.svelte b/web/src/lib/components/inputs/Resource.svelte index f0836beb..0e3af6e1 100644 --- a/web/src/lib/components/inputs/Resource.svelte +++ b/web/src/lib/components/inputs/Resource.svelte @@ -31,6 +31,7 @@ import TableInput from "$lib/components/inputs/table.svelte"; import type { Field } from "$lib/model/custom_form.svelte"; import type { ServiceInfos } from "$lib/model/service_specs.svelte"; + import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base/types.gen"; const dispatch = createEventDispatcher(); @@ -41,7 +42,7 @@ noDecorate?: boolean; readonly?: boolean; showDescription?: boolean; - specs?: Field | ServiceInfos; + specs?: Field | ServiceInfos | HappydnsCheckerOptionDocumentation; type: string; value: any; } diff --git a/web/src/lib/components/modals/RunCheckModal.svelte b/web/src/lib/components/modals/RunCheckModal.svelte new file mode 100644 index 00000000..d3951beb --- /dev/null +++ b/web/src/lib/components/modals/RunCheckModal.svelte @@ -0,0 +1,326 @@ + + + + + + + {$t("checkers.run-check.title")}: {checkDisplayName} + + + {#if checkStatusPromise && scopedOptionsPromise} + {#await Promise.all([checkStatusPromise, scopedOptionsPromise])} +
+ +

{$t("checkers.run-check.loading-options")}

+
+ {:then [status, _domainOpts]} + {@const rules = status.rules || []} + {@const activeRulesForOpts = rules.map( + (r: HappydnsCheckerDefinition | null, i: number) => + activeRules[i] !== false ? r : null, + )} + {@const runOpts = [ + ...(status.options?.runOpts || []), + ...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []), + ]} + {@const otherOpts = [ + ...(status.options?.adminOpts || []), + ...(status.options?.userOpts || []), + ...(status.options?.domainOpts || []), + ...activeRulesForOpts.flatMap((r: any) => [ + ...(r?.options?.adminOpts || []), + ...(r?.options?.userOpts || []), + ...(r?.options?.domainOpts || []), + ]), + ].filter((o: any) => o.id)} +
{ + e.preventDefault(); + handleRunCheck(); + }} + > + {#if runOpts.length > 0 || otherOpts.length > 0} +

+ {#if runOpts.length > 0} + {$t("checkers.run-check.configure-info")} + {:else} + + {$t("checkers.run-check.no-run-options")} + {/if} +

+ {#each runOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} + {#if otherOpts.length > 0} + + {#if showAdvanced} + {#each otherOpts as optDoc} + {@const optName = (optDoc as any).id} + + + + {/each} + {/if} + {/if} + {:else} + + + {$t("checkers.run-check.no-options")} + + {/if} + {#if rules.length >= 1} +
+ + + {#each rules as rule, idx} + {@const isActive = activeRules[idx] !== false} +
+ (activeRules[idx] = !isActive)} + /> +
+ {/each} +
+ {/if} +
+ {:catch error} + + + {$t("checkers.run-check.error-loading-options", { error: error.message })} + + {/await} + {/if} +
+ + + + +
diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index cd034c59..3794012a 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -46,6 +46,7 @@ interface Params { countdown?: string; error?: string; options?: string; + key?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/checkers.ts b/web/src/lib/utils/checkers.ts index 5d8e25eb..7c91d0e9 100644 --- a/web/src/lib/utils/checkers.ts +++ b/web/src/lib/utils/checkers.ts @@ -124,11 +124,12 @@ export function downloadBlob(content: string, filename: string, mime: string) { URL.revokeObjectURL(url); } -export function formatCheckDate(date: string | undefined): string { +export function formatCheckDate(date: string | Date | undefined): string { if (!date) return ""; try { + if (date instanceof Date) return date.toLocaleString(); return new Date(date).toLocaleString(); } catch { - return date; + return String(date); } } diff --git a/web/src/routes/checkers/+layout.ts b/web/src/routes/checkers/+layout.ts new file mode 100644 index 00000000..f3b81ba1 --- /dev/null +++ b/web/src/routes/checkers/+layout.ts @@ -0,0 +1,31 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-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 . +// +// 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 . + +import { type Load } from "@sveltejs/kit"; +import { get } from "svelte/store"; + +import { checkers, refreshCheckers } from "$lib/stores/checkers"; + +export const load: Load = async ({ parent }) => { + if (get(checkers) === undefined) refreshCheckers(); + + return await parent(); +}; diff --git a/web/src/routes/checkers/+page.svelte b/web/src/routes/checkers/+page.svelte new file mode 100644 index 00000000..9fd0962b --- /dev/null +++ b/web/src/routes/checkers/+page.svelte @@ -0,0 +1,99 @@ + + + + + + {$t("checkers.title")} - happyDomain + + + + + {#if $checkers} + {$t("checkers.available-count", { + count: Object.keys($checkers).length, + })} + {/if} + + + + + + + + + + + + + + {#if !$checkers} + +

+ + {$t("checkers.loading")} +

+
+ {:else} + {#if Object.keys($checkers).length == 0} +

+ {$t("checkers.no-checkers")} +

+ {:else} + + {/if} + {/if} +
diff --git a/web/src/routes/checkers/[checkerId]/+layout.svelte b/web/src/routes/checkers/[checkerId]/+layout.svelte new file mode 100644 index 00000000..78ac2f36 --- /dev/null +++ b/web/src/routes/checkers/[checkerId]/+layout.svelte @@ -0,0 +1,53 @@ + + + + + + + + + + + {@render children?.()} + + + diff --git a/web/src/routes/checkers/[checkerId]/+page.svelte b/web/src/routes/checkers/[checkerId]/+page.svelte new file mode 100644 index 00000000..ecb6763a --- /dev/null +++ b/web/src/routes/checkers/[checkerId]/+page.svelte @@ -0,0 +1,239 @@ + + + + + + {resolvedStatus?.name ?? checkerId} - {$t("checkers.title")} - happyDomain + + +
+ + + {#await checkStatusPromise} + +

+ + {$t("checkers.loading-info")} +

+
+ {:then status} + {#if status} + {@const adminOpts = status.options?.adminOpts || []} + {@const userOpts = status.options?.userOpts || []} + {@const rulesAdminOpts = (status.rules || []).flatMap((r) => r.options?.adminOpts || [])} + {@const rulesUserOpts = (status.rules || []).flatMap((r) => r.options?.userOpts || [])} + {@const allEditableOpts = [...adminOpts, ...userOpts, ...rulesAdminOpts, ...rulesUserOpts]} + {@const editableGroups = [ + { label: $t("checkers.detail.admin-options"), opts: adminOpts }, + { label: $t("checkers.detail.configuration"), opts: userOpts }, + ]} + {@const readOnlyGroups = [ + { key: "domainOpts", label: $t("checkers.option-groups.domain-settings"), opts: status.options?.domainOpts || [] }, + { key: "serviceOpts", label: $t("checkers.option-groups.service-settings"), opts: status.options?.serviceOpts || [] }, + { key: "runOpts", label: $t("checkers.option-groups.checker-parameters"), opts: status.options?.runOpts || [] }, + ]} + {@const orphanedOpts = getOrphanedOptions(allEditableOpts, readOnlyGroups)} + + + + + {$t("checkers.detail.checker-information")} + + +
+
{$t("checkers.detail.name")}
+
{status.name}
+ +
{$t("checkers.detail.availability")}
+
+ {#each getAvailBadges(status.availability) as badge} + {badge.label} + {:else} + + {$t("checkers.availability.general")} + + {/each} +
+
+
+
+ + {#if status.rules && status.rules.length > 0} + + {/if} + + + + cleanOrphanedOptions(allEditableOpts)} + /> + +
+ {:else} + + + {$t("checkers.checker-info-not-found")} + + {/if} + {:catch error} + + + {$t("checkers.error-loading-checker", { error: error.message })} + + {/await} +
diff --git a/web/src/routes/domains/[dn]/+layout.svelte b/web/src/routes/domains/[dn]/+layout.svelte index fd5dc9cc..7376cf6e 100644 --- a/web/src/routes/domains/[dn]/+layout.svelte +++ b/web/src/routes/domains/[dn]/+layout.svelte @@ -29,6 +29,7 @@ import { Button, Col, Container, Icon, Row, Spinner } from "@sveltestrap/sveltestrap"; import { deleteDomain as APIDeleteDomain } from "$lib/api/domains"; + import ChecksSidebarContent from "$lib/components/checkers/ChecksSidebarContent.svelte"; import SelectDomain from "$lib/components/domains/SelectDomain.svelte"; import type { Domain } from "$lib/model/domain"; import type { ZoneMeta } from "$lib/model/zone"; @@ -42,6 +43,7 @@ import ServiceDetailsOffcanvas from "./ServiceDetailsOffcanvas.svelte"; import ServiceSidebar from "./ServiceSidebar.svelte"; import ZoneSidebar from "./ZoneSidebar.svelte"; + import { thisZone } from "$lib/stores/thiszone"; interface Props { data: { domain: Domain }; @@ -57,13 +59,15 @@ "/domains/" + encodeURIComponent(domainLink(dn)) + (page.route.id - ? page.route.id.startsWith("/domains/[dn]/logs") - ? "/logs" - : page.route.id.startsWith("/domains/[dn]/history") - ? "/history" - : page.route.id.startsWith("/domains/[dn]/[[historyid]]/export") - ? "/export" - : "" + ? page.route.id.startsWith("/domains/[dn]/checks") + ? "/checks" + : page.route.id.startsWith("/domains/[dn]/logs") + ? "/logs" + : page.route.id.startsWith("/domains/[dn]/history") + ? "/history" + : page.route.id.startsWith("/domains/[dn]/[[historyid]]/export") + ? "/export" + : "" : ""), ); } @@ -145,7 +149,15 @@ - {#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/[[historyid]]/export"))} + {#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks")} + + {:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/[[historyid]]/export"))} {$t("zones.return-to")} + {:else if page.route.id && page.route.id.startsWith("/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]/checks")} + {:else if page.route.id === "/domains/[dn]/[[historyid]]/[subdomain]/[serviceid]"} {/if} -
- {#if !(data.domain.zone_history && $domains_idx[selectedDomain] && data.domain.id === $domains_idx[selectedDomain].id && selectedHistory)} +
- - + {/if} + - - + {#if $currentCheckInfo?.has_html_report} + + {/if} + {#if $currentCheckInfo?.has_metrics} + + {/if} {#if $currentCheckInfo?.has_html_report} + + + From 573b1aa90a878b66b44b71879d1852657bdd2db0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:51:01 +0700 Subject: [PATCH 50/54] checker: add tiered RetentionPolicy Introduce a pure RetentionPolicy.Decide function that partitions check executions into keep/drop sets according to a tiered policy: - 0..7 days -> every execution - 7..30 days -> 2 per day per (checker, target) - 30..D/2 -> 1 per week per (checker, target) - D/2..D days -> 1 per month per (checker, target) - > D days -> dropped The function is intentionally storage-agnostic so the upcoming janitor goroutine can call it on any execution slice and so it can be unit tested directly. All thresholds are configurable to allow per-user overrides via UserQuota. --- internal/usecase/checker/retention.go | 183 +++++++++++++++++++++ internal/usecase/checker/retention_test.go | 128 ++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 internal/usecase/checker/retention.go create mode 100644 internal/usecase/checker/retention_test.go diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go new file mode 100644 index 00000000..1c67840b --- /dev/null +++ b/internal/usecase/checker/retention.go @@ -0,0 +1,183 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "sort" + "time" + + "git.happydns.org/happyDomain/model" +) + +// RetentionPolicy describes how check executions are thinned out as they age. +// +// The policy is intentionally tiered: users care about full detail for recent +// runs, but only need sparse historical samples to spot long-term trends. +// +// Default behaviour, given a RetentionDays of D: +// +// age window | kept +// ------------------------- | ------------------------------------------ +// 0 .. 7 days | every execution +// 7 .. 30 days | up to 2 executions per day per (checker,target) +// 30 .. D/2 days | up to 1 execution per week per (checker,target) +// D/2 .. D days | up to 1 execution per month per (checker,target) +// > D days | dropped +// +// All thresholds and bucket counts are configurable so the policy can be +// tuned per-user via the admin UserQuota. +type RetentionPolicy struct { + // RetentionDays is the hard cap on age. Executions older than this are + // always dropped. Must be > 0. + RetentionDays int + + // FullDetailDays: every execution kept under this age. + FullDetailDays int + // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep + // PerDayKept executions per UTC day per (checker,target). + DailyBucketDays int + PerDayKept int + // WeeklyBucketDays: between DailyBucketDays and WeeklyBucketDays, keep + // PerWeekKept executions per ISO week per (checker,target). + WeeklyBucketDays int + PerWeekKept int + // Beyond WeeklyBucketDays and up to RetentionDays, keep PerMonthKept + // executions per calendar month per (checker,target). + PerMonthKept int +} + +// DefaultRetentionPolicy returns the standard tiered policy for the given +// retention horizon. +func DefaultRetentionPolicy(retentionDays int) RetentionPolicy { + if retentionDays <= 0 { + retentionDays = 365 + } + return RetentionPolicy{ + RetentionDays: retentionDays, + FullDetailDays: 7, + DailyBucketDays: 30, + PerDayKept: 2, + WeeklyBucketDays: max(retentionDays/2, 31), + PerWeekKept: 1, + PerMonthKept: 1, + } +} + +// Decide partitions executions into the ones to keep and the ones to drop +// according to the policy. The function is pure: it does not touch storage. +// +// Executions are grouped by (CheckerID, Target) and ordered most-recent-first +// inside each group, so the newest execution in a bucket is the one preserved. +func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) { + if len(executions) == 0 { + return nil, nil + } + + // Group by (checker, target). + groups := map[string][]*happydns.Execution{} + for _, e := range executions { + if e == nil { + continue + } + key := e.CheckerID + "|" + e.Target.String() + groups[key] = append(groups[key], e) + } + + hardCutoff := now.AddDate(0, 0, -p.RetentionDays) + fullCutoff := now.AddDate(0, 0, -p.FullDetailDays) + dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays) + weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays) + + for _, group := range groups { + // Most recent first. + sort.Slice(group, func(i, j int) bool { + return group[i].StartedAt.After(group[j].StartedAt) + }) + + dayBuckets := map[string]int{} + weekBuckets := map[string]int{} + monthBuckets := map[string]int{} + + for _, e := range group { + t := e.StartedAt + switch { + case t.Before(hardCutoff): + drop = append(drop, e.Id) + case !t.Before(fullCutoff): + // 0 .. FullDetailDays — keep everything. + keep = append(keep, e.Id) + case !t.Before(dailyCutoff): + k := t.UTC().Format("2006-01-02") + if dayBuckets[k] < p.PerDayKept { + dayBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + case !t.Before(weeklyCutoff): + y, w := t.UTC().ISOWeek() + k := isoWeekKey(y, w) + if weekBuckets[k] < p.PerWeekKept { + weekBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + default: + k := t.UTC().Format("2006-01") + if monthBuckets[k] < p.PerMonthKept { + monthBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } + } + } + } + + return keep, drop +} + +func isoWeekKey(year, week int) string { + return time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006") + "-W" + twoDigits(week) +} + +func twoDigits(n int) string { + if n < 10 { + return "0" + itoa(n) + } + return itoa(n) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [4]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go new file mode 100644 index 00000000..7d1383b1 --- /dev/null +++ b/internal/usecase/checker/retention_test.go @@ -0,0 +1,128 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "fmt" + "testing" + "time" + + "git.happydns.org/happyDomain/model" +) + +func mkExec(id string, age time.Duration, now time.Time) *happydns.Execution { + return &happydns.Execution{ + Id: happydns.Identifier(id), + CheckerID: "ping", + Target: happydns.CheckTarget{DomainId: "example.com"}, + StartedAt: now.Add(-age), + } +} + +func TestDecide_Empty(t *testing.T) { + p := DefaultRetentionPolicy(365) + keep, drop := p.Decide(nil, time.Now()) + if len(keep) != 0 || len(drop) != 0 { + t.Fatalf("expected empty results, got keep=%d drop=%d", len(keep), len(drop)) + } +} + +func TestDecide_FullDetailWindow(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + var execs []*happydns.Execution + for i := 0; i < 50; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now)) + } + + keep, drop := p.Decide(execs, now) + if len(drop) != 0 { + t.Fatalf("expected no drops in <7d window, got %d", len(drop)) + } + if len(keep) != 50 { + t.Fatalf("expected 50 keeps, got %d", len(keep)) + } +} + +func TestDecide_DailyBucket(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 10 executions on the same day, ~10 days ago (inside daily window). + var execs []*happydns.Execution + for i := 0; i < 10; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), 10*24*time.Hour+time.Duration(i)*time.Hour, now)) + } + + keep, drop := p.Decide(execs, now) + if len(keep) != p.PerDayKept { + t.Fatalf("expected %d keeps in daily bucket, got %d", p.PerDayKept, len(keep)) + } + if len(drop) != 10-p.PerDayKept { + t.Fatalf("expected %d drops, got %d", 10-p.PerDayKept, len(drop)) + } +} + +func TestDecide_HardCutoff(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(30) + + execs := []*happydns.Execution{ + mkExec("recent", 1*24*time.Hour, now), + mkExec("old", 100*24*time.Hour, now), + } + + keep, drop := p.Decide(execs, now) + if len(keep) != 1 || string(keep[0]) != "recent" { + t.Fatalf("expected 'recent' to be kept, got %v", keep) + } + if len(drop) != 1 || string(drop[0]) != "old" { + t.Fatalf("expected 'old' to be dropped, got %v", drop) + } +} + +func TestDecide_GroupedByTarget(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 5 executions same day, 10 days ago, two different targets. + mk := func(id, dom string) *happydns.Execution { + return &happydns.Execution{ + Id: happydns.Identifier(id), + CheckerID: "ping", + Target: happydns.CheckTarget{DomainId: dom}, + StartedAt: now.Add(-10 * 24 * time.Hour), + } + } + var execs []*happydns.Execution + for i := 0; i < 5; i++ { + execs = append(execs, mk(fmt.Sprintf("a%d", i), "a.example")) + execs = append(execs, mk(fmt.Sprintf("b%d", i), "b.example")) + } + + keep, _ := p.Decide(execs, now) + // PerDayKept per group => 2 * 2 groups = 4 + if len(keep) != 2*p.PerDayKept { + t.Fatalf("expected %d keeps, got %d", 2*p.PerDayKept, len(keep)) + } +} From 011766f1dc592433cd4ff45fa1cecba1577f54fc Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:51:52 +0700 Subject: [PATCH 51/54] checker: add Janitor goroutine to enforce retention policy The Janitor periodically walks every CheckPlan, loads its executions, and deletes the ones that the tiered RetentionPolicy says to drop. Per-user overrides are honoured: if a user's UserQuota.RetentionDays is set, that horizon replaces the system default for the user's plans. User lookups are cached per sweep to avoid repeated storage hits. The janitor is the long-tail counterpart of the (still TODO) cheap hard cap that will be applied at execution-creation time. It runs immediately on Start() and then every configured interval (default 6h). --- internal/usecase/checker/janitor.go | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 internal/usecase/checker/janitor.go diff --git a/internal/usecase/checker/janitor.go b/internal/usecase/checker/janitor.go new file mode 100644 index 00000000..5dff150a --- /dev/null +++ b/internal/usecase/checker/janitor.go @@ -0,0 +1,185 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "context" + "log" + "sync" + "time" + + "git.happydns.org/happyDomain/model" +) + +// JanitorUserResolver resolves a user from a CheckTarget so the janitor can +// honour per-user retention overrides stored in UserQuota. +type JanitorUserResolver interface { + GetUser(id happydns.Identifier) (*happydns.User, error) +} + +// Janitor periodically prunes old check executions according to the tiered +// RetentionPolicy. It is the long-tail enforcement counterpart of the cheap +// hard cap applied at execution-creation time. +type Janitor struct { + planStore CheckPlanStorage + execStore ExecutionStorage + userResolver JanitorUserResolver + defaultPolicy RetentionPolicy + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + running bool +} + +// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy +// is applied to executions of users that did not customize their retention +// horizon via UserQuota. +func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor { + if interval <= 0 { + interval = 6 * time.Hour + } + return &Janitor{ + planStore: planStore, + execStore: execStore, + userResolver: userResolver, + defaultPolicy: defaultPolicy, + interval: interval, + } +} + +// Start launches the janitor loop in a goroutine. It runs an immediate sweep +// once the loop is up. +func (j *Janitor) Start(ctx context.Context) { + j.mu.Lock() + if j.running { + j.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + j.cancel = cancel + j.running = true + j.mu.Unlock() + + go j.loop(ctx) +} + +// Stop halts the janitor. +func (j *Janitor) Stop() { + j.mu.Lock() + defer j.mu.Unlock() + if j.cancel != nil { + j.cancel() + } + j.running = false +} + +func (j *Janitor) loop(ctx context.Context) { + // Run immediately, then on the configured interval. + j.RunOnce(ctx) + + ticker := time.NewTicker(j.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + j.RunOnce(ctx) + } + } +} + +// RunOnce performs a single sweep over all check plans, applying the per-user +// retention policy. Returns the number of executions deleted. +func (j *Janitor) RunOnce(ctx context.Context) int { + iter, err := j.planStore.ListAllCheckPlans() + if err != nil { + log.Printf("Janitor: failed to list check plans: %v", err) + return 0 + } + + now := time.Now() + deleted := 0 + + // Cache user policies to avoid resolving the same user repeatedly. + policyByUser := map[string]RetentionPolicy{} + + for iter.Next() { + select { + case <-ctx.Done(): + return deleted + default: + } + + plan := iter.Item() + if plan == nil { + continue + } + + execs, err := j.execStore.ListExecutionsByPlan(plan.Id) + if err != nil { + log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err) + continue + } + if len(execs) == 0 { + continue + } + + policy := j.policyForTarget(plan.Target, policyByUser) + _, drop := policy.Decide(execs, now) + + for _, id := range drop { + if err := j.execStore.DeleteExecution(id); err != nil { + log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err) + continue + } + deleted++ + } + } + + if deleted > 0 { + log.Printf("Janitor: pruned %d executions", deleted) + } + return deleted +} + +func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy { + uid := target.UserId + if uid == "" || j.userResolver == nil { + return j.defaultPolicy + } + if p, ok := cache[uid]; ok { + return p + } + policy := j.defaultPolicy + id, err := happydns.NewIdentifierFromString(uid) + if err == nil { + if user, err := j.userResolver.GetUser(id); err == nil && user != nil { + if user.Quota.RetentionDays > 0 { + policy = DefaultRetentionPolicy(user.Quota.RetentionDays) + } + } + } + cache[uid] = policy + return policy +} From 94cb9a41295c95c7ca26a5802f9a8ff376a35184 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:52:46 +0700 Subject: [PATCH 52/54] checker: pause scheduling for paused or inactive users Add a job-level gate to the scheduler. When set, the gate is consulted on every popped job; if it returns false, the job is skipped and re-enqueued for its next interval without invoking the engine. A new UserGater builds such a gate from a user resolver and an inactivity threshold: - users with UserQuota.SchedulingPaused are always blocked (admin kill switch); - users whose LastSeen is older than their effective inactivity horizon (UserQuota.InactivityPauseDays, falling back to a system default) are blocked until they log in again; - lookups are cached for 5 minutes so the scheduler hot path stays cheap, with an Invalidate hook for use on user updates. This addresses the "free trial then forgotten" failure mode described in the design notes. --- internal/usecase/checker/scheduler.go | 30 +++++++ internal/usecase/checker/user_gate.go | 119 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/usecase/checker/user_gate.go diff --git a/internal/usecase/checker/scheduler.go b/internal/usecase/checker/scheduler.go index 31af4afd..3e74fb5e 100644 --- a/internal/usecase/checker/scheduler.go +++ b/internal/usecase/checker/scheduler.go @@ -108,6 +108,19 @@ type Scheduler struct { running bool ctx context.Context maxConcurrency int + + // gate, if set, is consulted before launching each job. Returning false + // causes the scheduler to skip (and reschedule) the job, e.g. when the + // owning user is paused or has been inactive for too long. + gate func(target happydns.CheckTarget) bool +} + +// SetGate installs a job gate evaluated before each execution. It is safe to +// call after Start(); the gate is consulted on every job pop. +func (s *Scheduler) SetGate(gate func(target happydns.CheckTarget) bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.gate = gate } // NewScheduler creates a new Scheduler. @@ -247,8 +260,25 @@ func (s *Scheduler) run(ctx context.Context) { continue } job := heap.Pop(&s.queue).(*SchedulerJob) + gate := s.gate s.mu.Unlock() + // Honour the user-level gate before doing any work. + if gate != nil && !gate(job.Target) { + log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String()) + now := time.Now() + for job.NextRun.Before(now) { + job.NextRun = job.NextRun.Add(job.Interval) + } + 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() + continue + } + // Find plan if applicable. var plan *happydns.CheckPlan if job.PlanID != nil { diff --git a/internal/usecase/checker/user_gate.go b/internal/usecase/checker/user_gate.go new file mode 100644 index 00000000..ffcf9458 --- /dev/null +++ b/internal/usecase/checker/user_gate.go @@ -0,0 +1,119 @@ +// 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 . +// +// 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 . + +package checker + +import ( + "sync" + "time" + + "git.happydns.org/happyDomain/model" +) + +// UserGater builds a Scheduler gate function that filters out check jobs +// belonging to users that are paused or have been inactive for too long. +// +// Lookups are cached for a short TTL so the scheduler hot path does not hit +// storage on every job pop. +type UserGater struct { + resolver JanitorUserResolver + defaultInactivityDays int + cacheTTL time.Duration + + mu sync.Mutex + cache map[string]gateCacheEntry +} + +type gateCacheEntry struct { + allow bool + expires time.Time +} + +// NewUserGater creates a UserGater. defaultInactivityDays is used for users +// whose UserQuota.InactivityPauseDays is zero. A negative effective value +// disables inactivity-based pausing for that user. +func NewUserGater(resolver JanitorUserResolver, defaultInactivityDays int) *UserGater { + return &UserGater{ + resolver: resolver, + defaultInactivityDays: defaultInactivityDays, + cacheTTL: 5 * time.Minute, + cache: map[string]gateCacheEntry{}, + } +} + +// Allow returns true if the scheduler should run jobs for the given target. +func (g *UserGater) Allow(target happydns.CheckTarget) bool { + uid := target.UserId + if uid == "" || g.resolver == nil { + return true + } + + g.mu.Lock() + if e, ok := g.cache[uid]; ok && time.Now().Before(e.expires) { + g.mu.Unlock() + return e.allow + } + g.mu.Unlock() + + allow := g.compute(uid) + + g.mu.Lock() + g.cache[uid] = gateCacheEntry{allow: allow, expires: time.Now().Add(g.cacheTTL)} + g.mu.Unlock() + + return allow +} + +// Invalidate drops any cached decision for the given user. Call this when a +// user's quota or LastSeen changes (e.g. on login or admin update). +func (g *UserGater) Invalidate(userID string) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.cache, userID) +} + +func (g *UserGater) compute(uid string) bool { + id, err := happydns.NewIdentifierFromString(uid) + if err != nil { + return true + } + user, err := g.resolver.GetUser(id) + if err != nil || user == nil { + // Be conservative: allow rather than silently dropping work. + return true + } + if user.Quota.SchedulingPaused { + return false + } + + days := user.Quota.InactivityPauseDays + if days == 0 { + days = g.defaultInactivityDays + } + if days <= 0 { + return true + } + if user.LastSeen.IsZero() { + return true + } + cutoff := time.Now().AddDate(0, 0, -days) + return user.LastSeen.After(cutoff) +} From 5b29ed560576f7faf7cb08225f414f2e9b79c9a2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 11:54:43 +0700 Subject: [PATCH 53/54] app: wire checker retention janitor and user gate Construct the retention janitor and the user gate alongside the checker scheduler. Three new options drive their behaviour: --checker-retention-days (default 365) --checker-janitor-interval (default 6h) --checker-inactivity-pause-days (default 90) The janitor starts immediately on App.Start and is shut down on App.Stop. The user gate is installed on the scheduler with the same storage-backed user resolver, so paused users and users that haven't logged in for the configured horizon stop being checked until they come back. --- internal/app/app.go | 24 ++++++++++++++++++++++++ internal/config/cli.go | 4 ++++ model/config.go | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index 355d8309..cc695da3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -76,6 +76,7 @@ type Usecases struct { checkerPlanUC *checkerUC.CheckPlanUsecase checkerStatusUC *checkerUC.CheckStatusUsecase checkerScheduler *checkerUC.Scheduler + checkerJanitor *checkerUC.Janitor } type App struct { @@ -274,6 +275,21 @@ func (app *App) initUsecases() { 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) + // Install the user-level gate so paused or long-inactive users do not + // get checked. The same user resolver is reused by the janitor for + // per-user retention overrides. + gater := checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays) + app.usecases.checkerScheduler.SetGate(gater.Allow) + + // Retention janitor. + app.usecases.checkerJanitor = checkerUC.NewJanitor( + app.store, + app.store, + app.store, + checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays), + app.cfg.CheckerJanitorInterval, + ) + // Wire scheduler notifications for incremental queue updates. domainService.SetSchedulerNotifier(app.usecases.checkerScheduler) app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler) @@ -348,6 +364,10 @@ func (app *App) Start() { app.usecases.checkerScheduler.Start(context.Background()) } + if app.usecases.checkerJanitor != nil { + app.usecases.checkerJanitor.Start(context.Background()) + } + log.Printf("Public interface listening on %s\n", app.cfg.Bind) if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %s\n", err) @@ -365,6 +385,10 @@ func (app *App) Stop() { app.usecases.checkerScheduler.Stop() } + if app.usecases.checkerJanitor != nil { + app.usecases.checkerJanitor.Stop() + } + // Close storage if app.store != nil { app.store.Close() diff --git a/internal/config/cli.go b/internal/config/cli.go index fecf0c7d..36106b4b 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -25,6 +25,7 @@ import ( "flag" "fmt" "runtime" + "time" "git.happydns.org/happyDomain/internal/storage" "git.happydns.org/happyDomain/model" @@ -47,6 +48,9 @@ func declareFlags(o *happydns.Options) { flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)") flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.") flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously") + flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)") + flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs") + flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)") flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server") flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user") diff --git a/model/config.go b/model/config.go index 45048aa6..b81e1bde 100644 --- a/model/config.go +++ b/model/config.go @@ -26,6 +26,7 @@ import ( "net/mail" "net/url" "path" + "time" ) // Options stores the configuration of the software. @@ -97,6 +98,20 @@ type Options struct { // run simultaneously. Defaults to runtime.NumCPU(). CheckerMaxConcurrency int + // CheckerRetentionDays is the system-wide default for how many days of + // check execution history are kept. Per-user UserQuota.RetentionDays + // overrides this value. + CheckerRetentionDays int + + // CheckerJanitorInterval is how often the retention janitor runs. + CheckerJanitorInterval time.Duration + + // CheckerInactivityPauseDays is the system-wide default number of days + // without login after which the scheduler stops running checks for a + // user. 0 disables inactivity pausing globally; per-user UserQuota + // overrides this value. + CheckerInactivityPauseDays int + // CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or ""). CaptchaProvider string From b1d7df8d3c190dc26a4d481c379035624ae8dc25 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 8 Apr 2026 12:02:04 +0700 Subject: [PATCH 54/54] checker: keep 1 report per hour after the first day Insert an hourly tier between the full-detail window and the daily bucket so users still get sub-day resolution for the first week: 0..1 day -> all 1..7 days -> 1 per hour 7..30 -> 2 per day ... --- internal/usecase/checker/retention.go | 23 +++++++++++++--- internal/usecase/checker/retention_test.go | 31 ++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/internal/usecase/checker/retention.go b/internal/usecase/checker/retention.go index 1c67840b..51483fde 100644 --- a/internal/usecase/checker/retention.go +++ b/internal/usecase/checker/retention.go @@ -37,7 +37,8 @@ import ( // // age window | kept // ------------------------- | ------------------------------------------ -// 0 .. 7 days | every execution +// 0 .. 1 day | every execution +// 1 .. 7 days | up to 1 execution per hour per (checker,target) // 7 .. 30 days | up to 2 executions per day per (checker,target) // 30 .. D/2 days | up to 1 execution per week per (checker,target) // D/2 .. D days | up to 1 execution per month per (checker,target) @@ -52,7 +53,11 @@ type RetentionPolicy struct { // FullDetailDays: every execution kept under this age. FullDetailDays int - // DailyBucketDays: between FullDetailDays and DailyBucketDays, keep + // HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep + // PerHourKept executions per UTC hour per (checker,target). + HourlyBucketDays int + PerHourKept int + // DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep // PerDayKept executions per UTC day per (checker,target). DailyBucketDays int PerDayKept int @@ -73,7 +78,9 @@ func DefaultRetentionPolicy(retentionDays int) RetentionPolicy { } return RetentionPolicy{ RetentionDays: retentionDays, - FullDetailDays: 7, + FullDetailDays: 1, + HourlyBucketDays: 7, + PerHourKept: 1, DailyBucketDays: 30, PerDayKept: 2, WeeklyBucketDays: max(retentionDays/2, 31), @@ -104,6 +111,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) hardCutoff := now.AddDate(0, 0, -p.RetentionDays) fullCutoff := now.AddDate(0, 0, -p.FullDetailDays) + hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays) dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays) weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays) @@ -113,6 +121,7 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) return group[i].StartedAt.After(group[j].StartedAt) }) + hourBuckets := map[string]int{} dayBuckets := map[string]int{} weekBuckets := map[string]int{} monthBuckets := map[string]int{} @@ -125,6 +134,14 @@ func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) case !t.Before(fullCutoff): // 0 .. FullDetailDays — keep everything. keep = append(keep, e.Id) + case !t.Before(hourlyCutoff): + k := t.UTC().Format("2006-01-02T15") + if hourBuckets[k] < p.PerHourKept { + hourBuckets[k]++ + keep = append(keep, e.Id) + } else { + drop = append(drop, e.Id) + } case !t.Before(dailyCutoff): k := t.UTC().Format("2006-01-02") if dayBuckets[k] < p.PerDayKept { diff --git a/internal/usecase/checker/retention_test.go b/internal/usecase/checker/retention_test.go index 7d1383b1..3badb107 100644 --- a/internal/usecase/checker/retention_test.go +++ b/internal/usecase/checker/retention_test.go @@ -50,17 +50,38 @@ func TestDecide_FullDetailWindow(t *testing.T) { now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) p := DefaultRetentionPolicy(365) + // 20 executions in the first 20 minutes — all inside 0..1 day window. var execs []*happydns.Execution - for i := 0; i < 50; i++ { - execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Hour, now)) + for i := 0; i < 20; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Minute, now)) } keep, drop := p.Decide(execs, now) if len(drop) != 0 { - t.Fatalf("expected no drops in <7d window, got %d", len(drop)) + t.Fatalf("expected no drops in <1d window, got %d", len(drop)) } - if len(keep) != 50 { - t.Fatalf("expected 50 keeps, got %d", len(keep)) + if len(keep) != 20 { + t.Fatalf("expected 20 keeps, got %d", len(keep)) + } +} + +func TestDecide_HourlyBucket(t *testing.T) { + now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) + p := DefaultRetentionPolicy(365) + + // 6 executions in the same hour ~3 days ago (inside hourly window). + var execs []*happydns.Execution + base := 3*24*time.Hour + 30*time.Minute + for i := 0; i < 6; i++ { + execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Minute, now)) + } + + keep, drop := p.Decide(execs, now) + if len(keep) != p.PerHourKept { + t.Fatalf("expected %d keeps in hourly bucket, got %d", p.PerHourKept, len(keep)) + } + if len(drop) != 6-p.PerHourKept { + t.Fatalf("expected %d drops, got %d", 6-p.PerHourKept, len(drop)) } }