Compare commits

...

38 commits

Author SHA1 Message Date
a0b052608f web-admin: wire dashboard to /metrics with collapsible details
Some checks failed
continuous-integration/drone/push Build is failing
Replaces the three REST count calls with a single Prometheus scrape that
auto-refreshes every 15s, surfaces queue/worker/in-flight/RSS/version/uptime
as featured cards, and tucks counters and Go runtime stats under a
"Show more metrics" Collapse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:46:37 +07:00
b444adc141 checkers: add Prometheus text format for metrics export
The metrics endpoints now negotiate response format via the Accept
header: application/json returns the JSON array, anything else returns
the Prometheus text exposition format.
2026-04-15 19:45:59 +07:00
e4911c81db Instrument check scheduler with Prometheus metrics
Track queue depth on enqueue and pop, active worker count, check execution
duration per checker, and check result status counters.
2026-04-15 19:45:04 +07:00
3708cd7f91 Instrument DNS provider adapter with Prometheus metrics
Add providerName field to DNSControlAdapterNSProvider and wrap GetZoneRecords,
GetZoneCorrections, CreateDomain, and ListZones with timing and call counters
using happydomain_provider_api_calls_total and happydomain_provider_api_duration_seconds.
2026-04-15 19:44:18 +07:00
cbd03c5d2b Add storage stats Prometheus collector for business entity counts
Expose four live gauges queried at each scrape via a custom Collector:
- happydomain_registered_users_total
- happydomain_domains_total
- happydomain_zones_total
- happydomain_providers_total
2026-04-15 19:44:18 +07:00
515d811c5b Wire metrics into app: HTTP middleware, storage instrumentation, build info
- Add HTTP metrics middleware to public router in setupRouter()
- Wrap storage with InstrumentedStorage after initialization
- Set build info metric from main() with actual version string
- Promote prometheus/client_golang to direct dependency
2026-04-15 19:43:28 +07:00
a198ecd614 Expose /metrics endpoint on admin socket via promhttp 2026-04-15 19:43:27 +07:00
57e5ab2a11 Add Prometheus metrics package with HTTP middleware and storage instrumentation
- internal/metrics/metrics.go: defines all metric variables (http, scheduler,
  provider, storage, build info) using promauto for zero-config registration
- internal/metrics/http.go: Gin middleware recording request count, duration,
  and in-flight gauge using c.FullPath() to avoid high-cardinality labels
- internal/app/instrumented_storage.go: InstrumentedStorage wrapper implementing
  storage.Storage, recording operation counts and durations for all entities
2026-04-15 19:43:27 +07:00
40b4bab4a8 checker: pause scheduling for paused or inactive users
All checks were successful
continuous-integration/drone/push Build is passing
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.
2026-04-15 19:39:30 +07:00
8d030071e1 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).
2026-04-15 19:38:38 +07:00
b9035bb6b4 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
  ...
2026-04-15 19:30:29 +07:00
fd5bfb637d 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.
2026-04-15 19:30:14 +07:00
ce9da66a76 model: add UserQuota struct for admin-controlled per-user limits
Introduce a UserQuota field on the User model to hold admin-controlled
limits and flags that the user cannot modify. Only checker-related
fields are defined for now (max checks per day, retention days,
inactivity pause days, scheduling kill switch); future paid-plan
attributes will be added here later.

The user-facing API only exposes settings updates and account deletion,
so Quota cannot be written through it. Updates go through the existing
admin user PUT endpoint, with a new editor card in the admin UI under
/users/[uid].
2026-04-15 19:30:14 +07:00
a18237aaa5 New checker: Matrix federation 2026-04-15 19:30:14 +07:00
1bb2592f4d New checker: zonemaster 2026-04-15 19:30:14 +07:00
33279f8c6e New checker: ICMP ping checker with RTT and packet loss metrics 2026-04-15 19:30:14 +07:00
9d76a0a62a checkers: add HTTP transport layer
Introduce a transport abstraction so observation providers can run either
locally or be delegated to a remote HTTP endpoint. When an admin sets the
"endpoint" option, the engine substitutes the local provider with an
HTTPObservationProvider that POSTs to {endpoint}/collect.
2026-04-15 19:30:14 +07:00
ac50d02819 checkers: add incremental scheduler updates on domain/zone changes
Instead of rebuilding the entire scheduler queue, incrementally add or
remove jobs when domains are created/deleted or zones are
imported/published. A wake channel interrupts the run loop so new jobs
are picked up immediately. A jobKeys index prevents duplicate entries.

Hook points: domain creation, domain deletion, zone import, and zone
publish (correction apply) all notify the scheduler via the narrow
SchedulerDomainNotifier interface, wired through setter methods to
avoid initialization ordering issues.
2026-04-15 19:30:14 +07:00
cab205c97f checkers: store observations as json.RawMessage with cross-checker reuse
Refactor observation data pipeline to serialize once after collection and
keep json.RawMessage throughout storage and API responses. This eliminates
double-serialization and makes DB round-trips lossless.
2026-04-15 19:30:14 +07:00
6d0423480d checkers: add NoOverride field support for checker options
Prevent more specific scopes from overriding option values locked at a
higher scope (e.g. admin). Includes defense-in-depth stripping on
Set/Add operations, merge-time preservation, and frontend filtering.
2026-04-15 19:30:14 +07:00
1d8700db74 checkers: show worst check status badge on domain list
Add DomainWithCheckStatus model and GetWorstDomainStatuses usecase to
compute the most critical checker status per domain. The GET /domains
endpoint now returns status alongside each domain. The frontend domain
store, list components, and table row display dynamic status badges
with color and icon instead of a hardcoded "OK".

ZoneList is made generic (T extends HappydnsDomain) so the badges
snippet preserves the caller's concrete type without unsafe casts.
2026-04-15 19:30:14 +07:00
3b3ac3b046 checkers: show children checkers on domain page and hide scheduling for non-domain checkers
Add a separate section on the domain checks page to display zone and
service-level checkers that can be configured but won't produce results
at the domain scope. Hide the scheduling and rules cards when configuring
a non-domain checker from the domain context.
2026-04-15 19:30:14 +07:00
1a39b4322e checkers: add frontend metrics chart on execution pages
Add Chart.js-based line chart for checker metrics. The chart appears
on the executions list page (aggregated) and on individual execution
detail pages. Metrics view mode is selectable via the sidebar alongside
HTML report and raw JSON views.
2026-04-15 19:30:14 +07:00
6f9c653101 checkers: add metrics export in JSON format
Observation providers can now implement CheckerMetricsReporter to
extract time-series metrics from their stored data. The controller
returns the metrics as a JSON array.

Routes: user-level (/api/checkers/metrics), domain-level, per-checker,
and per-execution.
2026-04-15 19:30:14 +07:00
a8062c3eca checkers: add HTML report rendering for observation providers
Introduce CheckerHTMLReporter interface that observation providers can
implement to render rich HTML documents from their data. The Zonemaster
provider implements it with collapsible accordions and severity badges.

Adds API endpoint GET .../observations/:obsKey/report, frontend stores
for view mode switching (HTML/JSON), and wires the sidebar toggle buttons.
2026-04-15 19:30:14 +07:00
09d151b234 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
2026-04-15 19:30:14 +07:00
7b41ed3060 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
2026-04-15 19:30:14 +07:00
3008e04a8c 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
2026-04-15 19:30:14 +07:00
f6d0969db0 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
2026-04-15 19:30:13 +07:00
4ce33ade83 checkers: add storage interfaces, implementations, and tidy
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/InMemory backends
- Integrate checker storage into the main Storage interface
- Add tidy methods for checker entities (plans, configurations,
  evaluations, executions, snapshots, observation cache) and
  secondary index cleanup
2026-04-15 19:30:13 +07:00
4bb19bf989 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).
2026-04-15 19:30:13 +07:00
802d24c4b6 checkers: load external checker plugins from .so files
All checks were successful
continuous-integration/drone/push Build is passing
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.
2026-04-15 19:30:13 +07:00
d5ec413b7d 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
2026-04-15 19:30:13 +07:00
3f0ca0b37e tidy: delete User record when cleaning up unverified AuthUsers
When TidyUsers removes an AuthUser with unverified email and no login
after 7 days, the corresponding User record was left orphaned in the
database. Now DeleteUser is called before dropping the AuthUser.
2026-04-14 18:56:57 +07:00
90e4d30ae4 web: Make images occupy the background
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-14 09:08:47 +07:00
3163a10c45 web: add collapsible domain groups with persistent state
All checks were successful
continuous-integration/drone/push Build is passing
Add click-to-collapse and double-click-to-solo behavior on group
headers in ZoneList, with a chevron indicator. Collapsed state is
persisted in localStorage and cleared on login/logout.
2026-04-14 05:08:01 +07:00
197fd9c796 Redesign UI: modernize layout, login, resolver, and some components
Refresh the overall visual design across the frontend: floating labels
and centered card layout for auth pages, animated sidebar+results view
for the resolver, polished dropdown menus, toasts, and feedback widget,
and cleaner global styles (navbar shadow, border, transitions).
2026-04-14 05:08:01 +07:00
52e176c73a chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-13 00:12:13 +00:00
206 changed files with 25878 additions and 1208 deletions

View file

@ -0,0 +1,33 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
matrix "git.happydns.org/checker-matrix/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(matrix.Provider())
// Not Externalizable checker as it already calls a HTTP API
checker.RegisterChecker(matrix.Definition())
}

32
checkers/ping.go Normal file
View file

@ -0,0 +1,32 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
ping "git.happydns.org/checker-ping/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(ping.Provider())
checker.RegisterExternalizableChecker(ping.Definition())
}

33
checkers/zonemaster.go Normal file
View file

@ -0,0 +1,33 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checkers
import (
zonemaster "git.happydns.org/checker-zonemaster/checker"
"git.happydns.org/happyDomain/internal/checker"
)
func init() {
checker.RegisterObservationProvider(zonemaster.Provider())
// Not Externalizable checker as it already calls a HTTP API
checker.RegisterChecker(zonemaster.Definition())
}

View file

@ -26,13 +26,16 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/earthboundkid/versioninfo/v2"
"github.com/fatih/color"
_ "git.happydns.org/happyDomain/checkers"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/app"
"git.happydns.org/happyDomain/internal/config"
"git.happydns.org/happyDomain/internal/metrics"
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
@ -54,11 +57,19 @@ func main() {
LastCommit: versioninfo.Revision,
DirtyBuild: versioninfo.DirtyBuild,
}
v := Version
if Version == "custom-build" {
controller.HDVersion.Version = versioninfo.Short()
v = versioninfo.Short()
controller.HDVersion.Version = v
} else {
versioninfo.Version = Version
}
metrics.SetBuildInfo(
v,
versioninfo.Revision,
versioninfo.LastCommit.UTC().Format(time.RFC3339),
versioninfo.DirtyBuild,
)
log.Println("This is happyDomain", versioninfo.Short())

60
docs/metrics.md Normal file
View file

@ -0,0 +1,60 @@
# happyDomain Metrics
happyDomain exposes Prometheus metrics at `GET /metrics` on the **admin
socket only** (Unix socket or loopback). The admin socket is not
authenticated; do not expose it to untrusted networks. The public HTTP API
does **not** serve `/metrics`.
All metric names are prefixed with `happydomain_`.
## Exported metrics
| Metric | Type | Labels | Cardinality bound | Description |
|---|---|---|---|---|
| `happydomain_http_requests_total` | counter | `method`, `path`, `status` | HTTP methods × Gin route templates × HTTP status codes (low hundreds) | Total HTTP requests served. `path` is the Gin route template (e.g. `/api/domains/:domain`), never the raw URL, to keep cardinality bounded. |
| `happydomain_http_request_duration_seconds` | histogram | `method`, `path` | same as above | HTTP request latency, default Prometheus buckets. |
| `happydomain_http_requests_in_flight` | gauge | | 1 | HTTP requests currently being served. |
| `happydomain_scheduler_queue_depth` | gauge (func) | | 1 | Sampled at scrape time via `RegisterSchedulerQueueDepth`. Reports 0 when no scheduler is registered. |
| `happydomain_scheduler_active_workers` | gauge | | 1 | Workers currently executing a check. |
| `happydomain_scheduler_checks_total` | counter | `checker`, `status` | #checker types × {`success`, `error`} | Total scheduler check executions. Checker IDs are system-defined, never user input. |
| `happydomain_scheduler_check_duration_seconds` | histogram | `checker` | #checker types | Check execution latency. |
| `happydomain_provider_api_calls_total` | counter | `provider`, `operation`, `status` | #providers × #ops × {`success`, `error`} | DNS provider API calls. `provider` is the dnscontrol provider name (bounded set). |
| `happydomain_provider_api_duration_seconds` | histogram | `provider`, `operation` | same | DNS provider API latency. |
| `happydomain_storage_operations_total` | counter | `operation`, `entity`, `status` | ~6 ops × ~5 entities × {`success`, `error`} | Storage operations. |
| `happydomain_storage_operation_duration_seconds` | histogram | `operation`, `entity` | same | Storage operation latency. |
| `happydomain_storage_stats_errors_total` | counter | `entity` | #entities | Errors encountered while collecting storage stats during a scrape. Alert on a non-zero rate — silent storage failures otherwise produce gaps in the gauges below. |
| `happydomain_registered_users_total` | gauge | | 1 | Registered user accounts (sampled live at scrape time). |
| `happydomain_domains_total` | gauge | | 1 | Domains managed across all users. |
| `happydomain_zones_total` | gauge | | 1 | Zone snapshots stored. |
| `happydomain_providers_total` | gauge | | 1 | Provider configurations across all users. |
| `happydomain_build_info` | gauge | `version`, `revision`, `dirty`, `build_date` | 1 per build | Always 1; metadata is in the labels. |
## Cardinality rules
- **Never** add a label whose value comes from user input: domain name, user
ID, zone ID, provider URL, raw HTTP path, etc.
- New labels MUST have a documented finite bound in the table above before
the metric is merged.
- Histograms inherit the cardinality of their labels — be especially careful.
## Security
`/metrics` exposes business intelligence (entity counts, provider mix,
latency profiles) and operational shape (queue depth, worker counts). It is
intentionally only mounted on the admin socket (`internal/app/admin.go`).
Bind that socket to a Unix path or `127.0.0.1` only — exposing it on a
network interface will leak this information to anyone who can reach it.
## Implementation notes
- The HTTP middleware uses `c.FullPath()` (Gin route template) to populate
the `path` label. See `internal/metrics/http.go`.
- The scheduler queue depth gauge is a `GaugeFunc` that calls back into the
scheduler at scrape time, installed via
`metrics.RegisterSchedulerQueueDepth`. The scheduler unregisters its
accessor in `Stop()` so stopped schedulers do not leak their queue.
- The storage stats collector runs each `Count*` query in its own goroutine
with a `recover()` guard, so a panicking backend cannot crash the scrape.
Failures increment `happydomain_storage_stats_errors_total{entity=…}`.
- `happydomain_build_info` is set once at startup from `cmd/happyDomain/main.go`
using `versioninfo.Revision`, `LastCommit`, and `DirtyBuild`.

View file

@ -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)).

View file

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

9
go.mod
View file

@ -5,6 +5,10 @@ go 1.25.0
toolchain go1.26.2
require (
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.3.0
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
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
@ -179,9 +183,10 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.5
github.com/prometheus/procfs v0.20.1 // indirect
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
github.com/quic-go/qpack v0.6.0 // indirect

10
go.sum
View file

@ -12,6 +12,14 @@ 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-matrix v0.0.0-20260407211824-2bb91d33d489 h1:pTGfGq88Dj4Y60LJLSW4FvpUubeYpNlwuxKt/2IFzdo=
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489/go.mod h1:fQjY1yWYFucu+Ebn5uYM7ZWTJNQIgjMENI/8tqlaR98=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc h1:jKEOx2NDbHHxjCy1fUkcn1RgpzOKbE+bGRsF+ITNigI=
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc/go.mod h1:wphWmslFhKcpWfJTrHdChv8DkhUP9jwis7V2jy7vOX0=
git.happydns.org/checker-sdk-go v0.3.0 h1:XJEteWMqEaO2LJJpXRld+h0NOAdEjw9zzif1jQC12gI=
git.happydns.org/checker-sdk-go v0.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc h1:y5xjoqLA/WztFWhEUifOwnJ6POjl+Udw6bWjzQ2afOw=
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc/go.mod h1:B1P23OMm82GfAtYw8vCbspc7qULsFA0u/tqR+SGAaNw=
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=
@ -573,6 +581,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=

View file

@ -26,11 +26,13 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v4/models"
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/model"
)
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
auditor = p.RecordAuditor
}
return &DNSControlAdapterNSProvider{provider, auditor}, nil
return &DNSControlAdapterNSProvider{
DNSServiceProvider: provider,
RecordAuditor: auditor,
providerName: configAdapter.DNSControlName(),
}, nil
}
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
DNSServiceProvider dnscontrol.DNSServiceProvider
// RecordAuditor validates records for provider-specific requirements
RecordAuditor dnscontrol.RecordAuditor
// providerName is the DNSControl provider name used for metrics labels
providerName string
}
// CanListZones checks if the provider supports listing zones (domains).
@ -169,6 +177,26 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
return ok
}
// observeProviderCall starts timing a provider API call and returns a closure
// that records the outcome. Intended use:
//
// defer p.observeProviderCall("op")(&err)
//
// The returned closure reads *err at defer-execution time, so it observes the
// final value of the named return even if it is reassigned later in the
// function (including from a recover() block).
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if err != nil && *err != nil {
status = "error"
}
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
}
}
// CanCreateDomain checks if the provider supports creating new domains.
// Returns true if the provider implements the ZoneCreator interface.
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
@ -182,6 +210,7 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
var records models.Records
defer p.observeProviderCall("get_zone_records")(&err)
defer func() {
if a := recover(); a != nil {
err = fmt.Errorf("%s", a)
@ -211,6 +240,8 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
// before computing corrections.
// Returns a slice of corrections, the total number of corrections needed, and any error.
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
defer p.observeProviderCall("get_zone_corrections")(&err)
var dc *models.DomainConfig
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
if err != nil {
@ -261,23 +292,31 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
// CreateDomain creates a new zone (domain) on the provider.
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
// Returns an error if the provider doesn't support domain creation or if creation fails.
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
defer p.observeProviderCall("create_domain")(&err)
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
if !ok {
return fmt.Errorf("Provider doesn't support domain creation.")
err = fmt.Errorf("Provider doesn't support domain creation.")
return
}
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
return
}
// ListZones retrieves a list of all zones (domains) managed by this provider.
// Returns a slice of domain names or an error if the provider doesn't support listing
// or if the operation fails.
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
defer p.observeProviderCall("list_zones")(&err)
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
if !ok {
return nil, fmt.Errorf("Provider doesn't support domain listing.")
err = fmt.Errorf("Provider doesn't support domain listing.")
return
}
return zl.ListZones()
zones, err = zl.ListZones()
return
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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, 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)
}

View file

@ -74,7 +74,7 @@ func NewDomainController(
func (dc *DomainController) ListDomains(c *gin.Context) {
user := middleware.MyUser(c)
if user != nil {
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
apidc.GetDomains(c)
return
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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})
}

View file

@ -24,6 +24,7 @@ package controller
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
@ -168,7 +169,20 @@ func (uc *UserController) UpdateUser(c *gin.Context) {
}
uu.Id = user.Id
happydns.ApiResponse(c, uu, uc.store.CreateOrUpdateUser(uu))
updated, err := uc.userService.UpdateUser(uu.Id, func(u *happydns.User) {
// Stamp quota update time if quota fields changed.
if uu.Quota != u.Quota {
uu.Quota.UpdatedAt = time.Now()
}
u.Email = uu.Email
u.CreatedAt = uu.CreatedAt
u.LastSeen = uu.LastSeen
u.Settings = uu.Settings
u.Quota = uu.Quota
})
happydns.ApiResponse(c, updated, err)
}
// deleteUser removes a specific user from the database.

View file

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

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareCheckersRoutes(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)
}

View file

@ -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)
declareCheckersRoutes(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)

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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)
}

View file

@ -0,0 +1,244 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"io"
"log"
"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
plannedProvider checkerUC.PlannedJobProvider
}
// NewCheckerController creates a new CheckerController.
func NewCheckerController(
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
) *CheckerController {
return &CheckerController{
engine: engine,
OptionsUC: optionsUC,
planUC: planUC,
statusUC: statusUC,
plannedProvider: plannedProvider,
}
}
// 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
}
// --- 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) {
def, _ := c.Get("checker")
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")
var req happydns.CheckerRunRequest
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
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 func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
}
}()
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) {
exec := c.MustGet("execution").(*happydns.Execution)
c.JSON(http.StatusOK, exec)
}

View file

@ -0,0 +1,303 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// respondWithMetrics writes metrics in the format requested by the Accept header.
// JSON is the default (preserving the previous API contract for clients that
// send Accept: */* or omit the header). Prometheus text exposition is only
// returned when explicitly requested via Accept: text/plain.
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
if metrics == nil {
metrics = []happydns.CheckMetric{}
}
if wantsPrometheusText(c.GetHeader("Accept")) {
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(renderPrometheus(metrics)))
return
}
c.JSON(http.StatusOK, metrics)
}
const maxLimit = 1000
// wantsPrometheusText returns true when the Accept header explicitly asks for
// text/plain (or the Prometheus exposition media type) without also accepting
// JSON. This keeps the JSON API the default for browsers and generic clients
// while letting `curl -H 'Accept: text/plain'` opt into the Prometheus format.
func wantsPrometheusText(accept string) bool {
if accept == "" {
return false
}
if strings.Contains(accept, "application/json") {
return false
}
return strings.Contains(accept, "text/plain") ||
strings.Contains(accept, "application/openmetrics-text")
}
// escapePromLabelValue escapes a label value for the Prometheus text exposition
// format. The spec only allows three escape sequences inside label values:
// `\\`, `\"` and `\n`. Using fmt's %q is unsafe because it can emit \xNN or
// \uNNNN sequences that Prometheus rejects.
func escapePromLabelValue(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
for i := 0; i < len(s); i++ {
switch c := s[i]; c {
case '\\':
b.WriteString(`\\`)
case '"':
b.WriteString(`\"`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteByte(c)
}
}
return b.String()
}
// renderPrometheus formats metrics as Prometheus text exposition format
// (version 0.0.4). It only emits constructs allowed by that format: HELP/TYPE
// metadata and untyped samples — no OpenMetrics-only directives such as # UNIT.
func renderPrometheus(metrics []happydns.CheckMetric) string {
type metricMeta struct {
unit string
}
seen := map[string]metricMeta{}
var names []string
for _, m := range metrics {
if _, ok := seen[m.Name]; !ok {
seen[m.Name] = metricMeta{unit: m.Unit}
names = append(names, m.Name)
}
}
sort.Strings(names)
var b strings.Builder
nameIdx := map[string]int{}
for i, name := range names {
nameIdx[name] = i
}
// Sort metrics by name order, then by timestamp.
sorted := make([]happydns.CheckMetric, len(metrics))
copy(sorted, metrics)
sort.Slice(sorted, func(i, j int) bool {
ni, nj := nameIdx[sorted[i].Name], nameIdx[sorted[j].Name]
if ni != nj {
return ni < nj
}
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
})
currentName := ""
for _, m := range sorted {
if m.Name != currentName {
currentName = m.Name
meta := seen[m.Name]
if meta.unit != "" {
// Surface the unit as a HELP comment so it stays parseable
// under Prometheus text 0.0.4 (which has no # UNIT directive).
fmt.Fprintf(&b, "# HELP %s unit: %s\n", m.Name, meta.unit)
}
fmt.Fprintf(&b, "# TYPE %s untyped\n", m.Name)
}
b.WriteString(m.Name)
if len(m.Labels) > 0 {
b.WriteByte('{')
first := true
labelKeys := make([]string, 0, len(m.Labels))
for k := range m.Labels {
labelKeys = append(labelKeys, k)
}
sort.Strings(labelKeys)
for _, k := range labelKeys {
if !first {
b.WriteByte(',')
}
fmt.Fprintf(&b, "%s=\"%s\"", k, escapePromLabelValue(m.Labels[k]))
first = false
}
b.WriteByte('}')
}
fmt.Fprintf(&b, " %g", m.Value)
if !m.Timestamp.IsZero() {
fmt.Fprintf(&b, " %d", m.Timestamp.UnixMilli())
}
b.WriteByte('\n')
}
return b.String()
}
func getLimitParam(c *gin.Context, defaultLimit int) int {
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
if parsed > maxLimit {
return maxLimit
}
return parsed
}
}
return defaultLimit
}
// GetUserMetrics returns metrics across all checkers for the authenticated user.
//
// @Summary Get all user metrics
// @Description Returns metrics from all recent executions for the authenticated user. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /checkers/metrics [get]
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
target := targetFromContext(c)
userID := happydns.TargetIdentifier(target.UserId)
if userID == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetDomainMetrics returns metrics for a domain and its service children.
//
// @Summary Get domain metrics
// @Description Returns metrics from recent executions for a domain and all its services. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param domain path string true "Domain identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/metrics [get]
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
target := targetFromContext(c)
domainID := happydns.TargetIdentifier(target.DomainId)
if domainID == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetCheckerMetrics returns metrics for a specific checker on a target.
//
// @Summary Get checker metrics
// @Description Returns metrics from recent executions of a specific checker on a target. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
checkerID := c.Param("checkerId")
target := targetFromContext(c)
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetExecutionMetrics returns metrics for a single execution.
//
// @Summary Get execution metrics
// @Description Returns metrics extracted from a single execution's observation snapshot. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
// @Tags checkers
// @Produce json,plain
// @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 false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
return
}
target := targetFromContext(c)
exec, err := cc.statusUC.GetExecution(target, execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}

View file

@ -0,0 +1,117 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"strings"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"git.happydns.org/happyDomain/model"
)
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
// Include a label value with characters that fmt's %q would have escaped
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
// format. The output must still parse cleanly via the upstream parser.
out := renderPrometheus([]happydns.CheckMetric{
{
Name: "happydomain_check_latency_seconds",
Unit: "seconds",
Value: 0.123,
Timestamp: time.Unix(1700000000, 0),
Labels: map[string]string{
"target": "exämple.com", // non-ASCII
"note": "line1\nline2", // newline (must become \n)
"quoted": `he said "hi"`, // quotes
"slash": `a\b`, // backslash
},
},
{
Name: "happydomain_check_latency_seconds",
Value: 0.456,
Labels: map[string]string{
"target": "second.example",
},
},
})
p := expfmt.NewTextParser(model.LegacyValidation)
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
}
}
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Value: 1,
Labels: map[string]string{
"a": `\`,
"b": `"`,
"c": "\n",
},
}})
if !strings.Contains(out, `a="\\"`) {
t.Errorf("backslash not escaped: %q", out)
}
if !strings.Contains(out, `b="\""`) {
t.Errorf("quote not escaped: %q", out)
}
if !strings.Contains(out, `c="\n"`) {
t.Errorf("newline not escaped: %q", out)
}
}
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
out := renderPrometheus([]happydns.CheckMetric{{
Name: "x",
Unit: "seconds",
Value: 1,
}})
if strings.Contains(out, "# UNIT") {
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
}
}
func TestWantsPrometheusText(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"", false},
{"*/*", false},
{"application/json", false},
{"application/json, text/plain", false}, // explicit JSON wins
{"text/plain", true},
{"text/plain; version=0.0.4", true},
{"application/openmetrics-text; version=1.0.0", true},
}
for _, tc := range cases {
if got := wantsPrometheusText(tc.accept); got != tc.want {
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
}
}
}

View file

@ -0,0 +1,223 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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.MergeCheckerOptions(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
}
if _, err := cc.OptionsUC.AddCheckerOptions(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, 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)
}

View file

@ -0,0 +1,196 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// PlanHandler is a middleware that validates the planId path parameter,
// checks target scope, and sets "plan" in context.
func (cc *CheckerController) PlanHandler(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(targetFromContext(c), planID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Set("plan", plan)
c.Next()
}
// 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.ListCheckPlansByTargetAndChecker(target, checkerID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, plans)
}
// 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
plan.CheckerID = c.Param("checkerId")
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) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
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) {
existing := c.MustGet("plan").(*happydns.CheckPlan)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = targetFromContext(c)
plan.CheckerID = c.Param("checkerId")
updated, err := cc.planUC.UpdateCheckPlan(plan.Target, existing.Id, &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) {
plan := c.MustGet("plan").(*happydns.CheckPlan)
if err := cc.planUC.DeleteCheckPlan(targetFromContext(c), plan.Id); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,313 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"strconv"
"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"
)
// ExecutionHandler is a middleware that validates the executionId path parameter,
// checks target scope, and sets "execution" in context.
func (cc *CheckerController) ExecutionHandler(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(targetFromContext(c), execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
c.Set("execution", exec)
c.Next()
}
// 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 := checkerUC.ListPlannedExecutions(cc.plannedProvider, 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) {
exec := c.MustGet("execution").(*happydns.Execution)
if err := cc.statusUC.DeleteExecution(targetFromContext(c), exec.Id); 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) {
exec := c.MustGet("execution").(*happydns.Execution)
snap, err := cc.statusUC.GetObservationsByExecution(targetFromContext(c), exec.Id)
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) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", 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) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
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) {
exec := c.MustGet("execution").(*happydns.Execution)
eval, err := cc.statusUC.GetResultsByExecution(targetFromContext(c), exec.Id)
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"})
}
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
//
// @Summary Get execution observation HTML report
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
// @Tags checkers
// @Produce html
// @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 {string} string "HTML document"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
obsKey := c.Param("obsKey")
val, err := cc.statusUC.GetSnapshotByExecution(targetFromContext(c), exec.Id, obsKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation not available"})
return
}
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if !supported {
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
return
}
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
}

View file

@ -0,0 +1,757 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// --- Stub types ---
// stubCheckerEngine implements happydns.CheckerEngine for testing.
type stubCheckerEngine struct {
exec *happydns.Execution
eval *happydns.CheckEvaluation
err error
}
func (s *stubCheckerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
if s.err != nil {
return nil, s.err
}
if s.exec != nil {
return s.exec, nil
}
id, _ := happydns.NewRandomIdentifier()
return &happydns.Execution{
Id: id,
CheckerID: checkerID,
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionPending,
}, nil
}
func (s *stubCheckerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
if s.err != nil {
return nil, s.err
}
if s.eval != nil {
return s.eval, nil
}
return &happydns.CheckEvaluation{
CheckerID: exec.CheckerID,
Target: exec.Target,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
}, nil
}
// testObservationProvider is a no-op provider for tests.
type testObservationProvider struct{}
func (p *testObservationProvider) Key() happydns.ObservationKey { return "test_ctrl_obs" }
func (p *testObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"v": 1}, nil
}
// testHTMLObservationProvider implements CheckerHTMLReporter for HTML report tests.
type testHTMLObservationProvider struct{}
func (p *testHTMLObservationProvider) Key() happydns.ObservationKey { return "test_html_obs" }
func (p *testHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"html": true}, nil
}
func (p *testHTMLObservationProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
return "<html><body>test report</body></html>", nil
}
// testCheckRule produces a fixed status.
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 {
return happydns.CheckState{Status: r.status, Code: r.name}
}
// registerTestChecker registers a checker for controller tests and returns its ID.
// Uses a unique name to avoid collisions with other tests.
var testCheckerSeq int
func registerTestChecker() string {
testCheckerSeq++
id := fmt.Sprintf("ctrl_test_checker_%d", testCheckerSeq)
checkerPkg.RegisterObservationProvider(&testObservationProvider{})
checkerPkg.RegisterChecker(&happydns.CheckerDefinition{
ID: id,
Name: "Controller Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_a", status: happydns.StatusOK},
},
})
return id
}
// newTestController creates a CheckerController with in-memory storage.
func newTestController(engine happydns.CheckerEngine) *CheckerController {
cc, _ := newTestControllerWithStorage(engine)
return cc
}
// newTestControllerWithStorage creates a CheckerController and returns the underlying storage.
func newTestControllerWithStorage(engine happydns.CheckerEngine) (*CheckerController, storage.Storage) {
store, err := inmemory.Instantiate()
if err != nil {
panic(err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
planUC := checkerUC.NewCheckPlanUsecase(store)
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
return NewCheckerController(engine, optionsUC, planUC, statusUC, nil), store
}
// --- targetFromContext tests ---
func TestTargetFromContext_Empty(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
target := targetFromContext(c)
if target.UserId != "" {
t.Errorf("expected empty UserId, got %q", target.UserId)
}
if target.DomainId != "" {
t.Errorf("expected empty DomainId, got %q", target.DomainId)
}
if target.ServiceId != "" {
t.Errorf("expected empty ServiceId, got %q", target.ServiceId)
}
}
func TestTargetFromContext_WithUser(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
c.Set("LoggedUser", user)
target := targetFromContext(c)
if target.UserId != uid.String() {
t.Errorf("expected UserId %q, got %q", uid.String(), target.UserId)
}
}
func TestTargetFromContext_WithDomain(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
did, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{Id: did}
c.Set("domain", domain)
target := targetFromContext(c)
if target.DomainId != did.String() {
t.Errorf("expected DomainId %q, got %q", did.String(), target.DomainId)
}
}
func TestTargetFromContext_WithService(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
sid, _ := happydns.NewRandomIdentifier()
c.Set("serviceid", happydns.Identifier(sid))
target := targetFromContext(c)
if target.ServiceId != sid.String() {
t.Errorf("expected ServiceId %q, got %q", sid.String(), target.ServiceId)
}
}
func TestTargetFromContext_WithServiceAndZone(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
sid, _ := happydns.NewRandomIdentifier()
svc := &happydns.Service{
ServiceMeta: happydns.ServiceMeta{
Id: sid,
Type: "svcs.TestType",
},
}
zone := &happydns.Zone{
Services: map[happydns.Subdomain][]*happydns.Service{
"": {svc},
},
}
c.Set("serviceid", happydns.Identifier(sid))
c.Set("zone", zone)
target := targetFromContext(c)
if target.ServiceType != "svcs.TestType" {
t.Errorf("expected ServiceType %q, got %q", "svcs.TestType", target.ServiceType)
}
}
// --- ListCheckers tests ---
func TestListCheckers_ReturnsRegistered(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers", nil)
cc.ListCheckers(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if _, ok := result[checkerID]; !ok {
t.Errorf("expected checker %q in response, got keys: %v", checkerID, keysOf(result))
}
}
func keysOf(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// --- CheckerHandler tests ---
func TestCheckerHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/nonexistent", nil)
c.Params = gin.Params{{Key: "checkerId", Value: "nonexistent_checker_xyz"}}
cc.CheckerHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if _, ok := resp["errmsg"]; !ok {
t.Error("expected errmsg in response")
}
}
func TestCheckerHandler_Found(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
c.Params = gin.Params{{Key: "checkerId", Value: checkerID}}
// CheckerHandler calls c.Next(), so we need to verify context is set.
// Use a gin engine to test the middleware chain.
router := gin.New()
router.GET("/checkers/:checkerId", cc.CheckerHandler, cc.GetChecker)
req := httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var def map[string]any
if err := json.Unmarshal(w2.Body.Bytes(), &def); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if def["id"] != checkerID {
t.Errorf("expected checker id %q, got %v", checkerID, def["id"])
}
}
// --- TriggerCheck tests ---
func TestTriggerCheck_Sync_Returns200(t *testing.T) {
checkerID := registerTestChecker()
eval := &happydns.CheckEvaluation{
CheckerID: checkerID,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "ok"}},
}
engine := &stubCheckerEngine{eval: eval}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions?sync=true", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["checkerId"] != checkerID {
t.Errorf("expected checkerId %q, got %v", checkerID, result["checkerId"])
}
}
func TestTriggerCheck_Async_Returns202(t *testing.T) {
checkerID := registerTestChecker()
engine := &stubCheckerEngine{}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %s", w.Code, w.Body.String())
}
}
func TestTriggerCheck_EngineError_Returns500(t *testing.T) {
checkerID := registerTestChecker()
engine := &stubCheckerEngine{err: fmt.Errorf("engine failure")}
cc := newTestController(engine)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
body, _ := json.Marshal(happydns.CheckerRunRequest{})
req := httptest.NewRequest("POST", "/checkers/"+checkerID+"/executions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := gin.New()
router.POST("/checkers/:checkerId/executions", func(c *gin.Context) {
c.Set("LoggedUser", user)
c.Next()
}, cc.TriggerCheck)
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// --- GetExecutionStatus tests ---
func TestGetExecutionStatus_ReturnsExecution(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
execID, _ := happydns.NewRandomIdentifier()
exec := &happydns.Execution{
Id: execID,
CheckerID: "test",
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "done"},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/"+execID.String(), nil)
c.Set("execution", exec)
cc.GetExecutionStatus(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["checkerId"] != "test" {
t.Errorf("expected checkerId %q, got %v", "test", result["checkerId"])
}
}
// --- GetChecker tests ---
func TestGetChecker_ReturnsDefinition(t *testing.T) {
checkerID := registerTestChecker()
cc := newTestController(&stubCheckerEngine{})
def := checkerPkg.FindChecker(checkerID)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/checkers/"+checkerID, nil)
c.Set("checker", def)
cc.GetChecker(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["id"] != checkerID {
t.Errorf("expected id %q, got %v", checkerID, result["id"])
}
}
// --- ExecutionHandler tests ---
func TestExecutionHandler_InvalidID(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/not-valid", nil)
c.Params = gin.Params{{Key: "executionId", Value: "not-valid"}}
cc.ExecutionHandler(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestExecutionHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
fakeID, _ := happydns.NewRandomIdentifier()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/executions/"+fakeID.String(), nil)
c.Params = gin.Params{{Key: "executionId", Value: fakeID.String()}}
cc.ExecutionHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// --- PlanHandler tests ---
func TestPlanHandler_InvalidID(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plans/not-valid", nil)
c.Params = gin.Params{{Key: "planId", Value: "not-valid"}}
cc.PlanHandler(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestPlanHandler_NotFound(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
fakeID, _ := happydns.NewRandomIdentifier()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plans/"+fakeID.String(), nil)
c.Params = gin.Params{{Key: "planId", Value: fakeID.String()}}
cc.PlanHandler(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// --- GetExecutionHTMLReport tests ---
// seedExecutionWithObservations creates an execution backed by a snapshot containing the given
// observation data. It returns the execution (with ID assigned by the store).
func seedExecutionWithObservations(t *testing.T, store storage.Storage, target happydns.CheckTarget, data map[happydns.ObservationKey]json.RawMessage) *happydns.Execution {
t.Helper()
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: data,
}
if err := store.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "html_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := store.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation: %v", err)
}
exec := &happydns.Execution{
CheckerID: "html_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := store.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution: %v", err)
}
return exec
}
func init() {
// Register the HTML observation provider once for tests.
checkerPkg.RegisterObservationProvider(&testHTMLObservationProvider{})
}
func TestGetExecutionHTMLReport_ObservationsNotAvailable(t *testing.T) {
cc := newTestController(&stubCheckerEngine{})
// Create an execution with no evaluation/snapshot backing.
fakeExecID, _ := happydns.NewRandomIdentifier()
exec := &happydns.Execution{
Id: fakeExecID,
CheckerID: "html_test_checker",
Status: happydns.ExecutionDone,
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetExecutionHTMLReport_ObservationKeyNotFound(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "nonexistent_key"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// testNoHTMLObservationProvider is a provider that does NOT implement CheckerHTMLReporter.
type testNoHTMLObservationProvider struct{}
func (p *testNoHTMLObservationProvider) Key() happydns.ObservationKey { return "test_no_html_obs" }
func (p *testNoHTMLObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
return map[string]any{"v": 1}, nil
}
func init() {
checkerPkg.RegisterObservationProvider(&testNoHTMLObservationProvider{})
}
func TestGetExecutionHTMLReport_ProviderDoesNotSupportHTML(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_no_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_no_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 (unsupported), got %d: %s", w.Code, w.Body.String())
}
}
func TestGetExecutionHTMLReport_Success(t *testing.T) {
cc, store := newTestControllerWithStorage(&stubCheckerEngine{})
target := happydns.CheckTarget{DomainId: "d1"}
exec := seedExecutionWithObservations(t, store, target, map[happydns.ObservationKey]json.RawMessage{
"test_html_obs": json.RawMessage(`{"v":1}`),
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/report", nil)
c.Set("execution", exec)
c.Params = gin.Params{{Key: "obsKey", Value: "test_html_obs"}}
cc.GetExecutionHTMLReport(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if body != "<html><body>test report</body></html>" {
t.Errorf("unexpected body: %s", body)
}
ct := w.Header().Get("Content-Type")
if ct != "text/html; charset=utf-8" {
t.Errorf("expected Content-Type text/html, got %q", ct)
}
csp := w.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("expected Content-Security-Policy header to be set")
}
xcto := w.Header().Get("X-Content-Type-Options")
if xcto != "nosniff" {
t.Errorf("expected X-Content-Type-Options nosniff, got %q", xcto)
}
}
// --- getLimitParam tests ---
func newContextWithQuery(query string) *gin.Context {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/?"+query, nil)
return c
}
func TestGetLimitParam(t *testing.T) {
tests := []struct {
name string
query string
defaultLimit int
expected int
}{
{"empty query returns default", "", 100, 100},
{"valid limit", "limit=50", 100, 50},
{"zero returns default", "limit=0", 100, 100},
{"negative returns default", "limit=-5", 100, 100},
{"non-numeric returns default", "limit=abc", 100, 100},
{"large value capped to maxLimit", "limit=1500", 100, 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newContextWithQuery(tt.query)
got := getLimitParam(c, tt.defaultLimit)
if got != tt.expected {
t.Errorf("getLimitParam(%q, %d) = %d, want %d", tt.query, tt.defaultLimit, got, tt.expected)
}
})
}
}

View file

@ -30,6 +30,7 @@ import (
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -37,13 +38,15 @@ type DomainController struct {
domainService happydns.DomainUsecase
remoteZoneImporter happydns.RemoteZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
checkStatusUC *checkerUC.CheckStatusUsecase
}
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *DomainController {
return &DomainController{
domainService: domainService,
remoteZoneImporter: remoteZoneImporter,
zoneImporter: zoneImporter,
checkStatusUC: checkStatusUC,
}
}
@ -56,7 +59,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {array} happydns.Domain
// @Success 200 {array} happydns.DomainWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
// @Router /domains [get]
@ -73,7 +76,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
return
}
c.JSON(http.StatusOK, domains)
var statusByDomain map[string]*happydns.Status
if dc.checkStatusUC != nil {
var err error
statusByDomain, err = dc.checkStatusUC.GetWorstDomainStatuses(user.Id)
if err != nil {
log.Printf("GetWorstDomainStatuses: %s", err.Error())
}
}
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
for _, d := range domains {
entry := &happydns.DomainWithCheckStatus{Domain: d}
if statusByDomain != nil {
entry.LastCheckStatus = statusByDomain[d.Id.String()]
}
result = append(result, entry)
}
c.JSON(http.StatusOK, result)
}
// AddDomain appends a new domain to those managed.

View file

@ -0,0 +1,296 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// --- Stub types for domain tests ---
// stubDomainUsecase implements happydns.DomainUsecase for testing.
type stubDomainUsecase struct {
domains []*happydns.Domain
err error
}
func (s *stubDomainUsecase) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) DeleteDomain(id happydns.Identifier) error {
return fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ExtendsDomainWithZoneMeta(d *happydns.Domain) (*happydns.DomainWithZoneMetadata, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomain(user *happydns.User, id happydns.Identifier) (*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) GetUserDomainByFQDN(user *happydns.User, fqdn string) ([]*happydns.Domain, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *stubDomainUsecase) ListUserDomains(user *happydns.User) ([]*happydns.Domain, error) {
if s.err != nil {
return nil, s.err
}
return s.domains, nil
}
func (s *stubDomainUsecase) UpdateDomain(id happydns.Identifier, user *happydns.User, fn func(*happydns.Domain)) error {
return fmt.Errorf("not implemented")
}
// newDomainTestContext creates a gin context with a logged-in user and a recorder.
func newDomainTestContext(user *happydns.User) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/domains", nil)
if user != nil {
c.Set("LoggedUser", user)
}
return w, c
}
// --- GetDomains tests ---
func TestGetDomains_Unauthenticated(t *testing.T) {
dc := NewDomainController(&stubDomainUsecase{}, nil, nil, nil)
w, c := newDomainTestContext(nil)
dc.GetDomains(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestGetDomains_ListError(t *testing.T) {
stub := &stubDomainUsecase{err: fmt.Errorf("db failure")}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
func TestGetDomains_EmptyList(t *testing.T) {
stub := &stubDomainUsecase{domains: []*happydns.Domain{}}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 0 {
t.Errorf("expected 0 domains, got %d", len(result))
}
}
func TestGetDomains_NilCheckStatusUC(t *testing.T) {
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "example.com."},
{Id: did2, DomainName: "example.org."},
},
}
dc := NewDomainController(stub, nil, nil, nil)
uid, _ := happydns.NewRandomIdentifier()
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 domains, got %d", len(result))
}
for _, d := range result {
if d.LastCheckStatus != nil {
t.Errorf("expected nil LastCheckStatus when checkStatusUC is nil, got %v for domain %s", *d.LastCheckStatus, d.DomainName)
}
}
}
func TestGetDomains_WithCheckStatuses(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
did3, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did1, DomainName: "warn.example.com.", Owner: uid},
{Id: did2, DomainName: "ok.example.com.", Owner: uid},
{Id: did3, DomainName: "unchecked.example.com.", Owner: uid},
},
}
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("failed to create in-memory store: %v", err)
}
statusUC := checkerUC.NewCheckStatusUsecase(store, store, store, store)
// Create executions: domain 1 has WARN, domain 2 has OK, domain 3 has none.
for _, tc := range []struct {
domainId happydns.Identifier
status happydns.Status
}{
{did1, happydns.StatusOK},
{did1, happydns.StatusWarn},
{did2, happydns.StatusOK},
} {
exec := &happydns.Execution{
CheckerID: "test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: tc.domainId.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: tc.status},
}
if err := store.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
dc := NewDomainController(stub, nil, nil, statusUC)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []happydns.DomainWithCheckStatus
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 domains, got %d", len(result))
}
statusByDomain := make(map[string]*happydns.Status)
for _, d := range result {
statusByDomain[d.Id.String()] = d.LastCheckStatus
}
// Domain 1: worst is WARN.
if s := statusByDomain[did1.String()]; s == nil {
t.Error("expected non-nil status for domain 1 (warn.example.com)")
} else if *s != happydns.StatusWarn {
t.Errorf("expected WARN for domain 1, got %v", *s)
}
// Domain 2: worst is OK.
if s := statusByDomain[did2.String()]; s == nil {
t.Error("expected non-nil status for domain 2 (ok.example.com)")
} else if *s != happydns.StatusOK {
t.Errorf("expected OK for domain 2, got %v", *s)
}
// Domain 3: no executions → nil.
if s := statusByDomain[did3.String()]; s != nil {
t.Errorf("expected nil status for domain 3 (unchecked.example.com), got %v", *s)
}
}
func TestGetDomains_ResponseIncludesDomainFields(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
pid, _ := happydns.NewRandomIdentifier()
stub := &stubDomainUsecase{
domains: []*happydns.Domain{
{Id: did, DomainName: "test.example.com.", Owner: uid, ProviderId: pid, Group: "mygroup"},
},
}
dc := NewDomainController(stub, nil, nil, nil)
user := &happydns.User{Id: uid}
w, c := newDomainTestContext(user)
dc.GetDomains(c)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
var result []json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected 1 domain, got %d", len(result))
}
// Verify the JSON contains the expected domain fields (embedded from *Domain).
var fields map[string]json.RawMessage
if err := json.Unmarshal(result[0], &fields); err != nil {
t.Fatalf("failed to unmarshal domain entry: %v", err)
}
for _, key := range []string{"id", "id_owner", "id_provider", "domain", "group"} {
if _, ok := fields[key]; !ok {
t.Errorf("expected field %q in response JSON", key)
}
}
// last_check_status should be omitted when nil (omitempty).
if _, ok := fields["last_check_status"]; ok {
t.Error("expected last_check_status to be omitted when nil")
}
}

View file

@ -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 := middleware.MyUser(c)
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.

View file

@ -0,0 +1,114 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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"
)
// declareCheckerOptionsRoutes registers the options sub-routes on a checker group.
func declareCheckerOptionsRoutes(checkerID *gin.RouterGroup, cc *controller.CheckerController) {
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)
}
// 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,
plannedProvider checkerUC.PlannedJobProvider,
) *controller.CheckerController {
cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC, plannedProvider)
// Global: /api/checkers
checkers := apiRoutes.Group("/checkers")
checkers.GET("", cc.ListCheckers)
checkers.GET("/metrics", cc.GetUserMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
checkerID.GET("", cc.GetChecker)
declareCheckerOptionsRoutes(checkerID, cc)
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)
checkers.GET("/metrics", cc.GetDomainMetrics)
checkerID := checkers.Group("/:checkerId")
checkerID.Use(cc.CheckerHandler)
declareCheckerOptionsRoutes(checkerID, cc)
// Plans (schedules).
checkerID.GET("/plans", cc.ListCheckPlans)
checkerID.POST("/plans", cc.CreateCheckPlan)
planID := checkerID.Group("/plans/:planId")
planID.Use(cc.PlanHandler)
planID.GET("", cc.GetCheckPlan)
planID.PUT("", cc.UpdateCheckPlan)
planID.DELETE("", cc.DeleteCheckPlan)
// Per-checker metrics.
checkerID.GET("/metrics", cc.GetCheckerMetrics)
// Executions.
executions := checkerID.Group("/executions")
executions.GET("", cc.ListExecutions)
executions.POST("", cc.TriggerCheck)
executions.DELETE("", cc.DeleteCheckerExecutions)
executionID := executions.Group("/:executionId")
executionID.Use(cc.ExecutionHandler)
executionID.GET("", cc.GetExecutionStatus)
executionID.DELETE("", cc.DeleteExecution)
// Metrics (under execution).
executionID.GET("/metrics", cc.GetExecutionMetrics)
// Observations (under execution).
executionID.GET("/observations", cc.GetExecutionObservations)
executionID.GET("/observations/:obsKey", cc.GetExecutionObservation)
executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport)
// Results (under execution).
executionID.GET("/results", cc.GetExecutionResults)
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
}

View file

@ -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"
"git.happydns.org/happyDomain/model"
)
@ -39,11 +40,14 @@ func DeclareDomainRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkStatusUC,
)
router.GET("/domains", dc.GetDomains)
@ -61,6 +65,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 +77,6 @@ func DeclareDomainRoutes(
zoneCorrApplier,
zoneServiceUC,
serviceUC,
cc,
)
}

View file

@ -24,12 +24,14 @@ 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"
)
// Dependencies holds all use cases required to register the public API routes.
// It is a plain struct — no methods, no interface — constructed once in app.go.
// It is a plain struct - no methods, no interface - constructed once in app.go.
type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
@ -50,6 +52,12 @@ type Dependencies struct {
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerEngine happydns.CheckerEngine
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
CheckPlanUC *checkerUC.CheckPlanUsecase
CheckStatusUC *checkerUC.CheckStatusUsecase
PlannedProvider checkerUC.PlannedJobProvider
}
// @title happyDomain API
@ -105,6 +113,19 @@ 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,
dep.PlannedProvider,
)
}
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(
apiAuthRoutes,
@ -116,6 +137,8 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
cc,
dep.CheckStatusUC,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

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

View file

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

View file

@ -31,8 +31,10 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
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 +57,11 @@ 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)
}
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
admin.DeclareRoutes(
app.cfg,
@ -71,6 +78,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)

View file

@ -33,11 +33,13 @@ import (
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/captcha"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
"git.happydns.org/happyDomain/internal/session"
"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 +71,14 @@ type Usecases struct {
zoneService happydns.ZoneServiceUsecase
orchestrator *orchestrator.Orchestrator
checkerEngine happydns.CheckerEngine
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
checkerPlanUC *checkerUC.CheckPlanUsecase
checkerStatusUC *checkerUC.CheckStatusUsecase
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
}
type App struct {
@ -93,6 +103,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 +121,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()
@ -162,6 +178,9 @@ func (app *App) initStorageEngine() {
if err = app.store.MigrateSchema(); err != nil {
log.Fatal("Could not migrate database: ", err)
}
app.store = newInstrumentedStorage(app.store)
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
}
}
@ -224,12 +243,13 @@ func (app *App) initUsecases() {
app.store,
)
app.usecases.user = userUC.NewUserUsecases(
userService := userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.user = userService
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
@ -246,6 +266,44 @@ 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.store,
)
// Build 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.
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store, app.usecases.checkerUserGater.Allow)
// Invalidate the scheduler's user gate cache whenever a user is updated
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
userService.SetOnUserChanged(func(id happydns.Identifier) {
app.usecases.checkerUserGater.Invalidate(id.String())
})
// Retention janitor.
app.usecases.checkerJanitor = checkerUC.NewJanitor(
app.store,
app.store,
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)
}
func (app *App) setupRouter() {
@ -255,7 +313,7 @@ func (app *App) setupRouter() {
gin.ForceConsoleColor()
app.router = gin.New()
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
session.COOKIE_NAME,
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
))
@ -291,6 +349,12 @@ 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,
PlannedProvider: app.usecases.checkerScheduler,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
@ -308,6 +372,14 @@ func (app *App) Start() {
go app.insights.Run()
}
if app.usecases.checkerScheduler != nil {
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)
@ -321,6 +393,14 @@ func (app *App) Stop() {
log.Fatal("Server Shutdown:", err)
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Stop()
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Stop()
}
// Close storage
if app.store != nil {
app.store.Close()

View file

@ -0,0 +1,566 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package app
import (
"time"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
// every operation.
type instrumentedStorage struct {
inner storage.Storage
}
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
func newInstrumentedStorage(s storage.Storage) storage.Storage {
return &instrumentedStorage{inner: s}
}
// observe starts a timer and returns a closure that, when called with a
// pointer to the named return error, records the operation outcome. Use as:
//
// defer observe("get", "user")(&err)
//
// The closure reads *err at defer-execution time, so it captures the final
// value of the named return.
func observe(operation, entity string) func(err *error) {
start := time.Now()
return func(err *error) {
status := "success"
if err != nil && *err != nil {
status = "error"
}
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
}
}
// Schema / lifecycle (not instrumented: hot-path-free, called once)
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
func (s *instrumentedStorage) MigrateSchema() error { return s.inner.MigrateSchema() }
func (s *instrumentedStorage) Close() error { return s.inner.Close() }
// AuthUser
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
defer observe("list", "authuser")(&err)
return s.inner.ListAllAuthUsers()
}
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
defer observe("get", "authuser")(&err)
return s.inner.GetAuthUser(id)
}
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
defer observe("get", "authuser")(&err)
return s.inner.GetAuthUserByEmail(email)
}
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
defer observe("get", "authuser")(&err)
return s.inner.AuthUserExists(email)
}
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
defer observe("create", "authuser")(&err)
return s.inner.CreateAuthUser(user)
}
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
defer observe("update", "authuser")(&err)
return s.inner.UpdateAuthUser(user)
}
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.DeleteAuthUser(user)
}
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
defer observe("delete", "authuser")(&err)
return s.inner.ClearAuthUsers()
}
// Domain
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
defer observe("list", "domain")(&err)
return s.inner.ListAllDomains()
}
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
defer observe("list", "domain")(&err)
return s.inner.ListDomains(user)
}
func (s *instrumentedStorage) CountDomains() (ret int, err error) {
defer observe("count", "domain")(&err)
return s.inner.CountDomains()
}
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.GetDomain(domainid)
}
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
defer observe("get", "domain")(&err)
return s.inner.GetDomainByDN(user, fqdn)
}
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
defer observe("create", "domain")(&err)
return s.inner.CreateDomain(domain)
}
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
defer observe("update", "domain")(&err)
return s.inner.UpdateDomain(domain)
}
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
defer observe("delete", "domain")(&err)
return s.inner.DeleteDomain(domainid)
}
func (s *instrumentedStorage) ClearDomains() (err error) {
defer observe("delete", "domain")(&err)
return s.inner.ClearDomains()
}
// DomainLog
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListAllDomainLogs()
}
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
defer observe("list", "domain_log")(&err)
return s.inner.ListDomainLogs(domain)
}
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("create", "domain_log")(&err)
return s.inner.CreateDomainLog(domain, log)
}
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("update", "domain_log")(&err)
return s.inner.UpdateDomainLog(domain, log)
}
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
defer observe("delete", "domain_log")(&err)
return s.inner.DeleteDomainLog(domain, log)
}
// Insight
func (s *instrumentedStorage) InsightsRun() (err error) {
defer observe("run", "insight")(&err)
return s.inner.InsightsRun()
}
func (s *instrumentedStorage) LastInsightsRun() (t *time.Time, id happydns.Identifier, err error) {
defer observe("get", "insight")(&err)
return s.inner.LastInsightsRun()
}
// Provider
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
defer observe("list", "provider")(&err)
return s.inner.ListAllProviders()
}
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
defer observe("list", "provider")(&err)
return s.inner.ListProviders(user)
}
func (s *instrumentedStorage) CountProviders() (ret int, err error) {
defer observe("count", "provider")(&err)
return s.inner.CountProviders()
}
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
defer observe("get", "provider")(&err)
return s.inner.GetProvider(prvdid)
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
defer observe("create", "provider")(&err)
return s.inner.CreateProvider(prvd)
}
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
defer observe("update", "provider")(&err)
return s.inner.UpdateProvider(prvd)
}
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
defer observe("delete", "provider")(&err)
return s.inner.DeleteProvider(prvdid)
}
func (s *instrumentedStorage) ClearProviders() (err error) {
defer observe("delete", "provider")(&err)
return s.inner.ClearProviders()
}
// Session
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
defer observe("list", "session")(&err)
return s.inner.ListAllSessions()
}
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
defer observe("get", "session")(&err)
return s.inner.GetSession(sessionid)
}
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListAuthUserSessions(user)
}
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListUserSessions(userid)
}
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
defer observe("update", "session")(&err)
return s.inner.UpdateSession(session)
}
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
defer observe("delete", "session")(&err)
return s.inner.DeleteSession(sessionid)
}
func (s *instrumentedStorage) ClearSessions() (err error) {
defer observe("delete", "session")(&err)
return s.inner.ClearSessions()
}
// CheckPlan
func (s *instrumentedStorage) ListAllCheckPlans() (ret happydns.Iterator[happydns.CheckPlan], err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListAllCheckPlans()
}
func (s *instrumentedStorage) ListCheckPlansByTarget(target happydns.CheckTarget) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByTarget(target)
}
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByChecker(checkerID)
}
func (s *instrumentedStorage) ListCheckPlansByUser(userId happydns.Identifier) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByUser(userId)
}
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
defer observe("get", "check_plan")(&err)
return s.inner.GetCheckPlan(planID)
}
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("create", "check_plan")(&err)
return s.inner.CreateCheckPlan(plan)
}
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("update", "check_plan")(&err)
return s.inner.UpdateCheckPlan(plan)
}
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.DeleteCheckPlan(planID)
}
func (s *instrumentedStorage) ClearCheckPlans() (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.ClearCheckPlans()
}
// CheckEvaluation
func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier) (ret []*happydns.CheckEvaluation, err error) {
defer observe("list", "check_evaluation")(&err)
return s.inner.ListEvaluationsByPlan(planID)
}
func (s *instrumentedStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.CheckEvaluation, err error) {
defer observe("list", "check_evaluation")(&err)
return s.inner.ListEvaluationsByChecker(checkerID, target, limit)
}
func (s *instrumentedStorage) GetEvaluation(evalID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
defer observe("get", "check_evaluation")(&err)
return s.inner.GetEvaluation(evalID)
}
func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
defer observe("get", "check_evaluation")(&err)
return s.inner.GetLatestEvaluation(planID)
}
func (s *instrumentedStorage) CreateEvaluation(eval *happydns.CheckEvaluation) (err error) {
defer observe("create", "check_evaluation")(&err)
return s.inner.CreateEvaluation(eval)
}
func (s *instrumentedStorage) DeleteEvaluation(evalID happydns.Identifier) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluation(evalID)
}
func (s *instrumentedStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.DeleteEvaluationsByChecker(checkerID, target)
}
func (s *instrumentedStorage) ClearEvaluations() (err error) {
defer observe("delete", "check_evaluation")(&err)
return s.inner.ClearEvaluations()
}
// Execution
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByPlan(planID)
}
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByChecker(checkerID, target, limit)
}
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByUser(userId, limit)
}
func (s *instrumentedStorage) GetExecution(execID happydns.Identifier) (ret *happydns.Execution, err error) {
defer observe("get", "execution")(&err)
return s.inner.GetExecution(execID)
}
func (s *instrumentedStorage) CreateExecution(exec *happydns.Execution) (err error) {
defer observe("create", "execution")(&err)
return s.inner.CreateExecution(exec)
}
func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err error) {
defer observe("update", "execution")(&err)
return s.inner.UpdateExecution(exec)
}
func (s *instrumentedStorage) DeleteExecution(execID happydns.Identifier) (err error) {
defer observe("delete", "execution")(&err)
return s.inner.DeleteExecution(execID)
}
func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
defer observe("delete", "execution")(&err)
return s.inner.DeleteExecutionsByChecker(checkerID, target)
}
func (s *instrumentedStorage) ClearExecutions() (err error) {
defer observe("delete", "execution")(&err)
return s.inner.ClearExecutions()
}
// ObservationSnapshot
func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happydns.ObservationSnapshot, err error) {
defer observe("get", "observation_snapshot")(&err)
return s.inner.GetSnapshot(snapID)
}
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
defer observe("create", "observation_snapshot")(&err)
return s.inner.CreateSnapshot(snap)
}
func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err error) {
defer observe("delete", "observation_snapshot")(&err)
return s.inner.DeleteSnapshot(snapID)
}
func (s *instrumentedStorage) ClearSnapshots() (err error) {
defer observe("delete", "observation_snapshot")(&err)
return s.inner.ClearSnapshots()
}
// ObservationCache
func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (ret *happydns.ObservationCacheEntry, err error) {
defer observe("get", "observation_cache")(&err)
return s.inner.GetCachedObservation(target, key)
}
func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) (err error) {
defer observe("put", "observation_cache")(&err)
return s.inner.PutCachedObservation(target, key, entry)
}
// SchedulerState
func (s *instrumentedStorage) GetLastSchedulerRun() (ret time.Time, err error) {
defer observe("get", "scheduler_state")(&err)
return s.inner.GetLastSchedulerRun()
}
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
defer observe("set", "scheduler_state")(&err)
return s.inner.SetLastSchedulerRun(t)
}
// CheckerConfiguration
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptions], err error) {
defer observe("list", "check_config")(&err)
return s.inner.ListAllCheckerConfigurations()
}
func (s *instrumentedStorage) ListCheckerConfiguration(name string) (ret []*happydns.CheckerOptionsPositional, err error) {
defer observe("list", "check_config")(&err)
return s.inner.ListCheckerConfiguration(name)
}
func (s *instrumentedStorage) GetCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
defer observe("get", "check_config")(&err)
return s.inner.GetCheckerConfiguration(name, a, b, c)
}
func (s *instrumentedStorage) UpdateCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
defer observe("update", "check_config")(&err)
return s.inner.UpdateCheckerConfiguration(name, a, b, c, opts)
}
func (s *instrumentedStorage) DeleteCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.DeleteCheckerConfiguration(name, a, b, c)
}
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
defer observe("delete", "check_config")(&err)
return s.inner.ClearCheckerConfigurations()
}
// User
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
defer observe("list", "user")(&err)
return s.inner.ListAllUsers()
}
func (s *instrumentedStorage) CountUsers() (ret int, err error) {
defer observe("count", "user")(&err)
return s.inner.CountUsers()
}
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUser(userid)
}
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUserByEmail(email)
}
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
defer observe("update", "user")(&err)
return s.inner.CreateOrUpdateUser(user)
}
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
defer observe("delete", "user")(&err)
return s.inner.DeleteUser(userid)
}
func (s *instrumentedStorage) ClearUsers() (err error) {
defer observe("delete", "user")(&err)
return s.inner.ClearUsers()
}
// Zone
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
defer observe("list", "zone")(&err)
return s.inner.ListAllZones()
}
func (s *instrumentedStorage) CountZones() (ret int, err error) {
defer observe("count", "zone")(&err)
return s.inner.CountZones()
}
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
defer observe("get", "zone")(&err)
return s.inner.GetZoneMeta(zoneid)
}
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
defer observe("get", "zone")(&err)
return s.inner.GetZone(zoneid)
}
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
defer observe("create", "zone")(&err)
return s.inner.CreateZone(zone)
}
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
defer observe("update", "zone")(&err)
return s.inner.UpdateZone(zone)
}
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
defer observe("delete", "zone")(&err)
return s.inner.DeleteZone(zoneid)
}
func (s *instrumentedStorage) ClearZones() (err error) {
defer observe("delete", "zone")(&err)
return s.inner.ClearZones()
}

238
internal/app/plugins.go Normal file
View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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...)
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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")
}
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//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")
}
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"strings"
"git.happydns.org/happyDomain/model"
)
// WorstStatusAggregator aggregates check states by taking the worst status.
type WorstStatusAggregator struct{}
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
if len(states) == 0 {
return happydns.CheckState{Status: happydns.StatusUnknown}
}
worst := states[0].Status
var messages []string
for _, s := range states {
if s.Status > worst {
worst = s.Status
}
if s.Message != "" {
messages = append(messages, s.Message)
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(messages, "; "),
}
}

View file

@ -0,0 +1,117 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"testing"
"git.happydns.org/happyDomain/model"
)
func TestWorstStatusAggregator_Empty(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate(nil)
if got.Status != happydns.StatusUnknown {
t.Errorf("Aggregate(nil) status = %v, want StatusUnknown", got.Status)
}
if got.Message != "" {
t.Errorf("Aggregate(nil) message = %q, want empty", got.Message)
}
}
func TestWorstStatusAggregator_Single(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate([]happydns.CheckState{
{Status: happydns.StatusOK, Message: "all good"},
})
if got.Status != happydns.StatusOK {
t.Errorf("status = %v, want StatusOK", got.Status)
}
if got.Message != "all good" {
t.Errorf("message = %q, want %q", got.Message, "all good")
}
}
func TestWorstStatusAggregator_PicksWorst(t *testing.T) {
agg := WorstStatusAggregator{}
tests := []struct {
name string
states []happydns.CheckState
wantStat happydns.Status
}{
{
name: "ok and warn",
states: []happydns.CheckState{
{Status: happydns.StatusOK},
{Status: happydns.StatusWarn},
},
wantStat: happydns.StatusWarn,
},
{
name: "crit among ok and warn",
states: []happydns.CheckState{
{Status: happydns.StatusOK},
{Status: happydns.StatusCrit},
{Status: happydns.StatusWarn},
},
wantStat: happydns.StatusCrit,
},
{
name: "error is worst",
states: []happydns.CheckState{
{Status: happydns.StatusCrit},
{Status: happydns.StatusError},
{Status: happydns.StatusOK},
},
wantStat: happydns.StatusError,
},
{
name: "info and ok",
states: []happydns.CheckState{
{Status: happydns.StatusInfo},
{Status: happydns.StatusOK},
},
wantStat: happydns.StatusInfo,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := agg.Aggregate(tt.states)
if got.Status != tt.wantStat {
t.Errorf("status = %v, want %v", got.Status, tt.wantStat)
}
})
}
}
func TestWorstStatusAggregator_ConcatenatesMessages(t *testing.T) {
agg := WorstStatusAggregator{}
got := agg.Aggregate([]happydns.CheckState{
{Status: happydns.StatusOK, Message: "check A passed"},
{Status: happydns.StatusWarn, Message: ""},
{Status: happydns.StatusCrit, Message: "check C failed"},
})
want := "check A passed; check C failed"
if got.Message != want {
t.Errorf("message = %q, want %q", got.Message, want)
}
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// observation.go implements the observation subsystem, which is the data
// collection layer for the checker framework. An observation represents a
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
// headers, TLS certificate details). Observations are identified by an
// ObservationKey and collected on demand by registered ObservationProviders.
//
// The ObservationContext provides lazy-loading, cached, thread-safe access to
// observations: the first checker that requests a given observation triggers
// its collection, and subsequent checkers reuse the cached result. This
// design decouples data collection from evaluation: checkers declare which
// observations they need, and the context ensures each is collected at most
// once per check run. Observations can also be persisted as snapshots and
// reused across runs when freshness requirements allow.
//
// Observation providers may optionally implement reporting interfaces
// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable
// reports or extract time-series metrics from collected data.
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
//
// 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...)
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// blockingProvider is an ObservationProvider whose Collect blocks on the
// release channel until the test signals it. It records how many concurrent
// Collect calls are in flight at any moment.
type blockingProvider struct {
key happydns.ObservationKey
release chan struct{}
calls int32
}
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()
}
}

View file

@ -0,0 +1,116 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// httpClient is a shared client with a sensible timeout for remote checker
// endpoints. The per-request context can shorten this further.
var httpClient = &http.Client{
Timeout: 30 * time.Second,
}
// maxErrorBodySize is the maximum number of bytes read from an error response
// body to include in the error message.
const maxErrorBodySize = 4096
// HTTPObservationProvider is an ObservationProvider that delegates data
// collection to a remote HTTP endpoint via POST /collect.
type HTTPObservationProvider struct {
observationKey happydns.ObservationKey
endpoint string // base URL without trailing slash
}
// NewHTTPObservationProvider creates a new HTTP-backed observation provider.
// endpoint is the base URL of the remote checker (e.g. "http://checker-ping:8080").
func NewHTTPObservationProvider(key happydns.ObservationKey, endpoint string) *HTTPObservationProvider {
return &HTTPObservationProvider{
observationKey: key,
endpoint: strings.TrimSuffix(endpoint, "/"),
}
}
// Key returns the observation key this provider handles.
func (p *HTTPObservationProvider) Key() happydns.ObservationKey {
return p.observationKey
}
// Collect sends the observation request to the remote endpoint and returns
// the raw JSON data. The returned value is a json.RawMessage which
// ObservationContext.Get() will marshal without double-encoding.
func (p *HTTPObservationProvider) Collect(ctx context.Context, opts happydns.CheckerOptions) (any, error) {
reqBody := happydns.ExternalCollectRequest{
Key: p.observationKey,
Options: opts,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to marshal request: %w", p.observationKey, err)
}
url := p.endpoint + "/collect"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to create request: %w", p.observationKey, err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP provider %s: request failed: %w", p.observationKey, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
return nil, fmt.Errorf("HTTP provider %s: endpoint returned status %d: %s", p.observationKey, resp.StatusCode, string(respBody))
}
var result happydns.ExternalCollectResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("HTTP provider %s: failed to decode response: %w", p.observationKey, err)
}
if result.Error != "" {
return nil, fmt.Errorf("HTTP provider %s: remote error: %s", p.observationKey, result.Error)
}
if result.Data == nil {
return nil, fmt.Errorf("HTTP provider %s: remote returned empty data", p.observationKey)
}
// Return json.RawMessage directly - it implements json.Marshaler,
// so ObservationContext.Get() won't double-encode it.
return result.Data, nil
}

View file

@ -0,0 +1,240 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func TestHTTPObservationProvider_Key(t *testing.T) {
p := NewHTTPObservationProvider("my_key", "http://example.com")
if got := p.Key(); got != "my_key" {
t.Errorf("Key() = %q, want %q", got, "my_key")
}
}
func TestHTTPObservationProvider_TrailingSlashTrimmed(t *testing.T) {
p := NewHTTPObservationProvider("k", "http://example.com/")
if p.endpoint != "http://example.com" {
t.Errorf("endpoint = %q, want trailing slash trimmed", p.endpoint)
}
}
func TestHTTPObservationProvider_CollectSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/collect" {
t.Errorf("expected /collect, got %s", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", ct)
}
// Verify request body is well-formed.
var req happydns.ExternalCollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if req.Key != "test_obs" {
t.Errorf("request Key = %q, want %q", req.Key, "test_obs")
}
if v, ok := req.Options["foo"]; !ok || v != "bar" {
t.Errorf("request Options[foo] = %v, want %q", v, "bar")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"value":42}`),
})
}))
defer srv.Close()
p := NewHTTPObservationProvider("test_obs", srv.URL)
opts := happydns.CheckerOptions{"foo": "bar"}
result, err := p.Collect(context.Background(), opts)
if err != nil {
t.Fatalf("Collect() returned error: %v", err)
}
raw, ok := result.(json.RawMessage)
if !ok {
t.Fatalf("expected json.RawMessage, got %T", result)
}
var data map[string]int
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("failed to unmarshal result: %v", err)
}
if data["value"] != 42 {
t.Errorf("value = %d, want 42", data["value"])
}
}
func TestHTTPObservationProvider_CollectRemoteError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Error: "something went wrong",
})
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for remote error response")
}
if !strings.Contains(err.Error(), "something went wrong") {
t.Errorf("error = %q, want it to contain remote error message", err)
}
}
func TestHTTPObservationProvider_CollectEmptyData(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{})
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for empty data response")
}
if !strings.Contains(err.Error(), "empty data") {
t.Errorf("error = %q, want it to mention empty data", err)
}
}
func TestHTTPObservationProvider_CollectNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal failure", http.StatusInternalServerError)
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for non-200 status")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error = %q, want it to contain status code 500", err)
}
if !strings.Contains(err.Error(), "internal failure") {
t.Errorf("error = %q, want it to contain response body excerpt", err)
}
}
func TestHTTPObservationProvider_CollectInvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, "not json")
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for invalid JSON response")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("error = %q, want it to mention decode failure", err)
}
}
func TestHTTPObservationProvider_CollectContextCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Block until the request context is cancelled.
<-r.Context().Done()
}))
defer srv.Close()
p := NewHTTPObservationProvider("k", srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
_, err := p.Collect(ctx, nil)
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
func TestHTTPObservationProvider_CollectConnectionRefused(t *testing.T) {
// Use a server that is immediately closed to simulate connection refused.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
endpoint := srv.URL
srv.Close()
p := NewHTTPObservationProvider("k", endpoint)
_, err := p.Collect(context.Background(), nil)
if err == nil {
t.Fatal("expected error for connection refused")
}
if !strings.Contains(err.Error(), "request failed") {
t.Errorf("error = %q, want it to mention request failure", err)
}
}
func TestHTTPObservationProvider_IntegrationWithObservationContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"temp":23.5}`),
})
}))
defer srv.Close()
key := happydns.ObservationKey("http_test_obs")
p := NewHTTPObservationProvider(key, srv.URL)
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(key, p)
var dest map[string]float64
if err := oc.Get(context.Background(), key, &dest); err != nil {
t.Fatalf("ObservationContext.Get() returned error: %v", err)
}
if dest["temp"] != 23.5 {
t.Errorf("temp = %v, want 23.5", dest["temp"])
}
// Second call should use the cached value, not hit the server again.
var dest2 map[string]float64
if err := oc.Get(context.Background(), key, &dest2); err != nil {
t.Fatalf("second Get() returned error: %v", err)
}
if dest2["temp"] != 23.5 {
t.Errorf("cached temp = %v, want 23.5", dest2["temp"])
}
}

View file

@ -0,0 +1,60 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The checker definition registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
sdk.RegisterChecker(c)
}
// RegisterExternalizableChecker registers a checker that supports being
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
// so the administrator can optionally configure a remote URL.
// When the endpoint is left empty, the checker runs locally as usual.
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
sdk.RegisterExternalizableChecker(c)
}
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
sdk.RegisterObservationProvider(p)
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return sdk.GetCheckers()
}
// FindChecker returns the checker definition with the given ID, or nil.
func FindChecker(id string) *happydns.CheckerDefinition {
return sdk.FindChecker(id)
}

View file

@ -24,6 +24,8 @@ package config // import "git.happydns.org/happyDomain/config"
import (
"flag"
"fmt"
"runtime"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@ -45,6 +47,10 @@ 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.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")
@ -60,6 +66,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
}

View file

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

View file

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

View file

@ -0,0 +1,181 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package 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")
}
}
func TestValidateMapValues_RequiredNonString(t *testing.T) {
fields := []happydns.Field{
{Id: "count", Type: "int", Required: true, Label: "Count"},
}
// Missing required int field.
if err := ValidateMapValues(map[string]any{}, fields); err == nil {
t.Fatal("expected error for missing required int field")
}
// Nil value for required int field.
if err := ValidateMapValues(map[string]any{"count": nil}, fields); err == nil {
t.Fatal("expected error for nil required int field")
}
// Zero value passes (not treated as empty for non-string types).
if err := ValidateMapValues(map[string]any{"count": float64(0)}, fields); err != nil {
t.Fatalf("unexpected error for zero-value required int: %v", err)
}
// Valid non-zero value.
if err := ValidateMapValues(map[string]any{"count": float64(5)}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateMapValues_ChoicesWithTypeCheck(t *testing.T) {
fields := []happydns.Field{
{Id: "color", Type: "string", Choices: []string{"red", "green", "blue"}},
}
// Valid choice passes both choices and type check.
if err := ValidateMapValues(map[string]any{"color": "red"}, fields); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Invalid choice fails at choices check (type is correct).
if err := ValidateMapValues(map[string]any{"color": "yellow"}, fields); err == nil {
t.Fatal("expected error for invalid choice with type+choices field")
}
// Wrong type fails at choices check before reaching type check.
if err := ValidateMapValues(map[string]any{"color": float64(42)}, fields); err == nil {
t.Fatal("expected error for non-string value on choices+type field")
}
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"errors"
"log"
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// StatsProvider is the minimal interface required by StorageStatsCollector to
// count business entities. It is implemented by
// internal/storage.StatsProvider, which delegates to the backend's native
// Count* methods so each scrape runs O(prefix scan) rather than O(full decode).
type StatsProvider interface {
CountUsers() (int, error)
CountDomains() (int, error)
CountZones() (int, error)
CountProviders() (int, error)
}
// statsErrorsTotal counts failed Count* calls during a Prometheus scrape so
// silent storage failures remain visible (and alertable) instead of producing
// gaps in the gauge series.
var statsErrorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_stats_errors_total",
Help: "Total number of errors encountered while collecting storage stats for the /metrics endpoint.",
}, []string{"entity"})
// StorageStatsCollector is a Prometheus Collector that queries storage at each
// scrape to report accurate business-entity counts.
type StorageStatsCollector struct {
provider StatsProvider
usersDesc *prometheus.Desc
domainsDesc *prometheus.Desc
zonesDesc *prometheus.Desc
providersDesc *prometheus.Desc
}
// NewStorageStatsCollector creates a new collector backed by the given
// StatsProvider and registers it (and its companion error counter) with the
// default Prometheus registry. Re-registration is tolerated, so calling this
// twice — for instance from tests — does not panic.
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
c := &StorageStatsCollector{
provider: p,
usersDesc: prometheus.NewDesc(
"happydomain_registered_users_total",
"Current number of registered user accounts.",
nil, nil,
),
domainsDesc: prometheus.NewDesc(
"happydomain_domains_total",
"Current number of domains managed across all users.",
nil, nil,
),
zonesDesc: prometheus.NewDesc(
"happydomain_zones_total",
"Current number of zone snapshots stored.",
nil, nil,
),
providersDesc: prometheus.NewDesc(
"happydomain_providers_total",
"Current number of provider configurations across all users.",
nil, nil,
),
}
registerOrLog(c)
registerOrLog(statsErrorsTotal)
return c
}
// registerOrLog registers a collector with the default registry, tolerating
// "already registered" so test setups and repeated app initialisations are safe.
func registerOrLog(c prometheus.Collector) {
if err := prometheus.Register(c); err != nil {
var are prometheus.AlreadyRegisteredError
if errors.As(err, &are) {
return
}
log.Printf("metrics: failed to register collector: %v", err)
}
}
// Describe implements prometheus.Collector.
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.usersDesc
ch <- c.domainsDesc
ch <- c.zonesDesc
ch <- c.providersDesc
}
// Collect implements prometheus.Collector. It queries storage live so the
// values always reflect the actual database state. Each backend call runs in
// its own goroutine to keep the scrape latency bounded by the slowest count
// rather than their sum.
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
type job struct {
entity string
desc *prometheus.Desc
fn func() (int, error)
}
jobs := []job{
{"user", c.usersDesc, c.provider.CountUsers},
{"domain", c.domainsDesc, c.provider.CountDomains},
{"zone", c.zonesDesc, c.provider.CountZones},
{"provider", c.providersDesc, c.provider.CountProviders},
}
type result struct {
desc *prometheus.Desc
val float64
ok bool
}
results := make([]result, len(jobs))
var wg sync.WaitGroup
for i, j := range jobs {
wg.Add(1)
go func(i int, j job) {
defer wg.Done()
// A panic inside a backend Count* implementation must not
// crash the scrape goroutine: convert it into a stats error
// so the failure is visible via happydomain_storage_stats_errors_total
// instead of producing an unrecoverable process crash.
defer func() {
if r := recover(); r != nil {
statsErrorsTotal.WithLabelValues(j.entity).Inc()
log.Printf("metrics: panic while collecting %s count: %v", j.entity, r)
}
}()
n, err := j.fn()
if err != nil {
statsErrorsTotal.WithLabelValues(j.entity).Inc()
return
}
results[i] = result{desc: j.desc, val: float64(n), ok: true}
}(i, j)
}
wg.Wait()
for _, r := range results {
if !r.ok {
continue
}
ch <- prometheus.MustNewConstMetric(r.desc, prometheus.GaugeValue, r.val)
}
}

54
internal/metrics/http.go Normal file
View file

@ -0,0 +1,54 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
// rather than the actual URL, avoiding high-cardinality labels.
func HTTPMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
HTTPRequestsInFlight.Inc()
c.Next()
HTTPRequestsInFlight.Dec()
path := c.FullPath()
if path == "" {
path = "unknown"
}
method := c.Request.Method
status := strconv.Itoa(c.Writer.Status())
duration := time.Since(start).Seconds()
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
}
}

137
internal/metrics/metrics.go Normal file
View file

@ -0,0 +1,137 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"strconv"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// HTTP metrics
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_http_request_duration_seconds",
Help: "Duration of HTTP requests in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path"})
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_http_requests_in_flight",
Help: "Current number of HTTP requests being served.",
})
// Scheduler metrics
//
// schedulerQueueDepthFn is consulted at scrape time by the GaugeFunc
// registered below. The scheduler installs its accessor via
// RegisterSchedulerQueueDepth at construction, which avoids sprinkling
// gauge.Set calls across every queue mutation site.
schedulerQueueDepthFn atomic.Pointer[func() float64]
// SchedulerQueueDepth is kept as a package-level var (rather than the
// blank identifier) so it is discoverable via grep alongside the other
// metric vars and easy to reference from tests.
SchedulerQueueDepth = promauto.NewGaugeFunc(prometheus.GaugeOpts{
Name: "happydomain_scheduler_queue_depth",
Help: "Number of items currently in the check scheduler queue.",
}, func() float64 {
if fn := schedulerQueueDepthFn.Load(); fn != nil {
return (*fn)()
}
return 0
})
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
Name: "happydomain_scheduler_active_workers",
Help: "Number of check scheduler workers currently executing a check.",
})
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_scheduler_checks_total",
Help: "Total number of checks executed by the scheduler.",
}, []string{"checker", "status"})
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_scheduler_check_duration_seconds",
Help: "Duration of individual check executions in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"checker"})
// DNS provider API metrics
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_provider_api_calls_total",
Help: "Total number of DNS provider API calls.",
}, []string{"provider", "operation", "status"})
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_provider_api_duration_seconds",
Help: "Duration of DNS provider API calls in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"provider", "operation"})
// Storage metrics
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "happydomain_storage_operations_total",
Help: "Total number of storage operations.",
}, []string{"operation", "entity", "status"})
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "happydomain_storage_operation_duration_seconds",
Help: "Duration of storage operations in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"operation", "entity"})
// Build info. Always 1; the metadata is carried in the labels so that
// dashboards and alerts can group/diff across deployments.
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "happydomain_build_info",
Help: "Build information about the running happyDomain instance. Always 1; metadata is in the labels.",
}, []string{"version", "revision", "dirty", "build_date"})
)
// SetBuildInfo records the application build metadata in the build info
// metric. Call this once during application startup. buildDate should be
// formatted as RFC3339 (UTC) and may be empty if unknown.
func SetBuildInfo(version, revision, buildDate string, dirty bool) {
BuildInfo.WithLabelValues(version, revision, strconv.FormatBool(dirty), buildDate).Set(1)
}
// RegisterSchedulerQueueDepth installs the accessor used at scrape time to
// report the current scheduler queue depth. The function is invoked from the
// Prometheus scrape goroutine, so it must be safe to call concurrently with
// queue mutations and must not block for long. Passing nil unregisters the
// accessor (the gauge will then report 0).
func RegisterSchedulerQueueDepth(fn func() float64) {
if fn == nil {
schedulerQueueDepthFn.Store(nil)
return
}
schedulerQueueDepthFn.Store(&fn)
}

View file

@ -0,0 +1,270 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package metrics
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func init() {
gin.SetMode(gin.TestMode)
}
// --- HTTPMiddleware -------------------------------------------------------
func TestHTTPMiddleware_RecordsRouteTemplateNotRawPath(t *testing.T) {
// Reset to keep assertions independent from any other test in the package.
HTTPRequestsTotal.Reset()
HTTPRequestDuration.Reset()
r := gin.New()
r.Use(HTTPMiddleware())
r.GET("/api/domains/:domain", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/domains/example.com", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Route template — not the raw URL — must be used as the path label,
// otherwise cardinality explodes with one series per domain name.
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "/api/domains/:domain", "200")); got != 1 {
t.Fatalf("expected 1 request recorded for route template, got %v", got)
}
if got := testutil.CollectAndCount(HTTPRequestsTotal); got != 1 {
t.Fatalf("expected exactly one series, got %d (cardinality leak?)", got)
}
}
func TestHTTPMiddleware_UnmatchedRouteUsesUnknownLabel(t *testing.T) {
HTTPRequestsTotal.Reset()
HTTPRequestDuration.Reset()
r := gin.New()
r.Use(HTTPMiddleware())
req := httptest.NewRequest(http.MethodGet, "/no/such/route", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "unknown", "404")); got != 1 {
t.Fatalf("expected 1 request recorded under 'unknown' path, got %v", got)
}
}
func TestHTTPMiddleware_InFlightBalanced(t *testing.T) {
HTTPRequestsInFlight.Set(0)
r := gin.New()
r.Use(HTTPMiddleware())
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
for range 5 {
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(httptest.NewRecorder(), req)
}
if got := testutil.ToFloat64(HTTPRequestsInFlight); got != 0 {
t.Fatalf("in-flight gauge should return to 0 after requests complete, got %v", got)
}
}
// --- StorageStatsCollector ------------------------------------------------
type fakeStatsProvider struct {
users, domains, zones, providers int
usersErr, domainsErr error
zonesPanic bool
}
func (f *fakeStatsProvider) CountUsers() (int, error) { return f.users, f.usersErr }
func (f *fakeStatsProvider) CountDomains() (int, error) { return f.domains, f.domainsErr }
func (f *fakeStatsProvider) CountZones() (int, error) {
if f.zonesPanic {
panic("boom")
}
return f.zones, nil
}
func (f *fakeStatsProvider) CountProviders() (int, error) { return f.providers, nil }
// collectorFor builds a StorageStatsCollector against a private registry so
// that tests can run in parallel without sharing state with the default
// registry or with each other.
func collectorFor(p StatsProvider) *StorageStatsCollector {
return &StorageStatsCollector{
provider: p,
usersDesc: prometheus.NewDesc(
"happydomain_registered_users_total", "users", nil, nil),
domainsDesc: prometheus.NewDesc(
"happydomain_domains_total", "domains", nil, nil),
zonesDesc: prometheus.NewDesc(
"happydomain_zones_total", "zones", nil, nil),
providersDesc: prometheus.NewDesc(
"happydomain_providers_total", "providers", nil, nil),
}
}
func TestStorageStatsCollector_HappyPath(t *testing.T) {
c := collectorFor(&fakeStatsProvider{users: 3, domains: 7, zones: 11, providers: 2})
if got := testutil.CollectAndCount(c); got != 4 {
t.Fatalf("expected 4 metrics, got %d", got)
}
}
func TestStorageStatsCollector_ErrorSkipsMetricAndIncrementsErrorCounter(t *testing.T) {
statsErrorsTotal.Reset()
c := collectorFor(&fakeStatsProvider{
users: 3,
domainsErr: errors.New("db down"),
zones: 1, providers: 1,
})
// 4 jobs, 1 errors out → 3 metrics emitted.
if got := testutil.CollectAndCount(c); got != 3 {
t.Fatalf("expected 3 metrics when one count fails, got %d", got)
}
if got := testutil.ToFloat64(statsErrorsTotal.WithLabelValues("domain")); got != 1 {
t.Fatalf("expected stats error counter for 'domain' to be 1, got %v", got)
}
}
func TestStorageStatsCollector_PanicIsRecovered(t *testing.T) {
statsErrorsTotal.Reset()
c := collectorFor(&fakeStatsProvider{users: 1, domains: 1, providers: 1, zonesPanic: true})
// Must not crash the test process; panicking job is dropped, others succeed.
got := testutil.CollectAndCount(c)
if got != 3 {
t.Fatalf("expected 3 metrics when zones panics, got %d", got)
}
if v := testutil.ToFloat64(statsErrorsTotal.WithLabelValues("zone")); v != 1 {
t.Fatalf("expected zone stats error counter to be 1, got %v", v)
}
}
// --- Scheduler queue depth gauge -----------------------------------------
func TestRegisterSchedulerQueueDepth(t *testing.T) {
t.Cleanup(func() { RegisterSchedulerQueueDepth(nil) })
RegisterSchedulerQueueDepth(func() float64 { return 42 })
// The gauge func is registered against the default registry by promauto.
// Gather and look for our specific metric.
mfs, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
var found bool
for _, mf := range mfs {
if mf.GetName() != "happydomain_scheduler_queue_depth" {
continue
}
found = true
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 42 {
t.Fatalf("expected queue depth 42, got %v", v)
}
}
if !found {
t.Fatal("happydomain_scheduler_queue_depth not registered")
}
// nil clears the accessor → gauge falls back to 0.
RegisterSchedulerQueueDepth(nil)
mfs, _ = prometheus.DefaultGatherer.Gather()
for _, mf := range mfs {
if mf.GetName() != "happydomain_scheduler_queue_depth" {
continue
}
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 0 {
t.Fatalf("expected queue depth 0 after clearing accessor, got %v", v)
}
}
}
// --- SetBuildInfo --------------------------------------------------------
func TestSetBuildInfo(t *testing.T) {
BuildInfo.Reset()
SetBuildInfo("1.2.3-test", "abcdef0", "2026-04-08T00:00:00Z", true)
if got := testutil.ToFloat64(BuildInfo.WithLabelValues("1.2.3-test", "abcdef0", "true", "2026-04-08T00:00:00Z")); got != 1 {
t.Fatalf("expected build_info{...}=1, got %v", got)
}
}
// --- /metrics endpoint exposition format ---------------------------------
// TestMetricsEndpointParses guards against the whole exposition pipeline
// emitting something that an actual Prometheus scraper would reject.
func TestMetricsEndpointParses(t *testing.T) {
// Drive at least one observation through every metric family touched by
// instrumentation so the endpoint isn't trivially empty.
HTTPRequestsTotal.WithLabelValues("GET", "/x", "200").Inc()
StorageOperationsTotal.WithLabelValues("get", "user", "success").Inc()
SchedulerChecksTotal.WithLabelValues("dns", "success").Inc()
ProviderAPICallsTotal.WithLabelValues("dummy", "list", "success").Inc()
SetBuildInfo("test", "deadbee", "2026-04-08T00:00:00Z", false)
srv := httptest.NewServer(promhttp.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatalf("GET /metrics: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
parser := expfmt.NewTextParser(model.LegacyValidation)
mfs, err := parser.TextToMetricFamilies(resp.Body)
if err != nil {
t.Fatalf("invalid prometheus exposition format: %v", err)
}
// Sanity-check a few of the metrics we expect to find.
for _, name := range []string{
"happydomain_http_requests_total",
"happydomain_storage_operations_total",
"happydomain_scheduler_checks_total",
"happydomain_provider_api_calls_total",
"happydomain_build_info",
} {
if _, ok := mfs[name]; !ok {
t.Errorf("expected metric %q in /metrics output", name)
}
}
}

View file

@ -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,13 @@ type ProviderAndDomainStorage interface {
type Storage interface {
authuser.AuthUserStorage
checker.CheckPlanStorage
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage

View file

@ -0,0 +1,316 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"sort"
"strings"
"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() {
evalId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
eval, err := s.GetEvaluation(evalId)
if err != nil {
continue
}
evals = append(evals, eval)
}
return evals, nil
}
func (s *KVStorage) ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error) {
iter := s.db.Search("chckeval|")
return NewKVIterator[happydns.CheckEvaluation](s.db, iter), 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() {
evalId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
eval, err := s.GetEvaluation(evalId)
if 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, true); 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, true); 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())
if err := s.db.Delete(indexKey); err != nil {
log.Printf("DeleteEvaluation: failed to delete plan index %s: %v\n", indexKey, err)
}
}
// Clean up checker+target index.
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
if err := s.db.Delete(checkerIndexKey); err != nil {
log.Printf("DeleteEvaluation: failed to delete checker index %s: %v\n", checkerIndexKey, err)
}
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() {
evalId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
eval, err := s.GetEvaluation(evalId)
if err != nil {
// Primary record already gone; just clean up this index entry
// and attempt to clean up the plan index (best-effort scan).
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
s.deleteEvalPlanIndexByEvalID(evalId)
continue
}
// Delete plan index if applicable.
if eval.PlanID != nil {
planIndexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Delete(planIndexKey); err != nil {
log.Printf("DeleteEvaluationsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
}
}
// Delete primary record.
if err := s.db.Delete(fmt.Sprintf("chckeval|%s", eval.Id.String())); err != nil {
log.Printf("DeleteEvaluationsByChecker: failed to delete primary record %s: %v\n", eval.Id.String(), err)
}
// Delete this checker index entry.
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}
// deleteEvalPlanIndexByEvalID scans plan indexes to remove any entry for the
// given evaluation ID. Used when the primary record is already gone and we
// don't know which plan it belonged to.
func (s *KVStorage) deleteEvalPlanIndexByEvalID(evalId happydns.Identifier) {
suffix := "|" + evalId.String()
iter := s.db.Search("chckeval-plan|")
defer iter.Release()
for iter.Next() {
if strings.HasSuffix(iter.Key(), suffix) {
if err := s.db.Delete(iter.Key()); err != nil {
log.Printf("deleteEvalPlanIndexByEvalID: failed to delete %s: %v\n", iter.Key(), err)
}
}
}
}
func (s *KVStorage) TidyEvaluationIndexes() error {
// Tidy chckeval-plan|{planId}|{evalId} indexes.
planIter := s.db.Search("chckeval-plan|")
defer planIter.Release()
for planIter.Next() {
key := planIter.Key()
// Extract planId and evalId from "chckeval-plan|{planId}|{evalId}".
rest := strings.TrimPrefix(key, "chckeval-plan|")
parts := strings.SplitN(rest, "|", 2)
if len(parts) != 2 {
_ = s.db.Delete(key)
continue
}
planId, err := happydns.NewIdentifierFromString(parts[0])
if err != nil {
_ = s.db.Delete(key)
continue
}
evalId, err := happydns.NewIdentifierFromString(parts[1])
if err != nil {
_ = s.db.Delete(key)
continue
}
// Check plan exists.
if _, err := s.GetCheckPlan(planId); err != nil {
log.Printf("Deleting stale evaluation plan index (plan %s not found): %s\n", parts[0], key)
_ = s.db.Delete(key)
continue
}
// Check primary record exists.
if _, err := s.GetEvaluation(evalId); err != nil {
log.Printf("Deleting stale evaluation plan index (evaluation %s not found): %s\n", parts[1], key)
_ = s.db.Delete(key)
}
}
// Tidy chckeval-chkr|{checkerID}|{target}|{evalId} indexes.
chkrIter := s.db.Search("chckeval-chkr|")
defer chkrIter.Release()
for chkrIter.Next() {
key := chkrIter.Key()
// The evalId is the last segment after the last "|".
lastPipe := strings.LastIndex(key, "|")
if lastPipe < 0 {
_ = s.db.Delete(key)
continue
}
evalIdStr := key[lastPipe+1:]
evalId, err := happydns.NewIdentifierFromString(evalIdStr)
if err != nil {
_ = s.db.Delete(key)
continue
}
if _, err := s.GetEvaluation(evalId); err != nil {
log.Printf("Deleting stale evaluation checker index (evaluation %s not found): %s\n", evalIdStr, key)
_ = s.db.Delete(key)
}
}
return nil
}
func (s *KVStorage) ClearEvaluations() error {
// Delete secondary indexes (chckeval-plan|..., chckeval-chkr|...).
idxIter := s.db.Search("chckeval-")
defer idxIter.Release()
for idxIter.Next() {
if err := s.db.Delete(idxIter.Key()); err != nil {
return err
}
}
// Delete primary records (chckeval|...).
iter, err := s.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"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
}

View file

@ -0,0 +1,175 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
// checkerOptionsKey builds the positional KV key for checker options.
// Format: chckrcfg|{checkerName}|{userId}|{domainId}|{serviceId}
func checkerOptionsKey(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) string {
return fmt.Sprintf("chckrcfg|%s|%s|%s|%s", checkerName,
happydns.FormatIdentifier(userId), happydns.FormatIdentifier(domainId), happydns.FormatIdentifier(serviceId))
}
// parseCheckerOptionsKey extracts the positional components from a KV key.
func parseCheckerOptionsKey(key string) (checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.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 := happydns.NewIdentifierFromString(parts[1]); err == nil {
userId = &id
}
}
if parts[2] != "" {
if id, err := happydns.NewIdentifierFromString(parts[2]); err == nil {
domainId = &id
}
}
if parts[3] != "" {
if id, err := happydns.NewIdentifierFromString(parts[3]); err == nil {
serviceId = &id
}
}
return
}
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptionsPositional], error) {
iter := s.db.Search("chckrcfg|")
return &checkerOptionsIterator{KVIterator: NewKVIterator[happydns.CheckerOptions](s.db, iter)}, nil
}
// checkerOptionsIterator wraps KVIterator[CheckerOptions] and enriches each
// item with positional fields parsed from the storage key.
type checkerOptionsIterator struct {
*KVIterator[happydns.CheckerOptions]
}
func (it *checkerOptionsIterator) Item() *happydns.CheckerOptionsPositional {
opts := it.KVIterator.Item()
if opts == nil {
return nil
}
cn, uid, did, sid := parseCheckerOptionsKey(it.Key())
return &happydns.CheckerOptionsPositional{
CheckName: cn,
UserId: uid,
DomainId: did,
ServiceId: sid,
Options: *opts,
}
}
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 {
log.Printf("ListCheckerConfiguration: error decoding checker config at key %q: %s", iter.Key(), err)
continue
}
cn, uid, did, sid := 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 := 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 := 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 := 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
}

View file

@ -34,6 +34,10 @@ func (s *KVStorage) ListAllDomains() (happydns.Iterator[happydns.Domain], error)
return NewKVIterator[happydns.Domain](s.db, iter), nil
}
func (s *KVStorage) CountDomains() (int, error) {
return s.countByPrefix("domain-")
}
func (s *KVStorage) ListDomains(u *happydns.User) (domains []*happydns.Domain, err error) {
iter := s.db.Search("domain-")
defer iter.Release()

View file

@ -0,0 +1,448 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"sort"
"strings"
"git.happydns.org/happyDomain/model"
)
func executionUserIndexKey(userId string, execId string) string {
return fmt.Sprintf("chckexec-user|%s|%s", userId, execId)
}
func executionDomainIndexKey(domainId string, execId string) string {
return fmt.Sprintf("chckexec-domain|%s|%s", domainId, execId)
}
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() {
execId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
exec, err := s.GetExecution(execId)
if err != nil {
continue
}
execs = append(execs, exec)
}
return execs, nil
}
// listRecentExecutions scans a prefix, decodes executions, sorts by most
// recent first, and applies an optional limit.
func (s *KVStorage) listRecentExecutions(prefix string, limit int) ([]*happydns.Execution, error) {
iter := s.db.Search(prefix)
defer iter.Release()
var execs []*happydns.Execution
for iter.Next() {
execId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
exec, err := s.GetExecution(execId)
if 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) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit)
}
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit)
}
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit)
}
func (s *KVStorage) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
iter := s.db.Search("chckexec|")
return NewKVIterator[happydns.Execution](s.db, iter), 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, true); 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, true); err != nil {
return err
}
// Secondary index by user.
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
// Secondary index by domain.
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
// Load the old record so we can detect changed index keys.
old, err := s.GetExecution(exec.Id)
if err != nil {
return err
}
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
return err
}
// Clean up stale plan index if PlanID changed.
if old.PlanID != nil {
oldPlanKey := fmt.Sprintf("chckexec-plan|%s|%s", old.PlanID.String(), exec.Id.String())
newPlanKey := ""
if exec.PlanID != nil {
newPlanKey = fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
}
if oldPlanKey != newPlanKey {
if err := s.db.Delete(oldPlanKey); err != nil {
log.Printf("UpdateExecution: failed to delete stale plan index %s: %v\n", oldPlanKey, 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, true); err != nil {
return err
}
}
// Clean up stale checker+target index if CheckerID or Target changed.
oldCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", old.CheckerID, old.Target.String(), exec.Id.String())
newCheckerKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
if oldCheckerKey != newCheckerKey {
if err := s.db.Delete(oldCheckerKey); err != nil {
log.Printf("UpdateExecution: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
}
}
// Update secondary index by checker+target.
if err := s.db.Put(newCheckerKey, true); err != nil {
return err
}
// Clean up stale user index if UserId changed.
if old.Target.UserId != "" && old.Target.UserId != exec.Target.UserId {
if err := s.db.Delete(executionUserIndexKey(old.Target.UserId, exec.Id.String())); err != nil {
log.Printf("UpdateExecution: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
}
}
// Update secondary index by user.
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
// Clean up stale domain index if DomainId changed.
if old.Target.DomainId != "" && old.Target.DomainId != exec.Target.DomainId {
if err := s.db.Delete(executionDomainIndexKey(old.Target.DomainId, exec.Id.String())); err != nil {
log.Printf("UpdateExecution: failed to delete stale domain index for domain %s: %v\n", old.Target.DomainId, err)
}
}
// Update secondary index by domain.
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); 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 {
log.Printf("DeleteExecution: failed to delete plan index %s: %v\n", indexKey, err)
}
}
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), execID.String())
if err := s.db.Delete(checkerIndexKey); err != nil {
log.Printf("DeleteExecution: failed to delete checker index %s: %v\n", checkerIndexKey, err)
}
if exec.Target.UserId != "" {
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, execID.String())); err != nil {
log.Printf("DeleteExecution: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
}
}
if exec.Target.DomainId != "" {
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, execID.String())); err != nil {
log.Printf("DeleteExecution: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, 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() {
execId, err := lastKeySegment(iter.Key())
if err != nil {
continue
}
exec, err := s.GetExecution(execId)
if err != nil {
// Primary record already gone; just clean up this index entry
// and attempt to clean up other indexes (best-effort scan).
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
s.deleteExecSecondaryIndexesByExecID(execId)
continue
}
if exec.PlanID != nil {
planIndexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Delete(planIndexKey); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete plan index %s: %v\n", planIndexKey, err)
}
}
if exec.Target.UserId != "" {
if err := s.db.Delete(executionUserIndexKey(exec.Target.UserId, exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete user index for user %s: %v\n", exec.Target.UserId, err)
}
}
if exec.Target.DomainId != "" {
if err := s.db.Delete(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete domain index for domain %s: %v\n", exec.Target.DomainId, err)
}
}
if err := s.db.Delete(fmt.Sprintf("chckexec|%s", exec.Id.String())); err != nil {
log.Printf("DeleteExecutionsByChecker: failed to delete primary record %s: %v\n", exec.Id.String(), err)
}
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}
// deleteExecSecondaryIndexesByExecID scans plan, user and domain indexes to
// remove any entry for the given execution ID. Used when the primary record is
// already gone and we don't know which plan/user/domain it belonged to.
func (s *KVStorage) deleteExecSecondaryIndexesByExecID(execId happydns.Identifier) {
suffix := "|" + execId.String()
for _, prefix := range []string{"chckexec-plan|", "chckexec-user|", "chckexec-domain|"} {
iter := s.db.Search(prefix)
for iter.Next() {
if strings.HasSuffix(iter.Key(), suffix) {
if err := s.db.Delete(iter.Key()); err != nil {
log.Printf("deleteExecSecondaryIndexesByExecID: failed to delete %s: %v\n", iter.Key(), err)
}
}
}
iter.Release()
}
}
// tidyTwoPartIndex removes stale secondary index entries of the form
// prefix{ownerId}|{execId}. If validateOwner is non-nil, entries whose
// owner ID fails validation are also removed.
func (s *KVStorage) tidyTwoPartIndex(prefix, label string, validateOwner func(happydns.Identifier) bool) {
iter := s.db.Search(prefix)
defer iter.Release()
for iter.Next() {
key := iter.Key()
rest := strings.TrimPrefix(key, prefix)
parts := strings.SplitN(rest, "|", 2)
if len(parts) != 2 {
_ = s.db.Delete(key)
continue
}
ownerId, err := happydns.NewIdentifierFromString(parts[0])
if err != nil {
_ = s.db.Delete(key)
continue
}
execId, err := happydns.NewIdentifierFromString(parts[1])
if err != nil {
_ = s.db.Delete(key)
continue
}
if validateOwner != nil && !validateOwner(ownerId) {
log.Printf("Deleting stale execution %s index (%s %s not found): %s\n", label, label, parts[0], key)
_ = s.db.Delete(key)
continue
}
if _, err := s.GetExecution(execId); err != nil {
log.Printf("Deleting stale execution %s index (execution %s not found): %s\n", label, parts[1], key)
_ = s.db.Delete(key)
}
}
}
func (s *KVStorage) TidyExecutionIndexes() error {
// Tidy chckexec-plan|{planId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-plan|", "plan", func(id happydns.Identifier) bool {
_, err := s.GetCheckPlan(id)
return err == nil
})
// Tidy chckexec-chkr|{checkerID}|{target}|{execId} indexes.
chkrIter := s.db.Search("chckexec-chkr|")
defer chkrIter.Release()
for chkrIter.Next() {
key := chkrIter.Key()
lastPipe := strings.LastIndex(key, "|")
if lastPipe < 0 {
_ = s.db.Delete(key)
continue
}
execIdStr := key[lastPipe+1:]
execId, err := happydns.NewIdentifierFromString(execIdStr)
if err != nil {
_ = s.db.Delete(key)
continue
}
if _, err := s.GetExecution(execId); err != nil {
log.Printf("Deleting stale execution checker index (execution %s not found): %s\n", execIdStr, key)
_ = s.db.Delete(key)
}
}
// Tidy chckexec-user|{userId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-user|", "user", func(id happydns.Identifier) bool {
_, err := s.GetUser(id)
return err == nil
})
// Tidy chckexec-domain|{domainId}|{execId} indexes.
s.tidyTwoPartIndex("chckexec-domain|", "domain", func(id happydns.Identifier) bool {
_, err := s.GetDomain(id)
return err == nil
})
return nil
}
func (s *KVStorage) ClearExecutions() error {
// Delete secondary indexes (chckexec-plan|..., chckexec-chkr|..., chckexec-user|..., chckexec-domain|...).
idxIter := s.db.Search("chckexec-")
defer idxIter.Release()
for idxIter.Next() {
if err := s.db.Delete(idxIter.Key()); err != nil {
return err
}
}
// Delete primary records (chckexec|...).
iter, err := s.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,50 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
return fmt.Sprintf("obscache|%s-%s", target.String(), key)
}
func (s *KVStorage) ListAllCachedObservations() (happydns.Iterator[happydns.ObservationCacheEntry], error) {
iter := s.db.Search("obscache|")
return NewKVIterator[happydns.ObservationCacheEntry](s.db, iter), nil
}
func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
entry := &happydns.ObservationCacheEntry{}
err := s.db.Get(obsCacheKey(target, key), entry)
if err != nil {
return nil, err
}
return entry, nil
}
func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
return s.db.Put(obsCacheKey(target, key), entry)
}

View file

@ -0,0 +1,71 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error) {
iter := s.db.Search("chcksnap|")
return NewKVIterator[happydns.ObservationSnapshot](s.db, iter), nil
}
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, err := s.ListAllSnapshots()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
if err := s.db.Delete(iter.Key()); err != nil {
return err
}
}
return nil
}

View file

@ -34,6 +34,10 @@ func (s *KVStorage) ListAllProviders() (happydns.Iterator[happydns.ProviderMessa
return NewKVIterator[happydns.ProviderMessage](s.db, iter), nil
}
func (s *KVStorage) CountProviders() (int, error) {
return s.countByPrefix("provider-")
}
func (s *KVStorage) getProviderMeta(id happydns.Identifier) (*happydns.ProviderMessage, error) {
srcMsg := &happydns.ProviderMessage{}
err := s.db.Get(id.String(), srcMsg)

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"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)
}

View file

@ -22,7 +22,11 @@
package database
import (
"fmt"
"strings"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
type KVStorage struct {
@ -38,3 +42,26 @@ func NewKVDatabase(impl storage.KVStorage) (storage.Storage, error) {
func (s *KVStorage) Close() error {
return s.db.Close()
}
// lastKeySegment extracts the identifier after the last "|" in a KV key.
func lastKeySegment(key string) (happydns.Identifier, error) {
i := strings.LastIndex(key, "|")
if i < 0 {
return happydns.Identifier{}, fmt.Errorf("key %q has no pipe separator", key)
}
return happydns.NewIdentifierFromString(key[i+1:])
}
// countByPrefix counts the number of keys matching the given prefix without
// decoding their values. It is the foundation of the Count* methods exposed
// to observability code.
func (s *KVStorage) countByPrefix(prefix string) (int, error) {
iter := s.db.Search(prefix)
defer iter.Release()
n := 0
for iter.Next() {
n++
}
return n, iter.Err()
}

View file

@ -33,6 +33,10 @@ func (s *KVStorage) ListAllUsers() (happydns.Iterator[happydns.User], error) {
return NewKVIterator[happydns.User](s.db, iter), nil
}
func (s *KVStorage) CountUsers() (int, error) {
return s.countByPrefix("user-")
}
func (s *KVStorage) getUser(key string) (*happydns.User, error) {
u := &happydns.User{}
err := s.db.Get(key, &u)

View file

@ -33,6 +33,10 @@ func (s *KVStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], err
return NewKVIterator[happydns.ZoneMessage](s.db, iter), nil
}
func (s *KVStorage) CountZones() (int, error) {
return s.countByPrefix("domain.zone-")
}
func (s *KVStorage) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
z := &happydns.ZoneMessage{}
err := s.db.Get(fmt.Sprintf("domain.zone-%s", id.String()), &z)

View file

@ -0,0 +1,40 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package storage
// StatsProvider implements metrics.StatsProvider using a Storage. It delegates
// to the storage backend's native Count* methods so each Prometheus scrape
// runs at most one cheap key-prefix scan per entity instead of decoding every
// record.
type StatsProvider struct {
store Storage
}
// NewStatsProvider creates a StatsProvider backed by the given Storage.
func NewStatsProvider(s Storage) *StatsProvider {
return &StatsProvider{store: s}
}
func (p *StatsProvider) CountUsers() (int, error) { return p.store.CountUsers() }
func (p *StatsProvider) CountDomains() (int, error) { return p.store.CountDomains() }
func (p *StatsProvider) CountZones() (int, error) { return p.store.CountZones() }
func (p *StatsProvider) CountProviders() (int, error) { return p.store.CountProviders() }

View file

@ -61,12 +61,13 @@ func (lu *loginUsecase) CompleteAuthentication(uinfo happydns.UserInfo) (*happyd
return nil, fmt.Errorf("unable to create user account: %w", err)
}
} else if (uinfo.GetEmail() != "" && user.Email != uinfo.GetEmail()) || time.Since(user.LastSeen) > time.Hour*12 {
if uinfo.GetEmail() != "" {
user.Email = uinfo.GetEmail()
}
user.LastSeen = time.Now()
err = lu.store.CreateOrUpdateUser(user)
email := uinfo.GetEmail()
user, err = lu.userService.UpdateUser(user.Id, func(u *happydns.User) {
if email != "" {
u.Email = email
}
u.LastSeen = time.Now()
})
if err != nil {
return nil, fmt.Errorf("has a correct JWT, user has been found, but an error occured when trying to update the user's information: %w", err)
}

View file

@ -0,0 +1,135 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// targetMatchesResource verifies that every non-empty field in scope
// matches the corresponding field in resource. Returns false if any
// scope-specified field does not match, indicating the resource belongs
// to a different user/domain/service than the caller's scope.
func targetMatchesResource(scope, resource happydns.CheckTarget) bool {
if scope.UserId != "" && scope.UserId != resource.UserId {
return false
}
if scope.DomainId != "" && scope.DomainId != resource.DomainId {
return false
}
if scope.ServiceId != "" && scope.ServiceId != resource.ServiceId {
return false
}
return true
}
// 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)
}
// ListCheckPlansByTargetAndChecker returns all check plans matching both the
// given target and the given checkerID, filtering in a single pass to avoid
// fetching then discarding unrelated plans.
func (u *CheckPlanUsecase) ListCheckPlansByTargetAndChecker(target happydns.CheckTarget, checkerID string) ([]*happydns.CheckPlan, error) {
plans, err := u.store.ListCheckPlansByTarget(target)
if err != nil {
return nil, err
}
filtered := plans[:0]
for _, p := range plans {
if p.CheckerID == checkerID {
filtered = append(filtered, p)
}
}
return filtered, nil
}
// 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 and verifies it belongs to the given scope.
func (u *CheckPlanUsecase) GetCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) (*happydns.CheckPlan, error) {
plan, err := u.store.GetCheckPlan(planID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, plan.Target) {
return nil, happydns.ErrCheckPlanNotFound
}
return plan, nil
}
// UpdateCheckPlan fetches the existing plan, verifies scope ownership,
// validates the checker exists, preserves Id and Target (immutable),
// and persists the merged result.
func (u *CheckPlanUsecase) UpdateCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) {
existing, err := u.store.GetCheckPlan(planID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, existing.Target) {
return nil, happydns.ErrCheckPlanNotFound
}
if checkerPkg.FindChecker(updated.CheckerID) == nil {
return nil, fmt.Errorf("checker %q not found", updated.CheckerID)
}
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 after verifying scope ownership.
func (u *CheckPlanUsecase) DeleteCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) error {
plan, err := u.store.GetCheckPlan(planID)
if err != nil {
return err
}
if !targetMatchesResource(scope, plan.Target) {
return happydns.ErrCheckPlanNotFound
}
return u.store.DeleteCheckPlan(planID)
}

View file

@ -0,0 +1,385 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker_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(target, 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_ListByTargetAndChecker(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create a plan for plan_test_checker.
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Query for the matching checker - should return the plan.
plans, err := uc.ListCheckPlansByTargetAndChecker(target, "plan_test_checker")
if err != nil {
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
}
if len(plans) != 1 {
t.Errorf("expected 1 plan, got %d", len(plans))
}
// Query for a different checker on the same target - should return nothing.
plans2, err := uc.ListCheckPlansByTargetAndChecker(target, "other_checker")
if err != nil {
t.Fatalf("ListCheckPlansByTargetAndChecker() error: %v", err)
}
if len(plans2) != 0 {
t.Errorf("expected 0 plans for different checker, 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(target, 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_UpdateScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Update with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.UpdateCheckPlan(wrongScope, plan.Id, &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Enabled: map[string]bool{"rule_a": false},
})
if err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
// Verify the original plan is unchanged.
got, err := uc.GetCheckPlan(target, plan.Id)
if err != nil {
t.Fatalf("GetCheckPlan() error: %v", err)
}
if got.Enabled != nil {
t.Errorf("expected original plan to be unchanged, got Enabled=%v", got.Enabled)
}
}
func TestCheckPlanUsecase_GetScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Get with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetCheckPlan(wrongScope, plan.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
}
func TestCheckPlanUsecase_DeleteScopeMismatch(t *testing.T) {
uc, _ := setupPlanUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
plan := &happydns.CheckPlan{
CheckerID: "plan_test_checker",
Target: target,
}
if err := uc.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
// Delete with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
if err := uc.DeleteCheckPlan(wrongScope, plan.Id); err == nil {
t.Fatal("expected error when scope doesn't match plan target")
}
// Verify the plan still exists.
_, err := uc.GetCheckPlan(target, plan.Id)
if err != nil {
t.Fatalf("plan should still exist after failed delete: %v", err)
}
}
func TestCheckPlanUsecase_UpdateNotFound(t *testing.T) {
uc, _ := setupPlanUC(t)
fakeID, _ := happydns.NewRandomIdentifier()
_, err := uc.UpdateCheckPlan(happydns.CheckTarget{}, 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(target, plan.Id); err != nil {
t.Fatalf("DeleteCheckPlan() error: %v", err)
}
_, err := uc.GetCheckPlan(target, 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
}

View file

@ -0,0 +1,362 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"encoding/json"
"log"
"slices"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// worstStatusMaxExecs is the maximum number of executions fetched when
// computing worst-status aggregations. It prevents unbounded memory usage
// on long-lived accounts while being generous enough for any realistic
// scenario.
const worstStatusMaxExecs = 10000
// CheckStatusUsecase handles aggregation of checker statuses and evaluation/execution queries.
type CheckStatusUsecase struct {
planStore CheckPlanStorage
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
}
// 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,
}
}
// ListPlannedExecutions returns synthetic Execution records for upcoming scheduled jobs.
// Returns nil if provider is nil.
func ListPlannedExecutions(provider PlannedJobProvider, checkerID string, target happydns.CheckTarget) []*happydns.Execution {
if provider == nil {
return nil
}
jobs := provider.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
}
if len(def.Availability.LimitToServices) > 0 && target.ServiceType != "" {
if !slices.Contains(def.Availability.LimitToServices, target.ServiceType) {
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 {
log.Printf("ListCheckerStatuses: failed to fetch latest execution for checker %s: %v", def.ID, err)
} else if 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 after verifying scope ownership.
func (u *CheckStatusUsecase) GetExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.Execution, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return exec, nil
}
// 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 after verifying scope.
func (u *CheckStatusUsecase) GetObservationsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.ObservationSnapshot, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return u.snapshotForExecution(exec)
}
// DeleteExecution deletes an execution record by ID after verifying scope ownership.
func (u *CheckStatusUsecase) DeleteExecution(scope happydns.CheckTarget, execID happydns.Identifier) error {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return err
}
if !targetMatchesResource(scope, exec.Target) {
return happydns.ErrExecutionNotFound
}
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)
}
// worstStatuses groups executions by a key extracted via keyFn, keeps only
// the latest execution per (key, checker) pair, and returns the worst status
// per key.
func worstStatuses(execs []*happydns.Execution, keyFn func(*happydns.Execution) string) map[string]*happydns.Status {
type groupKey struct {
key string
checker string
}
latest := map[groupKey]*happydns.Execution{}
for _, exec := range execs {
k := keyFn(exec)
if k == "" || exec.Status != happydns.ExecutionDone {
continue
}
gk := groupKey{key: k, checker: exec.CheckerID}
if prev, ok := latest[gk]; !ok || exec.StartedAt.After(prev.StartedAt) {
latest[gk] = exec
}
}
worst := map[string]*happydns.Status{}
for gk, exec := range latest {
s := exec.Result.Status
if s == happydns.StatusUnknown {
continue
}
if prev, ok := worst[gk.key]; !ok || s > *prev {
worst[gk.key] = &s
}
}
if len(worst) == 0 {
return nil
}
return worst
}
// GetWorstDomainStatuses fetches all executions for a user and returns the worst
// (most critical) status per domain. It keeps only the latest execution per
// (domain, checker) pair and reports the worst status among them.
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs)
if err != nil {
return nil, err
}
return worstStatuses(execs, func(e *happydns.Execution) string {
return e.Target.DomainId
}), nil
}
// GetWorstServiceStatuses returns the worst check status for each service in the zone.
// It fetches all executions for the domain in a single query, then aggregates
// the worst status per service in memory.
func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier, zone *happydns.Zone) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs)
if err != nil {
return nil, err
}
return worstStatuses(execs, func(e *happydns.Execution) string {
return e.Target.ServiceId
}), nil
}
// GetResultsByExecution returns the evaluation (with per-rule states) for an execution after verifying scope.
func (u *CheckStatusUsecase) GetResultsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) (*happydns.CheckEvaluation, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
if exec.EvaluationID == nil {
return nil, happydns.ErrCheckEvaluationNotFound
}
return u.evalStore.GetEvaluation(*exec.EvaluationID)
}
// snapshotForExecution returns the observation snapshot associated with an execution.
func (u *CheckStatusUsecase) snapshotForExecution(exec *happydns.Execution) (*happydns.ObservationSnapshot, error) {
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)
}
// extractMetricsFromExecution extracts metrics from a single execution's snapshot.
func (u *CheckStatusUsecase) extractMetricsFromExecution(exec *happydns.Execution) ([]happydns.CheckMetric, error) {
if exec.Status != happydns.ExecutionDone || exec.EvaluationID == nil {
return nil, nil
}
snap, err := u.snapshotForExecution(exec)
if err != nil {
return nil, err
}
return checkerPkg.GetAllMetrics(snap)
}
// extractMetricsFromExecutions extracts metrics from a list of executions.
func (u *CheckStatusUsecase) extractMetricsFromExecutions(execs []*happydns.Execution) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
for _, exec := range execs {
metrics, err := u.extractMetricsFromExecution(exec)
if err != nil {
log.Printf("extractMetricsFromExecutions: exec %s: %v", exec.Id.String(), err)
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, nil
}
// GetMetricsByExecution extracts metrics from a single execution's snapshot after verifying scope.
func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) ([]happydns.CheckMetric, error) {
exec, err := u.execStore.GetExecution(execID)
if err != nil {
return nil, err
}
if !targetMatchesResource(scope, exec.Target) {
return nil, happydns.ErrExecutionNotFound
}
return u.extractMetricsFromExecution(exec)
}
// GetMetricsByChecker extracts metrics from recent executions of a checker on a target.
func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetMetricsByUser extracts metrics from recent executions for a user across all checkers.
func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, limit)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetMetricsByDomain extracts metrics from recent executions for a domain (including services).
func (u *CheckStatusUsecase) GetMetricsByDomain(domainId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit)
if err != nil {
return nil, err
}
return u.extractMetricsFromExecutions(execs)
}
// GetSnapshotByExecution returns the raw observation data for a single key from an execution after verifying scope.
func (u *CheckStatusUsecase) GetSnapshotByExecution(scope happydns.CheckTarget, execID happydns.Identifier, obsKey string) (json.RawMessage, error) {
snap, err := u.GetObservationsByExecution(scope, execID)
if err != nil {
return nil, err
}
raw, ok := snap.Data[obsKey]
if !ok {
return nil, happydns.ErrSnapshotNotFound
}
return raw, nil
}

View file

@ -0,0 +1,839 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage"
"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, storage.Storage) {
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.Instantiate()
if err != nil {
t.Fatalf("Instantiate() 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(happydns.CheckTarget{}, 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(happydns.CheckTarget{}, fakeID)
if err == nil {
t.Fatal("expected error for nonexistent execution")
}
}
func TestCheckStatusUsecase_GetExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Access with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetExecution(wrongScope, exec.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match execution target")
}
}
func TestCheckStatusUsecase_DeleteExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
if err := uc.DeleteExecution(target, exec.Id); err != nil {
t.Fatalf("DeleteExecution() error: %v", err)
}
_, err := uc.GetExecution(target, exec.Id)
if err == nil {
t.Fatal("expected error after deletion")
}
}
func TestCheckStatusUsecase_DeleteExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Delete with wrong scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
if err := uc.DeleteExecution(wrongScope, exec.Id); err == nil {
t.Fatal("expected error when scope doesn't match")
}
// Original should still exist.
_, err := uc.GetExecution(target, exec.Id)
if err != nil {
t.Fatalf("execution should still exist after failed delete: %v", err)
}
}
func TestCheckStatusUsecase_DeleteExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
if err := uc.DeleteExecutionsByChecker("status_test_checker", target); err != nil {
t.Fatalf("DeleteExecutionsByChecker() error: %v", err)
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 0)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) != 0 {
t.Errorf("expected 0 executions after bulk delete, got %d", len(execs))
}
}
func TestCheckStatusUsecase_ListExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 5; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 3)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) > 3 {
t.Errorf("expected at most 3 executions with limit, got %d", len(execs))
}
}
func TestCheckStatusUsecase_GetWorstDomainStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
// Domain 1: one OK and one WARN execution.
for _, status := range []happydns.Status{happydns.StatusOK, happydns.StatusWarn} {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: status},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
// Domain 2: only OK.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstDomainStatuses(uid)
if err != nil {
t.Fatalf("GetWorstDomainStatuses() error: %v", err)
}
// Domain 1 should have worst status WARN.
if s, ok := worst[did1.String()]; !ok {
t.Error("expected domain 1 in results")
} else if *s != happydns.StatusWarn {
t.Errorf("expected worst status WARN for domain 1, got %v", *s)
}
// Domain 2 should have worst status OK.
if s, ok := worst[did2.String()]; !ok {
t.Error("expected domain 2 in results")
} else if *s != happydns.StatusOK {
t.Errorf("expected worst status OK for domain 2, got %v", *s)
}
}
func TestCheckStatusUsecase_GetWorstServiceStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
sid1, _ := happydns.NewRandomIdentifier()
sid2, _ := happydns.NewRandomIdentifier()
// Service 1: CRIT execution.
exec1 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusCrit},
}
if err := ms.CreateExecution(exec1); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Service 2: OK execution.
exec2 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec2); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstServiceStatuses(uid, did, nil)
if err != nil {
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
}
if s, ok := worst[sid1.String()]; !ok {
t.Error("expected service 1 in results")
} else if *s != happydns.StatusCrit {
t.Errorf("expected CRIT for service 1, got %v", *s)
}
if s, ok := worst[sid2.String()]; !ok {
t.Error("expected service 2 in results")
} else if *s != happydns.StatusOK {
t.Errorf("expected OK for service 2, got %v", *s)
}
}
func TestCheckStatusUsecase_GetWorstServiceStatuses_Empty(t *testing.T) {
uc, _, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
result, err := uc.GetWorstServiceStatuses(uid, did, nil)
if err != nil {
t.Fatalf("GetWorstServiceStatuses() error: %v", err)
}
if result != nil {
t.Errorf("expected nil for empty results, got %v", result)
}
}
func TestCheckStatusUsecase_GetResultsByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create evaluation.
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
States: []happydns.CheckState{{Status: happydns.StatusOK, Code: "test"}},
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
// Create execution referencing the evaluation.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetResultsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetResultsByExecution() error: %v", err)
}
if len(got.States) != 1 {
t.Errorf("expected 1 state, got %d", len(got.States))
}
}
func TestCheckStatusUsecase_GetResultsByExecution_NoEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
target := happydns.CheckTarget{}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionPending,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
_, err := uc.GetResultsByExecution(target, exec.Id)
if err == nil {
t.Fatal("expected error for execution without evaluation")
}
}
func TestCheckStatusUsecase_ListPlannedExecutions(t *testing.T) {
// Test with nil provider.
result := checkerUC.ListPlannedExecutions(nil, "checker", happydns.CheckTarget{})
if result != nil {
t.Errorf("expected nil for nil provider, got %v", result)
}
}
func TestCheckStatusUsecase_GetObservationsByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create snapshot.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
// Create evaluation referencing the snapshot.
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
// Create execution referencing the evaluation.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetObservationsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetObservationsByExecution() error: %v", err)
}
if !got.Id.Equals(snap.Id) {
t.Errorf("expected snapshot ID %s, got %s", snap.Id, got.Id)
}
}
func TestCheckStatusUsecase_GetObservationsByExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetObservationsByExecution(wrongScope, exec.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match")
}
}
// --- Metrics extraction tests ---
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NilEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: nil,
StartedAt: time.Now(),
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetMetricsByExecution() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for nil evaluation, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_ExtractMetricsFromExecution_NotDone(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionPending,
StartedAt: time.Now(),
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
metrics, err := uc.GetMetricsByExecution(target, exec.Id)
if err != nil {
t.Fatalf("GetMetricsByExecution() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for pending execution, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_GetMetricsByChecker_Empty(t *testing.T) {
uc, _, _ := setupStatusUC(t)
target := happydns.CheckTarget{UserId: "nonexistent", DomainId: "d1"}
metrics, err := uc.GetMetricsByChecker("status_test_checker", target, 100)
if err != nil {
t.Fatalf("GetMetricsByChecker() error: %v", err)
}
if len(metrics) != 0 {
t.Errorf("expected empty metrics for checker with no executions, got %d", len(metrics))
}
}
func TestCheckStatusUsecase_GetMetricsByUser(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
metrics, err := uc.GetMetricsByUser(uid, 100)
if err != nil {
t.Fatalf("GetMetricsByUser() error: %v", err)
}
// Without observation providers registered in tests, metrics will be empty,
// but the call must succeed without error.
_ = metrics
}
func TestCheckStatusUsecase_GetMetricsByDomain(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
metrics, err := uc.GetMetricsByDomain(did, 100)
if err != nil {
t.Fatalf("GetMetricsByDomain() error: %v", err)
}
_ = metrics
}
func TestCheckStatusUsecase_GetMetricsByUser_LimitApplied(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
for i := 0; i < 5; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
// Call with limit=2; underlying list should be limited.
metrics, err := uc.GetMetricsByUser(uid, 2)
if err != nil {
t.Fatalf("GetMetricsByUser(limit=2) error: %v", err)
}
_ = metrics
}
func TestCheckStatusUsecase_GetSnapshotByExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create snapshot with observation data.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: map[happydns.ObservationKey]json.RawMessage{
"dns_records": json.RawMessage(`{"records":["A 1.2.3.4"]}`),
},
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
raw, err := uc.GetSnapshotByExecution(target, exec.Id, "dns_records")
if err != nil {
t.Fatalf("GetSnapshotByExecution() error: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal observation data: %v", err)
}
if _, ok := parsed["records"]; !ok {
t.Error("expected 'records' key in observation data")
}
}
func TestCheckStatusUsecase_GetSnapshotByExecution_KeyNotFound(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: map[happydns.ObservationKey]json.RawMessage{},
}
if err := ms.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
eval := &happydns.CheckEvaluation{
CheckerID: "status_test_checker",
Target: target,
SnapshotID: snap.Id,
}
if err := ms.CreateEvaluation(eval); err != nil {
t.Fatalf("CreateEvaluation() error: %v", err)
}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
EvaluationID: &eval.Id,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
_, err := uc.GetSnapshotByExecution(target, exec.Id, "nonexistent_key")
if err == nil {
t.Fatal("expected error for nonexistent observation key")
}
}
func TestCheckStatusUsecase_GetSnapshotByExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetSnapshotByExecution(wrongScope, exec.Id, "any_key")
if err == nil {
t.Fatal("expected error when scope doesn't match")
}
}

View file

@ -0,0 +1,241 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"encoding/json"
"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
cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
func NewCheckerEngine(
optionsUC *CheckerOptionsUsecase,
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
cacheStore: cacheStore,
}
}
// 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)
}
// Build observation cache lookup for cross-checker reuse.
var cacheLookup checkerPkg.ObservationCacheLookup
if e.cacheStore != nil {
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
entry, err := e.cacheStore.GetCachedObservation(target, key)
if err != nil {
return nil, time.Time{}, err
}
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
if err != nil {
return nil, time.Time{}, err
}
raw, ok := snap.Data[key]
if !ok {
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
}
return raw, entry.CollectedAt, nil
}
}
var freshness time.Duration
if plan != nil && plan.Interval != nil {
freshness = *plan.Interval
} else if plan != nil && def.Interval != nil {
freshness = def.Interval.Default
}
// Create observation context for lazy data collection.
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
// If an endpoint is configured, override observation providers with HTTP transport.
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
for _, key := range def.ObservationKeys {
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
}
}
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
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)
}
// Update observation cache pointers for cross-checker reuse.
if e.cacheStore != nil {
for key := range snap.Data {
if err := e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
}); err != nil {
log.Printf("warning: failed to cache observation %q for target %s: %v", key, target.String(), 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
}

View file

@ -0,0 +1,586 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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, 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 {
var data map[string]any
if err := obs.Get(ctx, "test_obs", &data); err != nil {
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
}
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
}
func TestCheckerEngine_RunOK(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() 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, 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.Instantiate()
if err != nil {
t.Fatalf("Instantiate() 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, 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.Instantiate()
if err != nil {
t.Fatalf("Instantiate() 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, 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.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, 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")
}
}
func TestCheckerEngine_RunWithScheduledTrigger(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_sched",
Name: "Test Checker Scheduled",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_sched", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
planID, _ := happydns.NewRandomIdentifier()
plan := &happydns.CheckPlan{
Id: planID,
CheckerID: "test_checker_sched",
Target: target,
}
exec, err := engine.CreateExecution("test_checker_sched", target, plan)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
// Verify the trigger is set to Schedule when plan is provided.
if exec.Trigger.Type != happydns.TriggerSchedule {
t.Errorf("expected TriggerSchedule, got %v", exec.Trigger.Type)
}
if exec.PlanID == nil || !exec.PlanID.Equals(planID) {
t.Errorf("expected PlanID %s, got %v", planID, exec.PlanID)
}
eval, err := engine.RunExecution(context.Background(), exec, plan, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
if eval == nil {
t.Fatal("expected non-nil evaluation")
}
}
func TestCheckerEngine_RunExecution_CheckerDisappeared(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_disappear",
Name: "Test Checker Disappear",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_d", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
exec, err := engine.CreateExecution("test_checker_disappear", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
// Simulate the checker being unregistered between Create and Run
// by using a fake checker ID on the execution.
exec.CheckerID = "vanished_checker"
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
if err == nil {
t.Fatal("expected error when checker has disappeared")
}
// The execution should be marked as failed.
persisted, err := store.GetExecution(exec.Id)
if err != nil {
t.Fatalf("GetExecution() returned error: %v", err)
}
if persisted.Status != happydns.ExecutionFailed {
t.Errorf("expected execution status Failed, got %d", persisted.Status)
}
}
func TestCheckerEngine_RunPopulatesObservationCache(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
checker.RegisterObservationProvider(&testObservationProvider{})
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "test_checker_cache",
Name: "Test Checker Cache",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_cache", status: happydns.StatusOK},
},
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution("test_checker_cache", target, nil)
if err != nil {
t.Fatalf("CreateExecution() returned error: %v", err)
}
_, err = engine.RunExecution(context.Background(), exec, nil, nil)
if err != nil {
t.Fatalf("RunExecution() returned error: %v", err)
}
// Verify observation cache was populated for the "test_obs" key.
entry, err := store.GetCachedObservation(target, "test_obs")
if err != nil {
t.Fatalf("GetCachedObservation() returned error: %v", err)
}
if entry.SnapshotID.IsEmpty() {
t.Error("expected non-empty snapshot ID in cache entry")
}
if entry.CollectedAt.IsZero() {
t.Error("expected non-zero CollectedAt in cache entry")
}
// Verify the cached snapshot actually exists and contains the data.
snap, err := store.GetSnapshot(entry.SnapshotID)
if err != nil {
t.Fatalf("GetSnapshot() returned error: %v", err)
}
if _, ok := snap.Data["test_obs"]; !ok {
t.Error("expected 'test_obs' key in snapshot data")
}
}
func TestCheckerEngine_RunWithEndpointOverride(t *testing.T) {
// Start a fake remote checker that responds to POST /collect.
var gotRequest happydns.ExternalCollectRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/collect" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Data: json.RawMessage(`{"value":99}`),
})
}))
defer srv.Close()
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
const checkerID = "test_checker_endpoint"
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: checkerID,
Name: "Test Checker Endpoint",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{"test_obs"},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_endpoint", status: happydns.StatusOK},
},
})
// Store admin-level configuration with the endpoint pointing to our test server.
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
"endpoint": srv.URL,
}); err != nil {
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution(checkerID, 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")
}
// The engine should have delegated to the HTTP endpoint.
if gotRequest.Key != "test_obs" {
t.Errorf("remote received Key = %q, want %q", gotRequest.Key, "test_obs")
}
if exec.Result.Status != happydns.StatusOK {
t.Errorf("expected status OK, got %s", exec.Result.Status)
}
}
func TestCheckerEngine_RunWithEndpointOverride_RemoteFailure(t *testing.T) {
// Start a remote checker that always returns an error.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(happydns.ExternalCollectResponse{
Error: "remote collector is down",
})
}))
defer srv.Close()
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
const checkerID = "test_checker_endpoint_fail"
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: checkerID,
Name: "Test Checker Endpoint Fail",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []happydns.ObservationKey{"test_obs"},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_endpoint_fail", status: happydns.StatusOK},
},
})
if err := store.UpdateCheckerConfiguration(checkerID, nil, nil, nil, happydns.CheckerOptions{
"endpoint": srv.URL,
}); err != nil {
t.Fatalf("UpdateCheckerConfiguration() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
exec, err := engine.CreateExecution(checkerID, 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)
}
// The rule should report an error state because observation collection failed.
if exec.Result.Status != happydns.StatusError {
t.Errorf("expected status Error, got %s", exec.Result.Status)
}
if len(eval.States) != 1 {
t.Fatalf("expected 1 state, got %d", len(eval.States))
}
}

View file

@ -0,0 +1,645 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"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 = computeFieldMeta(def).noOverrideIds
}
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 {
meta := computeFieldMeta(def)
autoFillIds = meta.autoFillIds
noOverrideScopes = meta.noOverrideScopes
}
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)
}
// MergeCheckerOptions computes the result of merging newOpts into the existing
// options at the given scope level WITHOUT persisting it. This allows callers to
// validate the merged result before committing it to storage.
// Keys with nil or empty-string values are removed from the merged map.
func (u *CheckerOptionsUsecase) MergeCheckerOptions(
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 = computeFieldMeta(def).noOverrideScopes
}
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
}
}
return existing, nil
}
// 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 = computeFieldMeta(def).noOverrideScopes
}
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 := computeFieldMeta(def).autoFillIds
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 {
meta := computeFieldMeta(def)
if defScope, ok := meta.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)
}
// checkerFieldMeta holds pre-computed field metadata for a checker definition,
// avoiding repeated scans of the same option groups and rules.
type checkerFieldMeta struct {
autoFillIds map[string]string
noOverrideIds map[string]bool
noOverrideScopes map[string]happydns.CheckScopeType
}
// computeFieldMeta scans all option groups and rules of a checker definition
// once and returns the consolidated field metadata.
func computeFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
meta := checkerFieldMeta{
autoFillIds: make(map[string]string),
noOverrideIds: make(map[string]bool),
noOverrideScopes: make(map[string]happydns.CheckScopeType),
}
scanDoc := func(doc happydns.CheckerOptionsDocumentation) {
type scopedGroup struct {
fields []happydns.CheckerOptionDocumentation
scope happydns.CheckScopeType
}
groups := []scopedGroup{
{doc.AdminOpts, happydns.CheckScopeAdmin},
{doc.UserOpts, happydns.CheckScopeUser},
{doc.DomainOpts, happydns.CheckScopeDomain},
{doc.ServiceOpts, happydns.CheckScopeService},
{doc.RunOpts, happydns.CheckScopeService}, // RunOpts have no distinct scope; use Service as ceiling.
}
for _, g := range groups {
for _, f := range g.fields {
if f.AutoFill != "" {
meta.autoFillIds[f.Id] = f.AutoFill
}
if f.NoOverride {
meta.noOverrideIds[f.Id] = true
meta.noOverrideScopes[f.Id] = g.scope
}
}
}
}
scanDoc(def.Options)
for _, rule := range def.Rules {
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
scanDoc(rwo.Options())
}
}
return meta
}
// 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 := computeFieldMeta(def).autoFillIds
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) {
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
if err != nil {
return nil, err
}
def := checkerPkg.FindChecker(checkerName)
// Merge stored options from least to most specific, respecting NoOverride.
var meta checkerFieldMeta
if def != nil {
meta = computeFieldMeta(def)
}
storedOpts := make(happydns.CheckerOptions)
for _, p := range positionals {
for k, v := range p.Options {
if meta.noOverrideIds[k] {
if _, exists := storedOpts[k]; exists {
continue
}
}
storedOpts[k] = v
}
}
// Apply runtime overrides on top.
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
for id := range meta.noOverrideIds {
if v, ok := storedOpts[id]; ok {
merged[id] = v
}
}
// Resolve auto-fill values (always win).
if def != nil && len(meta.autoFillIds) > 0 {
target := happydns.CheckTarget{
UserId: happydns.FormatIdentifier(userId),
DomainId: happydns.FormatIdentifier(domainId),
ServiceId: happydns.FormatIdentifier(serviceId),
}
ctx, err := u.buildAutoFillContext(target)
if err != nil {
return nil, err
}
for fieldId, autoFillKey := range meta.autoFillIds {
if val, ok := ctx[autoFillKey]; ok {
merged[fieldId] = val
}
}
}
return merged, nil
}

File diff suppressed because it is too large Load diff

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package checker provides the usecase layer for the checker/monitoring system.
package checker // import "git.happydns.org/happyDomain/internal/usecase/checker"

View file

@ -0,0 +1,241 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"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 and evaluations 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
evalStore CheckEvaluationStorage
snapStore ObservationSnapshotStorage
userResolver JanitorUserResolver
defaultPolicy RetentionPolicy
interval time.Duration
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
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. evalStore and snapStore may be nil if evaluation
// pruning is not desired.
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, evalStore CheckEvaluationStorage, snapStore ObservationSnapshotStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
if interval <= 0 {
interval = 6 * time.Hour
}
return &Janitor{
planStore: planStore,
execStore: execStore,
evalStore: evalStore,
snapStore: snapStore,
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.done = make(chan struct{})
j.running = true
j.mu.Unlock()
go j.loop(ctx)
}
// Stop halts the janitor and waits for the current sweep to finish.
func (j *Janitor) Stop() {
j.mu.Lock()
cancel := j.cancel
done := j.done
j.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
j.mu.Lock()
j.running = false
j.mu.Unlock()
}
func (j *Janitor) loop(ctx context.Context) {
defer close(j.done)
// 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 to both executions and evaluations. Returns the total
// number of records deleted (executions + evaluations).
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
}
defer iter.Close()
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
}
policy := j.policyForTarget(plan.Target, policyByUser)
hardCutoff := now.AddDate(0, 0, -policy.RetentionDays)
// Prune executions using the tiered retention policy.
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)
} else if len(execs) > 0 {
// All executions share the same (CheckerID, Target) since they come
// from a single plan, so Decide's internal grouping is a no-op here.
_, 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++
}
}
// Prune evaluations older than the hard cutoff.
if j.evalStore != nil {
deleted += j.pruneEvaluations(plan.Id, hardCutoff)
}
}
if err := iter.Err(); err != nil {
log.Printf("Janitor: iterator error while walking check plans: %v", err)
}
if deleted > 0 {
log.Printf("Janitor: pruned %d records", deleted)
}
return deleted
}
// pruneEvaluations deletes evaluations for the given plan that are older than
// the cutoff, along with their associated snapshots.
func (j *Janitor) pruneEvaluations(planID happydns.Identifier, cutoff time.Time) int {
evals, err := j.evalStore.ListEvaluationsByPlan(planID)
if err != nil {
log.Printf("Janitor: failed to list evaluations for plan %s: %v", planID.String(), err)
return 0
}
deleted := 0
for _, eval := range evals {
if eval.EvaluatedAt.Before(cutoff) {
// Delete the associated snapshot first.
if j.snapStore != nil && !eval.SnapshotID.IsEmpty() {
if err := j.snapStore.DeleteSnapshot(eval.SnapshotID); err != nil {
log.Printf("Janitor: failed to delete snapshot %s: %v", eval.SnapshotID.String(), err)
}
}
if err := j.evalStore.DeleteEvaluation(eval.Id); err != nil {
log.Printf("Janitor: failed to delete evaluation %s: %v", eval.Id.String(), err)
continue
}
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
}

View file

@ -0,0 +1,668 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// --- mock execution store for janitor tests ---
type mockExecStore struct {
mu sync.Mutex
execs map[string][]*happydns.Execution // planID (base64) -> executions
errs map[string]error // planID (base64) -> error
}
func newMockExecStore() *mockExecStore {
return &mockExecStore{
execs: make(map[string][]*happydns.Execution),
errs: make(map[string]error),
}
}
func (s *mockExecStore) addExec(planID happydns.Identifier, exec *happydns.Execution) {
s.mu.Lock()
defer s.mu.Unlock()
key := planID.String()
s.execs[key] = append(s.execs[key], exec)
}
func (s *mockExecStore) setListError(planID happydns.Identifier, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.errs[planID.String()] = err
}
func (s *mockExecStore) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := planID.String()
if err, ok := s.errs[key]; ok {
return nil, err
}
return s.execs[key], nil
}
func (s *mockExecStore) DeleteExecution(execID happydns.Identifier) error {
s.mu.Lock()
defer s.mu.Unlock()
for planKey, execs := range s.execs {
for i, e := range execs {
if e.Id.Equals(execID) {
s.execs[planKey] = append(execs[:i], execs[i+1:]...)
return nil
}
}
}
return fmt.Errorf("execution %s not found", execID.String())
}
// Unused interface methods.
func (s *mockExecStore) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) CreateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) UpdateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) DeleteExecutionsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockExecStore) TidyExecutionIndexes() error { return nil }
func (s *mockExecStore) ClearExecutions() error { return nil }
// --- mock user resolver ---
type mockUserResolver struct {
users map[string]*happydns.User
}
func (r *mockUserResolver) GetUser(id happydns.Identifier) (*happydns.User, error) {
if u, ok := r.users[id.String()]; ok {
return u, nil
}
return nil, fmt.Errorf("user %s not found", id.String())
}
// --- counting wrapper ---
type countingUserResolver struct {
inner JanitorUserResolver
calls *int
}
func (r *countingUserResolver) GetUser(id happydns.Identifier) (*happydns.User, error) {
*r.calls++
return r.inner.GetUser(id)
}
// --- failing plan store ---
type failingPlanStore struct {
mockPlanStore
err error
}
func (s *failingPlanStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
return nil, s.err
}
// --- helpers ---
func makePlan(id string, userID string) *happydns.CheckPlan {
return &happydns.CheckPlan{
Id: happydns.Identifier(id),
CheckerID: "ping",
Target: happydns.CheckTarget{
UserId: userID,
DomainId: "example.com",
},
}
}
func makeExec(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),
}
}
// --- tests ---
func TestJanitor_RunOnce_NoPlans(t *testing.T) {
ps := &mockPlanStore{}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 0 {
t.Fatalf("expected 0 deletions, got %d", deleted)
}
}
func TestJanitor_RunOnce_NoExecutions(t *testing.T) {
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 0 {
t.Fatalf("expected 0 deletions, got %d", deleted)
}
}
func TestJanitor_RunOnce_PrunesExpiredExecutions(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
// One recent execution (1 hour old) and one expired (100 days old with a 30-day policy).
es.addExec(plan.Id, makeExec("recent", 1*time.Hour, now))
es.addExec(plan.Id, makeExec("old", 100*24*time.Hour, now))
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion, got %d", deleted)
}
// Verify the old execution was deleted.
remaining, _ := es.ListExecutionsByPlan(plan.Id)
if len(remaining) != 1 {
t.Fatalf("expected 1 remaining execution, got %d", len(remaining))
}
if !remaining[0].Id.Equals(happydns.Identifier("recent")) {
t.Fatalf("expected 'recent' to survive, got %s", remaining[0].Id.String())
}
}
func TestJanitor_RunOnce_PerUserRetentionOverride(t *testing.T) {
now := time.Now()
userID := happydns.Identifier("user1")
plan := makePlan("plan1", userID.String())
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
// Execution 20 days old. System default is 30 days (would keep), but user override is 10 days (should drop).
es.addExec(plan.Id, makeExec("exec1", 20*24*time.Hour, now))
resolver := &mockUserResolver{
users: map[string]*happydns.User{
userID.String(): {
Id: userID,
Quota: happydns.UserQuota{RetentionDays: 10},
},
},
}
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion (user retention=10d), got %d", deleted)
}
}
func TestJanitor_RunOnce_UserCacheAvoidsRepeatedLookups(t *testing.T) {
now := time.Now()
userID := happydns.Identifier("user1")
// Two plans for the same user.
plan1 := makePlan("plan1", userID.String())
plan2 := makePlan("plan2", userID.String())
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
es := newMockExecStore()
es.addExec(plan1.Id, makeExec("e1", 20*24*time.Hour, now))
es.addExec(plan2.Id, makeExec("e2", 20*24*time.Hour, now))
calls := 0
resolver := &countingUserResolver{
inner: &mockUserResolver{
users: map[string]*happydns.User{
userID.String(): {
Id: userID,
Quota: happydns.UserQuota{RetentionDays: 10},
},
},
},
calls: &calls,
}
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(30), time.Hour)
j.RunOnce(context.Background())
if calls != 1 {
t.Fatalf("expected user resolver to be called once (cached), got %d", calls)
}
}
func TestJanitor_RunOnce_NilUserResolverUsesDefault(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "user1")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
// 20 days old with a 30-day default policy: should be kept.
es.addExec(plan.Id, makeExec("exec1", 20*24*time.Hour, now))
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 0 {
t.Fatalf("expected 0 deletions (within default 30d retention), got %d", deleted)
}
}
func TestJanitor_RunOnce_ListPlanError(t *testing.T) {
ps := &failingPlanStore{err: errors.New("storage down")}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 0 {
t.Fatalf("expected 0 on plan listing error, got %d", deleted)
}
}
func TestJanitor_RunOnce_ListExecErrorContinues(t *testing.T) {
now := time.Now()
plan1 := makePlan("plan1", "")
plan2 := makePlan("plan2", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
es := newMockExecStore()
// plan1 returns an error; plan2 has a deletable execution.
es.setListError(plan1.Id, errors.New("corrupt index"))
es.addExec(plan2.Id, makeExec("old", 100*24*time.Hour, now))
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion (plan1 error should be skipped), got %d", deleted)
}
}
func TestJanitor_RunOnce_ContextCancellation(t *testing.T) {
now := time.Now()
var plans []*happydns.CheckPlan
es := newMockExecStore()
// Create many plans with expired executions.
for i := 0; i < 100; i++ {
id := fmt.Sprintf("plan%d", i)
plan := makePlan(id, "")
plans = append(plans, plan)
es.addExec(plan.Id, makeExec(fmt.Sprintf("exec%d", i), 100*24*time.Hour, now))
}
ps := &mockPlanStore{plans: plans}
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(ctx)
// Should have stopped early - not all 100 should be deleted.
if deleted >= 100 {
t.Fatalf("expected early exit from cancellation, but all %d were deleted", deleted)
}
}
func TestJanitor_StartStop(t *testing.T) {
ps := &mockPlanStore{}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), 50*time.Millisecond)
ctx := context.Background()
j.Start(ctx)
// Let it run a couple of ticks.
time.Sleep(150 * time.Millisecond)
j.Stop()
// Verify it actually stopped by checking that Stop doesn't hang.
}
func TestJanitor_DoubleStartIsNoop(t *testing.T) {
ps := &mockPlanStore{}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
ctx := context.Background()
j.Start(ctx)
j.Start(ctx) // should not panic or start a second goroutine
j.Stop()
}
func TestJanitor_StopBeforeStartIsNoop(t *testing.T) {
ps := &mockPlanStore{}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
// Should not panic or hang.
j.Stop()
}
func TestJanitor_DefaultInterval(t *testing.T) {
ps := &mockPlanStore{}
es := newMockExecStore()
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), 0)
if j.interval != 6*time.Hour {
t.Fatalf("expected default interval 6h, got %v", j.interval)
}
}
func TestJanitor_RunOnce_MultiplePlansMultipleUsers(t *testing.T) {
now := time.Now()
user1 := happydns.Identifier("user1")
user2 := happydns.Identifier("user2")
plan1 := makePlan("plan1", user1.String())
plan2 := makePlan("plan2", user2.String())
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
es := newMockExecStore()
// user1 has retention=10d, exec at 15 days -> should be pruned.
es.addExec(plan1.Id, makeExec("u1_exec", 15*24*time.Hour, now))
// user2 has retention=30d, exec at 15 days -> should be kept.
es.addExec(plan2.Id, makeExec("u2_exec", 15*24*time.Hour, now))
resolver := &mockUserResolver{
users: map[string]*happydns.User{
user1.String(): {Id: user1, Quota: happydns.UserQuota{RetentionDays: 10}},
user2.String(): {Id: user2, Quota: happydns.UserQuota{RetentionDays: 30}},
},
}
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(365), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion (user1 only), got %d", deleted)
}
remaining1, _ := es.ListExecutionsByPlan(plan1.Id)
if len(remaining1) != 0 {
t.Fatalf("expected user1's exec to be deleted, got %d remaining", len(remaining1))
}
remaining2, _ := es.ListExecutionsByPlan(plan2.Id)
if len(remaining2) != 1 {
t.Fatalf("expected user2's exec to be kept, got %d remaining", len(remaining2))
}
}
// --- mock evaluation store for janitor tests ---
type mockEvalStore struct {
mu sync.Mutex
evals map[string][]*happydns.CheckEvaluation // planID (base64) -> evaluations
}
func newMockEvalStore() *mockEvalStore {
return &mockEvalStore{
evals: make(map[string][]*happydns.CheckEvaluation),
}
}
func (s *mockEvalStore) addEval(planID happydns.Identifier, eval *happydns.CheckEvaluation) {
s.mu.Lock()
defer s.mu.Unlock()
key := planID.String()
s.evals[key] = append(s.evals[key], eval)
}
func (s *mockEvalStore) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.evals[planID.String()], nil
}
func (s *mockEvalStore) DeleteEvaluation(evalID happydns.Identifier) error {
s.mu.Lock()
defer s.mu.Unlock()
for planKey, evals := range s.evals {
for i, e := range evals {
if e.Id.Equals(evalID) {
s.evals[planKey] = append(evals[:i], evals[i+1:]...)
return nil
}
}
}
return fmt.Errorf("evaluation %s not found", evalID.String())
}
// Unused interface methods.
func (s *mockEvalStore) ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error) {
return nil, nil
}
func (s *mockEvalStore) ListEvaluationsByChecker(string, happydns.CheckTarget, int) ([]*happydns.CheckEvaluation, error) {
return nil, nil
}
func (s *mockEvalStore) GetEvaluation(happydns.Identifier) (*happydns.CheckEvaluation, error) {
return nil, nil
}
func (s *mockEvalStore) GetLatestEvaluation(happydns.Identifier) (*happydns.CheckEvaluation, error) {
return nil, nil
}
func (s *mockEvalStore) CreateEvaluation(*happydns.CheckEvaluation) error { return nil }
func (s *mockEvalStore) DeleteEvaluationsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockEvalStore) TidyEvaluationIndexes() error { return nil }
func (s *mockEvalStore) ClearEvaluations() error { return nil }
// --- mock snapshot store for janitor tests ---
type mockSnapStore struct {
mu sync.Mutex
deleted []string // snapshot IDs that were deleted
failNext bool
}
func newMockSnapStore() *mockSnapStore {
return &mockSnapStore{}
}
func (s *mockSnapStore) DeleteSnapshot(snapID happydns.Identifier) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.failNext {
s.failNext = false
return fmt.Errorf("snapshot %s delete failed", snapID.String())
}
s.deleted = append(s.deleted, snapID.String())
return nil
}
func (s *mockSnapStore) deletedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.deleted)
}
// Unused interface methods.
func (s *mockSnapStore) ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error) {
return nil, nil
}
func (s *mockSnapStore) GetSnapshot(happydns.Identifier) (*happydns.ObservationSnapshot, error) {
return nil, nil
}
func (s *mockSnapStore) CreateSnapshot(*happydns.ObservationSnapshot) error { return nil }
func (s *mockSnapStore) ClearSnapshots() error { return nil }
// --- helpers ---
func makeEval(id string, snapID string, age time.Duration, now time.Time, planID happydns.Identifier) *happydns.CheckEvaluation {
pid := planID
return &happydns.CheckEvaluation{
Id: happydns.Identifier(id),
PlanID: &pid,
CheckerID: "ping",
Target: happydns.CheckTarget{DomainId: "example.com"},
SnapshotID: happydns.Identifier(snapID),
EvaluatedAt: now.Add(-age),
}
}
// --- evaluation pruning tests ---
func TestJanitor_RunOnce_PrunesExpiredEvaluations(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
evs := newMockEvalStore()
ss := newMockSnapStore()
evs.addEval(plan.Id, makeEval("recent_eval", "snap1", 1*time.Hour, now, plan.Id))
evs.addEval(plan.Id, makeEval("old_eval", "snap2", 100*24*time.Hour, now, plan.Id))
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion, got %d", deleted)
}
remaining, _ := evs.ListEvaluationsByPlan(plan.Id)
if len(remaining) != 1 {
t.Fatalf("expected 1 remaining evaluation, got %d", len(remaining))
}
if !remaining[0].Id.Equals(happydns.Identifier("recent_eval")) {
t.Fatalf("expected 'recent_eval' to survive, got %s", remaining[0].Id.String())
}
if ss.deletedCount() != 1 {
t.Fatalf("expected 1 snapshot deleted, got %d", ss.deletedCount())
}
}
func TestJanitor_RunOnce_PrunesBothExecutionsAndEvaluations(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
evs := newMockEvalStore()
ss := newMockSnapStore()
es.addExec(plan.Id, makeExec("old_exec", 100*24*time.Hour, now))
evs.addEval(plan.Id, makeEval("old_eval", "snap1", 100*24*time.Hour, now, plan.Id))
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 2 {
t.Fatalf("expected 2 deletions (1 exec + 1 eval), got %d", deleted)
}
}
func TestJanitor_RunOnce_EvalPruningRespectsPerUserRetention(t *testing.T) {
now := time.Now()
userID := happydns.Identifier("user1")
plan := makePlan("plan1", userID.String())
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
evs := newMockEvalStore()
ss := newMockSnapStore()
// Evaluation 20 days old. System default is 30 days (would keep), but user override is 10 days (should drop).
evs.addEval(plan.Id, makeEval("eval1", "snap1", 20*24*time.Hour, now, plan.Id))
resolver := &mockUserResolver{
users: map[string]*happydns.User{
userID.String(): {
Id: userID,
Quota: happydns.UserQuota{RetentionDays: 10},
},
},
}
j := NewJanitor(ps, es, evs, ss, resolver, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
if deleted != 1 {
t.Fatalf("expected 1 deletion (user retention=10d), got %d", deleted)
}
}
func TestJanitor_RunOnce_NilEvalStoreSkipsEvalPruning(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
es.addExec(plan.Id, makeExec("old", 100*24*time.Hour, now))
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
// Should only delete the execution, not panic on nil evalStore.
if deleted != 1 {
t.Fatalf("expected 1 deletion, got %d", deleted)
}
}
func TestJanitor_RunOnce_SnapshotDeleteFailureContinues(t *testing.T) {
now := time.Now()
plan := makePlan("plan1", "")
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
es := newMockExecStore()
evs := newMockEvalStore()
ss := newMockSnapStore()
ss.failNext = true
evs.addEval(plan.Id, makeEval("old_eval", "snap1", 100*24*time.Hour, now, plan.Id))
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
deleted := j.RunOnce(context.Background())
// Evaluation should still be deleted even if snapshot deletion fails.
if deleted != 1 {
t.Fatalf("expected 1 deletion despite snapshot failure, got %d", deleted)
}
}

View file

@ -0,0 +1,196 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"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 .. 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)
// > 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
// 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
// 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: min(1, retentionDays),
HourlyBucketDays: min(7, retentionDays),
PerHourKept: 1,
DailyBucketDays: min(30, retentionDays),
PerDayKept: 2,
WeeklyBucketDays: min(max(retentionDays/2, 31), retentionDays),
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
}
// Clamp bucket counts: a zero or negative value would silently drop
// every execution in that tier, which is almost certainly a
// misconfiguration rather than intent.
if p.PerHourKept < 1 {
p.PerHourKept = 1
}
if p.PerDayKept < 1 {
p.PerDayKept = 1
}
if p.PerWeekKept < 1 {
p.PerWeekKept = 1
}
if p.PerMonthKept < 1 {
p.PerMonthKept = 1
}
// 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)
hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays)
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)
})
hourBuckets := map[string]int{}
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(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 {
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 fmt.Sprintf("%d-W%02d", year, week)
}

View file

@ -0,0 +1,262 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"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)
// 20 executions in the first 20 minutes, all inside 0..1 day window.
var execs []*happydns.Execution
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 <1d window, got %d", len(drop))
}
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))
}
}
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_WeeklyBucket(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(365)
// 8 executions in the same ISO week, ~60 days ago (inside weekly window).
var execs []*happydns.Execution
base := 60 * 24 * time.Hour
for i := 0; i < 8; i++ {
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Hour, now))
}
keep, drop := p.Decide(execs, now)
if len(keep) != p.PerWeekKept {
t.Fatalf("expected %d keeps in weekly bucket, got %d", p.PerWeekKept, len(keep))
}
if len(drop) != 8-p.PerWeekKept {
t.Fatalf("expected %d drops, got %d", 8-p.PerWeekKept, len(drop))
}
}
func TestDecide_MonthlyBucket(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(365)
// 6 executions in the same calendar month, ~300 days ago (inside monthly window,
// beyond weekly window which is 365/2 = 182 days).
var execs []*happydns.Execution
base := 300 * 24 * time.Hour
for i := 0; i < 6; i++ {
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Hour, now))
}
keep, drop := p.Decide(execs, now)
if len(keep) != p.PerMonthKept {
t.Fatalf("expected %d keeps in monthly bucket, got %d", p.PerMonthKept, len(keep))
}
if len(drop) != 6-p.PerMonthKept {
t.Fatalf("expected %d drops, got %d", 6-p.PerMonthKept, len(drop))
}
}
func TestDecide_ZeroBucketCountsClamped(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(365)
p.PerDayKept = 0
// 5 executions ~10 days ago (daily bucket).
var execs []*happydns.Execution
for i := 0; i < 5; 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)
// Clamped to 1, so exactly 1 kept.
if len(keep) != 1 {
t.Fatalf("expected 1 keep after clamping PerDayKept=0 to 1, got %d", len(keep))
}
if len(drop) != 4 {
t.Fatalf("expected 4 drops, got %d", 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_SmallRetentionCollapseTiers(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(3)
// With retentionDays=3, tiers collapse:
// FullDetailDays=1, HourlyBucketDays=3, DailyBucketDays=3,
// WeeklyBucketDays=3 - only full-detail and hourly tiers are reachable.
var execs []*happydns.Execution
// 3 executions inside full-detail window (< 1 day).
for i := 0; i < 3; i++ {
execs = append(execs, mkExec(fmt.Sprintf("recent%d", i), time.Duration(i)*time.Minute, now))
}
// 4 executions in the same hour, ~2 days ago (hourly tier).
base := 2*24*time.Hour + 30*time.Minute
for i := 0; i < 4; i++ {
execs = append(execs, mkExec(fmt.Sprintf("hourly%d", i), base+time.Duration(i)*time.Minute, now))
}
// 1 execution beyond retention (5 days ago).
execs = append(execs, mkExec("expired", 5*24*time.Hour, now))
keep, drop := p.Decide(execs, now)
// 3 full-detail + 1 hourly kept + 3 hourly dropped + 1 expired dropped
if len(keep) != 3+p.PerHourKept {
t.Fatalf("expected %d keeps, got %d", 3+p.PerHourKept, len(keep))
}
if len(drop) != 4-p.PerHourKept+1 {
t.Fatalf("expected %d drops, got %d", 4-p.PerHourKept+1, len(drop))
}
}
func TestDecide_BoundaryFullDetailToHourly(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
p := DefaultRetentionPolicy(365)
// Execution exactly at the full-detail boundary (age == exactly 1 day).
// !t.Before(fullCutoff) is true when t == fullCutoff, so this lands in full-detail.
exactBoundary := mkExec("boundary", 24*time.Hour, now)
// Execution 1 second past the boundary (age == 1 day + 1s) lands in hourly.
pastBoundary := mkExec("past", 24*time.Hour+time.Second, now)
keep, drop := p.Decide([]*happydns.Execution{exactBoundary, pastBoundary}, now)
// Both should be kept (one as full-detail, one as hourly).
if len(keep) != 2 {
t.Fatalf("expected 2 keeps, got %d (keep=%v, drop=%v)", len(keep), keep, drop)
}
if len(drop) != 0 {
t.Fatalf("expected 0 drops, got %d", len(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))
}
}

View file

@ -0,0 +1,806 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"container/heap"
"context"
"hash/fnv"
"log"
"slices"
"sync"
"time"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/metrics"
"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
jobKeys map[string]bool
engine happydns.CheckerEngine
planStore CheckPlanStorage
domainStore DomainLister
zoneStore ZoneGetter
stateStore SchedulerStateStorage
cancel context.CancelFunc
wake chan struct{}
done chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
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
}
// NewScheduler creates a new Scheduler. The optional gate function, if
// non-nil, is consulted before launching each job; returning false causes
// the scheduler to skip (and reschedule) the job.
func NewScheduler(
engine happydns.CheckerEngine,
maxConcurrency int,
planStore CheckPlanStorage,
domainStore DomainLister,
zoneStore ZoneGetter,
stateStore SchedulerStateStorage,
gate func(target happydns.CheckTarget) bool,
) *Scheduler {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
s := &Scheduler{
engine: engine,
planStore: planStore,
domainStore: domainStore,
zoneStore: zoneStore,
stateStore: stateStore,
jobKeys: make(map[string]bool),
wake: make(chan struct{}, 1),
maxConcurrency: maxConcurrency,
gate: gate,
}
// The scheduler queue depth is exposed via a Prometheus GaugeFunc that
// reads the live queue length at scrape time. This avoids having to call
// gauge.Set after every queue mutation site (Push/Pop/Init/buildQueue/…).
metrics.RegisterSchedulerQueueDepth(s.queueDepthForMetrics)
return s
}
// queueDepthForMetrics returns the current queue length under the read lock.
// It is invoked from the Prometheus scrape goroutine.
func (s *Scheduler) queueDepthForMetrics() float64 {
s.mu.RLock()
defer s.mu.RUnlock()
return float64(s.queue.Len())
}
// 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.done = make(chan struct{})
s.buildQueue()
s.spreadOverdueJobs()
s.mu.Unlock()
go s.run(ctx)
}
// Stop halts the scheduler and waits for in-flight workers to finish.
func (s *Scheduler) Stop() {
s.mu.Lock()
s.running = false
cancel := s.cancel
done := s.done
s.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
// Drop the queue-depth accessor so a stopped scheduler does not keep its
// closure (and the captured queue) reachable for the lifetime of the
// process. This is essential in tests that spin schedulers up and down.
metrics.RegisterSchedulerQueueDepth(nil)
}
// 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 {
tmp := make(SchedulerQueue, s.queue.Len())
copy(tmp, s.queue)
for i, job := range tmp {
cp := *job
cp.index = i
tmp[i] = &cp
}
status.NextJobs = make([]*SchedulerJob, 0, n)
for range n {
status.NextJobs = append(status.NextJobs, heap.Pop(&tmp).(*SchedulerJob))
}
}
return status
}
// SetEnabled starts or stops the scheduler. The provided ctx is used as the
// parent context for the new scheduler loop when enabled is true.
func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error {
s.mu.RLock()
wasRunning := s.running
s.mu.RUnlock()
if wasRunning {
s.Stop()
}
if enabled {
s.Start(ctx)
}
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) {
defer func() {
if r := recover(); r != nil {
log.Printf("Scheduler: panic in run loop: %v", r)
}
s.wg.Wait()
close(s.done)
}()
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 <-s.wake:
continue
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 <-s.wake:
timer.Stop()
continue
case <-timer.C:
}
}
s.mu.Lock()
if s.queue.Len() == 0 {
s.mu.Unlock()
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())
s.rescheduleJob(job)
continue
}
// 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
}
}
s.wg.Add(1)
go func(j *SchedulerJob, p *happydns.CheckPlan) {
defer func() { <-sem; s.wg.Done() }()
metrics.SchedulerActiveWorkers.Inc()
checkStart := time.Now()
defer func() {
metrics.SchedulerActiveWorkers.Dec()
metrics.SchedulerCheckDuration.WithLabelValues(j.CheckerID).Observe(time.Since(checkStart).Seconds())
if r := recover(); r != nil {
log.Printf("Scheduler: panic in worker for checker %s on %s: %v", j.CheckerID, j.Target.String(), r)
}
}()
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 {
metrics.SchedulerChecksTotal.WithLabelValues(j.CheckerID, "error").Inc()
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)
status := "success"
if err != nil {
status = "error"
log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err)
}
metrics.SchedulerChecksTotal.WithLabelValues(j.CheckerID, status).Inc()
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 and re-enqueue.
s.rescheduleJob(job)
}
}
// rescheduleJob advances job.NextRun past the current time, adds jitter,
// and pushes the job back onto the scheduler queue.
func (s *Scheduler) rescheduleJob(job *SchedulerJob) {
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()
}
func (s *Scheduler) buildQueue() {
s.queue = s.queue[:0]
s.jobKeys = make(map[string]bool)
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, skipping queue build: %v", err)
return
}
disabledSet, planMap := buildPlanIndex(plans)
// 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 {
s.enqueueJob(c.id, c.def, domainTarget, disabledSet, planMap, lastRun)
}
// 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
}
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
}
}
}
}
}
// NotifyDomainChange incrementally adds scheduler jobs for a domain
// without rebuilding the entire queue. Call this after a domain is
// created or its zone is imported/published.
func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
checkers := checkerPkg.GetCheckers()
// Load plans relevant to this domain.
uid := domain.Owner
did := domain.Id
domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
if err != nil {
log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
}
disabledSet, planMap := buildPlanIndex(plans)
// Load services outside the lock to avoid holding the mutex during I/O.
services := s.loadDomainServices(domain)
// Build the set of desired job keys for this domain so we can detect stale entries.
wantKeys := make(map[string]bool)
didStr := did.String()
for checkerID, def := range checkers {
if def.Availability.ApplyToDomain {
key := checkerID + "|" + domainTarget.String()
if !disabledSet[key] {
wantKeys[key] = true
}
}
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
continue
}
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
key := checkerID + "|" + svcTarget.String()
if !disabledSet[key] {
wantKeys[key] = true
}
}
}
}
var added, removed int
s.mu.Lock()
// Remove stale jobs for this domain that are no longer wanted.
for i := 0; i < len(s.queue); {
job := s.queue[i]
if job.Target.DomainId == didStr {
key := job.CheckerID + "|" + job.Target.String()
if !wantKeys[key] {
delete(s.jobKeys, key)
s.queue[i] = s.queue[len(s.queue)-1]
s.queue[len(s.queue)-1] = nil
s.queue = s.queue[:len(s.queue)-1]
removed++
continue
}
}
i++
}
if removed > 0 {
heap.Init(&s.queue)
}
// Add new jobs for this domain.
for checkerID, def := range checkers {
if def.Availability.ApplyToDomain {
if s.enqueueJob(checkerID, def, domainTarget, disabledSet, planMap, time.Time{}) {
added++
}
}
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
continue
}
sid := svc.Id
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: sid.String(), ServiceType: svc.Type}
if s.enqueueJob(checkerID, def, svcTarget, disabledSet, planMap, time.Time{}) {
added++
}
}
}
}
s.mu.Unlock()
if added > 0 || removed > 0 {
log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs, removed %d stale jobs", domain.DomainName, added, removed)
// Wake the run loop so it re-evaluates the queue head.
select {
case s.wake <- struct{}{}:
default:
}
}
}
// NotifyDomainRemoved removes all scheduler jobs for the given domain.
func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
s.mu.Lock()
n := 0
for i := 0; i < len(s.queue); {
job := s.queue[i]
if job.Target.DomainId == domainID.String() {
key := job.CheckerID + "|" + job.Target.String()
delete(s.jobKeys, key)
// Swap with last and shrink.
s.queue[i] = s.queue[len(s.queue)-1]
s.queue[len(s.queue)-1] = nil
s.queue = s.queue[:len(s.queue)-1]
n++
} else {
i++
}
}
if n > 0 {
heap.Init(&s.queue)
}
s.mu.Unlock()
if n > 0 {
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
}
}
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
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
}
}
return
}
// enqueueJob creates and pushes a scheduler job if the key is not already
// present and not disabled. When lastActive is zero (e.g. NotifyDomainChange),
// the job is scheduled at now + jitter; otherwise offset-based grid scheduling
// is used. Must be called with s.mu held. Returns true if a job was added.
func (s *Scheduler) enqueueJob(checkerID string, def *happydns.CheckerDefinition, target happydns.CheckTarget, disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan, lastActive time.Time) bool {
targetStr := target.String()
key := checkerID + "|" + targetStr
if s.jobKeys[key] || disabledSet[key] {
return false
}
plan := planMap[key]
interval := s.effectiveInterval(def, plan)
var nextRun time.Time
if lastActive.IsZero() {
now := time.Now()
nextRun = now.Add(computeJitter(checkerID, targetStr, now, interval))
} else {
offset := computeOffset(checkerID, targetStr, interval)
nextRun = computeNextRun(interval, offset, lastActive)
}
job := &SchedulerJob{
CheckerID: checkerID,
Target: target,
Interval: interval,
NextRun: nextRun,
}
if plan != nil {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
s.jobKeys[key] = true
return true
}
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)
}

