Compare commits
38 commits
a15a970acc
...
a0b052608f
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b052608f | |||
| b444adc141 | |||
| e4911c81db | |||
| 3708cd7f91 | |||
| cbd03c5d2b | |||
| 515d811c5b | |||
| a198ecd614 | |||
| 57e5ab2a11 | |||
| 40b4bab4a8 | |||
| 8d030071e1 | |||
| b9035bb6b4 | |||
| fd5bfb637d | |||
| ce9da66a76 | |||
| a18237aaa5 | |||
| 1bb2592f4d | |||
| 33279f8c6e | |||
| 9d76a0a62a | |||
| ac50d02819 | |||
| cab205c97f | |||
| 6d0423480d | |||
| 1d8700db74 | |||
| 3b3ac3b046 | |||
| 1a39b4322e | |||
| 6f9c653101 | |||
| a8062c3eca | |||
| 09d151b234 | |||
| 7b41ed3060 | |||
| 3008e04a8c | |||
| f6d0969db0 | |||
| 4ce33ade83 | |||
| 4bb19bf989 | |||
| 802d24c4b6 | |||
| d5ec413b7d | |||
| 3f0ca0b37e | |||
| 90e4d30ae4 | |||
| 3163a10c45 | |||
| 197fd9c796 | |||
| 52e176c73a |
206 changed files with 25878 additions and 1208 deletions
33
checkers/matrix_federation.go
Normal file
33
checkers/matrix_federation.go
Normal 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
32
checkers/ping.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkers
|
||||
|
||||
import (
|
||||
ping "git.happydns.org/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
33
checkers/zonemaster.go
Normal 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())
|
||||
}
|
||||
|
|
@ -26,13 +26,16 @@ import (
|
|||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/earthboundkid/versioninfo/v2"
|
||||
"github.com/fatih/color"
|
||||
|
||||
_ "git.happydns.org/happyDomain/checkers"
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/app"
|
||||
"git.happydns.org/happyDomain/internal/config"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
||||
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
||||
|
|
@ -54,11 +57,19 @@ func main() {
|
|||
LastCommit: versioninfo.Revision,
|
||||
DirtyBuild: versioninfo.DirtyBuild,
|
||||
}
|
||||
v := Version
|
||||
if Version == "custom-build" {
|
||||
controller.HDVersion.Version = versioninfo.Short()
|
||||
v = versioninfo.Short()
|
||||
controller.HDVersion.Version = v
|
||||
} else {
|
||||
versioninfo.Version = Version
|
||||
}
|
||||
metrics.SetBuildInfo(
|
||||
v,
|
||||
versioninfo.Revision,
|
||||
versioninfo.LastCommit.UTC().Format(time.RFC3339),
|
||||
versioninfo.DirtyBuild,
|
||||
)
|
||||
|
||||
log.Println("This is happyDomain", versioninfo.Short())
|
||||
|
||||
|
|
|
|||
60
docs/metrics.md
Normal file
60
docs/metrics.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# happyDomain Metrics
|
||||
|
||||
happyDomain exposes Prometheus metrics at `GET /metrics` on the **admin
|
||||
socket only** (Unix socket or loopback). The admin socket is not
|
||||
authenticated; do not expose it to untrusted networks. The public HTTP API
|
||||
does **not** serve `/metrics`.
|
||||
|
||||
All metric names are prefixed with `happydomain_`.
|
||||
|
||||
## Exported metrics
|
||||
|
||||
| Metric | Type | Labels | Cardinality bound | Description |
|
||||
|---|---|---|---|---|
|
||||
| `happydomain_http_requests_total` | counter | `method`, `path`, `status` | HTTP methods × Gin route templates × HTTP status codes (low hundreds) | Total HTTP requests served. `path` is the Gin route template (e.g. `/api/domains/:domain`), never the raw URL, to keep cardinality bounded. |
|
||||
| `happydomain_http_request_duration_seconds` | histogram | `method`, `path` | same as above | HTTP request latency, default Prometheus buckets. |
|
||||
| `happydomain_http_requests_in_flight` | gauge | – | 1 | HTTP requests currently being served. |
|
||||
| `happydomain_scheduler_queue_depth` | gauge (func) | – | 1 | Sampled at scrape time via `RegisterSchedulerQueueDepth`. Reports 0 when no scheduler is registered. |
|
||||
| `happydomain_scheduler_active_workers` | gauge | – | 1 | Workers currently executing a check. |
|
||||
| `happydomain_scheduler_checks_total` | counter | `checker`, `status` | #checker types × {`success`, `error`} | Total scheduler check executions. Checker IDs are system-defined, never user input. |
|
||||
| `happydomain_scheduler_check_duration_seconds` | histogram | `checker` | #checker types | Check execution latency. |
|
||||
| `happydomain_provider_api_calls_total` | counter | `provider`, `operation`, `status` | #providers × #ops × {`success`, `error`} | DNS provider API calls. `provider` is the dnscontrol provider name (bounded set). |
|
||||
| `happydomain_provider_api_duration_seconds` | histogram | `provider`, `operation` | same | DNS provider API latency. |
|
||||
| `happydomain_storage_operations_total` | counter | `operation`, `entity`, `status` | ~6 ops × ~5 entities × {`success`, `error`} | Storage operations. |
|
||||
| `happydomain_storage_operation_duration_seconds` | histogram | `operation`, `entity` | same | Storage operation latency. |
|
||||
| `happydomain_storage_stats_errors_total` | counter | `entity` | #entities | Errors encountered while collecting storage stats during a scrape. Alert on a non-zero rate — silent storage failures otherwise produce gaps in the gauges below. |
|
||||
| `happydomain_registered_users_total` | gauge | – | 1 | Registered user accounts (sampled live at scrape time). |
|
||||
| `happydomain_domains_total` | gauge | – | 1 | Domains managed across all users. |
|
||||
| `happydomain_zones_total` | gauge | – | 1 | Zone snapshots stored. |
|
||||
| `happydomain_providers_total` | gauge | – | 1 | Provider configurations across all users. |
|
||||
| `happydomain_build_info` | gauge | `version`, `revision`, `dirty`, `build_date` | 1 per build | Always 1; metadata is in the labels. |
|
||||
|
||||
## Cardinality rules
|
||||
|
||||
- **Never** add a label whose value comes from user input: domain name, user
|
||||
ID, zone ID, provider URL, raw HTTP path, etc.
|
||||
- New labels MUST have a documented finite bound in the table above before
|
||||
the metric is merged.
|
||||
- Histograms inherit the cardinality of their labels — be especially careful.
|
||||
|
||||
## Security
|
||||
|
||||
`/metrics` exposes business intelligence (entity counts, provider mix,
|
||||
latency profiles) and operational shape (queue depth, worker counts). It is
|
||||
intentionally only mounted on the admin socket (`internal/app/admin.go`).
|
||||
Bind that socket to a Unix path or `127.0.0.1` only — exposing it on a
|
||||
network interface will leak this information to anyone who can reach it.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The HTTP middleware uses `c.FullPath()` (Gin route template) to populate
|
||||
the `path` label. See `internal/metrics/http.go`.
|
||||
- The scheduler queue depth gauge is a `GaugeFunc` that calls back into the
|
||||
scheduler at scrape time, installed via
|
||||
`metrics.RegisterSchedulerQueueDepth`. The scheduler unregisters its
|
||||
accessor in `Stop()` so stopped schedulers do not leak their queue.
|
||||
- The storage stats collector runs each `Count*` query in its own goroutine
|
||||
with a `recover()` guard, so a panicking backend cannot crash the scrape.
|
||||
Failures increment `happydomain_storage_stats_errors_total{entity=…}`.
|
||||
- `happydomain_build_info` is set once at startup from `cmd/happyDomain/main.go`
|
||||
using `versioninfo.Revision`, `LastCommit`, and `DirtyBuild`.
|
||||
149
docs/plugins/checker-plugin.md
Normal file
149
docs/plugins/checker-plugin.md
Normal 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)).
|
||||
|
|
@ -26,5 +26,5 @@ package main
|
|||
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
|
||||
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
|
||||
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
|
||||
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
|
||||
//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
|
||||
|
|
|
|||
9
go.mod
9
go.mod
|
|
@ -5,6 +5,10 @@ go 1.25.0
|
|||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
|
||||
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
|
||||
git.happydns.org/checker-sdk-go v0.3.0
|
||||
git.happydns.org/checker-zonemaster v0.0.0-20260407202727-979757b5a8fc
|
||||
github.com/StackExchange/dnscontrol/v4 v4.34.0
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
|
|
@ -179,9 +183,10 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
|
|||
auditor = p.RecordAuditor
|
||||
}
|
||||
|
||||
return &DNSControlAdapterNSProvider{provider, auditor}, nil
|
||||
return &DNSControlAdapterNSProvider{
|
||||
DNSServiceProvider: provider,
|
||||
RecordAuditor: auditor,
|
||||
providerName: configAdapter.DNSControlName(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
|
||||
|
|
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
|
|||
DNSServiceProvider dnscontrol.DNSServiceProvider
|
||||
// RecordAuditor validates records for provider-specific requirements
|
||||
RecordAuditor dnscontrol.RecordAuditor
|
||||
// providerName is the DNSControl provider name used for metrics labels
|
||||
providerName string
|
||||
}
|
||||
|
||||
// CanListZones checks if the provider supports listing zones (domains).
|
||||
|
|
@ -169,6 +177,26 @@ func (p *DNSControlAdapterNSProvider) CanListZones() bool {
|
|||
return ok
|
||||
}
|
||||
|
||||
// observeProviderCall starts timing a provider API call and returns a closure
|
||||
// that records the outcome. Intended use:
|
||||
//
|
||||
// defer p.observeProviderCall("op")(&err)
|
||||
//
|
||||
// The returned closure reads *err at defer-execution time, so it observes the
|
||||
// final value of the named return even if it is reassigned later in the
|
||||
// function (including from a recover() block).
|
||||
func (p *DNSControlAdapterNSProvider) observeProviderCall(operation string) func(err *error) {
|
||||
start := time.Now()
|
||||
return func(err *error) {
|
||||
status := "success"
|
||||
if err != nil && *err != nil {
|
||||
status = "error"
|
||||
}
|
||||
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, operation, status).Inc()
|
||||
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
// CanCreateDomain checks if the provider supports creating new domains.
|
||||
// Returns true if the provider implements the ZoneCreator interface.
|
||||
func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
||||
|
|
@ -182,6 +210,7 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
|||
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
|
||||
var records models.Records
|
||||
|
||||
defer p.observeProviderCall("get_zone_records")(&err)
|
||||
defer func() {
|
||||
if a := recover(); a != nil {
|
||||
err = fmt.Errorf("%s", a)
|
||||
|
|
@ -211,6 +240,8 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
|
|||
// before computing corrections.
|
||||
// Returns a slice of corrections, the total number of corrections needed, and any error.
|
||||
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
|
||||
defer p.observeProviderCall("get_zone_corrections")(&err)
|
||||
|
||||
var dc *models.DomainConfig
|
||||
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
|
||||
if err != nil {
|
||||
|
|
@ -261,23 +292,31 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
|
|||
// CreateDomain creates a new zone (domain) on the provider.
|
||||
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
|
||||
// Returns an error if the provider doesn't support domain creation or if creation fails.
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
|
||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
|
||||
defer p.observeProviderCall("create_domain")(&err)
|
||||
|
||||
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
|
||||
if !ok {
|
||||
return fmt.Errorf("Provider doesn't support domain creation.")
|
||||
err = fmt.Errorf("Provider doesn't support domain creation.")
|
||||
return
|
||||
}
|
||||
|
||||
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// ListZones retrieves a list of all zones (domains) managed by this provider.
|
||||
// Returns a slice of domain names or an error if the provider doesn't support listing
|
||||
// or if the operation fails.
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
|
||||
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
|
||||
defer p.observeProviderCall("list_zones")(&err)
|
||||
|
||||
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Provider doesn't support domain listing.")
|
||||
err = fmt.Errorf("Provider doesn't support domain listing.")
|
||||
return
|
||||
}
|
||||
|
||||
return zl.ListZones()
|
||||
zones, err = zl.ListZones()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
64
internal/api-admin/controller/check_controller.go
Normal file
64
internal/api-admin/controller/check_controller.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
100
internal/api-admin/controller/scheduler_controller.go
Normal file
100
internal/api-admin/controller/scheduler_controller.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/api-admin/route/checker.go
Normal file
51
internal/api-admin/route/checker.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
41
internal/api-admin/route/scheduler.go
Normal file
41
internal/api-admin/route/scheduler.go
Normal 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)
|
||||
}
|
||||
244
internal/api/controller/checker.go
Normal file
244
internal/api/controller/checker.go
Normal 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)
|
||||
}
|
||||
303
internal/api/controller/checker_metrics.go
Normal file
303
internal/api/controller/checker_metrics.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// respondWithMetrics writes metrics in the format requested by the Accept header.
|
||||
// JSON is the default (preserving the previous API contract for clients that
|
||||
// send Accept: */* or omit the header). Prometheus text exposition is only
|
||||
// returned when explicitly requested via Accept: text/plain.
|
||||
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
|
||||
if metrics == nil {
|
||||
metrics = []happydns.CheckMetric{}
|
||||
}
|
||||
|
||||
if wantsPrometheusText(c.GetHeader("Accept")) {
|
||||
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(renderPrometheus(metrics)))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
const maxLimit = 1000
|
||||
|
||||
// wantsPrometheusText returns true when the Accept header explicitly asks for
|
||||
// text/plain (or the Prometheus exposition media type) without also accepting
|
||||
// JSON. This keeps the JSON API the default for browsers and generic clients
|
||||
// while letting `curl -H 'Accept: text/plain'` opt into the Prometheus format.
|
||||
func wantsPrometheusText(accept string) bool {
|
||||
if accept == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(accept, "application/json") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(accept, "text/plain") ||
|
||||
strings.Contains(accept, "application/openmetrics-text")
|
||||
}
|
||||
|
||||
// escapePromLabelValue escapes a label value for the Prometheus text exposition
|
||||
// format. The spec only allows three escape sequences inside label values:
|
||||
// `\\`, `\"` and `\n`. Using fmt's %q is unsafe because it can emit \xNN or
|
||||
// \uNNNN sequences that Prometheus rejects.
|
||||
func escapePromLabelValue(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 2)
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderPrometheus formats metrics as Prometheus text exposition format
|
||||
// (version 0.0.4). It only emits constructs allowed by that format: HELP/TYPE
|
||||
// metadata and untyped samples — no OpenMetrics-only directives such as # UNIT.
|
||||
func renderPrometheus(metrics []happydns.CheckMetric) string {
|
||||
type metricMeta struct {
|
||||
unit string
|
||||
}
|
||||
seen := map[string]metricMeta{}
|
||||
var names []string
|
||||
|
||||
for _, m := range metrics {
|
||||
if _, ok := seen[m.Name]; !ok {
|
||||
seen[m.Name] = metricMeta{unit: m.Unit}
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var b strings.Builder
|
||||
nameIdx := map[string]int{}
|
||||
for i, name := range names {
|
||||
nameIdx[name] = i
|
||||
}
|
||||
|
||||
// Sort metrics by name order, then by timestamp.
|
||||
sorted := make([]happydns.CheckMetric, len(metrics))
|
||||
copy(sorted, metrics)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
ni, nj := nameIdx[sorted[i].Name], nameIdx[sorted[j].Name]
|
||||
if ni != nj {
|
||||
return ni < nj
|
||||
}
|
||||
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||
})
|
||||
|
||||
currentName := ""
|
||||
for _, m := range sorted {
|
||||
if m.Name != currentName {
|
||||
currentName = m.Name
|
||||
meta := seen[m.Name]
|
||||
if meta.unit != "" {
|
||||
// Surface the unit as a HELP comment so it stays parseable
|
||||
// under Prometheus text 0.0.4 (which has no # UNIT directive).
|
||||
fmt.Fprintf(&b, "# HELP %s unit: %s\n", m.Name, meta.unit)
|
||||
}
|
||||
fmt.Fprintf(&b, "# TYPE %s untyped\n", m.Name)
|
||||
}
|
||||
|
||||
b.WriteString(m.Name)
|
||||
if len(m.Labels) > 0 {
|
||||
b.WriteByte('{')
|
||||
first := true
|
||||
labelKeys := make([]string, 0, len(m.Labels))
|
||||
for k := range m.Labels {
|
||||
labelKeys = append(labelKeys, k)
|
||||
}
|
||||
sort.Strings(labelKeys)
|
||||
for _, k := range labelKeys {
|
||||
if !first {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=\"%s\"", k, escapePromLabelValue(m.Labels[k]))
|
||||
first = false
|
||||
}
|
||||
b.WriteByte('}')
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, " %g", m.Value)
|
||||
if !m.Timestamp.IsZero() {
|
||||
fmt.Fprintf(&b, " %d", m.Timestamp.UnixMilli())
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getLimitParam(c *gin.Context, defaultLimit int) int {
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
if parsed > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// GetUserMetrics returns metrics across all checkers for the authenticated user.
|
||||
//
|
||||
// @Summary Get all user metrics
|
||||
// @Description Returns metrics from all recent executions for the authenticated user. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /checkers/metrics [get]
|
||||
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
userID := happydns.TargetIdentifier(target.UserId)
|
||||
if userID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetDomainMetrics returns metrics for a domain and its service children.
|
||||
//
|
||||
// @Summary Get domain metrics
|
||||
// @Description Returns metrics from recent executions for a domain and all its services. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/metrics [get]
|
||||
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
domainID := happydns.TargetIdentifier(target.DomainId)
|
||||
if domainID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetCheckerMetrics returns metrics for a specific checker on a target.
|
||||
//
|
||||
// @Summary Get checker metrics
|
||||
// @Description Returns metrics from recent executions of a specific checker on a target. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
|
||||
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetExecutionMetrics returns metrics for a single execution.
|
||||
//
|
||||
// @Summary Get execution metrics
|
||||
// @Description Returns metrics extracted from a single execution's observation snapshot. Format depends on Accept header: application/json for JSON, otherwise Prometheus text.
|
||||
// @Tags checkers
|
||||
// @Produce json,plain
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(target, execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
117
internal/api/controller/checker_metrics_test.go
Normal file
117
internal/api/controller/checker_metrics_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestRenderPrometheus_ParsesAsValidExposition(t *testing.T) {
|
||||
// Include a label value with characters that fmt's %q would have escaped
|
||||
// as \xNN / \uNNNN — sequences which are NOT valid in Prometheus text
|
||||
// format. The output must still parse cleanly via the upstream parser.
|
||||
out := renderPrometheus([]happydns.CheckMetric{
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Unit: "seconds",
|
||||
Value: 0.123,
|
||||
Timestamp: time.Unix(1700000000, 0),
|
||||
Labels: map[string]string{
|
||||
"target": "exämple.com", // non-ASCII
|
||||
"note": "line1\nline2", // newline (must become \n)
|
||||
"quoted": `he said "hi"`, // quotes
|
||||
"slash": `a\b`, // backslash
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "happydomain_check_latency_seconds",
|
||||
Value: 0.456,
|
||||
Labels: map[string]string{
|
||||
"target": "second.example",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p := expfmt.NewTextParser(model.LegacyValidation)
|
||||
if _, err := p.TextToMetricFamilies(strings.NewReader(out)); err != nil {
|
||||
t.Fatalf("renderPrometheus output is not valid Prometheus text format: %v\noutput:\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_EscapesLabelValues(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Value: 1,
|
||||
Labels: map[string]string{
|
||||
"a": `\`,
|
||||
"b": `"`,
|
||||
"c": "\n",
|
||||
},
|
||||
}})
|
||||
if !strings.Contains(out, `a="\\"`) {
|
||||
t.Errorf("backslash not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `b="\""`) {
|
||||
t.Errorf("quote not escaped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `c="\n"`) {
|
||||
t.Errorf("newline not escaped: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPrometheus_NoOpenMetricsDirectives(t *testing.T) {
|
||||
out := renderPrometheus([]happydns.CheckMetric{{
|
||||
Name: "x",
|
||||
Unit: "seconds",
|
||||
Value: 1,
|
||||
}})
|
||||
if strings.Contains(out, "# UNIT") {
|
||||
t.Errorf("output contains OpenMetrics-only # UNIT directive incompatible with text/plain;version=0.0.4: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsPrometheusText(t *testing.T) {
|
||||
cases := []struct {
|
||||
accept string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"*/*", false},
|
||||
{"application/json", false},
|
||||
{"application/json, text/plain", false}, // explicit JSON wins
|
||||
{"text/plain", true},
|
||||
{"text/plain; version=0.0.4", true},
|
||||
{"application/openmetrics-text; version=1.0.0", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := wantsPrometheusText(tc.accept); got != tc.want {
|
||||
t.Errorf("wantsPrometheusText(%q) = %v, want %v", tc.accept, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
internal/api/controller/checker_options.go
Normal file
223
internal/api/controller/checker_options.go
Normal 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)
|
||||
}
|
||||
196
internal/api/controller/checker_plans.go
Normal file
196
internal/api/controller/checker_plans.go
Normal 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)
|
||||
}
|
||||
313
internal/api/controller/checker_results.go
Normal file
313
internal/api/controller/checker_results.go
Normal 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))
|
||||
}
|
||||
757
internal/api/controller/checker_test.go
Normal file
757
internal/api/controller/checker_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
296
internal/api/controller/domain_test.go
Normal file
296
internal/api/controller/domain_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
114
internal/api/route/checker.go
Normal file
114
internal/api/route/checker.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
admin "git.happydns.org/happyDomain/internal/api-admin/route"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/web-admin"
|
||||
|
|
@ -55,6 +57,11 @@ func NewAdmin(app *App) *Admin {
|
|||
|
||||
// Prepare usecases (admin uses unrestricted provider access)
|
||||
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
|
||||
if app.usecases.checkerOptionsUC == nil {
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
}
|
||||
|
||||
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
|
||||
admin.DeclareRoutes(
|
||||
app.cfg,
|
||||
|
|
@ -71,6 +78,8 @@ func NewAdmin(app *App) *Admin {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckScheduler: app.usecases.checkerScheduler,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, router)
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ import (
|
|||
api "git.happydns.org/happyDomain/internal/api/route"
|
||||
"git.happydns.org/happyDomain/internal/captcha"
|
||||
"git.happydns.org/happyDomain/internal/mailer"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/internal/newsletter"
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
|
|
@ -69,6 +71,14 @@ type Usecases struct {
|
|||
zoneService happydns.ZoneServiceUsecase
|
||||
|
||||
orchestrator *orchestrator.Orchestrator
|
||||
|
||||
checkerEngine happydns.CheckerEngine
|
||||
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
checkerPlanUC *checkerUC.CheckPlanUsecase
|
||||
checkerStatusUC *checkerUC.CheckStatusUsecase
|
||||
checkerScheduler *checkerUC.Scheduler
|
||||
checkerJanitor *checkerUC.Janitor
|
||||
checkerUserGater *checkerUC.UserGater
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -93,6 +103,9 @@ func NewApp(cfg *happydns.Options) *App {
|
|||
app.initStorageEngine()
|
||||
app.initNewsletter()
|
||||
app.initInsights()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -108,6 +121,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
|||
|
||||
app.initMailer()
|
||||
app.initNewsletter()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -162,6 +178,9 @@ func (app *App) initStorageEngine() {
|
|||
if err = app.store.MigrateSchema(); err != nil {
|
||||
log.Fatal("Could not migrate database: ", err)
|
||||
}
|
||||
|
||||
app.store = newInstrumentedStorage(app.store)
|
||||
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -224,12 +243,13 @@ func (app *App) initUsecases() {
|
|||
app.store,
|
||||
)
|
||||
|
||||
app.usecases.user = userUC.NewUserUsecases(
|
||||
userService := userUC.NewUserUsecases(
|
||||
app.store,
|
||||
app.newsletter,
|
||||
authUserService,
|
||||
sessionService,
|
||||
)
|
||||
app.usecases.user = userService
|
||||
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
|
||||
app.usecases.authUser = authUserService
|
||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||
|
|
@ -246,6 +266,44 @@ func (app *App) initUsecases() {
|
|||
providerAdminService,
|
||||
zoneService.UpdateZoneUC,
|
||||
)
|
||||
|
||||
// Checker system.
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
|
||||
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
|
||||
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
|
||||
app.usecases.checkerOptionsUC,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
)
|
||||
// Build the user-level gate so paused or long-inactive users do not
|
||||
// get checked. The same user resolver is reused by the janitor for
|
||||
// per-user retention overrides.
|
||||
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store, app.usecases.checkerUserGater.Allow)
|
||||
|
||||
// Invalidate the scheduler's user gate cache whenever a user is updated
|
||||
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
|
||||
userService.SetOnUserChanged(func(id happydns.Identifier) {
|
||||
app.usecases.checkerUserGater.Invalidate(id.String())
|
||||
})
|
||||
|
||||
// Retention janitor.
|
||||
app.usecases.checkerJanitor = checkerUC.NewJanitor(
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
|
||||
app.cfg.CheckerJanitorInterval,
|
||||
)
|
||||
|
||||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -255,7 +313,7 @@ func (app *App) setupRouter() {
|
|||
|
||||
gin.ForceConsoleColor()
|
||||
app.router = gin.New()
|
||||
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
|
||||
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
|
||||
session.COOKIE_NAME,
|
||||
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
|
||||
))
|
||||
|
|
@ -291,6 +349,12 @@ func (app *App) setupRouter() {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
|
||||
CheckerEngine: app.usecases.checkerEngine,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckPlanUC: app.usecases.checkerPlanUC,
|
||||
CheckStatusUC: app.usecases.checkerStatusUC,
|
||||
PlannedProvider: app.usecases.checkerScheduler,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
|
|
@ -308,6 +372,14 @@ func (app *App) Start() {
|
|||
go app.insights.Run()
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Start(context.Background())
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Start(context.Background())
|
||||
}
|
||||
|
||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
|
|
@ -321,6 +393,14 @@ func (app *App) Stop() {
|
|||
log.Fatal("Server Shutdown:", err)
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Stop()
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Stop()
|
||||
}
|
||||
|
||||
// Close storage
|
||||
if app.store != nil {
|
||||
app.store.Close()
|
||||
|
|
|
|||
566
internal/app/instrumented_storage.go
Normal file
566
internal/app/instrumented_storage.go
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
|
||||
// every operation.
|
||||
type instrumentedStorage struct {
|
||||
inner storage.Storage
|
||||
}
|
||||
|
||||
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
|
||||
func newInstrumentedStorage(s storage.Storage) storage.Storage {
|
||||
return &instrumentedStorage{inner: s}
|
||||
}
|
||||
|
||||
// observe starts a timer and returns a closure that, when called with a
|
||||
// pointer to the named return error, records the operation outcome. Use as:
|
||||
//
|
||||
// defer observe("get", "user")(&err)
|
||||
//
|
||||
// The closure reads *err at defer-execution time, so it captures the final
|
||||
// value of the named return.
|
||||
func observe(operation, entity string) func(err *error) {
|
||||
start := time.Now()
|
||||
return func(err *error) {
|
||||
status := "success"
|
||||
if err != nil && *err != nil {
|
||||
status = "error"
|
||||
}
|
||||
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
|
||||
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
// Schema / lifecycle (not instrumented: hot-path-free, called once)
|
||||
|
||||
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
|
||||
func (s *instrumentedStorage) MigrateSchema() error { return s.inner.MigrateSchema() }
|
||||
func (s *instrumentedStorage) Close() error { return s.inner.Close() }
|
||||
|
||||
// AuthUser
|
||||
|
||||
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
|
||||
defer observe("list", "authuser")(&err)
|
||||
return s.inner.ListAllAuthUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.GetAuthUser(id)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.GetAuthUserByEmail(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
|
||||
defer observe("get", "authuser")(&err)
|
||||
return s.inner.AuthUserExists(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("create", "authuser")(&err)
|
||||
return s.inner.CreateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("update", "authuser")(&err)
|
||||
return s.inner.UpdateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
|
||||
defer observe("delete", "authuser")(&err)
|
||||
return s.inner.DeleteAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
|
||||
defer observe("delete", "authuser")(&err)
|
||||
return s.inner.ClearAuthUsers()
|
||||
}
|
||||
|
||||
// Domain
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
|
||||
defer observe("list", "domain")(&err)
|
||||
return s.inner.ListAllDomains()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
|
||||
defer observe("list", "domain")(&err)
|
||||
return s.inner.ListDomains(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountDomains() (ret int, err error) {
|
||||
defer observe("count", "domain")(&err)
|
||||
return s.inner.CountDomains()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
|
||||
defer observe("get", "domain")(&err)
|
||||
return s.inner.GetDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
|
||||
defer observe("get", "domain")(&err)
|
||||
return s.inner.GetDomainByDN(user, fqdn)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
|
||||
defer observe("create", "domain")(&err)
|
||||
return s.inner.CreateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
|
||||
defer observe("update", "domain")(&err)
|
||||
return s.inner.UpdateDomain(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "domain")(&err)
|
||||
return s.inner.DeleteDomain(domainid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearDomains() (err error) {
|
||||
defer observe("delete", "domain")(&err)
|
||||
return s.inner.ClearDomains()
|
||||
}
|
||||
|
||||
// DomainLog
|
||||
|
||||
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListAllDomainLogs()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
|
||||
defer observe("list", "domain_log")(&err)
|
||||
return s.inner.ListDomainLogs(domain)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("create", "domain_log")(&err)
|
||||
return s.inner.CreateDomainLog(domain, log)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("update", "domain_log")(&err)
|
||||
return s.inner.UpdateDomainLog(domain, log)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||
defer observe("delete", "domain_log")(&err)
|
||||
return s.inner.DeleteDomainLog(domain, log)
|
||||
}
|
||||
|
||||
// Insight
|
||||
|
||||
func (s *instrumentedStorage) InsightsRun() (err error) {
|
||||
defer observe("run", "insight")(&err)
|
||||
return s.inner.InsightsRun()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) LastInsightsRun() (t *time.Time, id happydns.Identifier, err error) {
|
||||
defer observe("get", "insight")(&err)
|
||||
return s.inner.LastInsightsRun()
|
||||
}
|
||||
|
||||
// Provider
|
||||
|
||||
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
|
||||
defer observe("list", "provider")(&err)
|
||||
return s.inner.ListAllProviders()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
|
||||
defer observe("list", "provider")(&err)
|
||||
return s.inner.ListProviders(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountProviders() (ret int, err error) {
|
||||
defer observe("count", "provider")(&err)
|
||||
return s.inner.CountProviders()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
|
||||
defer observe("get", "provider")(&err)
|
||||
return s.inner.GetProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("create", "provider")(&err)
|
||||
return s.inner.CreateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("update", "provider")(&err)
|
||||
return s.inner.UpdateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.DeleteProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearProviders() (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.ClearProviders()
|
||||
}
|
||||
|
||||
// Session
|
||||
|
||||
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListAllSessions()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
|
||||
defer observe("get", "session")(&err)
|
||||
return s.inner.GetSession(sessionid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListAuthUserSessions(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListUserSessions(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
|
||||
defer observe("update", "session")(&err)
|
||||
return s.inner.UpdateSession(session)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.DeleteSession(sessionid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearSessions() (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.ClearSessions()
|
||||
}
|
||||
|
||||
// CheckPlan
|
||||
|
||||
func (s *instrumentedStorage) ListAllCheckPlans() (ret happydns.Iterator[happydns.CheckPlan], err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListAllCheckPlans()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByTarget(target happydns.CheckTarget) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByTarget(target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByChecker(checkerID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByUser(userId happydns.Identifier) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
|
||||
defer observe("get", "check_plan")(&err)
|
||||
return s.inner.GetCheckPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("create", "check_plan")(&err)
|
||||
return s.inner.CreateCheckPlan(plan)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("update", "check_plan")(&err)
|
||||
return s.inner.UpdateCheckPlan(plan)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.DeleteCheckPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearCheckPlans() (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.ClearCheckPlans()
|
||||
}
|
||||
|
||||
// CheckEvaluation
|
||||
|
||||
func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier) (ret []*happydns.CheckEvaluation, err error) {
|
||||
defer observe("list", "check_evaluation")(&err)
|
||||
return s.inner.ListEvaluationsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.CheckEvaluation, err error) {
|
||||
defer observe("list", "check_evaluation")(&err)
|
||||
return s.inner.ListEvaluationsByChecker(checkerID, target, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetEvaluation(evalID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
|
||||
defer observe("get", "check_evaluation")(&err)
|
||||
return s.inner.GetEvaluation(evalID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (ret *happydns.CheckEvaluation, err error) {
|
||||
defer observe("get", "check_evaluation")(&err)
|
||||
return s.inner.GetLatestEvaluation(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateEvaluation(eval *happydns.CheckEvaluation) (err error) {
|
||||
defer observe("create", "check_evaluation")(&err)
|
||||
return s.inner.CreateEvaluation(eval)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteEvaluation(evalID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.DeleteEvaluation(evalID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.DeleteEvaluationsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearEvaluations() (err error) {
|
||||
defer observe("delete", "check_evaluation")(&err)
|
||||
return s.inner.ClearEvaluations()
|
||||
}
|
||||
|
||||
// Execution
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByPlan(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByChecker(checkerID, target, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
|
||||
defer observe("list", "execution")(&err)
|
||||
return s.inner.ListExecutionsByUser(userId, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetExecution(execID happydns.Identifier) (ret *happydns.Execution, err error) {
|
||||
defer observe("get", "execution")(&err)
|
||||
return s.inner.GetExecution(execID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateExecution(exec *happydns.Execution) (err error) {
|
||||
defer observe("create", "execution")(&err)
|
||||
return s.inner.CreateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err error) {
|
||||
defer observe("update", "execution")(&err)
|
||||
return s.inner.UpdateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteExecution(execID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.DeleteExecution(execID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.DeleteExecutionsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearExecutions() (err error) {
|
||||
defer observe("delete", "execution")(&err)
|
||||
return s.inner.ClearExecutions()
|
||||
}
|
||||
|
||||
// ObservationSnapshot
|
||||
|
||||
func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happydns.ObservationSnapshot, err error) {
|
||||
defer observe("get", "observation_snapshot")(&err)
|
||||
return s.inner.GetSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
|
||||
defer observe("create", "observation_snapshot")(&err)
|
||||
return s.inner.CreateSnapshot(snap)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "observation_snapshot")(&err)
|
||||
return s.inner.DeleteSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearSnapshots() (err error) {
|
||||
defer observe("delete", "observation_snapshot")(&err)
|
||||
return s.inner.ClearSnapshots()
|
||||
}
|
||||
|
||||
// ObservationCache
|
||||
|
||||
func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (ret *happydns.ObservationCacheEntry, err error) {
|
||||
defer observe("get", "observation_cache")(&err)
|
||||
return s.inner.GetCachedObservation(target, key)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) (err error) {
|
||||
defer observe("put", "observation_cache")(&err)
|
||||
return s.inner.PutCachedObservation(target, key, entry)
|
||||
}
|
||||
|
||||
// SchedulerState
|
||||
|
||||
func (s *instrumentedStorage) GetLastSchedulerRun() (ret time.Time, err error) {
|
||||
defer observe("get", "scheduler_state")(&err)
|
||||
return s.inner.GetLastSchedulerRun()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
|
||||
defer observe("set", "scheduler_state")(&err)
|
||||
return s.inner.SetLastSchedulerRun(t)
|
||||
}
|
||||
|
||||
// CheckerConfiguration
|
||||
|
||||
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptions], err error) {
|
||||
defer observe("list", "check_config")(&err)
|
||||
return s.inner.ListAllCheckerConfigurations()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckerConfiguration(name string) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||
defer observe("list", "check_config")(&err)
|
||||
return s.inner.ListCheckerConfiguration(name)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||
defer observe("get", "check_config")(&err)
|
||||
return s.inner.GetCheckerConfiguration(name, a, b, c)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
|
||||
defer observe("update", "check_config")(&err)
|
||||
return s.inner.UpdateCheckerConfiguration(name, a, b, c, opts)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_config")(&err)
|
||||
return s.inner.DeleteCheckerConfiguration(name, a, b, c)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
|
||||
defer observe("delete", "check_config")(&err)
|
||||
return s.inner.ClearCheckerConfigurations()
|
||||
}
|
||||
|
||||
// User
|
||||
|
||||
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
|
||||
defer observe("list", "user")(&err)
|
||||
return s.inner.ListAllUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountUsers() (ret int, err error) {
|
||||
defer observe("count", "user")(&err)
|
||||
return s.inner.CountUsers()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUser(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUserByEmail(email)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
|
||||
defer observe("update", "user")(&err)
|
||||
return s.inner.CreateOrUpdateUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.DeleteUser(userid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearUsers() (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.ClearUsers()
|
||||
}
|
||||
|
||||
// Zone
|
||||
|
||||
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
|
||||
defer observe("list", "zone")(&err)
|
||||
return s.inner.ListAllZones()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CountZones() (ret int, err error) {
|
||||
defer observe("count", "zone")(&err)
|
||||
return s.inner.CountZones()
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
|
||||
defer observe("get", "zone")(&err)
|
||||
return s.inner.GetZoneMeta(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
|
||||
defer observe("get", "zone")(&err)
|
||||
return s.inner.GetZone(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
|
||||
defer observe("create", "zone")(&err)
|
||||
return s.inner.CreateZone(zone)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
|
||||
defer observe("update", "zone")(&err)
|
||||
return s.inner.UpdateZone(zone)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "zone")(&err)
|
||||
return s.inner.DeleteZone(zoneid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ClearZones() (err error) {
|
||||
defer observe("delete", "zone")(&err)
|
||||
return s.inner.ClearZones()
|
||||
}
|
||||
238
internal/app/plugins.go
Normal file
238
internal/app/plugins.go
Normal 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...)
|
||||
}
|
||||
143
internal/app/plugins_checker_test.go
Normal file
143
internal/app/plugins_checker_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
37
internal/app/plugins_stub.go
Normal file
37
internal/app/plugins_stub.go
Normal 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
|
||||
}
|
||||
172
internal/app/plugins_test.go
Normal file
172
internal/app/plugins_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
51
internal/checker/aggregator.go
Normal file
51
internal/checker/aggregator.go
Normal 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, "; "),
|
||||
}
|
||||
}
|
||||
117
internal/checker/aggregator_test.go
Normal file
117
internal/checker/aggregator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
323
internal/checker/observation.go
Normal file
323
internal/checker/observation.go
Normal 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...)
|
||||
}
|
||||
168
internal/checker/observation_test.go
Normal file
168
internal/checker/observation_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
116
internal/checker/provider_http.go
Normal file
116
internal/checker/provider_http.go
Normal 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
|
||||
}
|
||||
240
internal/checker/provider_http_test.go
Normal file
240
internal/checker/provider_http_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
60
internal/checker/registry.go
Normal file
60
internal/checker/registry.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
181
internal/forms/field_test.go
Normal file
181
internal/forms/field_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
172
internal/metrics/collector.go
Normal file
172
internal/metrics/collector.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// StatsProvider is the minimal interface required by StorageStatsCollector to
|
||||
// count business entities. It is implemented by
|
||||
// internal/storage.StatsProvider, which delegates to the backend's native
|
||||
// Count* methods so each scrape runs O(prefix scan) rather than O(full decode).
|
||||
type StatsProvider interface {
|
||||
CountUsers() (int, error)
|
||||
CountDomains() (int, error)
|
||||
CountZones() (int, error)
|
||||
CountProviders() (int, error)
|
||||
}
|
||||
|
||||
// statsErrorsTotal counts failed Count* calls during a Prometheus scrape so
|
||||
// silent storage failures remain visible (and alertable) instead of producing
|
||||
// gaps in the gauge series.
|
||||
var statsErrorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_storage_stats_errors_total",
|
||||
Help: "Total number of errors encountered while collecting storage stats for the /metrics endpoint.",
|
||||
}, []string{"entity"})
|
||||
|
||||
// StorageStatsCollector is a Prometheus Collector that queries storage at each
|
||||
// scrape to report accurate business-entity counts.
|
||||
type StorageStatsCollector struct {
|
||||
provider StatsProvider
|
||||
|
||||
usersDesc *prometheus.Desc
|
||||
domainsDesc *prometheus.Desc
|
||||
zonesDesc *prometheus.Desc
|
||||
providersDesc *prometheus.Desc
|
||||
}
|
||||
|
||||
// NewStorageStatsCollector creates a new collector backed by the given
|
||||
// StatsProvider and registers it (and its companion error counter) with the
|
||||
// default Prometheus registry. Re-registration is tolerated, so calling this
|
||||
// twice — for instance from tests — does not panic.
|
||||
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
|
||||
c := &StorageStatsCollector{
|
||||
provider: p,
|
||||
usersDesc: prometheus.NewDesc(
|
||||
"happydomain_registered_users_total",
|
||||
"Current number of registered user accounts.",
|
||||
nil, nil,
|
||||
),
|
||||
domainsDesc: prometheus.NewDesc(
|
||||
"happydomain_domains_total",
|
||||
"Current number of domains managed across all users.",
|
||||
nil, nil,
|
||||
),
|
||||
zonesDesc: prometheus.NewDesc(
|
||||
"happydomain_zones_total",
|
||||
"Current number of zone snapshots stored.",
|
||||
nil, nil,
|
||||
),
|
||||
providersDesc: prometheus.NewDesc(
|
||||
"happydomain_providers_total",
|
||||
"Current number of provider configurations across all users.",
|
||||
nil, nil,
|
||||
),
|
||||
}
|
||||
|
||||
registerOrLog(c)
|
||||
registerOrLog(statsErrorsTotal)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// registerOrLog registers a collector with the default registry, tolerating
|
||||
// "already registered" so test setups and repeated app initialisations are safe.
|
||||
func registerOrLog(c prometheus.Collector) {
|
||||
if err := prometheus.Register(c); err != nil {
|
||||
var are prometheus.AlreadyRegisteredError
|
||||
if errors.As(err, &are) {
|
||||
return
|
||||
}
|
||||
log.Printf("metrics: failed to register collector: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.usersDesc
|
||||
ch <- c.domainsDesc
|
||||
ch <- c.zonesDesc
|
||||
ch <- c.providersDesc
|
||||
}
|
||||
|
||||
// Collect implements prometheus.Collector. It queries storage live so the
|
||||
// values always reflect the actual database state. Each backend call runs in
|
||||
// its own goroutine to keep the scrape latency bounded by the slowest count
|
||||
// rather than their sum.
|
||||
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
type job struct {
|
||||
entity string
|
||||
desc *prometheus.Desc
|
||||
fn func() (int, error)
|
||||
}
|
||||
jobs := []job{
|
||||
{"user", c.usersDesc, c.provider.CountUsers},
|
||||
{"domain", c.domainsDesc, c.provider.CountDomains},
|
||||
{"zone", c.zonesDesc, c.provider.CountZones},
|
||||
{"provider", c.providersDesc, c.provider.CountProviders},
|
||||
}
|
||||
|
||||
type result struct {
|
||||
desc *prometheus.Desc
|
||||
val float64
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(jobs))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, j := range jobs {
|
||||
wg.Add(1)
|
||||
go func(i int, j job) {
|
||||
defer wg.Done()
|
||||
// A panic inside a backend Count* implementation must not
|
||||
// crash the scrape goroutine: convert it into a stats error
|
||||
// so the failure is visible via happydomain_storage_stats_errors_total
|
||||
// instead of producing an unrecoverable process crash.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
statsErrorsTotal.WithLabelValues(j.entity).Inc()
|
||||
log.Printf("metrics: panic while collecting %s count: %v", j.entity, r)
|
||||
}
|
||||
}()
|
||||
n, err := j.fn()
|
||||
if err != nil {
|
||||
statsErrorsTotal.WithLabelValues(j.entity).Inc()
|
||||
return
|
||||
}
|
||||
results[i] = result{desc: j.desc, val: float64(n), ok: true}
|
||||
}(i, j)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, r := range results {
|
||||
if !r.ok {
|
||||
continue
|
||||
}
|
||||
ch <- prometheus.MustNewConstMetric(r.desc, prometheus.GaugeValue, r.val)
|
||||
}
|
||||
}
|
||||
54
internal/metrics/http.go
Normal file
54
internal/metrics/http.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
|
||||
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
|
||||
// rather than the actual URL, avoiding high-cardinality labels.
|
||||
func HTTPMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
HTTPRequestsInFlight.Inc()
|
||||
|
||||
c.Next()
|
||||
|
||||
HTTPRequestsInFlight.Dec()
|
||||
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = "unknown"
|
||||
}
|
||||
method := c.Request.Method
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
duration := time.Since(start).Seconds()
|
||||
|
||||
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
|
||||
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
|
||||
}
|
||||
}
|
||||
137
internal/metrics/metrics.go
Normal file
137
internal/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
// HTTP metrics
|
||||
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_http_requests_total",
|
||||
Help: "Total number of HTTP requests.",
|
||||
}, []string{"method", "path", "status"})
|
||||
|
||||
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_http_request_duration_seconds",
|
||||
Help: "Duration of HTTP requests in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method", "path"})
|
||||
|
||||
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "happydomain_http_requests_in_flight",
|
||||
Help: "Current number of HTTP requests being served.",
|
||||
})
|
||||
|
||||
// Scheduler metrics
|
||||
//
|
||||
// schedulerQueueDepthFn is consulted at scrape time by the GaugeFunc
|
||||
// registered below. The scheduler installs its accessor via
|
||||
// RegisterSchedulerQueueDepth at construction, which avoids sprinkling
|
||||
// gauge.Set calls across every queue mutation site.
|
||||
schedulerQueueDepthFn atomic.Pointer[func() float64]
|
||||
|
||||
// SchedulerQueueDepth is kept as a package-level var (rather than the
|
||||
// blank identifier) so it is discoverable via grep alongside the other
|
||||
// metric vars and easy to reference from tests.
|
||||
SchedulerQueueDepth = promauto.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "happydomain_scheduler_queue_depth",
|
||||
Help: "Number of items currently in the check scheduler queue.",
|
||||
}, func() float64 {
|
||||
if fn := schedulerQueueDepthFn.Load(); fn != nil {
|
||||
return (*fn)()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "happydomain_scheduler_active_workers",
|
||||
Help: "Number of check scheduler workers currently executing a check.",
|
||||
})
|
||||
|
||||
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_scheduler_checks_total",
|
||||
Help: "Total number of checks executed by the scheduler.",
|
||||
}, []string{"checker", "status"})
|
||||
|
||||
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_scheduler_check_duration_seconds",
|
||||
Help: "Duration of individual check executions in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"checker"})
|
||||
|
||||
// DNS provider API metrics
|
||||
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_provider_api_calls_total",
|
||||
Help: "Total number of DNS provider API calls.",
|
||||
}, []string{"provider", "operation", "status"})
|
||||
|
||||
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_provider_api_duration_seconds",
|
||||
Help: "Duration of DNS provider API calls in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"provider", "operation"})
|
||||
|
||||
// Storage metrics
|
||||
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "happydomain_storage_operations_total",
|
||||
Help: "Total number of storage operations.",
|
||||
}, []string{"operation", "entity", "status"})
|
||||
|
||||
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "happydomain_storage_operation_duration_seconds",
|
||||
Help: "Duration of storage operations in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"operation", "entity"})
|
||||
|
||||
// Build info. Always 1; the metadata is carried in the labels so that
|
||||
// dashboards and alerts can group/diff across deployments.
|
||||
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "happydomain_build_info",
|
||||
Help: "Build information about the running happyDomain instance. Always 1; metadata is in the labels.",
|
||||
}, []string{"version", "revision", "dirty", "build_date"})
|
||||
)
|
||||
|
||||
// SetBuildInfo records the application build metadata in the build info
|
||||
// metric. Call this once during application startup. buildDate should be
|
||||
// formatted as RFC3339 (UTC) and may be empty if unknown.
|
||||
func SetBuildInfo(version, revision, buildDate string, dirty bool) {
|
||||
BuildInfo.WithLabelValues(version, revision, strconv.FormatBool(dirty), buildDate).Set(1)
|
||||
}
|
||||
|
||||
// RegisterSchedulerQueueDepth installs the accessor used at scrape time to
|
||||
// report the current scheduler queue depth. The function is invoked from the
|
||||
// Prometheus scrape goroutine, so it must be safe to call concurrently with
|
||||
// queue mutations and must not block for long. Passing nil unregisters the
|
||||
// accessor (the gauge will then report 0).
|
||||
func RegisterSchedulerQueueDepth(fn func() float64) {
|
||||
if fn == nil {
|
||||
schedulerQueueDepthFn.Store(nil)
|
||||
return
|
||||
}
|
||||
schedulerQueueDepthFn.Store(&fn)
|
||||
}
|
||||
270
internal/metrics/metrics_test.go
Normal file
270
internal/metrics/metrics_test.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// --- HTTPMiddleware -------------------------------------------------------
|
||||
|
||||
func TestHTTPMiddleware_RecordsRouteTemplateNotRawPath(t *testing.T) {
|
||||
// Reset to keep assertions independent from any other test in the package.
|
||||
HTTPRequestsTotal.Reset()
|
||||
HTTPRequestDuration.Reset()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
r.GET("/api/domains/:domain", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/domains/example.com", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Route template — not the raw URL — must be used as the path label,
|
||||
// otherwise cardinality explodes with one series per domain name.
|
||||
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "/api/domains/:domain", "200")); got != 1 {
|
||||
t.Fatalf("expected 1 request recorded for route template, got %v", got)
|
||||
}
|
||||
if got := testutil.CollectAndCount(HTTPRequestsTotal); got != 1 {
|
||||
t.Fatalf("expected exactly one series, got %d (cardinality leak?)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_UnmatchedRouteUsesUnknownLabel(t *testing.T) {
|
||||
HTTPRequestsTotal.Reset()
|
||||
HTTPRequestDuration.Reset()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/no/such/route", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if got := testutil.ToFloat64(HTTPRequestsTotal.WithLabelValues("GET", "unknown", "404")); got != 1 {
|
||||
t.Fatalf("expected 1 request recorded under 'unknown' path, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_InFlightBalanced(t *testing.T) {
|
||||
HTTPRequestsInFlight.Set(0)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(HTTPMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
for range 5 {
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
r.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(HTTPRequestsInFlight); got != 0 {
|
||||
t.Fatalf("in-flight gauge should return to 0 after requests complete, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- StorageStatsCollector ------------------------------------------------
|
||||
|
||||
type fakeStatsProvider struct {
|
||||
users, domains, zones, providers int
|
||||
usersErr, domainsErr error
|
||||
zonesPanic bool
|
||||
}
|
||||
|
||||
func (f *fakeStatsProvider) CountUsers() (int, error) { return f.users, f.usersErr }
|
||||
func (f *fakeStatsProvider) CountDomains() (int, error) { return f.domains, f.domainsErr }
|
||||
func (f *fakeStatsProvider) CountZones() (int, error) {
|
||||
if f.zonesPanic {
|
||||
panic("boom")
|
||||
}
|
||||
return f.zones, nil
|
||||
}
|
||||
func (f *fakeStatsProvider) CountProviders() (int, error) { return f.providers, nil }
|
||||
|
||||
// collectorFor builds a StorageStatsCollector against a private registry so
|
||||
// that tests can run in parallel without sharing state with the default
|
||||
// registry or with each other.
|
||||
func collectorFor(p StatsProvider) *StorageStatsCollector {
|
||||
return &StorageStatsCollector{
|
||||
provider: p,
|
||||
usersDesc: prometheus.NewDesc(
|
||||
"happydomain_registered_users_total", "users", nil, nil),
|
||||
domainsDesc: prometheus.NewDesc(
|
||||
"happydomain_domains_total", "domains", nil, nil),
|
||||
zonesDesc: prometheus.NewDesc(
|
||||
"happydomain_zones_total", "zones", nil, nil),
|
||||
providersDesc: prometheus.NewDesc(
|
||||
"happydomain_providers_total", "providers", nil, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_HappyPath(t *testing.T) {
|
||||
c := collectorFor(&fakeStatsProvider{users: 3, domains: 7, zones: 11, providers: 2})
|
||||
|
||||
if got := testutil.CollectAndCount(c); got != 4 {
|
||||
t.Fatalf("expected 4 metrics, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_ErrorSkipsMetricAndIncrementsErrorCounter(t *testing.T) {
|
||||
statsErrorsTotal.Reset()
|
||||
c := collectorFor(&fakeStatsProvider{
|
||||
users: 3,
|
||||
domainsErr: errors.New("db down"),
|
||||
zones: 1, providers: 1,
|
||||
})
|
||||
|
||||
// 4 jobs, 1 errors out → 3 metrics emitted.
|
||||
if got := testutil.CollectAndCount(c); got != 3 {
|
||||
t.Fatalf("expected 3 metrics when one count fails, got %d", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(statsErrorsTotal.WithLabelValues("domain")); got != 1 {
|
||||
t.Fatalf("expected stats error counter for 'domain' to be 1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStatsCollector_PanicIsRecovered(t *testing.T) {
|
||||
statsErrorsTotal.Reset()
|
||||
c := collectorFor(&fakeStatsProvider{users: 1, domains: 1, providers: 1, zonesPanic: true})
|
||||
|
||||
// Must not crash the test process; panicking job is dropped, others succeed.
|
||||
got := testutil.CollectAndCount(c)
|
||||
if got != 3 {
|
||||
t.Fatalf("expected 3 metrics when zones panics, got %d", got)
|
||||
}
|
||||
if v := testutil.ToFloat64(statsErrorsTotal.WithLabelValues("zone")); v != 1 {
|
||||
t.Fatalf("expected zone stats error counter to be 1, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scheduler queue depth gauge -----------------------------------------
|
||||
|
||||
func TestRegisterSchedulerQueueDepth(t *testing.T) {
|
||||
t.Cleanup(func() { RegisterSchedulerQueueDepth(nil) })
|
||||
|
||||
RegisterSchedulerQueueDepth(func() float64 { return 42 })
|
||||
|
||||
// The gauge func is registered against the default registry by promauto.
|
||||
// Gather and look for our specific metric.
|
||||
mfs, err := prometheus.DefaultGatherer.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("gather: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, mf := range mfs {
|
||||
if mf.GetName() != "happydomain_scheduler_queue_depth" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 42 {
|
||||
t.Fatalf("expected queue depth 42, got %v", v)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("happydomain_scheduler_queue_depth not registered")
|
||||
}
|
||||
|
||||
// nil clears the accessor → gauge falls back to 0.
|
||||
RegisterSchedulerQueueDepth(nil)
|
||||
mfs, _ = prometheus.DefaultGatherer.Gather()
|
||||
for _, mf := range mfs {
|
||||
if mf.GetName() != "happydomain_scheduler_queue_depth" {
|
||||
continue
|
||||
}
|
||||
if v := mf.GetMetric()[0].GetGauge().GetValue(); v != 0 {
|
||||
t.Fatalf("expected queue depth 0 after clearing accessor, got %v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SetBuildInfo --------------------------------------------------------
|
||||
|
||||
func TestSetBuildInfo(t *testing.T) {
|
||||
BuildInfo.Reset()
|
||||
SetBuildInfo("1.2.3-test", "abcdef0", "2026-04-08T00:00:00Z", true)
|
||||
|
||||
if got := testutil.ToFloat64(BuildInfo.WithLabelValues("1.2.3-test", "abcdef0", "true", "2026-04-08T00:00:00Z")); got != 1 {
|
||||
t.Fatalf("expected build_info{...}=1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- /metrics endpoint exposition format ---------------------------------
|
||||
|
||||
// TestMetricsEndpointParses guards against the whole exposition pipeline
|
||||
// emitting something that an actual Prometheus scraper would reject.
|
||||
func TestMetricsEndpointParses(t *testing.T) {
|
||||
// Drive at least one observation through every metric family touched by
|
||||
// instrumentation so the endpoint isn't trivially empty.
|
||||
HTTPRequestsTotal.WithLabelValues("GET", "/x", "200").Inc()
|
||||
StorageOperationsTotal.WithLabelValues("get", "user", "success").Inc()
|
||||
SchedulerChecksTotal.WithLabelValues("dns", "success").Inc()
|
||||
ProviderAPICallsTotal.WithLabelValues("dummy", "list", "success").Inc()
|
||||
SetBuildInfo("test", "deadbee", "2026-04-08T00:00:00Z", false)
|
||||
|
||||
srv := httptest.NewServer(promhttp.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /metrics: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||
mfs, err := parser.TextToMetricFamilies(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid prometheus exposition format: %v", err)
|
||||
}
|
||||
|
||||
// Sanity-check a few of the metrics we expect to find.
|
||||
for _, name := range []string{
|
||||
"happydomain_http_requests_total",
|
||||
"happydomain_storage_operations_total",
|
||||
"happydomain_scheduler_checks_total",
|
||||
"happydomain_provider_api_calls_total",
|
||||
"happydomain_build_info",
|
||||
} {
|
||||
if _, ok := mfs[name]; !ok {
|
||||
t.Errorf("expected metric %q in /metrics output", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
316
internal/storage/kvtpl/check_evaluation.go
Normal file
316
internal/storage/kvtpl/check_evaluation.go
Normal 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
|
||||
}
|
||||
126
internal/storage/kvtpl/check_plan.go
Normal file
126
internal/storage/kvtpl/check_plan.go
Normal 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
|
||||
}
|
||||
175
internal/storage/kvtpl/checker_options.go
Normal file
175
internal/storage/kvtpl/checker_options.go
Normal 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
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ func (s *KVStorage) ListAllDomains() (happydns.Iterator[happydns.Domain], error)
|
|||
return NewKVIterator[happydns.Domain](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountDomains() (int, error) {
|
||||
return s.countByPrefix("domain-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListDomains(u *happydns.User) (domains []*happydns.Domain, err error) {
|
||||
iter := s.db.Search("domain-")
|
||||
defer iter.Release()
|
||||
|
|
|
|||
448
internal/storage/kvtpl/execution.go
Normal file
448
internal/storage/kvtpl/execution.go
Normal 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
|
||||
}
|
||||
50
internal/storage/kvtpl/observation_cache.go
Normal file
50
internal/storage/kvtpl/observation_cache.go
Normal 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)
|
||||
}
|
||||
71
internal/storage/kvtpl/observation_snapshot.go
Normal file
71
internal/storage/kvtpl/observation_snapshot.go
Normal 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
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ func (s *KVStorage) ListAllProviders() (happydns.Iterator[happydns.ProviderMessa
|
|||
return NewKVIterator[happydns.ProviderMessage](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountProviders() (int, error) {
|
||||
return s.countByPrefix("provider-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) getProviderMeta(id happydns.Identifier) (*happydns.ProviderMessage, error) {
|
||||
srcMsg := &happydns.ProviderMessage{}
|
||||
err := s.db.Get(id.String(), srcMsg)
|
||||
|
|
|
|||
44
internal/storage/kvtpl/scheduler_state.go
Normal file
44
internal/storage/kvtpl/scheduler_state.go
Normal 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)
|
||||
}
|
||||
|
|
@ -22,7 +22,11 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type KVStorage struct {
|
||||
|
|
@ -38,3 +42,26 @@ func NewKVDatabase(impl storage.KVStorage) (storage.Storage, error) {
|
|||
func (s *KVStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// lastKeySegment extracts the identifier after the last "|" in a KV key.
|
||||
func lastKeySegment(key string) (happydns.Identifier, error) {
|
||||
i := strings.LastIndex(key, "|")
|
||||
if i < 0 {
|
||||
return happydns.Identifier{}, fmt.Errorf("key %q has no pipe separator", key)
|
||||
}
|
||||
return happydns.NewIdentifierFromString(key[i+1:])
|
||||
}
|
||||
|
||||
// countByPrefix counts the number of keys matching the given prefix without
|
||||
// decoding their values. It is the foundation of the Count* methods exposed
|
||||
// to observability code.
|
||||
func (s *KVStorage) countByPrefix(prefix string) (int, error) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
n := 0
|
||||
for iter.Next() {
|
||||
n++
|
||||
}
|
||||
return n, iter.Err()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ func (s *KVStorage) ListAllUsers() (happydns.Iterator[happydns.User], error) {
|
|||
return NewKVIterator[happydns.User](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountUsers() (int, error) {
|
||||
return s.countByPrefix("user-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) getUser(key string) (*happydns.User, error) {
|
||||
u := &happydns.User{}
|
||||
err := s.db.Get(key, &u)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ func (s *KVStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMessage], err
|
|||
return NewKVIterator[happydns.ZoneMessage](s.db, iter), nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) CountZones() (int, error) {
|
||||
return s.countByPrefix("domain.zone-")
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
|
||||
z := &happydns.ZoneMessage{}
|
||||
err := s.db.Get(fmt.Sprintf("domain.zone-%s", id.String()), &z)
|
||||
|
|
|
|||
40
internal/storage/stats_provider.go
Normal file
40
internal/storage/stats_provider.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package storage
|
||||
|
||||
// StatsProvider implements metrics.StatsProvider using a Storage. It delegates
|
||||
// to the storage backend's native Count* methods so each Prometheus scrape
|
||||
// runs at most one cheap key-prefix scan per entity instead of decoding every
|
||||
// record.
|
||||
type StatsProvider struct {
|
||||
store Storage
|
||||
}
|
||||
|
||||
// NewStatsProvider creates a StatsProvider backed by the given Storage.
|
||||
func NewStatsProvider(s Storage) *StatsProvider {
|
||||
return &StatsProvider{store: s}
|
||||
}
|
||||
|
||||
func (p *StatsProvider) CountUsers() (int, error) { return p.store.CountUsers() }
|
||||
func (p *StatsProvider) CountDomains() (int, error) { return p.store.CountDomains() }
|
||||
func (p *StatsProvider) CountZones() (int, error) { return p.store.CountZones() }
|
||||
func (p *StatsProvider) CountProviders() (int, error) { return p.store.CountProviders() }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
135
internal/usecase/checker/check_plan_usecase.go
Normal file
135
internal/usecase/checker/check_plan_usecase.go
Normal 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)
|
||||
}
|
||||
385
internal/usecase/checker/check_plan_usecase_test.go
Normal file
385
internal/usecase/checker/check_plan_usecase_test.go
Normal 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
|
||||
}
|
||||
362
internal/usecase/checker/check_status_usecase.go
Normal file
362
internal/usecase/checker/check_status_usecase.go
Normal 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
|
||||
}
|
||||
839
internal/usecase/checker/check_status_usecase_test.go
Normal file
839
internal/usecase/checker/check_status_usecase_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
241
internal/usecase/checker/checker_engine.go
Normal file
241
internal/usecase/checker/checker_engine.go
Normal 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
|
||||
}
|
||||
586
internal/usecase/checker/checker_engine_test.go
Normal file
586
internal/usecase/checker/checker_engine_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
645
internal/usecase/checker/checker_options_usecase.go
Normal file
645
internal/usecase/checker/checker_options_usecase.go
Normal 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
|
||||
}
|
||||
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
File diff suppressed because it is too large
Load diff
23
internal/usecase/checker/doc.go
Normal file
23
internal/usecase/checker/doc.go
Normal 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"
|
||||
241
internal/usecase/checker/janitor.go
Normal file
241
internal/usecase/checker/janitor.go
Normal 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
|
||||
}
|
||||
668
internal/usecase/checker/janitor_test.go
Normal file
668
internal/usecase/checker/janitor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
196
internal/usecase/checker/retention.go
Normal file
196
internal/usecase/checker/retention.go
Normal 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)
|
||||
}
|
||||
262
internal/usecase/checker/retention_test.go
Normal file
262
internal/usecase/checker/retention_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
806
internal/usecase/checker/scheduler.go
Normal file
806
internal/usecase/checker/scheduler.go
Normal file
|
|
@ -0,0 +1,806 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
minSpacing = 2 * time.Second
|
||||
maxCatchUpWindow = 10 * time.Minute
|
||||
defaultInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// SchedulerJob represents a single scheduled checker execution.
|
||||
type SchedulerJob struct {
|
||||
CheckerID string `json:"checkerID"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
PlanID *happydns.Identifier `json:"planID" swaggertype:"string"`
|
||||
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
index int // heap index
|
||||
}
|
||||
|
||||
// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun.
|
||||
type SchedulerQueue []*SchedulerJob
|
||||
|
||||
func (q SchedulerQueue) Len() int { return len(q) }
|
||||
func (q SchedulerQueue) Less(i, j int) bool { return q[i].NextRun.Before(q[j].NextRun) }
|
||||
func (q SchedulerQueue) Swap(i, j int) {
|
||||
q[i], q[j] = q[j], q[i]
|
||||
q[i].index = i
|
||||
q[j].index = j
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Push(x any) {
|
||||
n := len(*q)
|
||||
job := x.(*SchedulerJob)
|
||||
job.index = n
|
||||
*q = append(*q, job)
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Pop() any {
|
||||
old := *q
|
||||
n := len(old)
|
||||
job := old[n-1]
|
||||
old[n-1] = nil
|
||||
job.index = -1
|
||||
*q = old[:n-1]
|
||||
return job
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Peek() *SchedulerJob {
|
||||
if len(*q) == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*q)[0]
|
||||
}
|
||||
|
||||
// SchedulerStatus holds a snapshot of the scheduler's current state.
|
||||
type SchedulerStatus struct {
|
||||
Running bool `json:"running"`
|
||||
JobCount int `json:"job_count"`
|
||||
NextJobs []*SchedulerJob `json:"next_jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Scheduler manages periodic execution of checkers.
|
||||
type Scheduler struct {
|
||||
queue SchedulerQueue
|
||||
jobKeys map[string]bool
|
||||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
wake chan struct{}
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
maxConcurrency int
|
||||
|
||||
// gate, if set, is consulted before launching each job. Returning false
|
||||
// causes the scheduler to skip (and reschedule) the job, e.g. when the
|
||||
// owning user is paused or has been inactive for too long.
|
||||
gate func(target happydns.CheckTarget) bool
|
||||
}
|
||||
|
||||
// NewScheduler creates a new Scheduler. The optional gate function, if
|
||||
// non-nil, is consulted before launching each job; returning false causes
|
||||
// the scheduler to skip (and reschedule) the job.
|
||||
func NewScheduler(
|
||||
engine happydns.CheckerEngine,
|
||||
maxConcurrency int,
|
||||
planStore CheckPlanStorage,
|
||||
domainStore DomainLister,
|
||||
zoneStore ZoneGetter,
|
||||
stateStore SchedulerStateStorage,
|
||||
gate func(target happydns.CheckTarget) bool,
|
||||
) *Scheduler {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
s := &Scheduler{
|
||||
engine: engine,
|
||||
planStore: planStore,
|
||||
domainStore: domainStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
jobKeys: make(map[string]bool),
|
||||
wake: make(chan struct{}, 1),
|
||||
maxConcurrency: maxConcurrency,
|
||||
gate: gate,
|
||||
}
|
||||
// The scheduler queue depth is exposed via a Prometheus GaugeFunc that
|
||||
// reads the live queue length at scrape time. This avoids having to call
|
||||
// gauge.Set after every queue mutation site (Push/Pop/Init/buildQueue/…).
|
||||
metrics.RegisterSchedulerQueueDepth(s.queueDepthForMetrics)
|
||||
return s
|
||||
}
|
||||
|
||||
// queueDepthForMetrics returns the current queue length under the read lock.
|
||||
// It is invoked from the Prometheus scrape goroutine.
|
||||
func (s *Scheduler) queueDepthForMetrics() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return float64(s.queue.Len())
|
||||
}
|
||||
|
||||
// Start begins the scheduler loop in a goroutine.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.mu.Lock()
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
s.done = make(chan struct{})
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
s.mu.Unlock()
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the scheduler and waits for in-flight workers to finish.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
// Drop the queue-depth accessor so a stopped scheduler does not keep its
|
||||
// closure (and the captured queue) reachable for the lifetime of the
|
||||
// process. This is essential in tests that spin schedulers up and down.
|
||||
metrics.RegisterSchedulerQueueDepth(nil)
|
||||
}
|
||||
|
||||
// GetStatus returns a snapshot of the scheduler's current state.
|
||||
func (s *Scheduler) GetStatus() SchedulerStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
status := SchedulerStatus{
|
||||
Running: s.running,
|
||||
JobCount: s.queue.Len(),
|
||||
}
|
||||
|
||||
n := min(20, s.queue.Len())
|
||||
if n > 0 {
|
||||
tmp := make(SchedulerQueue, s.queue.Len())
|
||||
copy(tmp, s.queue)
|
||||
for i, job := range tmp {
|
||||
cp := *job
|
||||
cp.index = i
|
||||
tmp[i] = &cp
|
||||
}
|
||||
status.NextJobs = make([]*SchedulerJob, 0, n)
|
||||
for range n {
|
||||
status.NextJobs = append(status.NextJobs, heap.Pop(&tmp).(*SchedulerJob))
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// SetEnabled starts or stops the scheduler. The provided ctx is used as the
|
||||
// parent context for the new scheduler loop when enabled is true.
|
||||
func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error {
|
||||
s.mu.RLock()
|
||||
wasRunning := s.running
|
||||
s.mu.RUnlock()
|
||||
|
||||
if wasRunning {
|
||||
s.Stop()
|
||||
}
|
||||
if enabled {
|
||||
s.Start(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildQueue rebuilds the scheduler queue and returns the new job count.
|
||||
func (s *Scheduler) RebuildQueue() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
return s.queue.Len()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Scheduler: panic in run loop: %v", r)
|
||||
}
|
||||
s.wg.Wait()
|
||||
close(s.done)
|
||||
}()
|
||||
|
||||
sem := make(chan struct{}, s.maxConcurrency)
|
||||
|
||||
for {
|
||||
s.mu.RLock()
|
||||
qLen := s.queue.Len()
|
||||
s.mu.RUnlock()
|
||||
|
||||
if qLen == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
continue
|
||||
case <-time.After(1 * time.Minute):
|
||||
s.mu.Lock()
|
||||
s.buildQueue()
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
next := s.queue.Peek()
|
||||
var delay time.Duration
|
||||
if next != nil {
|
||||
delay = time.Until(next.NextRun)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
timer.Stop()
|
||||
continue
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.queue.Len() == 0 {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
job := heap.Pop(&s.queue).(*SchedulerJob)
|
||||
gate := s.gate
|
||||
s.mu.Unlock()
|
||||
|
||||
// Honour the user-level gate before doing any work.
|
||||
if gate != nil && !gate(job.Target) {
|
||||
// log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String())
|
||||
s.rescheduleJob(job)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find plan if applicable.
|
||||
var plan *happydns.CheckPlan
|
||||
if job.PlanID != nil {
|
||||
p, err := s.planStore.GetCheckPlan(*job.PlanID)
|
||||
if err == nil {
|
||||
plan = p
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire a concurrency slot, but stay responsive to cancellation.
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
default:
|
||||
log.Printf("Scheduler: all %d workers busy, waiting for a slot (checker %s on %s)", s.maxConcurrency, job.CheckerID, job.Target.String())
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func(j *SchedulerJob, p *happydns.CheckPlan) {
|
||||
defer func() { <-sem; s.wg.Done() }()
|
||||
metrics.SchedulerActiveWorkers.Inc()
|
||||
checkStart := time.Now()
|
||||
defer func() {
|
||||
metrics.SchedulerActiveWorkers.Dec()
|
||||
metrics.SchedulerCheckDuration.WithLabelValues(j.CheckerID).Observe(time.Since(checkStart).Seconds())
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Scheduler: panic in worker for checker %s on %s: %v", j.CheckerID, j.Target.String(), r)
|
||||
}
|
||||
}()
|
||||
log.Printf("Scheduler: running checker %s on %s", j.CheckerID, j.Target.String())
|
||||
exec, err := s.engine.CreateExecution(j.CheckerID, j.Target, p)
|
||||
if err != nil {
|
||||
metrics.SchedulerChecksTotal.WithLabelValues(j.CheckerID, "error").Inc()
|
||||
log.Printf("Scheduler: checker %s on %s failed to create execution: %v", j.CheckerID, j.Target.String(), err)
|
||||
return
|
||||
}
|
||||
_, err = s.engine.RunExecution(ctx, exec, p, nil)
|
||||
status := "success"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err)
|
||||
}
|
||||
metrics.SchedulerChecksTotal.WithLabelValues(j.CheckerID, status).Inc()
|
||||
if s.stateStore != nil {
|
||||
if err := s.stateStore.SetLastSchedulerRun(time.Now()); err != nil {
|
||||
log.Printf("Scheduler: failed to persist last run time: %v", err)
|
||||
}
|
||||
}
|
||||
}(job, plan)
|
||||
|
||||
// Advance to next cycle and re-enqueue.
|
||||
s.rescheduleJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
// rescheduleJob advances job.NextRun past the current time, adds jitter,
|
||||
// and pushes the job back onto the scheduler queue.
|
||||
func (s *Scheduler) rescheduleJob(job *SchedulerJob) {
|
||||
now := time.Now()
|
||||
for job.NextRun.Before(now) {
|
||||
job.NextRun = job.NextRun.Add(job.Interval)
|
||||
}
|
||||
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
s.mu.Lock()
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) buildQueue() {
|
||||
s.queue = s.queue[:0]
|
||||
s.jobKeys = make(map[string]bool)
|
||||
|
||||
var lastRun time.Time
|
||||
if s.stateStore != nil {
|
||||
if t, err := s.stateStore.GetLastSchedulerRun(); err != nil {
|
||||
log.Printf("Scheduler: failed to read last run time: %v", err)
|
||||
} else {
|
||||
lastRun = t
|
||||
}
|
||||
}
|
||||
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
plans, err := s.loadAllPlans()
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load plans, skipping queue build: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
disabledSet, planMap := buildPlanIndex(plans)
|
||||
|
||||
// Collect checkers by scope for efficient iteration.
|
||||
var domainCheckers, serviceCheckers []struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
domainCheckers = append(domainCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
if def.Availability.ApplyToService {
|
||||
serviceCheckers = append(serviceCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discovery: enumerate all domains and schedule applicable checkers.
|
||||
domains := s.loadAllDomains()
|
||||
for _, domain := range domains {
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
for _, c := range domainCheckers {
|
||||
s.enqueueJob(c.id, c.def, domainTarget, disabledSet, planMap, lastRun)
|
||||
}
|
||||
|
||||
// Service-level discovery: load the latest zone and match services.
|
||||
if len(serviceCheckers) > 0 {
|
||||
services := s.loadDomainServices(domain)
|
||||
for _, svc := range services {
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
|
||||
|
||||
for _, c := range serviceCheckers {
|
||||
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainChange incrementally adds scheduler jobs for a domain
|
||||
// without rebuilding the entire queue. Call this after a domain is
|
||||
// created or its zone is imported/published.
|
||||
func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
|
||||
// Load plans relevant to this domain.
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
|
||||
}
|
||||
disabledSet, planMap := buildPlanIndex(plans)
|
||||
|
||||
// Load services outside the lock to avoid holding the mutex during I/O.
|
||||
services := s.loadDomainServices(domain)
|
||||
|
||||
// Build the set of desired job keys for this domain so we can detect stale entries.
|
||||
wantKeys := make(map[string]bool)
|
||||
didStr := did.String()
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
key := checkerID + "|" + domainTarget.String()
|
||||
if !disabledSet[key] {
|
||||
wantKeys[key] = true
|
||||
}
|
||||
}
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
|
||||
key := checkerID + "|" + svcTarget.String()
|
||||
if !disabledSet[key] {
|
||||
wantKeys[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var added, removed int
|
||||
s.mu.Lock()
|
||||
|
||||
// Remove stale jobs for this domain that are no longer wanted.
|
||||
for i := 0; i < len(s.queue); {
|
||||
job := s.queue[i]
|
||||
if job.Target.DomainId == didStr {
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
if !wantKeys[key] {
|
||||
delete(s.jobKeys, key)
|
||||
s.queue[i] = s.queue[len(s.queue)-1]
|
||||
s.queue[len(s.queue)-1] = nil
|
||||
s.queue = s.queue[:len(s.queue)-1]
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if removed > 0 {
|
||||
heap.Init(&s.queue)
|
||||
}
|
||||
|
||||
// Add new jobs for this domain.
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
if s.enqueueJob(checkerID, def, domainTarget, disabledSet, planMap, time.Time{}) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: sid.String(), ServiceType: svc.Type}
|
||||
if s.enqueueJob(checkerID, def, svcTarget, disabledSet, planMap, time.Time{}) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if added > 0 || removed > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs, removed %d stale jobs", domain.DomainName, added, removed)
|
||||
// Wake the run loop so it re-evaluates the queue head.
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainRemoved removes all scheduler jobs for the given domain.
|
||||
func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
|
||||
s.mu.Lock()
|
||||
n := 0
|
||||
for i := 0; i < len(s.queue); {
|
||||
job := s.queue[i]
|
||||
if job.Target.DomainId == domainID.String() {
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
delete(s.jobKeys, key)
|
||||
// Swap with last and shrink.
|
||||
s.queue[i] = s.queue[len(s.queue)-1]
|
||||
s.queue[len(s.queue)-1] = nil
|
||||
s.queue = s.queue[:len(s.queue)-1]
|
||||
n++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
heap.Init(&s.queue)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
|
||||
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
|
||||
disabledSet = make(map[string]bool)
|
||||
planMap = make(map[string]*happydns.CheckPlan)
|
||||
for _, p := range plans {
|
||||
key := p.CheckerID + "|" + p.Target.String()
|
||||
planMap[key] = p
|
||||
if p.IsFullyDisabled() {
|
||||
disabledSet[key] = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// enqueueJob creates and pushes a scheduler job if the key is not already
|
||||
// present and not disabled. When lastActive is zero (e.g. NotifyDomainChange),
|
||||
// the job is scheduled at now + jitter; otherwise offset-based grid scheduling
|
||||
// is used. Must be called with s.mu held. Returns true if a job was added.
|
||||
func (s *Scheduler) enqueueJob(checkerID string, def *happydns.CheckerDefinition, target happydns.CheckTarget, disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan, lastActive time.Time) bool {
|
||||
targetStr := target.String()
|
||||
key := checkerID + "|" + targetStr
|
||||
if s.jobKeys[key] || disabledSet[key] {
|
||||
return false
|
||||
}
|
||||
|
||||
plan := planMap[key]
|
||||
interval := s.effectiveInterval(def, plan)
|
||||
|
||||
var nextRun time.Time
|
||||
if lastActive.IsZero() {
|
||||
now := time.Now()
|
||||
nextRun = now.Add(computeJitter(checkerID, targetStr, now, interval))
|
||||
} else {
|
||||
offset := computeOffset(checkerID, targetStr, interval)
|
||||
nextRun = computeNextRun(interval, offset, lastActive)
|
||||
}
|
||||
|
||||
job := &SchedulerJob{
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
NextRun: nextRun,
|
||||
}
|
||||
if plan != nil {
|
||||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) {
|
||||
iter, err := s.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var plans []*happydns.CheckPlan
|
||||
for iter.Next() {
|
||||
plans = append(plans, iter.Item())
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllDomains() []*happydns.Domain {
|
||||
if s.domainStore == nil {
|
||||
return nil
|
||||
}
|
||||
iter, err := s.domainStore.ListAllDomains()
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to list domains for auto-discovery: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var domains []*happydns.Domain
|
||||
for iter.Next() {
|
||||
d := iter.Item()
|
||||
domains = append(domains, d)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
|
||||
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
latestZoneID := domain.ZoneHistory[len(domain.ZoneHistory)-1]
|
||||
zone, err := s.zoneStore.GetZone(latestZoneID)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", latestZoneID, domain.DomainName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var services []*happydns.ServiceMessage
|
||||
for _, svcs := range zone.Services {
|
||||
services = append(services, svcs...)
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
func (s *Scheduler) effectiveInterval(def *happydns.CheckerDefinition, plan *happydns.CheckPlan) time.Duration {
|
||||
interval := defaultInterval
|
||||
if def.Interval != nil {
|
||||
interval = def.Interval.Default
|
||||
}
|
||||
|
||||
if plan != nil && plan.Interval != nil {
|
||||
interval = *plan.Interval
|
||||
}
|
||||
|
||||
// Clamp to bounds.
|
||||
if def.Interval != nil {
|
||||
if interval < def.Interval.Min {
|
||||
interval = def.Interval.Min
|
||||
}
|
||||
if interval > def.Interval.Max {
|
||||
interval = def.Interval.Max
|
||||
}
|
||||
}
|
||||
|
||||
return interval
|
||||
}
|
||||
|
||||
func (s *Scheduler) spreadOverdueJobs() {
|
||||
now := time.Now()
|
||||
var overdue []*SchedulerJob
|
||||
|
||||
for s.queue.Len() > 0 && s.queue.Peek().NextRun.Before(now) {
|
||||
overdue = append(overdue, heap.Pop(&s.queue).(*SchedulerJob))
|
||||
}
|
||||
|
||||
if len(overdue) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
window := time.Duration(len(overdue)) * minSpacing
|
||||
window = min(window, maxCatchUpWindow)
|
||||
|
||||
for i, job := range overdue {
|
||||
delay := window * time.Duration(i) / time.Duration(len(overdue))
|
||||
job.NextRun = now.Add(delay)
|
||||
heap.Push(&s.queue, job)
|
||||
}
|
||||
}
|
||||
|
||||
// GetPlannedJobsForChecker returns a snapshot of scheduled jobs for the given checker and target.
|
||||
func (s *Scheduler) GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
tStr := target.String()
|
||||
var result []*SchedulerJob
|
||||
for _, job := range s.queue {
|
||||
if job.CheckerID == checkerID && job.Target.String() == tStr {
|
||||
cp := *job
|
||||
result = append(result, &cp)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// computeOffset returns a deterministic offset within the interval.
|
||||
func computeOffset(checkerID, targetStr string, interval time.Duration) time.Duration {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(checkerID + targetStr))
|
||||
return time.Duration(h.Sum64()%uint64(interval.Nanoseconds())) * time.Nanosecond
|
||||
}
|
||||
|
||||
// computeJitter returns a small deterministic jitter (~5% of interval).
|
||||
func computeJitter(checkerID, targetStr string, cycleTime time.Time, interval time.Duration) time.Duration {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(checkerID + targetStr + cycleTime.Format(time.RFC3339)))
|
||||
maxJitter := interval / 20 // 5%
|
||||
if maxJitter <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(h.Sum64()%uint64(maxJitter.Nanoseconds())) * time.Nanosecond
|
||||
}
|
||||
|
||||
// computeNextRun calculates the next run time based on interval, offset, and
|
||||
// the last time the scheduler was known to be active. When lastActive is zero
|
||||
// (first execution), it behaves as before. Otherwise it detects jobs that were
|
||||
// missed during downtime (slot in (lastActive, now]) and schedules them
|
||||
// immediately so spreadOverdueJobs can stagger them, while skipping jobs that
|
||||
// already ran (slot <= lastActive).
|
||||
func computeNextRun(interval, offset time.Duration, lastActive time.Time) time.Time {
|
||||
now := time.Now()
|
||||
|
||||
// Use Unix nanoseconds to avoid time.Duration overflow with ancient epochs.
|
||||
nowNano := now.UnixNano()
|
||||
intervalNano := int64(interval)
|
||||
offsetNano := int64(offset) % intervalNano
|
||||
|
||||
// Find the most recent grid slot <= now.
|
||||
cycleN := (nowNano - offsetNano) / intervalNano
|
||||
slotNano := cycleN*intervalNano + offsetNano
|
||||
if slotNano > nowNano {
|
||||
slotNano -= intervalNano
|
||||
}
|
||||
slot := time.Unix(0, slotNano)
|
||||
|
||||
if lastActive.IsZero() {
|
||||
// First execution: schedule at the next future slot.
|
||||
if !slot.After(now) {
|
||||
return slot.Add(interval)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
// Slot was missed during downtime, schedule now for catch-up.
|
||||
if slot.After(lastActive) && !slot.After(now) {
|
||||
return now
|
||||
}
|
||||
|
||||
// Slot already executed before shutdown; advance to next cycle.
|
||||
return slot.Add(interval)
|
||||
}
|
||||
729
internal/usecase/checker/scheduler_test.go
Normal file
729
internal/usecase/checker/scheduler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
128
internal/usecase/checker/storage.go
Normal file
128
internal/usecase/checker/storage.go
Normal 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
|
||||
}
|
||||
119
internal/usecase/checker/user_gate.go
Normal file
119
internal/usecase/checker/user_gate.go
Normal 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)
|
||||
}
|
||||
261
internal/usecase/checker/user_gate_test.go
Normal file
261
internal/usecase/checker/user_gate_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ type DomainStorage interface {
|
|||
// ListAllDomains retrieves the list of known Domains.
|
||||
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
|
||||
|
||||
// CountDomains returns the total number of Domains in storage.
|
||||
// Implementations should make this efficient (e.g. count keys without
|
||||
// decoding values) so it can be called from observability paths.
|
||||
CountDomains() (int, error)
|
||||
|
||||
// ListDomains retrieves all Domains associated to the given User.
|
||||
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ func (s *inMemoryZoneStorage) ListAllZones() (happydns.Iterator[happydns.ZoneMes
|
|||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *inMemoryZoneStorage) CountZones() (int, error) {
|
||||
return len(s.zones), nil
|
||||
}
|
||||
|
||||
func (s *inMemoryZoneStorage) GetZoneMeta(zoneid happydns.Identifier) (*happydns.ZoneMeta, error) {
|
||||
z, ok := s.zones[zoneid.String()]
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ type ProviderStorage interface {
|
|||
// ListAllProviders retrieves the list of known Providers.
|
||||
ListAllProviders() (happydns.Iterator[happydns.ProviderMessage], error)
|
||||
|
||||
// CountProviders returns the total number of Providers in storage.
|
||||
// Implementations should make this efficient (e.g. count keys without
|
||||
// decoding values) so it can be called from observability paths.
|
||||
CountProviders() (int, error)
|
||||
|
||||
// ListProviders retrieves all providers own by the given User.
|
||||
ListProviders(user *happydns.User) (happydns.ProviderMessages, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,21 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
|
|||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyAll() error {
|
||||
for _, tidy := range []func() error{tu.TidySessions, tu.TidyAuthUsers, tu.TidyUsers, tu.TidyProviders, tu.TidyDomains, tu.TidyZones, tu.TidyDomainLogs} {
|
||||
for _, tidy := range []func() error{
|
||||
tu.TidySessions,
|
||||
tu.TidyAuthUsers,
|
||||
tu.TidyUsers,
|
||||
tu.TidyProviders,
|
||||
tu.TidyDomains,
|
||||
tu.TidyZones,
|
||||
tu.TidyDomainLogs,
|
||||
tu.TidyCheckPlans,
|
||||
tu.TidyCheckerConfigurations,
|
||||
tu.TidyExecutions,
|
||||
tu.TidyCheckEvaluations,
|
||||
tu.TidySnapshots,
|
||||
tu.TidyObservationCache,
|
||||
} {
|
||||
if err := tidy(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -72,6 +86,244 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
|
|||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
|
||||
iter, err := tu.store.ListAllEvaluations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
eval := iter.Item()
|
||||
|
||||
drop := false
|
||||
|
||||
if eval.Target.UserId != "" {
|
||||
userId, err := happydns.NewIdentifierFromString(eval.Target.UserId)
|
||||
if err == nil {
|
||||
if _, err = tu.store.GetUser(userId); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
log.Printf("Deleting orphan check evaluation (user %s not found): %s\n", eval.Target.UserId, eval.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !drop && eval.Target.DomainId != "" {
|
||||
domainId, err := happydns.NewIdentifierFromString(eval.Target.DomainId)
|
||||
if err == nil {
|
||||
if _, err = tu.store.GetDomain(domainId); errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
log.Printf("Deleting orphan check evaluation (domain %s not found): %s\n", eval.Target.DomainId, eval.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !drop && eval.PlanID != nil {
|
||||
if _, err = tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
log.Printf("Deleting orphan check evaluation (plan %s not found): %s\n", eval.PlanID.String(), eval.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
|
||||
if drop {
|
||||
if err = tu.store.DeleteEvaluation(eval.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = iter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyEvaluationIndexes()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckPlans() error {
|
||||
iter, err := tu.store.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
plan := iter.Item()
|
||||
|
||||
if plan.Target.UserId != "" {
|
||||
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
|
||||
if err == nil {
|
||||
_, err = tu.store.GetUser(userId)
|
||||
if errors.Is(err, happydns.ErrUserNotFound) {
|
||||
log.Printf("Deleting orphan check plan (user %s not found): %s\n", plan.Target.UserId, plan.Id.String())
|
||||
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
|
||||
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if plan.Target.DomainId != "" {
|
||||
domainId, err := happydns.NewIdentifierFromString(plan.Target.DomainId)
|
||||
if err == nil {
|
||||
_, err = tu.store.GetDomain(domainId)
|
||||
if errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
log.Printf("Deleting orphan check plan (domain %s not found): %s\n", plan.Target.DomainId, plan.Id.String())
|
||||
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
|
||||
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
|
||||
iter, err := tu.store.ListAllCheckerConfigurations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
cfg := iter.Item()
|
||||
|
||||
if cfg.UserId != nil {
|
||||
if _, err = tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
log.Printf("Deleting orphan checker configuration (user %s not found): %s\n", cfg.UserId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DomainId != nil {
|
||||
domain, err := tu.store.GetDomain(*cfg.DomainId)
|
||||
if errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
log.Printf("Deleting orphan checker configuration (domain %s not found): %s\n", cfg.DomainId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.ServiceId != nil && len(domain.ZoneHistory) > 0 {
|
||||
zone, err := tu.store.GetZone(domain.ZoneHistory[len(domain.ZoneHistory)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, svcs := range zone.Services {
|
||||
for _, svc := range svcs {
|
||||
if svc.Id.Equals(*cfg.ServiceId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
log.Printf("Deleting orphan checker configuration (service %s not found in domain %s): %s\n", cfg.ServiceId.String(), cfg.DomainId.String(), cfg.CheckName)
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyExecutions() error {
|
||||
iter, err := tu.store.ListAllExecutions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
exec := iter.Item()
|
||||
|
||||
drop := false
|
||||
|
||||
if exec.Target.UserId != "" {
|
||||
userId, err := happydns.NewIdentifierFromString(exec.Target.UserId)
|
||||
if err == nil {
|
||||
if _, err = tu.store.GetUser(userId); errors.Is(err, happydns.ErrUserNotFound) {
|
||||
log.Printf("Deleting orphan execution (user %s not found): %s\n", exec.Target.UserId, exec.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !drop && exec.Target.DomainId != "" {
|
||||
domainId, err := happydns.NewIdentifierFromString(exec.Target.DomainId)
|
||||
if err == nil {
|
||||
if _, err = tu.store.GetDomain(domainId); errors.Is(err, happydns.ErrDomainNotFound) {
|
||||
log.Printf("Deleting orphan execution (domain %s not found): %s\n", exec.Target.DomainId, exec.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !drop && exec.PlanID != nil {
|
||||
if _, err = tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
|
||||
log.Printf("Deleting orphan execution (plan %s not found): %s\n", exec.PlanID.String(), exec.Id.String())
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
|
||||
if drop {
|
||||
if err = tu.store.DeleteExecution(exec.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = iter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyExecutionIndexes()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyObservationCache() error {
|
||||
iter, err := tu.store.ListAllCachedObservations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
entry := iter.Item()
|
||||
|
||||
if _, err = tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
|
||||
log.Printf("Deleting stale observation cache entry (snapshot %s not found)\n", entry.SnapshotID.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyDomains() error {
|
||||
iter, err := tu.store.ListAllDomains()
|
||||
if err != nil {
|
||||
|
|
@ -170,6 +422,45 @@ func (tu *tidyUpUsecase) TidySessions() error {
|
|||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidySnapshots() error {
|
||||
// Collect all snapshot IDs referenced by evaluations.
|
||||
evalIter, err := tu.store.ListAllEvaluations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer evalIter.Close()
|
||||
|
||||
referencedSnapshots := make(map[string]struct{})
|
||||
for evalIter.Next() {
|
||||
eval := evalIter.Item()
|
||||
if !eval.SnapshotID.IsEmpty() {
|
||||
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err = evalIter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete snapshots not referenced by any evaluation.
|
||||
iter, err := tu.store.ListAllSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
snap := iter.Item()
|
||||
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
|
||||
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyUsers() error {
|
||||
iter, err := tu.store.ListAllAuthUsers()
|
||||
if err != nil {
|
||||
|
|
@ -182,6 +473,9 @@ func (tu *tidyUpUsecase) TidyUsers() error {
|
|||
|
||||
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
|
||||
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
|
||||
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
|
||||
return err
|
||||
}
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
108
internal/usecase/tidy_usecase_test.go
Normal file
108
internal/usecase/tidy_usecase_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,11 @@ type UserStorage interface {
|
|||
// ListAllUsers retrieves the list of known Users.
|
||||
ListAllUsers() (happydns.Iterator[happydns.User], error)
|
||||
|
||||
// CountUsers returns the total number of Users in storage. Implementations
|
||||
// should make this efficient (e.g. count keys without decoding values) so
|
||||
// it can be called from observability paths like Prometheus scrapes.
|
||||
CountUsers() (int, error)
|
||||
|
||||
// GetUser retrieves the User with the given identifier.
|
||||
GetUser(userid happydns.Identifier) (*happydns.User, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -272,14 +272,17 @@ func Test_UpdateUser(t *testing.T) {
|
|||
}
|
||||
|
||||
// Update the user
|
||||
err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
updated, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Email = "updated@example.com"
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Email != "updated@example.com" {
|
||||
t.Errorf("returned user should have updated email, got %s", updated.Email)
|
||||
}
|
||||
|
||||
// Verify the user was updated
|
||||
// Verify the user was updated in storage
|
||||
updatedUser, err := service.GetUser(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving updated user: %v", err)
|
||||
|
|
@ -303,7 +306,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
|
|||
}
|
||||
|
||||
// Try to change the user ID
|
||||
err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
_, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Id = happydns.Identifier([]byte("new-id"))
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -319,7 +322,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
|
|||
func Test_UpdateUser_NotFound(t *testing.T) {
|
||||
service, _, _, _ := createTestService(t)
|
||||
|
||||
err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
|
||||
_, err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
|
||||
u.Email = "updated@example.com"
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -364,6 +367,89 @@ func Test_ChangeUserSettings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_ChangeUserSettings_PreservesQuota(t *testing.T) {
|
||||
service, mem, _, _ := createTestService(t)
|
||||
|
||||
// Create a user with quota set
|
||||
user := &happydns.User{
|
||||
Id: happydns.Identifier([]byte("user-123")),
|
||||
Email: "test@example.com",
|
||||
Settings: *happydns.DefaultUserSettings(),
|
||||
Quota: happydns.UserQuota{
|
||||
MaxChecksPerDay: 42,
|
||||
RetentionDays: 30,
|
||||
SchedulingPaused: true,
|
||||
},
|
||||
}
|
||||
if err := mem.CreateOrUpdateUser(user); err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Change settings (should not touch quota)
|
||||
err := service.ChangeUserSettings(user, happydns.UserSettings{Language: "de"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify quota is untouched in storage
|
||||
storedUser, err := service.GetUser(user.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving user: %v", err)
|
||||
}
|
||||
if storedUser.Quota.MaxChecksPerDay != 42 {
|
||||
t.Errorf("expected MaxChecksPerDay 42 after settings change, got %d", storedUser.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if storedUser.Quota.RetentionDays != 30 {
|
||||
t.Errorf("expected RetentionDays 30 after settings change, got %d", storedUser.Quota.RetentionDays)
|
||||
}
|
||||
if !storedUser.Quota.SchedulingPaused {
|
||||
t.Error("expected SchedulingPaused to remain true after settings change")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_UpdateUser_Quota(t *testing.T) {
|
||||
service, mem, _, _ := createTestService(t)
|
||||
|
||||
userID := happydns.Identifier([]byte("user-123"))
|
||||
user := &happydns.User{
|
||||
Id: userID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
if err := mem.CreateOrUpdateUser(user); err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Update quota through UpdateUser (simulates admin path)
|
||||
_, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Quota = happydns.UserQuota{
|
||||
MaxChecksPerDay: 100,
|
||||
RetentionDays: 60,
|
||||
InactivityPauseDays: -1,
|
||||
SchedulingPaused: true,
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
storedUser, err := service.GetUser(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving user: %v", err)
|
||||
}
|
||||
if storedUser.Quota.MaxChecksPerDay != 100 {
|
||||
t.Errorf("expected MaxChecksPerDay 100, got %d", storedUser.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if storedUser.Quota.RetentionDays != 60 {
|
||||
t.Errorf("expected RetentionDays 60, got %d", storedUser.Quota.RetentionDays)
|
||||
}
|
||||
if storedUser.Quota.InactivityPauseDays != -1 {
|
||||
t.Errorf("expected InactivityPauseDays -1, got %d", storedUser.Quota.InactivityPauseDays)
|
||||
}
|
||||
if !storedUser.Quota.SchedulingPaused {
|
||||
t.Error("expected SchedulingPaused true")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DeleteUser(t *testing.T) {
|
||||
service, mem, _, sessionCloser := createTestService(t)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue