Compare commits

...

25 commits

Author SHA1 Message Date
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
161 changed files with 22143 additions and 181 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

@ -30,6 +30,7 @@ import (
"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"

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

5
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,6 +183,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect

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

@ -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,177 @@
// 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"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// respondWithMetrics writes metrics as a JSON array.
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
if metrics == nil {
metrics = []happydns.CheckMetric{}
}
c.JSON(http.StatusOK, metrics)
}
const maxLimit = 1000
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 as a JSON array.
// @Tags checkers
// @Produce json
// @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 as a JSON array.
// @Tags checkers
// @Produce json
// @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 as a JSON array.
// @Tags checkers
// @Produce json
// @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 as a JSON array.
// @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 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,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

@ -33,6 +33,7 @@ import (
"github.com/gin-gonic/gin"
admin "git.happydns.org/happyDomain/internal/api-admin/route"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/web-admin"
@ -55,6 +56,9 @@ func NewAdmin(app *App) *Admin {
// Prepare usecases (admin uses unrestricted provider access)
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
if app.usecases.checkerOptionsUC == nil {
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
}
admin.DeclareRoutes(
app.cfg,
@ -71,6 +75,8 @@ func NewAdmin(app *App) *Admin {
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
CheckerOptionsUC: app.usecases.checkerOptionsUC,
CheckScheduler: app.usecases.checkerScheduler,
},
)
web.DeclareRoutes(app.cfg, router)

View file

@ -38,6 +38,7 @@ import (
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -69,6 +70,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 +102,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 +120,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()
@ -224,12 +239,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 +262,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() {
@ -291,6 +345,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 +368,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 +389,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()

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

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

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

@ -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,12 @@ 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:])
}

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,780 @@
// 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/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
}
return &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,
}
}
// 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
}
}
// 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() }()
defer func() {
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 {
log.Printf("Scheduler: checker %s on %s failed to create execution: %v", j.CheckerID, j.Target.String(), err)
return
}
_, err = s.engine.RunExecution(ctx, exec, p, nil)
if err != nil {
log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err)
}
if s.stateStore != nil {
if err := s.stateStore.SetLastSchedulerRun(time.Now()); err != nil {
log.Printf("Scheduler: failed to persist last run time: %v", err)
}
}
}(job, plan)
// Advance to next cycle 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

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

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

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

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

251
model/checker.go Normal file
View file

@ -0,0 +1,251 @@
// 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 happydns
import (
"context"
"encoding/json"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// The types and helpers needed by external checker plugins live in the
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
// aliases so the rest of the happyDomain codebase keeps relying on this model.
//
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
// defined in this file because they describe orchestration state that is
// internal to the happyDomain server and never crosses the plugin boundary.
// --- Re-exports from checker-sdk-go ---
type CheckScopeType = sdk.CheckScopeType
const (
CheckScopeAdmin = sdk.CheckScopeAdmin
CheckScopeUser = sdk.CheckScopeUser
CheckScopeDomain = sdk.CheckScopeDomain
CheckScopeZone = sdk.CheckScopeZone
CheckScopeService = sdk.CheckScopeService
)
const (
AutoFillDomainName = sdk.AutoFillDomainName
AutoFillSubdomain = sdk.AutoFillSubdomain
AutoFillZone = sdk.AutoFillZone
AutoFillServiceType = sdk.AutoFillServiceType
AutoFillService = sdk.AutoFillService
)
type (
CheckTarget = sdk.CheckTarget
CheckerAvailability = sdk.CheckerAvailability
CheckerOptions = sdk.CheckerOptions
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
Status = sdk.Status
CheckState = sdk.CheckState
CheckMetric = sdk.CheckMetric
ObservationKey = sdk.ObservationKey
CheckIntervalSpec = sdk.CheckIntervalSpec
ObservationProvider = sdk.ObservationProvider
CheckRuleInfo = sdk.CheckRuleInfo
CheckRule = sdk.CheckRule
CheckRuleWithOptions = sdk.CheckRuleWithOptions
ObservationGetter = sdk.ObservationGetter
CheckAggregator = sdk.CheckAggregator
CheckerHTMLReporter = sdk.CheckerHTMLReporter
CheckerMetricsReporter = sdk.CheckerMetricsReporter
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
CheckerDefinition = sdk.CheckerDefinition
OptionsValidator = sdk.OptionsValidator
ExternalCollectRequest = sdk.ExternalCollectRequest
ExternalCollectResponse = sdk.ExternalCollectResponse
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
ExternalReportRequest = sdk.ExternalReportRequest
)
const (
StatusOK = sdk.StatusOK
StatusInfo = sdk.StatusInfo
StatusUnknown = sdk.StatusUnknown
StatusWarn = sdk.StatusWarn
StatusCrit = sdk.StatusCrit
StatusError = sdk.StatusError
)
// --- Helpers for converting between target identifier strings and *Identifier ---
// TargetIdentifier parses a target identifier string into an *Identifier.
// Returns nil if the string is empty or cannot be parsed.
func TargetIdentifier(s string) *Identifier {
if s == "" {
return nil
}
id, err := NewIdentifierFromString(s)
if err != nil {
return nil
}
return &id
}
// FormatIdentifier returns the string representation of id, or "" if nil.
func FormatIdentifier(id *Identifier) string {
if id == nil {
return ""
}
return id.String()
}
// --- Host-only types (orchestration state) ---
// CheckerRunRequest is the JSON body for manually triggering a checker.
type CheckerRunRequest struct {
Options CheckerOptions `json:"options,omitempty"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// CheckerOptionsPositional stores options with their positional key components.
type CheckerOptionsPositional struct {
CheckName string `json:"checkName"`
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
Options CheckerOptions `json:"options"`
}
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
}
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
func (p *CheckPlan) IsFullyDisabled() bool {
if len(p.Enabled) == 0 {
return false
}
for _, v := range p.Enabled {
if v {
return false
}
}
return true
}
// IsRuleEnabled returns whether a specific rule is enabled.
// A nil or empty map means all rules are enabled. A missing key means enabled.
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
if len(p.Enabled) == 0 {
return true
}
v, ok := p.Enabled[ruleName]
if !ok {
return true
}
return v
}
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
type CheckerStatus struct {
*CheckerDefinition
LatestExecution *Execution `json:"latestExecution,omitempty"`
Plan *CheckPlan `json:"plan,omitempty"`
Enabled bool `json:"enabled"`
EnabledRules map[string]bool `json:"enabledRules"`
}
// CheckEvaluation is the result of running a checker on observed data.
type CheckEvaluation struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
CheckerID string `json:"checkerId" binding:"required"`
Target CheckTarget `json:"target" binding:"required"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
States []CheckState `json:"states" binding:"required" readonly:"true"`
}
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
type ExecutionStatus int
const (
ExecutionPending ExecutionStatus = iota
ExecutionRunning
ExecutionDone
ExecutionFailed
)
// TriggerType represents what initiated an execution.
type TriggerType int
const (
TriggerManual TriggerType = iota
TriggerSchedule
)
// TriggerInfo describes the trigger for an execution.
type TriggerInfo struct {
Type TriggerType `json:"type"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
}
// Execution represents a single run of a checker pipeline.
type Execution struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
Error string `json:"error,omitempty" readonly:"true"`
Result CheckState `json:"result" readonly:"true"`
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
}
// CheckerEngine orchestrates the full checker pipeline.
type CheckerEngine interface {
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
}

163
model/checker_test.go Normal file
View file

@ -0,0 +1,163 @@
// 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 happydns_test
import (
"testing"
happydns "git.happydns.org/happyDomain/model"
)
func TestCheckPlan_IsFullyDisabled(t *testing.T) {
tests := []struct {
name string
enabled map[string]bool
want bool
}{
{"nil map", nil, false},
{"empty map", map[string]bool{}, false},
{"all false", map[string]bool{"a": false, "b": false}, true},
{"one true", map[string]bool{"a": false, "b": true}, false},
{"all true", map[string]bool{"a": true, "b": true}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &happydns.CheckPlan{Enabled: tt.enabled}
if got := p.IsFullyDisabled(); got != tt.want {
t.Errorf("IsFullyDisabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckPlan_IsRuleEnabled(t *testing.T) {
tests := []struct {
name string
enabled map[string]bool
rule string
want bool
}{
{"nil map", nil, "any", true},
{"empty map", map[string]bool{}, "any", true},
{"rule explicitly enabled", map[string]bool{"r1": true}, "r1", true},
{"rule explicitly disabled", map[string]bool{"r1": false}, "r1", false},
{"rule missing from map", map[string]bool{"r1": false}, "r2", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &happydns.CheckPlan{Enabled: tt.enabled}
if got := p.IsRuleEnabled(tt.rule); got != tt.want {
t.Errorf("IsRuleEnabled(%q) = %v, want %v", tt.rule, got, tt.want)
}
})
}
}
func TestTargetIdentifier(t *testing.T) {
if got := happydns.TargetIdentifier(""); got != nil {
t.Errorf("TargetIdentifier(\"\") = %v, want nil", got)
}
if got := happydns.TargetIdentifier("not-valid-hex"); got != nil {
t.Errorf("TargetIdentifier(\"not-valid-hex\") = %v, want nil", got)
}
id, err := happydns.NewRandomIdentifier()
if err != nil {
t.Fatalf("NewRandomIdentifier: %v", err)
}
s := id.String()
got := happydns.TargetIdentifier(s)
if got == nil {
t.Fatalf("TargetIdentifier(%q) = nil, want non-nil", s)
}
if !got.Equals(id) {
t.Errorf("TargetIdentifier(%q) = %v, want %v", s, got, id)
}
}
func TestFormatIdentifier(t *testing.T) {
if got := happydns.FormatIdentifier(nil); got != "" {
t.Errorf("FormatIdentifier(nil) = %q, want empty", got)
}
id, err := happydns.NewRandomIdentifier()
if err != nil {
t.Fatalf("NewRandomIdentifier: %v", err)
}
got := happydns.FormatIdentifier(&id)
if got != id.String() {
t.Errorf("FormatIdentifier(&id) = %q, want %q", got, id.String())
}
}
func TestFieldFromCheckerOption(t *testing.T) {
opt := happydns.CheckerOptionDocumentation{
Id: "myopt",
Type: "string",
Label: "My Option",
Placeholder: "enter value",
Default: "default-val",
Choices: []string{"a", "b"},
Required: true,
Secret: true,
Hide: true,
Textarea: true,
Description: "help text",
}
f := happydns.FieldFromCheckerOption(opt)
if f.Id != opt.Id {
t.Errorf("Id = %q, want %q", f.Id, opt.Id)
}
if f.Type != opt.Type {
t.Errorf("Type = %q, want %q", f.Type, opt.Type)
}
if f.Label != opt.Label {
t.Errorf("Label = %q, want %q", f.Label, opt.Label)
}
if f.Placeholder != opt.Placeholder {
t.Errorf("Placeholder = %q, want %q", f.Placeholder, opt.Placeholder)
}
if f.Default != opt.Default {
t.Errorf("Default = %v, want %v", f.Default, opt.Default)
}
if len(f.Choices) != len(opt.Choices) {
t.Errorf("Choices len = %d, want %d", len(f.Choices), len(opt.Choices))
}
if f.Required != opt.Required {
t.Errorf("Required = %v, want %v", f.Required, opt.Required)
}
if f.Secret != opt.Secret {
t.Errorf("Secret = %v, want %v", f.Secret, opt.Secret)
}
if f.Hide != opt.Hide {
t.Errorf("Hide = %v, want %v", f.Hide, opt.Hide)
}
if f.Textarea != opt.Textarea {
t.Errorf("Textarea = %v, want %v", f.Textarea, opt.Textarea)
}
if f.Description != opt.Description {
t.Errorf("Description = %q, want %q", f.Description, opt.Description)
}
}

View file

@ -26,6 +26,7 @@ import (
"net/mail"
"net/url"
"path"
"time"
)
// Options stores the configuration of the software.
@ -93,12 +94,34 @@ type Options struct {
OIDCClients []OIDCSettings
// CheckerMaxConcurrency is the maximum number of checker jobs that can
// run simultaneously. Defaults to runtime.NumCPU().
CheckerMaxConcurrency int
// CheckerRetentionDays is the system-wide default for how many days of
// check execution history are kept. Per-user UserQuota.RetentionDays
// overrides this value.
CheckerRetentionDays int
// CheckerJanitorInterval is how often the retention janitor runs.
CheckerJanitorInterval time.Duration
// CheckerInactivityPauseDays is the system-wide default number of days
// without login after which the scheduler stops running checks for a
// user. 0 disables inactivity pausing globally; per-user UserQuota
// overrides this value.
CheckerInactivityPauseDays int
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
// 0 means always require captcha at login (when provider is configured).
CaptchaLoginThreshold int
// PluginsDirectories lists filesystem paths scanned at startup for
// checker plugins (.so files).
PluginsDirectories []string
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

@ -104,9 +104,23 @@ type DomainWithZoneMetadata struct {
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
}
type DomainWithCheckStatus struct {
*Domain
// LastCheckStatus is the worst status across the most recent result of each
// checker that has run on this domain. Nil if no results exist yet.
LastCheckStatus *Status `json:"last_check_status,omitempty"`
}
type Subdomain string
type Origin string
// SchedulerDomainNotifier is an optional callback to notify the scheduler
// about domain changes so it can incrementally update its job queue.
type SchedulerDomainNotifier interface {
NotifyDomainChange(domain *Domain)
NotifyDomainRemoved(domainID Identifier)
}
type DomainUsecase interface {
CreateDomain(context.Context, *User, *DomainCreationInput) (*Domain, error)
DeleteDomain(Identifier) error

View file

@ -27,15 +27,20 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("user not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -106,6 +106,25 @@ type Field struct {
Description string `json:"description,omitempty"`
}
// FieldFromCheckerOption converts a CheckerOptionDocumentation into a Field,
// mapping the common subset of attributes. Keep this in sync when either
// struct gains new fields.
func FieldFromCheckerOption(opt CheckerOptionDocumentation) Field {
return Field{
Id: opt.Id,
Type: opt.Type,
Label: opt.Label,
Placeholder: opt.Placeholder,
Default: opt.Default,
Choices: opt.Choices,
Required: opt.Required,
Secret: opt.Secret,
Hide: opt.Hide,
Textarea: opt.Textarea,
Description: opt.Description,
}
}
type FormState struct {
// Id for an already existing element.
Id *Identifier `json:"_id,omitempty" swaggertype:"string"`

View file

@ -26,6 +26,12 @@ import ()
type TidyUpUseCase interface {
TidyAll() error
TidyAuthUsers() error
TidyCheckEvaluations() error
TidyCheckPlans() error
TidyCheckerConfigurations() error
TidyExecutions() error
TidyObservationCache() error
TidySnapshots() error
TidyDomains() error
TidyDomainLogs() error
TidyProviders() error

View file

@ -42,6 +42,10 @@ type User struct {
// Settings holds the settings for an account.
Settings UserSettings `json:"settings" binding:"required"`
// Quota holds admin-controlled limits for the account. It is never
// writable through the user-facing API; only the admin API can update it.
Quota UserQuota `json:"quota"`
}
func (u *User) GetUserId() Identifier {
@ -63,5 +67,5 @@ type UserUsecase interface {
GenerateUserAvatar(*User, int, io.Writer) error
GetUser(Identifier) (*User, error)
GetUserByEmail(string) (*User, error)
UpdateUser(Identifier, func(*User)) error
UpdateUser(Identifier, func(*User)) (*User, error)
}

51
model/user_quota.go Normal file
View file

@ -0,0 +1,51 @@
// 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 happydns
import "time"
// UserQuota holds admin-controlled per-user limits and flags. These fields are
// never modifiable by the user; they can only be updated through the admin API.
//
// Only checker-related fields are defined for now. Future paid-plan attributes
// (plan tier, domain caps, payment metadata, ...) will be added here later.
type UserQuota struct {
// MaxChecksPerDay caps the number of checker executions per day for this
// user. 0 means "use the system default".
MaxChecksPerDay int `json:"max_checks_per_day,omitempty"`
// RetentionDays is the maximum age (in days) of checker executions kept in
// storage for this user. 0 means "use the system default".
RetentionDays int `json:"retention_days,omitempty"`
// InactivityPauseDays is the number of days without login after which the
// scheduler stops running checks for this user. 0 means "use the system
// default". A negative value disables the inactivity pause for this user.
InactivityPauseDays int `json:"inactivity_pause_days,omitempty"`
// SchedulingPaused, when true, completely disables the scheduler for this
// user (admin kill switch).
SchedulingPaused bool `json:"scheduling_paused,omitempty"`
// UpdatedAt records the last time these quotas were modified.
UpdatedAt time.Time `json:"updated_at,omitzero" format:"date-time"`
}

193
model/user_quota_test.go Normal file
View file

@ -0,0 +1,193 @@
// 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 happydns_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
func TestUserQuotaZeroValues(t *testing.T) {
q := happydns.UserQuota{}
if q.MaxChecksPerDay != 0 {
t.Errorf("zero UserQuota should have MaxChecksPerDay 0, got %d", q.MaxChecksPerDay)
}
if q.RetentionDays != 0 {
t.Errorf("zero UserQuota should have RetentionDays 0, got %d", q.RetentionDays)
}
if q.InactivityPauseDays != 0 {
t.Errorf("zero UserQuota should have InactivityPauseDays 0, got %d", q.InactivityPauseDays)
}
if q.SchedulingPaused {
t.Error("zero UserQuota should have SchedulingPaused false")
}
if !q.UpdatedAt.IsZero() {
t.Error("zero UserQuota should have zero UpdatedAt")
}
}
func TestUserQuotaJSON_RoundTrip(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
original := happydns.UserQuota{
MaxChecksPerDay: 100,
RetentionDays: 30,
InactivityPauseDays: 14,
SchedulingPaused: true,
UpdatedAt: now,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("failed to marshal UserQuota: %v", err)
}
var decoded happydns.UserQuota
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal UserQuota: %v", err)
}
if decoded.MaxChecksPerDay != original.MaxChecksPerDay {
t.Errorf("MaxChecksPerDay = %d; want %d", decoded.MaxChecksPerDay, original.MaxChecksPerDay)
}
if decoded.RetentionDays != original.RetentionDays {
t.Errorf("RetentionDays = %d; want %d", decoded.RetentionDays, original.RetentionDays)
}
if decoded.InactivityPauseDays != original.InactivityPauseDays {
t.Errorf("InactivityPauseDays = %d; want %d", decoded.InactivityPauseDays, original.InactivityPauseDays)
}
if decoded.SchedulingPaused != original.SchedulingPaused {
t.Errorf("SchedulingPaused = %v; want %v", decoded.SchedulingPaused, original.SchedulingPaused)
}
if !decoded.UpdatedAt.Equal(original.UpdatedAt) {
t.Errorf("UpdatedAt = %v; want %v", decoded.UpdatedAt, original.UpdatedAt)
}
}
func TestUserQuotaJSON_OmitEmpty(t *testing.T) {
q := happydns.UserQuota{}
data, err := json.Marshal(q)
if err != nil {
t.Fatalf("failed to marshal zero UserQuota: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("failed to unmarshal to map: %v", err)
}
for _, field := range []string{"max_checks_per_day", "retention_days", "inactivity_pause_days", "scheduling_paused"} {
if _, ok := m[field]; ok {
t.Errorf("zero-value field %q should be omitted from JSON, but was present", field)
}
}
}
func TestUserQuotaJSON_PartialDecode(t *testing.T) {
raw := `{"retention_days": 7, "scheduling_paused": true}`
var q happydns.UserQuota
if err := json.Unmarshal([]byte(raw), &q); err != nil {
t.Fatalf("failed to unmarshal partial JSON: %v", err)
}
if q.RetentionDays != 7 {
t.Errorf("RetentionDays = %d; want 7", q.RetentionDays)
}
if !q.SchedulingPaused {
t.Error("SchedulingPaused should be true")
}
if q.MaxChecksPerDay != 0 {
t.Errorf("MaxChecksPerDay should default to 0, got %d", q.MaxChecksPerDay)
}
if q.InactivityPauseDays != 0 {
t.Errorf("InactivityPauseDays should default to 0, got %d", q.InactivityPauseDays)
}
}
func TestUserQuotaJSON_NegativeInactivityPauseDays(t *testing.T) {
q := happydns.UserQuota{InactivityPauseDays: -1}
data, err := json.Marshal(q)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded happydns.UserQuota
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.InactivityPauseDays != -1 {
t.Errorf("InactivityPauseDays = %d; want -1", decoded.InactivityPauseDays)
}
}
func TestUserWithQuotaJSON_RoundTrip(t *testing.T) {
user := happydns.User{
Id: happydns.Identifier{0x01, 0x02},
Email: "test@example.com",
Quota: happydns.UserQuota{
MaxChecksPerDay: 50,
RetentionDays: 90,
SchedulingPaused: false,
},
}
data, err := json.Marshal(user)
if err != nil {
t.Fatalf("failed to marshal User with Quota: %v", err)
}
var decoded happydns.User
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal User with Quota: %v", err)
}
if decoded.Quota.MaxChecksPerDay != 50 {
t.Errorf("Quota.MaxChecksPerDay = %d; want 50", decoded.Quota.MaxChecksPerDay)
}
if decoded.Quota.RetentionDays != 90 {
t.Errorf("Quota.RetentionDays = %d; want 90", decoded.Quota.RetentionDays)
}
}
func TestUserWithEmptyQuotaJSON(t *testing.T) {
raw := `{"id":"AQID","email":"test@example.com","created_at":"0001-01-01T00:00:00Z","last_seen":"0001-01-01T00:00:00Z","settings":{}}`
var user happydns.User
if err := json.Unmarshal([]byte(raw), &user); err != nil {
t.Fatalf("failed to unmarshal User without quota field: %v", err)
}
if user.Quota.MaxChecksPerDay != 0 {
t.Errorf("missing quota should default MaxChecksPerDay to 0, got %d", user.Quota.MaxChecksPerDay)
}
if user.Quota.SchedulingPaused {
t.Error("missing quota should default SchedulingPaused to false")
}
}

View file

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

View file

@ -101,6 +101,12 @@
<NavItem>
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
</NavItem>
<NavItem>
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
</NavItem>
<NavItem>
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,131 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <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/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getCheckers } from "$lib/api-base";
import { availabilityBadges } from "$lib/utils";
let checkersQ = $state(getCheckers());
let searchQuery = $state("");
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Checkers
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead"> Manage all checkers </span>
{#await checkersQ then checkersR}
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
</InputGroup>
</Col>
</Row>
{#await checkersQ}
Please wait...
{:then checkersR}
{@const checkers = checkersR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !checkers || Object.keys(checkers).length == 0}
<tr>
<td colspan="3" class="text-center text-muted py-2">
No checkers available
</td>
</tr>
{:else}
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerId}</strong></td>
<td>
{#if availabilityBadges(checkerInfo.availability).length > 0}
{#each availabilityBadges(checkerInfo.availability) as badge}
<Badge color={badge.color} class="me-1">{badge.label}</Badge>
{/each}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerId}"
class="btn btn-sm btn-primary"
>
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checkers: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,434 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <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/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
Icon,
ListGroup,
ListGroupItem,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { toasts } from "$lib/stores/toasts";
import {
getCheckersByCheckerId,
getCheckersByCheckerIdOptions,
putCheckersByCheckerIdOptions,
} from "$lib/api-base";
import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base";
import ResourceInput from "$lib/components/inputs/Resource.svelte";
import { availabilityBadges, formatDuration } from "$lib/utils";
let checkerId = $derived(page.params.checkerId!);
let checkerQ = $derived(getCheckersByCheckerId({ path: { checkerId } }));
let checkerOptionsQ = $derived(getCheckersByCheckerIdOptions({ path: { checkerId } }));
let optionValues = $state<Record<string, unknown>>({});
let saving = $state(false);
$effect(() => {
checkerOptionsQ.then((optionsR) => {
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putCheckersByCheckerIdOptions({
path: { checkerId },
body: optionValues,
});
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
toasts.addToast({
message: `Checker options updated successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to update options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]) {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, unknown> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putCheckersByCheckerIdOptions({
path: { checkerId },
body: cleanedOptions,
});
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
toasts.addToast({
message: `Orphaned options removed successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to clean options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]): string[] {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checkers" class="mb-2">
<Icon name="arrow-left"></Icon>
Back to checkers
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{checkerId}
</h1>
</Col>
</Row>
{#await checkerQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading checker status...
</p>
</Card>
{:then checkerR}
{@const checker = checkerR.data}
{#if checker}
<Row class="mb-4">
<Col md={6}>
<Card class="mb-3">
<CardHeader>
<strong>Checker Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{checker.name}</dd>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if availabilityBadges(checker.availability).length > 0}
<div class="d-flex flex-wrap gap-1">
{#each availabilityBadges(checker.availability) as badge}
<Badge color={badge.color}
>{badge.label}-level</Badge
>
{/each}
</div>
{:else}
<Badge color="secondary">General</Badge>
{/if}
{#if checker.availability?.limitToProviders?.length}
<div class="mt-1 small text-muted">
Providers: {checker.availability.limitToProviders.join(
", ",
)}
</div>
{/if}
{#if checker.availability?.limitToServices?.length}
<div class="mt-1 small text-muted">
Services: {checker.availability.limitToServices.join(
", ",
)}
</div>
{/if}
</dd>
{#if checker.interval}
<dt class="col-sm-4">Interval:</dt>
<dd class="col-sm-8">
<span>default {formatDuration(checker.interval.default)}</span>
<span class="text-muted small ms-2">
(min {formatDuration(checker.interval.min)} / max {formatDuration(checker.interval.max)})
</span>
</dd>
{/if}
</dl>
</CardBody>
</Card>
{#if checker.rules && checker.rules.length > 0}
<Card>
<CardHeader class="d-flex align-items-center justify-content-between">
<div>
<strong>Check Rules</strong>
<Badge color="secondary" class="ms-2">
{checker.rules.length}
</Badge>
</div>
{#if checker.rules.reduce((acc, rule) => acc + rule.options?.adminOpts?.length, 0) > 0}
<Button
color="success"
size="sm"
onclick={saveOptions}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"
></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
Save
</Button>
{/if}
</CardHeader>
<ListGroup flush>
{#each checker.rules as rule, i}
{@const ruleOpts = rule.options?.adminOpts || []}
<ListGroupItem>
<div class="d-flex align-items-start gap-2 mb-1">
<Icon
name="check2-circle"
class="text-success mt-1 flex-shrink-0"
></Icon>
<div class="flex-grow-1">
<strong>{rule.name}</strong>
{#if rule.description}
<p class="text-muted small mb-0">
{rule.description}
</p>
{/if}
</div>
</div>
{#if ruleOpts.length > 0}
<div class="ms-4 mt-2">
<Form onsubmit={saveOptions}>
{#each ruleOpts as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</div>
{/if}
</ListGroupItem>
{/each}
</ListGroup>
</Card>
{/if}
</Col>
<Col md={6}>
{#await checkerOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading options...
</p>
</CardBody>
</Card>
{:then _optionsR}
{@const adminOpts = checker.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{
key: "userOpts",
label: "User Options",
opts: checker.options?.userOpts || [],
},
{
key: "domainOpts",
label: "Domain Options",
opts: checker.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: "Service Options",
opts: checker.options?.serviceOpts || [],
},
{
key: "runOpts",
label: "Run Options",
opts: checker.options?.runOpts || [],
},
]}
{@const rulesAdminOpts = (checker.rules || []).flatMap(
(r) => r.options?.adminOpts || [],
)}
{@const allAdminOpts = [...adminOpts, ...rulesAdminOpts]}
{@const hasAnyOpts =
allAdminOpts.length > 0 ||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(allAdminOpts)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
<strong>Orphaned options detected:</strong>
{orphanedOpts.join(", ")}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(allAdminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
Clean Up
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader
class="d-flex align-items-center justify-content-between"
>
<strong>Admin Options</strong>
<Button
form="adminoptsform"
color="success"
size="sm"
onclick={saveOptions}
disabled={saving}
>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"
></span>
{:else}
<Icon name="check-circle"></Icon>
{/if}
Save
</Button>
</CardHeader>
<CardBody>
<Form id="adminoptsform" onsubmit={saveOptions}>
{#each adminOpts as optDoc, index}
{#if optDoc.id}
<ResourceInput
edit
index={"" + index}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optDoc.id]}
/>
{/if}
{/each}
</Form>
</CardBody>
</Card>
{/if}
{#each readOnlyOptGroups.filter((g) => g.opts.length > 0) as group}
<Card class="mb-3">
<CardHeader>
<strong>{group.label}</strong>
<Badge color="secondary" class="ms-2">read-only</Badge>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each group.opts as opt}
<dt class="col-sm-4">{opt.label || opt.id}</dt>
<dd class="col-sm-8">
<span class="text-muted small"
>{opt.type || "string"}</span
>
{#if opt.description}
<div class="form-text">{opt.description}</div>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/each}
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
This checker has no configurable options.
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading options: {error.message}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error: checker data not found
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checker: {error.message}
</Alert>
{/await}
</Container>

View file

@ -0,0 +1,262 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <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/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import {
getScheduler,
postSchedulerEnable,
postSchedulerDisable,
postSchedulerRescheduleUpcoming,
} from "$lib/api-admin";
import type { CheckerSchedulerStatus } from "$lib/api-admin";
import { formatDuration, formatRelative } from "$lib/utils/datetime";
let status = $state<CheckerSchedulerStatus | null>(null);
let loading = $state(true);
let toggling = $state(false);
let rescheduling = $state(false);
let error = $state<string | null>(null);
async function fetchStatus() {
loading = true;
error = null;
try {
const { data, error: err } = await getScheduler();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
loading = false;
}
}
async function toggleScheduler() {
if (!status) return;
toggling = true;
error = null;
try {
const fn = status.running ? postSchedulerDisable : postSchedulerEnable;
const { data, error: err } = await fn();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
toggling = false;
}
}
async function rebuildQueue() {
rescheduling = true;
error = null;
try {
const { error: err } = await postSchedulerRescheduleUpcoming();
if (err) throw new Error(String(err));
await fetchStatus();
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
rescheduling = false;
}
}
onMount(fetchStatus);
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="clock-history"></Icon>
Scheduler
</h1>
<p class="text-muted lead">Monitor and control the checker scheduler</p>
</Col>
</Row>
{#if error}
<Card color="danger" body class="mb-4">
<Icon name="exclamation-triangle-fill"></Icon>
{error}
</Card>
{/if}
{#if loading}
<div class="d-flex align-items-center gap-2">
<Spinner size="sm" />
<span>Loading scheduler status...</span>
</div>
{:else if status}
<Card class="mb-4">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<span>
<Icon name="info-circle-fill"></Icon>
Scheduler Status
</span>
<div class="d-flex gap-2">
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
<Icon name="arrow-clockwise"></Icon> Refresh
</Button>
<Button
size="sm"
color={status.running ? "warning" : "success"}
disabled={toggling}
onclick={toggleScheduler}
>
{#if toggling}
<Spinner size="sm" />
{:else if status.running}
<Icon name="stop-fill"></Icon> Stop
{:else}
<Icon name="play-fill"></Icon> Start
{/if}
</Button>
<Button
size="sm"
color="primary"
outline
disabled={rescheduling}
onclick={rebuildQueue}
>
{#if rescheduling}
<Spinner size="sm" />
{:else}
<Icon name="calendar2-check"></Icon> Rebuild queue
{/if}
</Button>
</div>
</div>
</CardHeader>
<CardBody>
<div class="d-flex gap-4 align-items-center">
<div>
<small class="text-muted d-block">Status</small>
{#if status.running}
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
{:else}
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
{/if}
</div>
<div>
<small class="text-muted d-block">Jobs in queue</small>
<strong>{status.job_count ?? 0}</strong>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<Icon name="list-ol"></Icon>
Next scheduled jobs
<Badge color="secondary" class="ms-2">{status.next_jobs?.length ?? 0}</Badge>
</CardHeader>
<CardBody class="p-0">
<div class="table-responsive">
<Table hover class="mb-0">
<thead>
<tr>
<th>Checker</th>
<th>Target</th>
<th>Interval</th>
<th>Next run</th>
</tr>
</thead>
<tbody>
{#if !status.next_jobs || status.next_jobs.length === 0}
<tr>
<td colspan="4" class="text-center text-muted py-3">
No jobs scheduled
</td>
</tr>
{:else}
{#each status.next_jobs as job}
<tr>
<td>
<code>{job.checkerID ?? "—"}</code>
</td>
<td>
{#if job.target?.domainId}
<Badge
href={"/domains/" + job.target?.domainId}
color="info"
class="me-1"
>
domain
</Badge>
{/if}
{#if job.target?.serviceId}
<Badge
href={"/service/" + job.target?.serviceId}
color="warning"
class="me-1"
>
service
</Badge>
{/if}
{#if job.target?.userId}
<Badge
href={"/users/" + job.target?.userId}
color="secondary"
class="me-1"
>
user
</Badge>
{/if}
{#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId}
<span class="text-muted"></span>
{/if}
</td>
<td>{formatDuration(job.interval)}</td>
<td>
<span title={job.nextRun}
>{formatRelative(job.nextRun)}</span
>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
</CardBody>
</Card>
{/if}
</Container>

View file

@ -27,6 +27,7 @@
import { getUsersByUid, getUsersByUidDomains, getUsersByUidProviders } from "$lib/api-admin";
import UserInfoCard from "./UserInfoCard.svelte";
import UserQuotaCard from "./UserQuotaCard.svelte";
import UserDomainsCard from "./domains/UserDomainsCard.svelte";
import UserProvidersCard from "./providers/UserProvidersCard.svelte";
@ -55,8 +56,9 @@
{@const user = userR.data}
{#if user}
<Row>
<Col md={8} lg={6}>
<Col md={8} lg={6} class="d-flex flex-column gap-4">
<UserInfoCard {user} {uid} />
<UserQuotaCard {user} {uid} />
</Col>
<Col md={8} lg={6} class="d-flex flex-column gap-4">

View file

@ -0,0 +1,198 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <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/>.
-->
<script lang="ts">
import {
Alert,
Button,
Card,
CardBody,
CardHeader,
Form,
FormGroup,
FormText,
Icon,
Input,
Label,
Spinner,
} from "@sveltestrap/sveltestrap";
import { putUsersByUid } from "$lib/api-admin";
import { toasts } from "$lib/stores/toasts";
import type { HappydnsUser, HappydnsUserQuota } from "$lib/api-admin";
interface UserQuotaCardProps {
user: HappydnsUser;
uid: string;
}
let { user, uid }: UserQuotaCardProps = $props();
let maxChecksPerDay = $state(0);
let retentionDays = $state(0);
let inactivityPauseDays = $state(0);
let schedulingPaused = $state(false);
let updatedAt = $state<string | undefined>(undefined);
let loading = $state(false);
let errorMessage = $state("");
$effect(() => {
const q: HappydnsUserQuota = user?.quota ?? {};
maxChecksPerDay = q.max_checks_per_day ?? 0;
retentionDays = q.retention_days ?? 0;
inactivityPauseDays = q.inactivity_pause_days ?? 0;
schedulingPaused = q.scheduling_paused ?? false;
updatedAt = q.updated_at;
});
async function handleSubmit(e: Event) {
e.preventDefault();
loading = true;
errorMessage = "";
try {
const body: any = {
email: user.email,
created_at: user.created_at,
last_seen: user.last_seen,
settings: user.settings,
quota: {
max_checks_per_day: Number(maxChecksPerDay) || 0,
retention_days: Number(retentionDays) || 0,
inactivity_pause_days: Number(inactivityPauseDays) || 0,
scheduling_paused: schedulingPaused,
},
};
const res = await putUsersByUid({ path: { uid }, body });
const updated = (res?.data as HappydnsUser | undefined)?.quota;
if (updated?.updated_at) updatedAt = updated.updated_at;
toasts.addToast({
message: "Quota updated successfully",
type: "success",
timeout: 5000,
});
} catch (error) {
errorMessage = "Failed to update quota: " + error;
toasts.addErrorToast({ message: errorMessage, timeout: 10000 });
} finally {
loading = false;
}
}
</script>
<Card class="mb-4">
<CardHeader class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<Icon name="speedometer2" class="me-2"></Icon>
Admin Quota
</h5>
{#if updatedAt}
<small class="text-muted">
Updated {new Date(updatedAt).toLocaleString()}
</small>
{/if}
</CardHeader>
<CardBody>
<p class="text-muted small">
These limits are controlled by administrators and cannot be modified
by the user. A value of <code>0</code> means "use the system default".
</p>
{#if errorMessage}
<Alert color="danger" dismissible fade>{errorMessage}</Alert>
{/if}
<Form onsubmit={handleSubmit}>
<FormGroup>
<Label for="schedulingPaused" class="form-check-label">
<Input
type="checkbox"
id="schedulingPaused"
bind:checked={schedulingPaused}
/>
Pause scheduler for this user
</Label>
<FormText>
Admin kill switch — when enabled, no checks will run for this
user regardless of their plans.
</FormText>
</FormGroup>
<FormGroup>
<Label for="retentionDays">Retention (days)</Label>
<Input
type="number"
id="retentionDays"
min="0"
bind:value={retentionDays}
/>
<FormText>
Maximum age of stored check executions. Older entries are
pruned by the janitor according to the tiered retention policy.
</FormText>
</FormGroup>
<FormGroup>
<Label for="maxChecksPerDay">Max checks per day</Label>
<Input
type="number"
id="maxChecksPerDay"
min="0"
bind:value={maxChecksPerDay}
/>
<FormText>
Daily cap on the number of executions the scheduler may launch
for this user (enforced later).
</FormText>
</FormGroup>
<FormGroup>
<Label for="inactivityPauseDays">
Inactivity pause (days)
</Label>
<Input
type="number"
id="inactivityPauseDays"
bind:value={inactivityPauseDays}
/>
<FormText>
The scheduler stops running checks after this many days
without login. Use a negative value to disable.
</FormText>
</FormGroup>
<Button color="primary" type="submit" disabled={loading}>
{#if loading}
<Spinner size="sm" class="me-2" />
{:else}
<Icon name="check-circle" class="me-2"></Icon>
{/if}
Save Quota
</Button>
</Form>
</CardBody>
</Card>

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