View file

@ -0,0 +1,729 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"container/heap"
"context"
"sync"
"sync/atomic"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// --- mock engine ---
type mockEngine struct {
mu sync.Mutex
executions []*happydns.Execution
createErr error
runErr error
runDuration time.Duration
}
func (e *mockEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
if e.createErr != nil {
return nil, e.createErr
}
id, _ := happydns.NewRandomIdentifier()
exec := &happydns.Execution{
Id: id,
CheckerID: checkerID,
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionPending,
}
e.mu.Lock()
e.executions = append(e.executions, exec)
e.mu.Unlock()
return exec, nil
}
func (e *mockEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
if e.runDuration > 0 {
select {
case <-time.After(e.runDuration):
case <-ctx.Done():
return nil, ctx.Err()
}
}
if e.runErr != nil {
return nil, e.runErr
}
id, _ := happydns.NewRandomIdentifier()
return &happydns.CheckEvaluation{Id: id}, nil
}
func (e *mockEngine) executionCount() int {
e.mu.Lock()
defer e.mu.Unlock()
return len(e.executions)
}
// --- mock plan store ---
type mockPlanStore struct {
plans []*happydns.CheckPlan
}
func (s *mockPlanStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
return &sliceIterator[happydns.CheckPlan]{items: s.plans}, nil
}
func (s *mockPlanStore) 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 *mockPlanStore) ListCheckPlansByChecker(string) ([]*happydns.CheckPlan, error) {
return nil, nil
}
func (s *mockPlanStore) ListCheckPlansByUser(happydns.Identifier) ([]*happydns.CheckPlan, error) {
return nil, nil
}
func (s *mockPlanStore) GetCheckPlan(id happydns.Identifier) (*happydns.CheckPlan, error) {
for _, p := range s.plans {
if p.Id.Equals(id) {
return p, nil
}
}
return nil, happydns.ErrCheckPlanNotFound
}
func (s *mockPlanStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
id, _ := happydns.NewRandomIdentifier()
plan.Id = id
s.plans = append(s.plans, plan)
return nil
}
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) ClearCheckPlans() error { return nil }
// --- mock domain lister ---
type mockDomainLister struct {
domains []*happydns.Domain
}
func (d *mockDomainLister) ListAllDomains() (happydns.Iterator[happydns.Domain], error) {
return &sliceIterator[happydns.Domain]{items: d.domains}, nil
}
// --- mock zone getter ---
type mockZoneGetter struct {
zones map[string]*happydns.ZoneMessage
}
func (z *mockZoneGetter) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
zm, ok := z.zones[id.String()]
if !ok {
return nil, happydns.ErrZoneNotFound
}
return zm, nil
}
// --- mock state store ---
type mockStateStore struct {
mu sync.Mutex
lastRun time.Time
}
func (s *mockStateStore) GetLastSchedulerRun() (time.Time, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastRun, nil
}
func (s *mockStateStore) SetLastSchedulerRun(t time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.lastRun = t
return nil
}
// --- sliceIterator ---
type sliceIterator[T any] struct {
items []*T
idx int
cur *T
}
func (it *sliceIterator[T]) Next() bool {
if it.idx >= len(it.items) {
return false
}
it.cur = it.items[it.idx]
it.idx++
return true
}
func (it *sliceIterator[T]) NextWithError() bool { return it.Next() }
func (it *sliceIterator[T]) Item() *T { return it.cur }
func (it *sliceIterator[T]) DropItem() error { return nil }
func (it *sliceIterator[T]) Key() string { return "" }
func (it *sliceIterator[T]) Raw() any { return nil }
func (it *sliceIterator[T]) Err() error { return nil }
func (it *sliceIterator[T]) Close() {}
// --- helper to build a scheduler with mock deps ---
func newTestScheduler(engine happydns.CheckerEngine, domains []*happydns.Domain) (*Scheduler, *mockPlanStore, *mockStateStore) {
ps := &mockPlanStore{}
dl := &mockDomainLister{domains: domains}
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
ss := &mockStateStore{}
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil)
return sched, ps, ss
}
// --- computeNextRun tests (preserved from original) ---
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)
}
}
// --- Scheduler lifecycle tests ---
func TestScheduler_StartStop(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
status := sched.GetStatus()
if !status.Running {
t.Error("expected scheduler to be running after Start")
}
sched.Stop()
status = sched.GetStatus()
if status.Running {
t.Error("expected scheduler to be stopped after Stop")
}
}
func TestScheduler_StopIdempotent(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
// Stop without Start should not panic.
sched.Stop()
sched.Stop()
}
func TestScheduler_SetEnabled(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
ctx := context.Background()
// Start via SetEnabled.
sched.SetEnabled(ctx, true)
status := sched.GetStatus()
if !status.Running {
t.Error("expected scheduler to be running after SetEnabled(true)")
}
// Stop via SetEnabled.
sched.SetEnabled(ctx, false)
status = sched.GetStatus()
if status.Running {
t.Error("expected scheduler to be stopped after SetEnabled(false)")
}
// Restart via SetEnabled (this verifies the fixed context bug).
sched.SetEnabled(ctx, true)
status = sched.GetStatus()
if !status.Running {
t.Fatal("expected scheduler to be running after re-enable via SetEnabled(true)")
}
// Give it a moment and verify it's still running (not exited due to cancelled context).
time.Sleep(50 * time.Millisecond)
status = sched.GetStatus()
if !status.Running {
t.Error("scheduler exited prematurely after re-enable; likely using a cancelled context")
}
sched.Stop()
}
func TestScheduler_Gate(t *testing.T) {
engine := &mockEngine{}
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{
Id: did,
Owner: uid,
DomainName: "gate-test.example.",
}
var gated atomic.Int32
ps := &mockPlanStore{}
dl := &mockDomainLister{domains: []*happydns.Domain{domain}}
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
ss := &mockStateStore{}
sched := NewScheduler(engine, 2, ps, dl, zg, ss, func(target happydns.CheckTarget) bool {
gated.Add(1)
return false // block all jobs
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
defer sched.Stop()
// Wait briefly for the scheduler to attempt to run jobs.
time.Sleep(200 * time.Millisecond)
// The gate should have been called but no executions should have run.
if engine.executionCount() > 0 {
t.Error("expected no executions when gate blocks all jobs")
}
}
func TestScheduler_GetStatus_Empty(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
status := sched.GetStatus()
if status.Running {
t.Error("expected not running before Start")
}
if status.JobCount != 0 {
t.Errorf("expected 0 jobs, got %d", status.JobCount)
}
if len(status.NextJobs) != 0 {
t.Errorf("expected 0 next jobs, got %d", len(status.NextJobs))
}
}
func TestScheduler_RebuildQueue(t *testing.T) {
engine := &mockEngine{}
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{
Id: did,
Owner: uid,
DomainName: "rebuild.example.",
}
sched, _, _ := newTestScheduler(engine, []*happydns.Domain{domain})
count := sched.RebuildQueue()
if count == 0 {
// No checkers registered, so 0 is expected.
// This test verifies RebuildQueue doesn't panic.
}
status := sched.GetStatus()
if status.JobCount != count {
t.Errorf("expected JobCount %d, got %d", count, status.JobCount)
}
}
func TestScheduler_NotifyDomainRemoved(t *testing.T) {
engine := &mockEngine{}
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
domain := &happydns.Domain{
Id: did,
Owner: uid,
DomainName: "remove-test.example.",
}
sched, _, _ := newTestScheduler(engine, []*happydns.Domain{domain})
// Build the queue so jobs exist.
sched.mu.Lock()
sched.buildQueue()
initialCount := sched.queue.Len()
sched.mu.Unlock()
// Remove the domain.
sched.NotifyDomainRemoved(did)
sched.mu.RLock()
afterCount := sched.queue.Len()
sched.mu.RUnlock()
if initialCount > 0 && afterCount >= initialCount {
t.Errorf("expected jobs to decrease after domain removal, was %d, now %d", initialCount, afterCount)
}
// Verify no jobs reference the removed domain.
sched.mu.RLock()
for _, job := range sched.queue {
if job.Target.DomainId == did.String() {
t.Errorf("found job referencing removed domain %s", did)
}
}
sched.mu.RUnlock()
}
func TestScheduler_GetPlannedJobsForChecker(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Manually push a job into the queue.
sched.mu.Lock()
job := &SchedulerJob{
CheckerID: "test-checker",
Target: target,
Interval: time.Hour,
NextRun: time.Now().Add(time.Hour),
}
heap.Push(&sched.queue, job)
sched.mu.Unlock()
jobs := sched.GetPlannedJobsForChecker("test-checker", target)
if len(jobs) != 1 {
t.Fatalf("expected 1 planned job, got %d", len(jobs))
}
if jobs[0].CheckerID != "test-checker" {
t.Errorf("expected checker ID test-checker, got %s", jobs[0].CheckerID)
}
// Different checker should return empty.
jobs2 := sched.GetPlannedJobsForChecker("other-checker", target)
if len(jobs2) != 0 {
t.Errorf("expected 0 planned jobs for other checker, got %d", len(jobs2))
}
}
// --- Queue tests ---
func TestSchedulerQueue_HeapOrder(t *testing.T) {
q := &SchedulerQueue{}
heap.Init(q)
now := time.Now()
heap.Push(q, &SchedulerJob{CheckerID: "c", NextRun: now.Add(3 * time.Hour)})
heap.Push(q, &SchedulerJob{CheckerID: "a", NextRun: now.Add(1 * time.Hour)})
heap.Push(q, &SchedulerJob{CheckerID: "b", NextRun: now.Add(2 * time.Hour)})
first := heap.Pop(q).(*SchedulerJob)
if first.CheckerID != "a" {
t.Errorf("expected first popped job to be 'a', got %s", first.CheckerID)
}
second := heap.Pop(q).(*SchedulerJob)
if second.CheckerID != "b" {
t.Errorf("expected second popped job to be 'b', got %s", second.CheckerID)
}
third := heap.Pop(q).(*SchedulerJob)
if third.CheckerID != "c" {
t.Errorf("expected third popped job to be 'c', got %s", third.CheckerID)
}
}
func TestSchedulerQueue_Peek(t *testing.T) {
q := &SchedulerQueue{}
heap.Init(q)
if q.Peek() != nil {
t.Error("expected Peek on empty queue to return nil")
}
now := time.Now()
heap.Push(q, &SchedulerJob{CheckerID: "x", NextRun: now.Add(time.Hour)})
heap.Push(q, &SchedulerJob{CheckerID: "y", NextRun: now.Add(time.Minute)})
peeked := q.Peek()
if peeked.CheckerID != "y" {
t.Errorf("expected Peek to return earliest job 'y', got %s", peeked.CheckerID)
}
// Peek should not remove the item.
if q.Len() != 2 {
t.Errorf("expected queue length 2 after Peek, got %d", q.Len())
}
}
// --- spreadOverdueJobs tests ---
func TestSpreadOverdueJobs(t *testing.T) {
engine := &mockEngine{}
sched, _, _ := newTestScheduler(engine, nil)
now := time.Now()
// Add overdue jobs.
sched.mu.Lock()
for i := 0; i < 5; i++ {
heap.Push(&sched.queue, &SchedulerJob{
CheckerID: "overdue",
Target: happydns.CheckTarget{UserId: "u", DomainId: "d"},
Interval: time.Hour,
NextRun: now.Add(-time.Duration(i+1) * time.Hour),
})
}
sched.spreadOverdueJobs()
sched.mu.Unlock()
// All jobs should now be in the future (or at now).
sched.mu.RLock()
for _, job := range sched.queue {
if job.NextRun.Before(now.Add(-time.Second)) {
t.Errorf("expected job to be rescheduled to now or later, got %v", job.NextRun)
}
}
sched.mu.RUnlock()
}
// --- effectiveInterval tests ---
func TestEffectiveInterval_Defaults(t *testing.T) {
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
// No interval spec, no plan -> defaultInterval.
def := &happydns.CheckerDefinition{}
got := sched.effectiveInterval(def, nil)
if got != defaultInterval {
t.Errorf("expected %v, got %v", defaultInterval, got)
}
}
func TestEffectiveInterval_DefDefault(t *testing.T) {
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
def := &happydns.CheckerDefinition{
Interval: &happydns.CheckIntervalSpec{
Default: 2 * time.Hour,
Min: 1 * time.Hour,
Max: 12 * time.Hour,
},
}
got := sched.effectiveInterval(def, nil)
if got != 2*time.Hour {
t.Errorf("expected 2h, got %v", got)
}
}
func TestEffectiveInterval_PlanOverride(t *testing.T) {
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
def := &happydns.CheckerDefinition{
Interval: &happydns.CheckIntervalSpec{
Default: 2 * time.Hour,
Min: 1 * time.Hour,
Max: 12 * time.Hour,
},
}
interval := 6 * time.Hour
plan := &happydns.CheckPlan{Interval: &interval}
got := sched.effectiveInterval(def, plan)
if got != 6*time.Hour {
t.Errorf("expected 6h, got %v", got)
}
}
func TestEffectiveInterval_ClampMin(t *testing.T) {
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
def := &happydns.CheckerDefinition{
Interval: &happydns.CheckIntervalSpec{
Default: 2 * time.Hour,
Min: 1 * time.Hour,
Max: 12 * time.Hour,
},
}
interval := 10 * time.Minute // below min
plan := &happydns.CheckPlan{Interval: &interval}
got := sched.effectiveInterval(def, plan)
if got != 1*time.Hour {
t.Errorf("expected clamped to 1h, got %v", got)
}
}
func TestEffectiveInterval_ClampMax(t *testing.T) {
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
def := &happydns.CheckerDefinition{
Interval: &happydns.CheckIntervalSpec{
Default: 2 * time.Hour,
Min: 1 * time.Hour,
Max: 12 * time.Hour,
},
}
interval := 24 * time.Hour // above max
plan := &happydns.CheckPlan{Interval: &interval}
got := sched.effectiveInterval(def, plan)
if got != 12*time.Hour {
t.Errorf("expected clamped to 12h, got %v", got)
}
}
// --- buildPlanIndex tests ---
func TestBuildPlanIndex(t *testing.T) {
target := happydns.CheckTarget{UserId: "u1", DomainId: "d1"}
plans := []*happydns.CheckPlan{
{
CheckerID: "c1",
Target: target,
Enabled: map[string]bool{"r1": false, "r2": false},
},
{
CheckerID: "c2",
Target: target,
Enabled: map[string]bool{"r1": true},
},
}
disabled, planMap := buildPlanIndex(plans)
key1 := "c1|" + target.String()
key2 := "c2|" + target.String()
if !disabled[key1] {
t.Error("expected c1 to be in disabled set")
}
if disabled[key2] {
t.Error("expected c2 to NOT be in disabled set")
}
if planMap[key1] != plans[0] {
t.Error("expected planMap to contain c1 plan")
}
if planMap[key2] != plans[1] {
t.Error("expected planMap to contain c2 plan")
}
}
// --- computeJitter tests ---
func TestComputeJitter_Deterministic(t *testing.T) {
now := time.Now()
interval := time.Hour
j1 := computeJitter("c1", "t1", now, interval)
j2 := computeJitter("c1", "t1", now, interval)
if j1 != j2 {
t.Errorf("expected deterministic jitter, got %v and %v", j1, j2)
}
// Different inputs should (usually) produce different jitter.
j3 := computeJitter("c2", "t1", now, interval)
// Not guaranteed to differ, but very likely.
_ = j3
}
func TestComputeJitter_BoundedByInterval(t *testing.T) {
now := time.Now()
interval := time.Hour
maxJitter := interval / 20
j := computeJitter("c1", "t1", now, interval)
if j < 0 || j >= maxJitter {
t.Errorf("expected jitter in [0, %v), got %v", maxJitter, j)
}
}
func TestComputeJitter_ZeroInterval(t *testing.T) {
j := computeJitter("c1", "t1", time.Now(), 0)
if j != 0 {
t.Errorf("expected 0 jitter for zero interval, got %v", j)
}
}
// --- computeOffset tests ---
func TestComputeOffset_Deterministic(t *testing.T) {
interval := time.Hour
o1 := computeOffset("c1", "t1", interval)
o2 := computeOffset("c1", "t1", interval)
if o1 != o2 {
t.Errorf("expected deterministic offset, got %v and %v", o1, o2)
}
}
func TestComputeOffset_WithinInterval(t *testing.T) {
interval := time.Hour
o := computeOffset("c1", "t1", interval)
if o < 0 || o >= interval {
t.Errorf("expected offset in [0, %v), got %v", interval, o)
}
}

View file

@ -0,0 +1,128 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"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)
}
// 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 {
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.CheckerOptionsPositional], 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 {
ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error)
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
TidyEvaluationIndexes() error
ClearEvaluations() error
}
// ExecutionStorage provides persistence for execution records.
type ExecutionStorage interface {
ListAllExecutions() (happydns.Iterator[happydns.Execution], error)
ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error)
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error)
ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error)
ListExecutionsByDomain(domainId happydns.Identifier, 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
TidyExecutionIndexes() 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 {
ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error)
GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error)
CreateSnapshot(snap *happydns.ObservationSnapshot) error
DeleteSnapshot(snapID happydns.Identifier) error
ClearSnapshots() error
}
// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
// to the snapshot that holds the most recent data.
type ObservationCacheStorage interface {
ListAllCachedObservations() (happydns.Iterator[happydns.ObservationCacheEntry], error)
GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
}

View file

@ -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 <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"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)
}

View file

@ -0,0 +1,261 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// mockUserResolver is declared in janitor_test.go (same package).
func newGateResolver() *mockUserResolver {
return &mockUserResolver{users: make(map[string]*happydns.User)}
}
func addGateUser(r *mockUserResolver, quota happydns.UserQuota, lastSeen time.Time) string {
uid, _ := happydns.NewRandomIdentifier()
r.users[uid.String()] = &happydns.User{
Id: uid,
LastSeen: lastSeen,
Quota: quota,
}
return uid.String()
}
// --- Allow tests ---
func TestUserGater_ActiveUser(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Now())
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected active user to be allowed")
}
}
func TestUserGater_SchedulingPaused(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if g.Allow(target) {
t.Error("expected paused user to be blocked")
}
}
func TestUserGater_InactiveUser(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(0, 0, -100))
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if g.Allow(target) {
t.Error("expected inactive user (100 days) to be blocked with 90-day threshold")
}
}
func TestUserGater_InactiveUserWithinThreshold(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(0, 0, -30))
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected user seen 30 days ago to be allowed with 90-day threshold")
}
}
func TestUserGater_PerUserInactivityOverride(t *testing.T) {
r := newGateResolver()
// User has custom 14-day inactivity threshold, last seen 20 days ago.
uid := addGateUser(r, happydns.UserQuota{InactivityPauseDays: 14}, time.Now().AddDate(0, 0, -20))
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if g.Allow(target) {
t.Error("expected user with 14-day override to be blocked after 20 days")
}
}
func TestUserGater_NegativeInactivityDaysDisablesCheck(t *testing.T) {
r := newGateResolver()
// User opts out of inactivity pause with negative value, last seen 1 year ago.
uid := addGateUser(r, happydns.UserQuota{InactivityPauseDays: -1}, time.Now().AddDate(-1, 0, 0))
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected negative InactivityPauseDays to disable inactivity check")
}
}
func TestUserGater_ZeroDefaultInactivityDisablesCheck(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(-1, 0, 0))
g := NewUserGater(r, 0) // system default disabled
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected zero defaultInactivityDays to disable inactivity check")
}
}
func TestUserGater_NegativeDefaultInactivityDisablesCheck(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(-1, 0, 0))
g := NewUserGater(r, -1)
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected negative defaultInactivityDays to disable inactivity check")
}
}
func TestUserGater_ZeroLastSeenAllowed(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{}, time.Time{})
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
if !g.Allow(target) {
t.Error("expected zero LastSeen to be allowed (user never logged in yet)")
}
}
func TestUserGater_UnknownUserAllowed(t *testing.T) {
r := newGateResolver()
uid, _ := happydns.NewRandomIdentifier()
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid.String()}
if !g.Allow(target) {
t.Error("expected unknown user to be allowed (fail-open)")
}
}
func TestUserGater_EmptyUserIdAllowed(t *testing.T) {
r := newGateResolver()
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: ""}
if !g.Allow(target) {
t.Error("expected empty UserId to be allowed")
}
}
func TestUserGater_NilResolverAllowed(t *testing.T) {
g := NewUserGater(nil, 90)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String()}
if !g.Allow(target) {
t.Error("expected nil resolver to allow all targets")
}
}
// --- Cache tests ---
func TestUserGater_CacheHit(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
// First call populates cache.
if g.Allow(target) {
t.Fatal("expected paused user to be blocked")
}
// Remove user from resolver; cached result should still apply.
delete(r.users, uid)
if g.Allow(target) {
t.Error("expected cached blocked result to persist")
}
}
func TestUserGater_Invalidate(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
g := NewUserGater(r, 90)
target := happydns.CheckTarget{UserId: uid}
// Populate cache with blocked result.
if g.Allow(target) {
t.Fatal("expected paused user to be blocked")
}
// Admin unpauses the user.
r.users[uid].Quota.SchedulingPaused = false
// Without invalidation, cache still blocks.
if g.Allow(target) {
t.Fatal("expected cache to still block before invalidation")
}
// Invalidate and re-check.
g.Invalidate(uid)
if !g.Allow(target) {
t.Error("expected user to be allowed after invalidation and unpause")
}
}
func TestUserGater_CacheExpiry(t *testing.T) {
r := newGateResolver()
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
g := NewUserGater(r, 90)
g.cacheTTL = 10 * time.Millisecond // very short TTL for testing
target := happydns.CheckTarget{UserId: uid}
// Populate cache.
if g.Allow(target) {
t.Fatal("expected paused user to be blocked")
}
// Unpause and wait for cache expiry.
r.users[uid].Quota.SchedulingPaused = false
time.Sleep(20 * time.Millisecond)
if !g.Allow(target) {
t.Error("expected cache to expire and re-evaluate to allowed")
}
}

View file

@ -42,11 +42,12 @@ type DomainExistenceTester interface {
}
type Service struct {
store DomainStorage
providerService ProviderGetter
getZone *zoneUC.GetZoneUsecase
domainExistence DomainExistenceTester
domainLogAppender domainLogUC.DomainLogAppender
store DomainStorage
providerService ProviderGetter
getZone *zoneUC.GetZoneUsecase
domainExistence DomainExistenceTester
domainLogAppender domainLogUC.DomainLogAppender
schedulerNotifier happydns.SchedulerDomainNotifier
}
func NewService(
@ -65,6 +66,12 @@ func NewService(
}
}
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
// queue updates on domain creation/deletion.
func (s *Service) SetSchedulerNotifier(notifier happydns.SchedulerDomainNotifier) {
s.schedulerNotifier = notifier
}
// CreateDomain creates a new domain for the given user.
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
uz, err := happydns.NewDomain(user, input.DomainName, input.ProviderId)
@ -93,6 +100,10 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, input *
s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName)))
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyDomainChange(uz)
}
return uz, nil
}
@ -194,5 +205,9 @@ func (s *Service) DeleteDomain(domainID happydns.Identifier) error {
}
}
if s.schedulerNotifier != nil {
s.schedulerNotifier.NotifyDomainRemoved(domainID)
}
return nil
}

View file

@ -29,6 +29,11 @@ type DomainStorage interface {
// ListAllDomains retrieves the list of known Domains.
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
// CountDomains returns the total number of Domains in storage.
// Implementations should make this efficient (e.g. count keys without
// decoding values) so it can be called from observability paths.
CountDomains() (int, error)
// ListDomains retrieves all Domains associated to the given User.
ListDomains(user *happydns.User) ([]*happydns.Domain, error)

View file

@ -91,3 +91,10 @@ func NewOrchestrator(
ZoneImporter: zoneImporter,
}
}
// SetSchedulerNotifier sets the optional scheduler notifier on the
// sub-usecases that create or publish zones.
func (o *Orchestrator) SetSchedulerNotifier(notifier happydns.SchedulerDomainNotifier) {
o.RemoteZoneImporter.schedulerNotifier = notifier
o.ZoneCorrectionApplier.schedulerNotifier = notifier
}

View file

@ -34,10 +34,11 @@ import (
// from the provider and delegates to ZoneImporterUsecase to persist them. It
// also appends a domain log entry on success.
type RemoteZoneImporterUsecase struct {
appendDomainLog domainlogUC.DomainLogAppender
providerService ProviderGetter
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
appendDomainLog domainlogUC.DomainLogAppender
providerService ProviderGetter
zoneImporter happydns.ZoneImporterUsecase
zoneRetriever ZoneRetriever
schedulerNotifier happydns.SchedulerDomainNotifier
}
// NewRemoteZoneImporterUsecase creates a RemoteZoneImporterUsecase wired to
@ -79,5 +80,9 @@ func (uc *RemoteZoneImporterUsecase) Import(ctx context.Context, user *happydns.
log.Printf("unable to append domain log for %s: %s", domain.DomainName, err.Error())
}
if uc.schedulerNotifier != nil {
uc.schedulerNotifier.NotifyDomainChange(domain)
}
return myZone, nil
}

View file

@ -41,13 +41,14 @@ import (
// in the domain history. The WIP zone at ZoneHistory[0] is never modified.
type ZoneCorrectionApplierUsecase struct {
*ZoneCorrectionListerUsecase
appendDomainLog domainlogUC.DomainLogAppender
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
clock func() time.Time
appendDomainLog domainlogUC.DomainLogAppender
domainUpdater DomainUpdater
zoneCreator *zoneUC.CreateZoneUsecase
zoneGetter *zoneUC.GetZoneUsecase
zoneRetriever ZoneRetriever
zoneUpdater *zoneUC.UpdateZoneUsecase
schedulerNotifier happydns.SchedulerDomainNotifier
clock func() time.Time
}
// NewZoneCorrectionApplierUsecase creates a ZoneCorrectionApplierUsecase with
@ -288,6 +289,10 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
log.Printf("%s: unable to update WIP zone propagation times: %s", domain.DomainName, updateErr)
}
if uc.schedulerNotifier != nil {
uc.schedulerNotifier.NotifyDomainChange(domain)
}
return snapshot, nil
}

View file

@ -70,6 +70,10 @@ func (s *inMemoryZoneStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMes
return nil, fmt.Errorf("not implemented")
}
func (s *inMemoryZoneStorage) CountZones() (int, error) {
return len(s.zones), nil
}
func (s *inMemoryZoneStorage) GetZoneMeta(zoneid happydns.Identifier) (*happydns.ZoneMeta, error) {
z, ok := s.zones[zoneid.String()]
if !ok {

View file

@ -30,6 +30,11 @@ type ProviderStorage interface {
// ListAllProviders retrieves the list of known Providers.
ListAllProviders() (happydns.Iterator[happydns.ProviderMessage], error)
// CountProviders returns the total number of Providers in storage.
// Implementations should make this efficient (e.g. count keys without
// decoding values) so it can be called from observability paths.
CountProviders() (int, error)
// ListProviders retrieves all providers own by the given User.
ListProviders(user *happydns.User) (happydns.ProviderMessages, error)

View file

@ -41,7 +41,21 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
}
func (tu *tidyUpUsecase) TidyAll() error {
for _, tidy := range []func() error{tu.TidySessions, tu.TidyAuthUsers, tu.TidyUsers, tu.TidyProviders, tu.TidyDomains, tu.TidyZones, tu.TidyDomainLogs} {
for _, tidy := range []func() error{
tu.TidySessions,
tu.TidyAuthUsers,
tu.TidyUsers,
tu.TidyProviders,
tu.TidyDomains,
tu.TidyZones,
tu.TidyDomainLogs,
tu.TidyCheckPlans,
tu.TidyCheckerConfigurations,
tu.TidyExecutions,
tu.TidyCheckEvaluations,
tu.TidySnapshots,
tu.TidyObservationCache,
} {
if err := tidy(); err != nil {
return err
}
@ -72,6 +86,244 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
return iter.Err()
}
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
iter, err := tu.store.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
eval := iter.Item()
drop := false
if eval.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(eval.Target.UserId)
if err == nil {
if _, err = tu.store.GetUser(userId); errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan check evaluation (user %s not found): %s\n", eval.Target.UserId, eval.Id.String())
drop = true
}
}
}
if !drop && eval.Target.DomainId != "" {
domainId, err := happydns.NewIdentifierFromString(eval.Target.DomainId)
if err == nil {
if _, err = tu.store.GetDomain(domainId); errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan check evaluation (domain %s not found): %s\n", eval.Target.DomainId, eval.Id.String())
drop = true
}
}
}
if !drop && eval.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan check evaluation (plan %s not found): %s\n", eval.PlanID.String(), eval.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteEvaluation(eval.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return err
}
return tu.store.TidyEvaluationIndexes()
}
func (tu *tidyUpUsecase) TidyCheckPlans() error {
iter, err := tu.store.ListAllCheckPlans()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
plan := iter.Item()
if plan.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
if err == nil {
_, err = tu.store.GetUser(userId)
if errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan check plan (user %s not found): %s\n", plan.Target.UserId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
if plan.Target.DomainId != "" {
domainId, err := happydns.NewIdentifierFromString(plan.Target.DomainId)
if err == nil {
_, err = tu.store.GetDomain(domainId)
if errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan check plan (domain %s not found): %s\n", plan.Target.DomainId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
iter, err := tu.store.ListAllCheckerConfigurations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
cfg := iter.Item()
if cfg.UserId != nil {
if _, err = tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan checker configuration (user %s not found): %s\n", cfg.UserId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
} else if err != nil {
return err
}
}
if cfg.DomainId != nil {
domain, err := tu.store.GetDomain(*cfg.DomainId)
if errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan checker configuration (domain %s not found): %s\n", cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
} else if err != nil {
return err
}
if cfg.ServiceId != nil && len(domain.ZoneHistory) > 0 {
zone, err := tu.store.GetZone(domain.ZoneHistory[len(domain.ZoneHistory)-1])
if err != nil {
return err
}
found := false
for _, svcs := range zone.Services {
for _, svc := range svcs {
if svc.Id.Equals(*cfg.ServiceId) {
found = true
break
}
}
if found {
break
}
}
if !found {
log.Printf("Deleting orphan checker configuration (service %s not found in domain %s): %s\n", cfg.ServiceId.String(), cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
}
}
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyExecutions() error {
iter, err := tu.store.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
exec := iter.Item()
drop := false
if exec.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(exec.Target.UserId)
if err == nil {
if _, err = tu.store.GetUser(userId); errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan execution (user %s not found): %s\n", exec.Target.UserId, exec.Id.String())
drop = true
}
}
}
if !drop && exec.Target.DomainId != "" {
domainId, err := happydns.NewIdentifierFromString(exec.Target.DomainId)
if err == nil {
if _, err = tu.store.GetDomain(domainId); errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan execution (domain %s not found): %s\n", exec.Target.DomainId, exec.Id.String())
drop = true
}
}
}
if !drop && exec.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan execution (plan %s not found): %s\n", exec.PlanID.String(), exec.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteExecution(exec.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return err
}
return tu.store.TidyExecutionIndexes()
}
func (tu *tidyUpUsecase) TidyObservationCache() error {
iter, err := tu.store.ListAllCachedObservations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
entry := iter.Item()
if _, err = tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
log.Printf("Deleting stale observation cache entry (snapshot %s not found)\n", entry.SnapshotID.String())
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyDomains() error {
iter, err := tu.store.ListAllDomains()
if err != nil {
@ -170,6 +422,45 @@ func (tu *tidyUpUsecase) TidySessions() error {
return iter.Err()
}
func (tu *tidyUpUsecase) TidySnapshots() error {
// Collect all snapshot IDs referenced by evaluations.
evalIter, err := tu.store.ListAllEvaluations()
if err != nil {
return err
}
defer evalIter.Close()
referencedSnapshots := make(map[string]struct{})
for evalIter.Next() {
eval := evalIter.Item()
if !eval.SnapshotID.IsEmpty() {
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
}
}
if err = evalIter.Err(); err != nil {
return err
}
// Delete snapshots not referenced by any evaluation.
iter, err := tu.store.ListAllSnapshots()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
snap := iter.Item()
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyUsers() error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
@ -182,6 +473,9 @@ func (tu *tidyUpUsecase) TidyUsers() error {
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
return err
}
if err = iter.DropItem(); err != nil {
return err
}

View file

@ -0,0 +1,108 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package usecase_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/internal/storage/inmemory"
"git.happydns.org/happyDomain/internal/usecase"
"git.happydns.org/happyDomain/model"
)
func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
// Create a snapshot and a cache entry pointing to it.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: map[happydns.ObservationKey]json.RawMessage{
"obs_a": json.RawMessage(`{"x":1}`),
},
}
if err := store.CreateSnapshot(snap); err != nil {
t.Fatalf("CreateSnapshot() error: %v", err)
}
validEntry := &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
}
if err := store.PutCachedObservation(target, "obs_a", validEntry); err != nil {
t.Fatalf("PutCachedObservation() error: %v", err)
}
// Create a stale cache entry pointing to a non-existent snapshot.
staleSnapID, _ := happydns.NewRandomIdentifier()
staleEntry := &happydns.ObservationCacheEntry{
SnapshotID: staleSnapID,
CollectedAt: time.Now().Add(-time.Hour),
}
if err := store.PutCachedObservation(target, "obs_stale", staleEntry); err != nil {
t.Fatalf("PutCachedObservation() error: %v", err)
}
// Verify both entries exist before tidy.
if _, err := store.GetCachedObservation(target, "obs_a"); err != nil {
t.Fatalf("expected valid cache entry to exist: %v", err)
}
if _, err := store.GetCachedObservation(target, "obs_stale"); err != nil {
t.Fatalf("expected stale cache entry to exist: %v", err)
}
// Run tidy.
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
t.Fatalf("TidyObservationCache() error: %v", err)
}
// Valid entry should still exist.
if _, err := store.GetCachedObservation(target, "obs_a"); err != nil {
t.Errorf("expected valid cache entry to survive tidy: %v", err)
}
// Stale entry should be removed.
if _, err := store.GetCachedObservation(target, "obs_stale"); err == nil {
t.Error("expected stale cache entry to be removed by tidy")
}
}
func TestTidyObservationCache_EmptyCache(t *testing.T) {
store, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
}
}

View file

@ -29,6 +29,11 @@ type UserStorage interface {
// ListAllUsers retrieves the list of known Users.
ListAllUsers() (happydns.Iterator[happydns.User], error)
// CountUsers returns the total number of Users in storage. Implementations
// should make this efficient (e.g. count keys without decoding values) so
// it can be called from observability paths like Prometheus scrapes.
CountUsers() (int, error)
// GetUser retrieves the User with the given identifier.
GetUser(userid happydns.Identifier) (*happydns.User, error)

View file

@ -35,6 +35,7 @@ type Service struct {
newsletter happydns.NewsletterSubscriptor
authUser happydns.AuthUserUsecase
closeUserSessions happydns.SessionCloserUsecase
onUserChanged func(happydns.Identifier)
}
func NewUserUsecases(
@ -51,6 +52,13 @@ func NewUserUsecases(
}
}
// SetOnUserChanged installs a callback invoked after any successful user
// update (via UpdateUser). This is used to invalidate caches that depend on
// user state, such as the scheduler's UserGater.
func (s *Service) SetOnUserChanged(fn func(happydns.Identifier)) {
s.onUserChanged = fn
}
// CreateUser creates a new user with the given information.
func (s *Service) CreateUser(uinfo happydns.UserInfo) (*happydns.User, error) {
if uinfo.GetEmail() == "" {
@ -89,26 +97,30 @@ func (s *Service) GetUserByEmail(email string) (*happydns.User, error) {
}
// UpdateUser updates a user using the provided update function.
func (s *Service) UpdateUser(id happydns.Identifier, updateFn func(*happydns.User)) error {
func (s *Service) UpdateUser(id happydns.Identifier, updateFn func(*happydns.User)) (*happydns.User, error) {
user, err := s.store.GetUser(id)
if err != nil {
return err
return nil, err
}
updateFn(user)
if !user.Id.Equals(id) {
return happydns.ValidationError{Msg: "you cannot change the user identifier"}
return nil, happydns.ValidationError{Msg: "you cannot change the user identifier"}
}
if err := s.store.CreateOrUpdateUser(user); err != nil {
return happydns.InternalError{
return nil, happydns.InternalError{
Err: fmt.Errorf("failed to update user: %w", err),
UserMessage: "Sorry, we are currently unable to update your user. Please retry later.",
}
}
return nil
if s.onUserChanged != nil {
s.onUserChanged(id)
}
return user, nil
}
// ChangeUserSettings updates the settings for a user.

View file

@ -272,14 +272,17 @@ func Test_UpdateUser(t *testing.T) {
}
// Update the user
err := service.UpdateUser(userID, func(u *happydns.User) {
updated, err := service.UpdateUser(userID, func(u *happydns.User) {
u.Email = "updated@example.com"
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Email != "updated@example.com" {
t.Errorf("returned user should have updated email, got %s", updated.Email)
}
// Verify the user was updated
// Verify the user was updated in storage
updatedUser, err := service.GetUser(userID)
if err != nil {
t.Fatalf("unexpected error retrieving updated user: %v", err)
@ -303,7 +306,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
}
// Try to change the user ID
err := service.UpdateUser(userID, func(u *happydns.User) {
_, err := service.UpdateUser(userID, func(u *happydns.User) {
u.Id = happydns.Identifier([]byte("new-id"))
})
if err == nil {
@ -319,7 +322,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
func Test_UpdateUser_NotFound(t *testing.T) {
service, _, _, _ := createTestService(t)
err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
_, err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
u.Email = "updated@example.com"
})
if err == nil {
@ -364,6 +367,89 @@ func Test_ChangeUserSettings(t *testing.T) {
}
}
func Test_ChangeUserSettings_PreservesQuota(t *testing.T) {
service, mem, _, _ := createTestService(t)
// Create a user with quota set
user := &happydns.User{
Id: happydns.Identifier([]byte("user-123")),
Email: "test@example.com",
Settings: *happydns.DefaultUserSettings(),
Quota: happydns.UserQuota{
MaxChecksPerDay: 42,
RetentionDays: 30,
SchedulingPaused: true,
},
}
if err := mem.CreateOrUpdateUser(user); err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Change settings (should not touch quota)
err := service.ChangeUserSettings(user, happydns.UserSettings{Language: "de"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify quota is untouched in storage
storedUser, err := service.GetUser(user.Id)
if err != nil {
t.Fatalf("unexpected error retrieving user: %v", err)
}
if storedUser.Quota.MaxChecksPerDay != 42 {
t.Errorf("expected MaxChecksPerDay 42 after settings change, got %d", storedUser.Quota.MaxChecksPerDay)
}
if storedUser.Quota.RetentionDays != 30 {
t.Errorf("expected RetentionDays 30 after settings change, got %d", storedUser.Quota.RetentionDays)
}
if !storedUser.Quota.SchedulingPaused {
t.Error("expected SchedulingPaused to remain true after settings change")
}
}
func Test_UpdateUser_Quota(t *testing.T) {
service, mem, _, _ := createTestService(t)
userID := happydns.Identifier([]byte("user-123"))
user := &happydns.User{
Id: userID,
Email: "test@example.com",
}
if err := mem.CreateOrUpdateUser(user); err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// Update quota through UpdateUser (simulates admin path)
_, err := service.UpdateUser(userID, func(u *happydns.User) {
u.Quota = happydns.UserQuota{
MaxChecksPerDay: 100,
RetentionDays: 60,
InactivityPauseDays: -1,
SchedulingPaused: true,
}
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
storedUser, err := service.GetUser(userID)
if err != nil {
t.Fatalf("unexpected error retrieving user: %v", err)
}
if storedUser.Quota.MaxChecksPerDay != 100 {
t.Errorf("expected MaxChecksPerDay 100, got %d", storedUser.Quota.MaxChecksPerDay)
}
if storedUser.Quota.RetentionDays != 60 {
t.Errorf("expected RetentionDays 60, got %d", storedUser.Quota.RetentionDays)
}
if storedUser.Quota.InactivityPauseDays != -1 {
t.Errorf("expected InactivityPauseDays -1, got %d", storedUser.Quota.InactivityPauseDays)
}
if !storedUser.Quota.SchedulingPaused {
t.Error("expected SchedulingPaused true")
}
}
func Test_DeleteUser(t *testing.T) {
service, mem, _, sessionCloser := createTestService(t)

Some files were not shown because too many files have changed in this diff Show more