Compare commits
26 commits
40b4bab4a8
...
07eb4da7d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 07eb4da7d2 | |||
| 6f2b780fce | |||
| 7d0f3897bf | |||
| b7714b12b8 | |||
| a53c9d59da | |||
| 90db0c54cb | |||
| 5c63d6eaf3 | |||
| c33fd2d94e | |||
| b664bb8fb1 | |||
| 0a4e1d91ff | |||
| 29cbbbcd64 | |||
| b181073552 | |||
| 91e6f7b955 | |||
| 5cd3bc75f2 | |||
| 1742456561 | |||
| 5adaaa29ac | |||
| 0a1e7df5e3 | |||
| 6680ae3d4d | |||
| 006d2ad9af | |||
| 3da9039e97 | |||
| 2beb84e147 | |||
| 577a39df56 | |||
| 893fc4bfe6 | |||
| 3196eabcd0 | |||
| 0c48e960cb | |||
| 2f9f44a66d |
154 changed files with 20724 additions and 162 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())
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import (
|
|||
"github.com/earthboundkid/versioninfo/v2"
|
||||
"github.com/fatih/color"
|
||||
|
||||
_ "git.happydns.org/happyDomain/checkers"
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/app"
|
||||
"git.happydns.org/happyDomain/internal/config"
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
5
go.mod
5
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,6 +183,7 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
|
|
|
|||
10
go.sum
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=
|
||||
|
|
|
|||
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})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
177
internal/api/controller/checker_metrics.go
Normal file
177
internal/api/controller/checker_metrics.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// respondWithMetrics writes metrics as a JSON array.
|
||||
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
|
||||
if metrics == nil {
|
||||
metrics = []happydns.CheckMetric{}
|
||||
}
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
const maxLimit = 1000
|
||||
|
||||
func getLimitParam(c *gin.Context, defaultLimit int) int {
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
if parsed > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// GetUserMetrics returns metrics across all checkers for the authenticated user.
|
||||
//
|
||||
// @Summary Get all user metrics
|
||||
// @Description Returns metrics from all recent executions for the authenticated user as a JSON array.
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /checkers/metrics [get]
|
||||
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
userID := happydns.TargetIdentifier(target.UserId)
|
||||
if userID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetDomainMetrics returns metrics for a domain and its service children.
|
||||
//
|
||||
// @Summary Get domain metrics
|
||||
// @Description Returns metrics from recent executions for a domain and all its services as a JSON array.
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/metrics [get]
|
||||
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
domainID := happydns.TargetIdentifier(target.DomainId)
|
||||
if domainID == nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetCheckerMetrics returns metrics for a specific checker on a target.
|
||||
//
|
||||
// @Summary Get checker metrics
|
||||
// @Description Returns metrics from recent executions of a specific checker on a target as a JSON array.
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param limit query int false "Maximum number of executions (default: 100)"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
|
||||
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
target := targetFromContext(c)
|
||||
|
||||
limit := getLimitParam(c, 100)
|
||||
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
|
||||
// GetExecutionMetrics returns metrics for a single execution.
|
||||
//
|
||||
// @Summary Get execution metrics
|
||||
// @Description Returns metrics extracted from a single execution's observation snapshot as a JSON array.
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Success 200 {array} checker.CheckMetric
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
|
||||
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
|
||||
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
|
||||
exec, err := cc.statusUC.GetExecution(target, execID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithMetrics(c, metrics)
|
||||
}
|
||||
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,7 +24,9 @@ package route
|
|||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -50,6 +52,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)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
|
||||
admin "git.happydns.org/happyDomain/internal/api-admin/route"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/web-admin"
|
||||
|
|
@ -55,6 +56,9 @@ func NewAdmin(app *App) *Admin {
|
|||
|
||||
// Prepare usecases (admin uses unrestricted provider access)
|
||||
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
|
||||
if app.usecases.checkerOptionsUC == nil {
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
}
|
||||
|
||||
admin.DeclareRoutes(
|
||||
app.cfg,
|
||||
|
|
@ -71,6 +75,8 @@ func NewAdmin(app *App) *Admin {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckScheduler: app.usecases.checkerScheduler,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, router)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
|
|
@ -69,6 +70,13 @@ 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
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -93,6 +101,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 +119,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()
|
||||
|
|
@ -246,6 +260,38 @@ 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,
|
||||
)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
|
||||
|
||||
// Install the user-level gate so paused or long-inactive users do not
|
||||
// get checked. The same user resolver is reused by the janitor for
|
||||
// per-user retention overrides.
|
||||
gater := checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
|
||||
app.usecases.checkerScheduler.SetGate(gater.Allow)
|
||||
|
||||
// Retention janitor.
|
||||
app.usecases.checkerJanitor = checkerUC.NewJanitor(
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
|
||||
app.cfg.CheckerJanitorInterval,
|
||||
)
|
||||
|
||||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -291,6 +337,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 +360,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 +381,14 @@ func (app *App) Stop() {
|
|||
log.Fatal("Server Shutdown:", err)
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Stop()
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Stop()
|
||||
}
|
||||
|
||||
// Close storage
|
||||
if app.store != nil {
|
||||
app.store.Close()
|
||||
|
|
|
|||
238
internal/app/plugins.go
Normal file
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
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,12 @@ func NewKVDatabase(impl storage.KVStorage) (storage.Storage, error) {
|
|||
func (s *KVStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// lastKeySegment extracts the identifier after the last "|" in a KV key.
|
||||
func lastKeySegment(key string) (happydns.Identifier, error) {
|
||||
i := strings.LastIndex(key, "|")
|
||||
if i < 0 {
|
||||
return happydns.Identifier{}, fmt.Errorf("key %q has no pipe separator", key)
|
||||
}
|
||||
return happydns.NewIdentifierFromString(key[i+1:])
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
194
internal/usecase/checker/janitor.go
Normal file
194
internal/usecase/checker/janitor.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// 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 according to the tiered
|
||||
// RetentionPolicy. It is the long-tail enforcement counterpart of the cheap
|
||||
// hard cap applied at execution-creation time.
|
||||
type Janitor struct {
|
||||
planStore CheckPlanStorage
|
||||
execStore ExecutionStorage
|
||||
userResolver JanitorUserResolver
|
||||
defaultPolicy RetentionPolicy
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
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.
|
||||
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
return &Janitor{
|
||||
planStore: planStore,
|
||||
execStore: execStore,
|
||||
userResolver: userResolver,
|
||||
defaultPolicy: defaultPolicy,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the janitor loop in a goroutine. It runs an immediate sweep
|
||||
// once the loop is up.
|
||||
func (j *Janitor) Start(ctx context.Context) {
|
||||
j.mu.Lock()
|
||||
if j.running {
|
||||
j.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
j.cancel = cancel
|
||||
j.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.running = false
|
||||
j.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
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. Returns the number of executions deleted.
|
||||
func (j *Janitor) RunOnce(ctx context.Context) int {
|
||||
iter, err := j.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list check plans: %v", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
deleted := 0
|
||||
|
||||
// Cache user policies to avoid resolving the same user repeatedly.
|
||||
policyByUser := map[string]RetentionPolicy{}
|
||||
|
||||
for iter.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return deleted
|
||||
default:
|
||||
}
|
||||
|
||||
plan := iter.Item()
|
||||
if plan == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
if len(execs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
policy := j.policyForTarget(plan.Target, policyByUser)
|
||||
_, drop := policy.Decide(execs, now)
|
||||
|
||||
for _, id := range drop {
|
||||
if err := j.execStore.DeleteExecution(id); err != nil {
|
||||
log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("Janitor: pruned %d executions", deleted)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
|
||||
uid := target.UserId
|
||||
if uid == "" || j.userResolver == nil {
|
||||
return j.defaultPolicy
|
||||
}
|
||||
if p, ok := cache[uid]; ok {
|
||||
return p
|
||||
}
|
||||
policy := j.defaultPolicy
|
||||
id, err := happydns.NewIdentifierFromString(uid)
|
||||
if err == nil {
|
||||
if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
|
||||
if user.Quota.RetentionDays > 0 {
|
||||
policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
cache[uid] = policy
|
||||
return policy
|
||||
}
|
||||
200
internal/usecase/checker/retention.go
Normal file
200
internal/usecase/checker/retention.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// 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 (
|
||||
"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: 1,
|
||||
HourlyBucketDays: 7,
|
||||
PerHourKept: 1,
|
||||
DailyBucketDays: 30,
|
||||
PerDayKept: 2,
|
||||
WeeklyBucketDays: max(retentionDays/2, 31),
|
||||
PerWeekKept: 1,
|
||||
PerMonthKept: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Decide partitions executions into the ones to keep and the ones to drop
|
||||
// according to the policy. The function is pure: it does not touch storage.
|
||||
//
|
||||
// Executions are grouped by (CheckerID, Target) and ordered most-recent-first
|
||||
// inside each group, so the newest execution in a bucket is the one preserved.
|
||||
func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) {
|
||||
if len(executions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Group by (checker, target).
|
||||
groups := map[string][]*happydns.Execution{}
|
||||
for _, e := range executions {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
key := e.CheckerID + "|" + e.Target.String()
|
||||
groups[key] = append(groups[key], e)
|
||||
}
|
||||
|
||||
hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
|
||||
fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
|
||||
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 time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006") + "-W" + twoDigits(week)
|
||||
}
|
||||
|
||||
func twoDigits(n int) string {
|
||||
if n < 10 {
|
||||
return "0" + itoa(n)
|
||||
}
|
||||
return itoa(n)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
149
internal/usecase/checker/retention_test.go
Normal file
149
internal/usecase/checker/retention_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// 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_HardCutoff(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(30)
|
||||
|
||||
execs := []*happydns.Execution{
|
||||
mkExec("recent", 1*24*time.Hour, now),
|
||||
mkExec("old", 100*24*time.Hour, now),
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != 1 || string(keep[0]) != "recent" {
|
||||
t.Fatalf("expected 'recent' to be kept, got %v", keep)
|
||||
}
|
||||
if len(drop) != 1 || string(drop[0]) != "old" {
|
||||
t.Fatalf("expected 'old' to be dropped, got %v", drop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_GroupedByTarget(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 5 executions same day, 10 days ago, two different targets.
|
||||
mk := func(id, dom string) *happydns.Execution {
|
||||
return &happydns.Execution{
|
||||
Id: happydns.Identifier(id),
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{DomainId: dom},
|
||||
StartedAt: now.Add(-10 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 5; i++ {
|
||||
execs = append(execs, mk(fmt.Sprintf("a%d", i), "a.example"))
|
||||
execs = append(execs, mk(fmt.Sprintf("b%d", i), "b.example"))
|
||||
}
|
||||
|
||||
keep, _ := p.Decide(execs, now)
|
||||
// PerDayKept per group => 2 * 2 groups = 4
|
||||
if len(keep) != 2*p.PerDayKept {
|
||||
t.Fatalf("expected %d keeps, got %d", 2*p.PerDayKept, len(keep))
|
||||
}
|
||||
}
|
||||
781
internal/usecase/checker/scheduler.go
Normal file
781
internal/usecase/checker/scheduler.go
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
minSpacing = 2 * time.Second
|
||||
maxCatchUpWindow = 10 * time.Minute
|
||||
defaultInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// SchedulerJob represents a single scheduled checker execution.
|
||||
type SchedulerJob struct {
|
||||
CheckerID string `json:"checkerID"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
PlanID *happydns.Identifier `json:"planID" swaggertype:"string"`
|
||||
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
index int // heap index
|
||||
}
|
||||
|
||||
// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun.
|
||||
type SchedulerQueue []*SchedulerJob
|
||||
|
||||
func (q SchedulerQueue) Len() int { return len(q) }
|
||||
func (q SchedulerQueue) Less(i, j int) bool { return q[i].NextRun.Before(q[j].NextRun) }
|
||||
func (q SchedulerQueue) Swap(i, j int) {
|
||||
q[i], q[j] = q[j], q[i]
|
||||
q[i].index = i
|
||||
q[j].index = j
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Push(x any) {
|
||||
n := len(*q)
|
||||
job := x.(*SchedulerJob)
|
||||
job.index = n
|
||||
*q = append(*q, job)
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Pop() any {
|
||||
old := *q
|
||||
n := len(old)
|
||||
job := old[n-1]
|
||||
old[n-1] = nil
|
||||
job.index = -1
|
||||
*q = old[:n-1]
|
||||
return job
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Peek() *SchedulerJob {
|
||||
if len(*q) == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*q)[0]
|
||||
}
|
||||
|
||||
// SchedulerStatus holds a snapshot of the scheduler's current state.
|
||||
type SchedulerStatus struct {
|
||||
Running bool `json:"running"`
|
||||
JobCount int `json:"job_count"`
|
||||
NextJobs []*SchedulerJob `json:"next_jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Scheduler manages periodic execution of checkers.
|
||||
type Scheduler struct {
|
||||
queue SchedulerQueue
|
||||
jobKeys map[string]bool
|
||||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
wake chan struct{}
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
maxConcurrency int
|
||||
|
||||
// gate, if set, is consulted before launching each job. Returning false
|
||||
// causes the scheduler to skip (and reschedule) the job, e.g. when the
|
||||
// owning user is paused or has been inactive for too long.
|
||||
gate func(target happydns.CheckTarget) bool
|
||||
}
|
||||
|
||||
// SetGate installs a job gate evaluated before each execution. It is safe to
|
||||
// call after Start(); the gate is consulted on every job pop.
|
||||
func (s *Scheduler) SetGate(gate func(target happydns.CheckTarget) bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.gate = gate
|
||||
}
|
||||
|
||||
// NewScheduler creates a new Scheduler.
|
||||
func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, zoneStore ZoneGetter, stateStore SchedulerStateStorage) *Scheduler {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
return &Scheduler{
|
||||
engine: engine,
|
||||
planStore: planStore,
|
||||
domainStore: domainStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
jobKeys: make(map[string]bool),
|
||||
wake: make(chan struct{}, 1),
|
||||
maxConcurrency: maxConcurrency,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scheduler loop in a goroutine.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.mu.Lock()
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
s.done = make(chan struct{})
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
s.mu.Unlock()
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the scheduler and waits for in-flight workers to finish.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns a snapshot of the scheduler's current state.
|
||||
func (s *Scheduler) GetStatus() SchedulerStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
status := SchedulerStatus{
|
||||
Running: s.running,
|
||||
JobCount: s.queue.Len(),
|
||||
}
|
||||
|
||||
n := min(20, s.queue.Len())
|
||||
if n > 0 {
|
||||
tmp := make(SchedulerQueue, s.queue.Len())
|
||||
copy(tmp, s.queue)
|
||||
for i, job := range tmp {
|
||||
cp := *job
|
||||
cp.index = i
|
||||
tmp[i] = &cp
|
||||
}
|
||||
status.NextJobs = make([]*SchedulerJob, 0, n)
|
||||
for range n {
|
||||
status.NextJobs = append(status.NextJobs, heap.Pop(&tmp).(*SchedulerJob))
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// SetEnabled starts or stops the scheduler. The provided ctx is used as the
|
||||
// parent context for the new scheduler loop when enabled is true.
|
||||
func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error {
|
||||
s.mu.RLock()
|
||||
wasRunning := s.running
|
||||
s.mu.RUnlock()
|
||||
|
||||
if wasRunning {
|
||||
s.Stop()
|
||||
}
|
||||
if enabled {
|
||||
s.Start(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildQueue rebuilds the scheduler queue and returns the new job count.
|
||||
func (s *Scheduler) RebuildQueue() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
return s.queue.Len()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Scheduler: panic in run loop: %v", r)
|
||||
}
|
||||
s.wg.Wait()
|
||||
close(s.done)
|
||||
}()
|
||||
|
||||
sem := make(chan struct{}, s.maxConcurrency)
|
||||
|
||||
for {
|
||||
s.mu.RLock()
|
||||
qLen := s.queue.Len()
|
||||
s.mu.RUnlock()
|
||||
|
||||
if qLen == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.wake:
|
||||
continue
|
||||
case <-time.After(1 * time.Minute):
|
||||
s.mu.Lock()
|
||||
s.buildQueue()
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
next := s.queue.Peek()
|
||||
var delay time.Duration
|
||||
if next != nil {
|
||||
delay = time.Until(next.NextRun)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
timer.Stop()
|
||||
continue
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.queue.Len() == 0 {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
job := heap.Pop(&s.queue).(*SchedulerJob)
|
||||
gate := s.gate
|
||||
s.mu.Unlock()
|
||||
|
||||
// Honour the user-level gate before doing any work.
|
||||
if gate != nil && !gate(job.Target) {
|
||||
log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String())
|
||||
now := time.Now()
|
||||
for job.NextRun.Before(now) {
|
||||
job.NextRun = job.NextRun.Add(job.Interval)
|
||||
}
|
||||
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
s.mu.Lock()
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Find plan if applicable.
|
||||
var plan *happydns.CheckPlan
|
||||
if job.PlanID != nil {
|
||||
p, err := s.planStore.GetCheckPlan(*job.PlanID)
|
||||
if err == nil {
|
||||
plan = p
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire a concurrency slot, but stay responsive to cancellation.
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
default:
|
||||
log.Printf("Scheduler: all %d workers busy, waiting for a slot (checker %s on %s)", s.maxConcurrency, job.CheckerID, job.Target.String())
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func(j *SchedulerJob, p *happydns.CheckPlan) {
|
||||
defer func() { <-sem; s.wg.Done() }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Scheduler: panic in worker for checker %s on %s: %v", j.CheckerID, j.Target.String(), r)
|
||||
}
|
||||
}()
|
||||
log.Printf("Scheduler: running checker %s on %s", j.CheckerID, j.Target.String())
|
||||
exec, err := s.engine.CreateExecution(j.CheckerID, j.Target, p)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: checker %s on %s failed to create execution: %v", j.CheckerID, j.Target.String(), err)
|
||||
return
|
||||
}
|
||||
_, err = s.engine.RunExecution(ctx, exec, p, nil)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: checker %s on %s failed: %v", j.CheckerID, j.Target.String(), err)
|
||||
}
|
||||
if s.stateStore != nil {
|
||||
if err := s.stateStore.SetLastSchedulerRun(time.Now()); err != nil {
|
||||
log.Printf("Scheduler: failed to persist last run time: %v", err)
|
||||
}
|
||||
}
|
||||
}(job, plan)
|
||||
|
||||
// Advance to next cycle, skipping past cycles.
|
||||
now := time.Now()
|
||||
for job.NextRun.Before(now) {
|
||||
job.NextRun = job.NextRun.Add(job.Interval)
|
||||
}
|
||||
// Add jitter for next cycle.
|
||||
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
|
||||
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)
|
||||
}
|
||||
727
internal/usecase/checker/scheduler_test.go
Normal file
727
internal/usecase/checker/scheduler_test.go
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
// 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)
|
||||
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_SetGate(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
domain := &happydns.Domain{
|
||||
Id: did,
|
||||
Owner: uid,
|
||||
DomainName: "gate-test.example.",
|
||||
}
|
||||
|
||||
sched, _, _ := newTestScheduler(engine, []*happydns.Domain{domain})
|
||||
|
||||
var gated atomic.Int32
|
||||
sched.SetGate(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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
251
model/checker.go
Normal file
251
model/checker.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// The types and helpers needed by external checker plugins live in the
|
||||
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
|
||||
// aliases so the rest of the happyDomain codebase keeps relying on this model.
|
||||
//
|
||||
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
|
||||
// defined in this file because they describe orchestration state that is
|
||||
// internal to the happyDomain server and never crosses the plugin boundary.
|
||||
|
||||
// --- Re-exports from checker-sdk-go ---
|
||||
|
||||
type CheckScopeType = sdk.CheckScopeType
|
||||
|
||||
const (
|
||||
CheckScopeAdmin = sdk.CheckScopeAdmin
|
||||
CheckScopeUser = sdk.CheckScopeUser
|
||||
CheckScopeDomain = sdk.CheckScopeDomain
|
||||
CheckScopeZone = sdk.CheckScopeZone
|
||||
CheckScopeService = sdk.CheckScopeService
|
||||
)
|
||||
|
||||
const (
|
||||
AutoFillDomainName = sdk.AutoFillDomainName
|
||||
AutoFillSubdomain = sdk.AutoFillSubdomain
|
||||
AutoFillZone = sdk.AutoFillZone
|
||||
AutoFillServiceType = sdk.AutoFillServiceType
|
||||
AutoFillService = sdk.AutoFillService
|
||||
)
|
||||
|
||||
type (
|
||||
CheckTarget = sdk.CheckTarget
|
||||
CheckerAvailability = sdk.CheckerAvailability
|
||||
CheckerOptions = sdk.CheckerOptions
|
||||
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
|
||||
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
|
||||
Status = sdk.Status
|
||||
CheckState = sdk.CheckState
|
||||
CheckMetric = sdk.CheckMetric
|
||||
ObservationKey = sdk.ObservationKey
|
||||
CheckIntervalSpec = sdk.CheckIntervalSpec
|
||||
ObservationProvider = sdk.ObservationProvider
|
||||
CheckRuleInfo = sdk.CheckRuleInfo
|
||||
CheckRule = sdk.CheckRule
|
||||
CheckRuleWithOptions = sdk.CheckRuleWithOptions
|
||||
ObservationGetter = sdk.ObservationGetter
|
||||
CheckAggregator = sdk.CheckAggregator
|
||||
CheckerHTMLReporter = sdk.CheckerHTMLReporter
|
||||
CheckerMetricsReporter = sdk.CheckerMetricsReporter
|
||||
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
|
||||
CheckerDefinition = sdk.CheckerDefinition
|
||||
OptionsValidator = sdk.OptionsValidator
|
||||
ExternalCollectRequest = sdk.ExternalCollectRequest
|
||||
ExternalCollectResponse = sdk.ExternalCollectResponse
|
||||
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
|
||||
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
|
||||
ExternalReportRequest = sdk.ExternalReportRequest
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOK = sdk.StatusOK
|
||||
StatusInfo = sdk.StatusInfo
|
||||
StatusUnknown = sdk.StatusUnknown
|
||||
StatusWarn = sdk.StatusWarn
|
||||
StatusCrit = sdk.StatusCrit
|
||||
StatusError = sdk.StatusError
|
||||
)
|
||||
|
||||
// --- Helpers for converting between target identifier strings and *Identifier ---
|
||||
|
||||
// TargetIdentifier parses a target identifier string into an *Identifier.
|
||||
// Returns nil if the string is empty or cannot be parsed.
|
||||
func TargetIdentifier(s string) *Identifier {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
id, err := NewIdentifierFromString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
// FormatIdentifier returns the string representation of id, or "" if nil.
|
||||
func FormatIdentifier(id *Identifier) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
|
||||
// --- Host-only types (orchestration state) ---
|
||||
|
||||
// CheckerRunRequest is the JSON body for manually triggering a checker.
|
||||
type CheckerRunRequest struct {
|
||||
Options CheckerOptions `json:"options,omitempty"`
|
||||
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
|
||||
}
|
||||
|
||||
// CheckerOptionsPositional stores options with their positional key components.
|
||||
type CheckerOptionsPositional struct {
|
||||
CheckName string `json:"checkName"`
|
||||
UserId *Identifier `json:"userId,omitempty"`
|
||||
DomainId *Identifier `json:"domainId,omitempty"`
|
||||
ServiceId *Identifier `json:"serviceId,omitempty"`
|
||||
|
||||
Options CheckerOptions `json:"options"`
|
||||
}
|
||||
|
||||
// CheckPlan is an optional user override for a checker on a specific target.
|
||||
type CheckPlan struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
|
||||
Enabled map[string]bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
|
||||
func (p *CheckPlan) IsFullyDisabled() bool {
|
||||
if len(p.Enabled) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range p.Enabled {
|
||||
if v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRuleEnabled returns whether a specific rule is enabled.
|
||||
// A nil or empty map means all rules are enabled. A missing key means enabled.
|
||||
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
|
||||
if len(p.Enabled) == 0 {
|
||||
return true
|
||||
}
|
||||
v, ok := p.Enabled[ruleName]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
|
||||
type CheckerStatus struct {
|
||||
*CheckerDefinition
|
||||
LatestExecution *Execution `json:"latestExecution,omitempty"`
|
||||
Plan *CheckPlan `json:"plan,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnabledRules map[string]bool `json:"enabledRules"`
|
||||
}
|
||||
|
||||
// CheckEvaluation is the result of running a checker on observed data.
|
||||
type CheckEvaluation struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
|
||||
CheckerID string `json:"checkerId" binding:"required"`
|
||||
Target CheckTarget `json:"target" binding:"required"`
|
||||
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
States []CheckState `json:"states" binding:"required" readonly:"true"`
|
||||
}
|
||||
|
||||
// ObservationSnapshot holds data collected during an execution.
|
||||
type ObservationSnapshot struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
|
||||
}
|
||||
|
||||
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
|
||||
type ObservationCacheEntry struct {
|
||||
SnapshotID Identifier `json:"snapshotId"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
// ExecutionStatus represents the lifecycle state of an execution.
|
||||
type ExecutionStatus int
|
||||
|
||||
const (
|
||||
ExecutionPending ExecutionStatus = iota
|
||||
ExecutionRunning
|
||||
ExecutionDone
|
||||
ExecutionFailed
|
||||
)
|
||||
|
||||
// TriggerType represents what initiated an execution.
|
||||
type TriggerType int
|
||||
|
||||
const (
|
||||
TriggerManual TriggerType = iota
|
||||
TriggerSchedule
|
||||
)
|
||||
|
||||
// TriggerInfo describes the trigger for an execution.
|
||||
type TriggerInfo struct {
|
||||
Type TriggerType `json:"type"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
|
||||
}
|
||||
|
||||
// Execution represents a single run of a checker pipeline.
|
||||
type Execution struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
|
||||
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
|
||||
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
|
||||
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
|
||||
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
|
||||
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
|
||||
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
|
||||
Error string `json:"error,omitempty" readonly:"true"`
|
||||
Result CheckState `json:"result" readonly:"true"`
|
||||
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
|
||||
}
|
||||
|
||||
// CheckerEngine orchestrates the full checker pipeline.
|
||||
type CheckerEngine interface {
|
||||
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
|
||||
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
|
||||
}
|
||||
163
model/checker_test.go
Normal file
163
model/checker_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestCheckPlan_IsFullyDisabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled map[string]bool
|
||||
want bool
|
||||
}{
|
||||
{"nil map", nil, false},
|
||||
{"empty map", map[string]bool{}, false},
|
||||
{"all false", map[string]bool{"a": false, "b": false}, true},
|
||||
{"one true", map[string]bool{"a": false, "b": true}, false},
|
||||
{"all true", map[string]bool{"a": true, "b": true}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &happydns.CheckPlan{Enabled: tt.enabled}
|
||||
if got := p.IsFullyDisabled(); got != tt.want {
|
||||
t.Errorf("IsFullyDisabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPlan_IsRuleEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled map[string]bool
|
||||
rule string
|
||||
want bool
|
||||
}{
|
||||
{"nil map", nil, "any", true},
|
||||
{"empty map", map[string]bool{}, "any", true},
|
||||
{"rule explicitly enabled", map[string]bool{"r1": true}, "r1", true},
|
||||
{"rule explicitly disabled", map[string]bool{"r1": false}, "r1", false},
|
||||
{"rule missing from map", map[string]bool{"r1": false}, "r2", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &happydns.CheckPlan{Enabled: tt.enabled}
|
||||
if got := p.IsRuleEnabled(tt.rule); got != tt.want {
|
||||
t.Errorf("IsRuleEnabled(%q) = %v, want %v", tt.rule, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetIdentifier(t *testing.T) {
|
||||
if got := happydns.TargetIdentifier(""); got != nil {
|
||||
t.Errorf("TargetIdentifier(\"\") = %v, want nil", got)
|
||||
}
|
||||
|
||||
if got := happydns.TargetIdentifier("not-valid-hex"); got != nil {
|
||||
t.Errorf("TargetIdentifier(\"not-valid-hex\") = %v, want nil", got)
|
||||
}
|
||||
|
||||
id, err := happydns.NewRandomIdentifier()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRandomIdentifier: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
got := happydns.TargetIdentifier(s)
|
||||
if got == nil {
|
||||
t.Fatalf("TargetIdentifier(%q) = nil, want non-nil", s)
|
||||
}
|
||||
if !got.Equals(id) {
|
||||
t.Errorf("TargetIdentifier(%q) = %v, want %v", s, got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIdentifier(t *testing.T) {
|
||||
if got := happydns.FormatIdentifier(nil); got != "" {
|
||||
t.Errorf("FormatIdentifier(nil) = %q, want empty", got)
|
||||
}
|
||||
|
||||
id, err := happydns.NewRandomIdentifier()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRandomIdentifier: %v", err)
|
||||
}
|
||||
got := happydns.FormatIdentifier(&id)
|
||||
if got != id.String() {
|
||||
t.Errorf("FormatIdentifier(&id) = %q, want %q", got, id.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldFromCheckerOption(t *testing.T) {
|
||||
opt := happydns.CheckerOptionDocumentation{
|
||||
Id: "myopt",
|
||||
Type: "string",
|
||||
Label: "My Option",
|
||||
Placeholder: "enter value",
|
||||
Default: "default-val",
|
||||
Choices: []string{"a", "b"},
|
||||
Required: true,
|
||||
Secret: true,
|
||||
Hide: true,
|
||||
Textarea: true,
|
||||
Description: "help text",
|
||||
}
|
||||
|
||||
f := happydns.FieldFromCheckerOption(opt)
|
||||
|
||||
if f.Id != opt.Id {
|
||||
t.Errorf("Id = %q, want %q", f.Id, opt.Id)
|
||||
}
|
||||
if f.Type != opt.Type {
|
||||
t.Errorf("Type = %q, want %q", f.Type, opt.Type)
|
||||
}
|
||||
if f.Label != opt.Label {
|
||||
t.Errorf("Label = %q, want %q", f.Label, opt.Label)
|
||||
}
|
||||
if f.Placeholder != opt.Placeholder {
|
||||
t.Errorf("Placeholder = %q, want %q", f.Placeholder, opt.Placeholder)
|
||||
}
|
||||
if f.Default != opt.Default {
|
||||
t.Errorf("Default = %v, want %v", f.Default, opt.Default)
|
||||
}
|
||||
if len(f.Choices) != len(opt.Choices) {
|
||||
t.Errorf("Choices len = %d, want %d", len(f.Choices), len(opt.Choices))
|
||||
}
|
||||
if f.Required != opt.Required {
|
||||
t.Errorf("Required = %v, want %v", f.Required, opt.Required)
|
||||
}
|
||||
if f.Secret != opt.Secret {
|
||||
t.Errorf("Secret = %v, want %v", f.Secret, opt.Secret)
|
||||
}
|
||||
if f.Hide != opt.Hide {
|
||||
t.Errorf("Hide = %v, want %v", f.Hide, opt.Hide)
|
||||
}
|
||||
if f.Textarea != opt.Textarea {
|
||||
t.Errorf("Textarea = %v, want %v", f.Textarea, opt.Textarea)
|
||||
}
|
||||
if f.Description != opt.Description {
|
||||
t.Errorf("Description = %q, want %q", f.Description, opt.Description)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
"net/mail"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options stores the configuration of the software.
|
||||
|
|
@ -93,12 +94,34 @@ type Options struct {
|
|||
|
||||
OIDCClients []OIDCSettings
|
||||
|
||||
// CheckerMaxConcurrency is the maximum number of checker jobs that can
|
||||
// run simultaneously. Defaults to runtime.NumCPU().
|
||||
CheckerMaxConcurrency int
|
||||
|
||||
// CheckerRetentionDays is the system-wide default for how many days of
|
||||
// check execution history are kept. Per-user UserQuota.RetentionDays
|
||||
// overrides this value.
|
||||
CheckerRetentionDays int
|
||||
|
||||
// CheckerJanitorInterval is how often the retention janitor runs.
|
||||
CheckerJanitorInterval time.Duration
|
||||
|
||||
// CheckerInactivityPauseDays is the system-wide default number of days
|
||||
// without login after which the scheduler stops running checks for a
|
||||
// user. 0 disables inactivity pausing globally; per-user UserQuota
|
||||
// overrides this value.
|
||||
CheckerInactivityPauseDays int
|
||||
|
||||
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
|
||||
CaptchaProvider string
|
||||
|
||||
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
|
||||
// 0 means always require captcha at login (when provider is configured).
|
||||
CaptchaLoginThreshold int
|
||||
|
||||
// PluginsDirectories lists filesystem paths scanned at startup for
|
||||
// checker plugins (.so files).
|
||||
PluginsDirectories []string
|
||||
}
|
||||
|
||||
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.
|
||||
|
|
|
|||
|
|
@ -104,9 +104,23 @@ type DomainWithZoneMetadata struct {
|
|||
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
|
||||
}
|
||||
|
||||
type DomainWithCheckStatus struct {
|
||||
*Domain
|
||||
// LastCheckStatus is the worst status across the most recent result of each
|
||||
// checker that has run on this domain. Nil if no results exist yet.
|
||||
LastCheckStatus *Status `json:"last_check_status,omitempty"`
|
||||
}
|
||||
|
||||
type Subdomain string
|
||||
type Origin string
|
||||
|
||||
// SchedulerDomainNotifier is an optional callback to notify the scheduler
|
||||
// about domain changes so it can incrementally update its job queue.
|
||||
type SchedulerDomainNotifier interface {
|
||||
NotifyDomainChange(domain *Domain)
|
||||
NotifyDomainRemoved(domainID Identifier)
|
||||
}
|
||||
|
||||
type DomainUsecase interface {
|
||||
CreateDomain(context.Context, *User, *DomainCreationInput) (*Domain, error)
|
||||
DeleteDomain(Identifier) error
|
||||
|
|
|
|||
|
|
@ -27,15 +27,20 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrAuthUserNotFound = errors.New("user not found")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAuthUserNotFound = errors.New("user not found")
|
||||
ErrCheckPlanNotFound = errors.New("check plan not found")
|
||||
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
|
||||
ErrCheckerNotFound = errors.New("checker not found")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSnapshotNotFound = errors.New("snapshot not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||
|
|
|
|||
|
|
@ -106,6 +106,25 @@ type Field struct {
|
|||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// FieldFromCheckerOption converts a CheckerOptionDocumentation into a Field,
|
||||
// mapping the common subset of attributes. Keep this in sync when either
|
||||
// struct gains new fields.
|
||||
func FieldFromCheckerOption(opt CheckerOptionDocumentation) Field {
|
||||
return Field{
|
||||
Id: opt.Id,
|
||||
Type: opt.Type,
|
||||
Label: opt.Label,
|
||||
Placeholder: opt.Placeholder,
|
||||
Default: opt.Default,
|
||||
Choices: opt.Choices,
|
||||
Required: opt.Required,
|
||||
Secret: opt.Secret,
|
||||
Hide: opt.Hide,
|
||||
Textarea: opt.Textarea,
|
||||
Description: opt.Description,
|
||||
}
|
||||
}
|
||||
|
||||
type FormState struct {
|
||||
// Id for an already existing element.
|
||||
Id *Identifier `json:"_id,omitempty" swaggertype:"string"`
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ import ()
|
|||
type TidyUpUseCase interface {
|
||||
TidyAll() error
|
||||
TidyAuthUsers() error
|
||||
TidyCheckEvaluations() error
|
||||
TidyCheckPlans() error
|
||||
TidyCheckerConfigurations() error
|
||||
TidyExecutions() error
|
||||
TidyObservationCache() error
|
||||
TidySnapshots() error
|
||||
TidyDomains() error
|
||||
TidyDomainLogs() error
|
||||
TidyProviders() error
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ type User struct {
|
|||
|
||||
// Settings holds the settings for an account.
|
||||
Settings UserSettings `json:"settings" binding:"required"`
|
||||
|
||||
// Quota holds admin-controlled limits for the account. It is never
|
||||
// writable through the user-facing API; only the admin API can update it.
|
||||
Quota UserQuota `json:"quota"`
|
||||
}
|
||||
|
||||
func (u *User) GetUserId() Identifier {
|
||||
|
|
|
|||
51
model/user_quota.go
Normal file
51
model/user_quota.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import "time"
|
||||
|
||||
// UserQuota holds admin-controlled per-user limits and flags. These fields are
|
||||
// never modifiable by the user; they can only be updated through the admin API.
|
||||
//
|
||||
// Only checker-related fields are defined for now. Future paid-plan attributes
|
||||
// (plan tier, domain caps, payment metadata, ...) will be added here later.
|
||||
type UserQuota struct {
|
||||
// MaxChecksPerDay caps the number of checker executions per day for this
|
||||
// user. 0 means "use the system default".
|
||||
MaxChecksPerDay int `json:"max_checks_per_day,omitempty"`
|
||||
|
||||
// RetentionDays is the maximum age (in days) of checker executions kept in
|
||||
// storage for this user. 0 means "use the system default".
|
||||
RetentionDays int `json:"retention_days,omitempty"`
|
||||
|
||||
// InactivityPauseDays is the number of days without login after which the
|
||||
// scheduler stops running checks for this user. 0 means "use the system
|
||||
// default". A negative value disables the inactivity pause for this user.
|
||||
InactivityPauseDays int `json:"inactivity_pause_days,omitempty"`
|
||||
|
||||
// SchedulingPaused, when true, completely disables the scheduler for this
|
||||
// user (admin kill switch).
|
||||
SchedulingPaused bool `json:"scheduling_paused,omitempty"`
|
||||
|
||||
// UpdatedAt records the last time these quotas were modified.
|
||||
UpdatedAt time.Time `json:"updated_at,omitzero" format:"date-time"`
|
||||
}
|
||||
|
|
@ -154,6 +154,14 @@ type ZoneServices struct {
|
|||
Services []*Service `json:"services"`
|
||||
}
|
||||
|
||||
// ZoneWithServicesCheckStatus wraps a Zone with the worst check status for each service.
|
||||
type ZoneWithServicesCheckStatus struct {
|
||||
*Zone
|
||||
// ServicesCheckStatus holds the worst check status for each service,
|
||||
// keyed by service identifier string. Nil/absent if no results exist yet.
|
||||
ServicesCheckStatus map[string]*Status `json:"services_check_status,omitempty"`
|
||||
}
|
||||
|
||||
type ZoneUsecase interface {
|
||||
AddRecord(*Zone, string, Record) error
|
||||
CreateZone(*Zone) error
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@
|
|||
<NavItem>
|
||||
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
|
|
|
|||
131
web-admin/src/routes/checkers/+page.svelte
Normal file
131
web-admin/src/routes/checkers/+page.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Table,
|
||||
Row,
|
||||
Badge,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { getCheckers } from "$lib/api-base";
|
||||
import { availabilityBadges } from "$lib/utils";
|
||||
|
||||
let checkersQ = $state(getCheckers());
|
||||
|
||||
let searchQuery = $state("");
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col md={8}>
|
||||
<h1 class="display-5">
|
||||
<Icon name="puzzle-fill"></Icon>
|
||||
Checkers
|
||||
</h1>
|
||||
<p class="d-flex gap-3 align-items-center text-muted">
|
||||
<span class="lead"> Manage all checkers </span>
|
||||
{#await checkersQ then checkersR}
|
||||
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
|
||||
{/await}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row class="mb-4">
|
||||
<Col md={8} lg={6}>
|
||||
<InputGroup>
|
||||
<InputGroupText>
|
||||
<Icon name="search"></Icon>
|
||||
</InputGroupText>
|
||||
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checkersQ}
|
||||
Please wait...
|
||||
{:then checkersR}
|
||||
{@const checkers = checkersR.data}
|
||||
<div class="table-responsive">
|
||||
<Table hover bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin Name</th>
|
||||
<th>Availability</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !checkers || Object.keys(checkers).length == 0}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-muted py-2">
|
||||
No checkers available
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
|
||||
.toLowerCase()
|
||||
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]}
|
||||
<tr>
|
||||
<td><strong>{checkerInfo.name || checkerId}</strong></td>
|
||||
<td>
|
||||
{#if availabilityBadges(checkerInfo.availability).length > 0}
|
||||
{#each availabilityBadges(checkerInfo.availability) as badge}
|
||||
<Badge color={badge.color} class="me-1">{badge.label}</Badge>
|
||||
{/each}
|
||||
{:else}
|
||||
<Badge color="secondary">General</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="/checkers/{checkerId}"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
Manage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading checkers: {error.message}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</Container>
|
||||
434
web-admin/src/routes/checkers/[checkerId]/+page.svelte
Normal file
434
web-admin/src/routes/checkers/[checkerId]/+page.svelte
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
Icon,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Row,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import {
|
||||
getCheckersByCheckerId,
|
||||
getCheckersByCheckerIdOptions,
|
||||
putCheckersByCheckerIdOptions,
|
||||
} from "$lib/api-base";
|
||||
import type { HappydnsCheckerOptionDocumentation } from "$lib/api-base";
|
||||
import ResourceInput from "$lib/components/inputs/Resource.svelte";
|
||||
import { availabilityBadges, formatDuration } from "$lib/utils";
|
||||
|
||||
let checkerId = $derived(page.params.checkerId!);
|
||||
|
||||
let checkerQ = $derived(getCheckersByCheckerId({ path: { checkerId } }));
|
||||
let checkerOptionsQ = $derived(getCheckersByCheckerIdOptions({ path: { checkerId } }));
|
||||
let optionValues = $state<Record<string, unknown>>({});
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
checkerOptionsQ.then((optionsR) => {
|
||||
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
|
||||
});
|
||||
});
|
||||
|
||||
async function saveOptions() {
|
||||
saving = true;
|
||||
try {
|
||||
await putCheckersByCheckerIdOptions({
|
||||
path: { checkerId },
|
||||
body: optionValues,
|
||||
});
|
||||
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
|
||||
toasts.addToast({
|
||||
message: `Checker options updated successfully`,
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: "Failed to update options: " + error,
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]) {
|
||||
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
|
||||
const cleanedOptions: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(optionValues)) {
|
||||
if (validOptIds.has(key)) {
|
||||
cleanedOptions[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await putCheckersByCheckerIdOptions({
|
||||
path: { checkerId },
|
||||
body: cleanedOptions,
|
||||
});
|
||||
checkerOptionsQ = getCheckersByCheckerIdOptions({ path: { checkerId } });
|
||||
toasts.addToast({
|
||||
message: `Orphaned options removed successfully`,
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: "Failed to clean options: " + error,
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrphanedOptions(adminOpts: HappydnsCheckerOptionDocumentation[]): string[] {
|
||||
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
|
||||
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col>
|
||||
<Button color="link" href="/checkers" class="mb-2">
|
||||
<Icon name="arrow-left"></Icon>
|
||||
Back to checkers
|
||||
</Button>
|
||||
<h1 class="display-5">
|
||||
<Icon name="puzzle-fill"></Icon>
|
||||
{checkerId}
|
||||
</h1>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checkerQ}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Loading checker status...
|
||||
</p>
|
||||
</Card>
|
||||
{:then checkerR}
|
||||
{@const checker = checkerR.data}
|
||||
{#if checker}
|
||||
<Row class="mb-4">
|
||||
<Col md={6}>
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>Checker Information</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Name:</dt>
|
||||
<dd class="col-sm-8">{checker.name}</dd>
|
||||
|
||||
<dt class="col-sm-4">Availability:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{#if availabilityBadges(checker.availability).length > 0}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{#each availabilityBadges(checker.availability) as badge}
|
||||
<Badge color={badge.color}
|
||||
>{badge.label}-level</Badge
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Badge color="secondary">General</Badge>
|
||||
{/if}
|
||||
{#if checker.availability?.limitToProviders?.length}
|
||||
<div class="mt-1 small text-muted">
|
||||
Providers: {checker.availability.limitToProviders.join(
|
||||
", ",
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if checker.availability?.limitToServices?.length}
|
||||
<div class="mt-1 small text-muted">
|
||||
Services: {checker.availability.limitToServices.join(
|
||||
", ",
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</dd>
|
||||
|
||||
{#if checker.interval}
|
||||
<dt class="col-sm-4">Interval:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span>default {formatDuration(checker.interval.default)}</span>
|
||||
<span class="text-muted small ms-2">
|
||||
(min {formatDuration(checker.interval.min)} / max {formatDuration(checker.interval.max)})
|
||||
</span>
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{#if checker.rules && checker.rules.length > 0}
|
||||
<Card>
|
||||
<CardHeader class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<strong>Check Rules</strong>
|
||||
<Badge color="secondary" class="ms-2">
|
||||
{checker.rules.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if checker.rules.reduce((acc, rule) => acc + rule.options?.adminOpts?.length, 0) > 0}
|
||||
<Button
|
||||
color="success"
|
||||
size="sm"
|
||||
onclick={saveOptions}
|
||||
disabled={saving}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="spinner-border spinner-border-sm me-1"
|
||||
></span>
|
||||
{:else}
|
||||
<Icon name="check-circle"></Icon>
|
||||
{/if}
|
||||
Save
|
||||
</Button>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<ListGroup flush>
|
||||
{#each checker.rules as rule, i}
|
||||
{@const ruleOpts = rule.options?.adminOpts || []}
|
||||
<ListGroupItem>
|
||||
<div class="d-flex align-items-start gap-2 mb-1">
|
||||
<Icon
|
||||
name="check2-circle"
|
||||
class="text-success mt-1 flex-shrink-0"
|
||||
></Icon>
|
||||
<div class="flex-grow-1">
|
||||
<strong>{rule.name}</strong>
|
||||
{#if rule.description}
|
||||
<p class="text-muted small mb-0">
|
||||
{rule.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if ruleOpts.length > 0}
|
||||
<div class="ms-4 mt-2">
|
||||
<Form onsubmit={saveOptions}>
|
||||
{#each ruleOpts as optDoc, index}
|
||||
{#if optDoc.id}
|
||||
<ResourceInput
|
||||
edit
|
||||
index={"" + index}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={optionValues[optDoc.id]}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</Form>
|
||||
</div>
|
||||
{/if}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</Card>
|
||||
{/if}
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
{#await checkerOptionsQ}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Loading options...
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:then _optionsR}
|
||||
{@const adminOpts = checker.options?.adminOpts || []}
|
||||
{@const readOnlyOptGroups = [
|
||||
{
|
||||
key: "userOpts",
|
||||
label: "User Options",
|
||||
opts: checker.options?.userOpts || [],
|
||||
},
|
||||
{
|
||||
key: "domainOpts",
|
||||
label: "Domain Options",
|
||||
opts: checker.options?.domainOpts || [],
|
||||
},
|
||||
{
|
||||
key: "serviceOpts",
|
||||
label: "Service Options",
|
||||
opts: checker.options?.serviceOpts || [],
|
||||
},
|
||||
{
|
||||
key: "runOpts",
|
||||
label: "Run Options",
|
||||
opts: checker.options?.runOpts || [],
|
||||
},
|
||||
]}
|
||||
{@const rulesAdminOpts = (checker.rules || []).flatMap(
|
||||
(r) => r.options?.adminOpts || [],
|
||||
)}
|
||||
{@const allAdminOpts = [...adminOpts, ...rulesAdminOpts]}
|
||||
{@const hasAnyOpts =
|
||||
allAdminOpts.length > 0 ||
|
||||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
|
||||
{@const orphanedOpts = getOrphanedOptions(allAdminOpts)}
|
||||
|
||||
{#if orphanedOpts.length > 0}
|
||||
<Alert color="warning" class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
<strong>Orphaned options detected:</strong>
|
||||
{orphanedOpts.join(", ")}
|
||||
</div>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() => cleanOrphanedOptions(allAdminOpts)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Icon name="trash"></Icon>
|
||||
Clean Up
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if adminOpts.length > 0}
|
||||
<Card class="mb-3">
|
||||
<CardHeader
|
||||
class="d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<strong>Admin Options</strong>
|
||||
<Button
|
||||
form="adminoptsform"
|
||||
color="success"
|
||||
size="sm"
|
||||
onclick={saveOptions}
|
||||
disabled={saving}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="spinner-border spinner-border-sm me-1"
|
||||
></span>
|
||||
{:else}
|
||||
<Icon name="check-circle"></Icon>
|
||||
{/if}
|
||||
Save
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form id="adminoptsform" onsubmit={saveOptions}>
|
||||
{#each adminOpts as optDoc, index}
|
||||
{#if optDoc.id}
|
||||
<ResourceInput
|
||||
edit
|
||||
index={"" + index}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={optionValues[optDoc.id]}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#each readOnlyOptGroups.filter((g) => g.opts.length > 0) as group}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>{group.label}</strong>
|
||||
<Badge color="secondary" class="ms-2">read-only</Badge>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
{#each group.opts as opt}
|
||||
<dt class="col-sm-4">{opt.label || opt.id}</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="text-muted small"
|
||||
>{opt.type || "string"}</span
|
||||
>
|
||||
{#if opt.description}
|
||||
<div class="form-text">{opt.description}</div>
|
||||
{/if}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/each}
|
||||
|
||||
{#if !hasAnyOpts}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
This checker has no configurable options.
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="danger" class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading options: {error.message}
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/await}
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error: checker data not found
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading checker: {error.message}
|
||||
</Alert>
|
||||
{/await}
|
||||
</Container>
|
||||
262
web-admin/src/routes/scheduler/+page.svelte
Normal file
262
web-admin/src/routes/scheduler/+page.svelte
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
Spinner,
|
||||
Table,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import {
|
||||
getScheduler,
|
||||
postSchedulerEnable,
|
||||
postSchedulerDisable,
|
||||
postSchedulerRescheduleUpcoming,
|
||||
} from "$lib/api-admin";
|
||||
import type { CheckerSchedulerStatus } from "$lib/api-admin";
|
||||
import { formatDuration, formatRelative } from "$lib/utils/datetime";
|
||||
|
||||
let status = $state<CheckerSchedulerStatus | null>(null);
|
||||
let loading = $state(true);
|
||||
let toggling = $state(false);
|
||||
let rescheduling = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function fetchStatus() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const { data, error: err } = await getScheduler();
|
||||
if (err) throw new Error(String(err));
|
||||
status = data ?? null;
|
||||
} catch (e: any) {
|
||||
error = e.message ?? "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScheduler() {
|
||||
if (!status) return;
|
||||
toggling = true;
|
||||
error = null;
|
||||
try {
|
||||
const fn = status.running ? postSchedulerDisable : postSchedulerEnable;
|
||||
const { data, error: err } = await fn();
|
||||
if (err) throw new Error(String(err));
|
||||
status = data ?? null;
|
||||
} catch (e: any) {
|
||||
error = e.message ?? "Unknown error";
|
||||
} finally {
|
||||
toggling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildQueue() {
|
||||
rescheduling = true;
|
||||
error = null;
|
||||
try {
|
||||
const { error: err } = await postSchedulerRescheduleUpcoming();
|
||||
if (err) throw new Error(String(err));
|
||||
await fetchStatus();
|
||||
} catch (e: any) {
|
||||
error = e.message ?? "Unknown error";
|
||||
} finally {
|
||||
rescheduling = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(fetchStatus);
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col>
|
||||
<h1 class="display-5">
|
||||
<Icon name="clock-history"></Icon>
|
||||
Scheduler
|
||||
</h1>
|
||||
<p class="text-muted lead">Monitor and control the checker scheduler</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#if error}
|
||||
<Card color="danger" body class="mb-4">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{error}
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading scheduler status...</span>
|
||||
</div>
|
||||
{:else if status}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<Icon name="info-circle-fill"></Icon>
|
||||
Scheduler Status
|
||||
</span>
|
||||
<div class="d-flex gap-2">
|
||||
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
|
||||
<Icon name="arrow-clockwise"></Icon> Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={status.running ? "warning" : "success"}
|
||||
disabled={toggling}
|
||||
onclick={toggleScheduler}
|
||||
>
|
||||
{#if toggling}
|
||||
<Spinner size="sm" />
|
||||
{:else if status.running}
|
||||
<Icon name="stop-fill"></Icon> Stop
|
||||
{:else}
|
||||
<Icon name="play-fill"></Icon> Start
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
outline
|
||||
disabled={rescheduling}
|
||||
onclick={rebuildQueue}
|
||||
>
|
||||
{#if rescheduling}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="calendar2-check"></Icon> Rebuild queue
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="d-flex gap-4 align-items-center">
|
||||
<div>
|
||||
<small class="text-muted d-block">Status</small>
|
||||
{#if status.running}
|
||||
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted d-block">Jobs in queue</small>
|
||||
<strong>{status.job_count ?? 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon name="list-ol"></Icon>
|
||||
Next scheduled jobs
|
||||
<Badge color="secondary" class="ms-2">{status.next_jobs?.length ?? 0}</Badge>
|
||||
</CardHeader>
|
||||
<CardBody class="p-0">
|
||||
<div class="table-responsive">
|
||||
<Table hover class="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Checker</th>
|
||||
<th>Target</th>
|
||||
<th>Interval</th>
|
||||
<th>Next run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !status.next_jobs || status.next_jobs.length === 0}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-3">
|
||||
No jobs scheduled
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each status.next_jobs as job}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{job.checkerID ?? "—"}</code>
|
||||
</td>
|
||||
<td>
|
||||
{#if job.target?.domainId}
|
||||
<Badge
|
||||
href={"/domains/" + job.target?.domainId}
|
||||
color="info"
|
||||
class="me-1"
|
||||
>
|
||||
domain
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job.target?.serviceId}
|
||||
<Badge
|
||||
href={"/service/" + job.target?.serviceId}
|
||||
color="warning"
|
||||
class="me-1"
|
||||
>
|
||||
service
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job.target?.userId}
|
||||
<Badge
|
||||
href={"/users/" + job.target?.userId}
|
||||
color="secondary"
|
||||
class="me-1"
|
||||
>
|
||||
user
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId}
|
||||
<span class="text-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{formatDuration(job.interval)}</td>
|
||||
<td>
|
||||
<span title={job.nextRun}
|
||||
>{formatRelative(job.nextRun)}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</Container>
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
|
||||
import { getUsersByUid, getUsersByUidDomains, getUsersByUidProviders } from "$lib/api-admin";
|
||||
import UserInfoCard from "./UserInfoCard.svelte";
|
||||
import UserQuotaCard from "./UserQuotaCard.svelte";
|
||||
import UserDomainsCard from "./domains/UserDomainsCard.svelte";
|
||||
import UserProvidersCard from "./providers/UserProvidersCard.svelte";
|
||||
|
||||
|
|
@ -55,8 +56,9 @@
|
|||
{@const user = userR.data}
|
||||
{#if user}
|
||||
<Row>
|
||||
<Col md={8} lg={6}>
|
||||
<Col md={8} lg={6} class="d-flex flex-column gap-4">
|
||||
<UserInfoCard {user} {uid} />
|
||||
<UserQuotaCard {user} {uid} />
|
||||
</Col>
|
||||
|
||||
<Col md={8} lg={6} class="d-flex flex-column gap-4">
|
||||
|
|
|
|||
198
web-admin/src/routes/users/[uid]/UserQuotaCard.svelte
Normal file
198
web-admin/src/routes/users/[uid]/UserQuotaCard.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Form,
|
||||
FormGroup,
|
||||
FormText,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { putUsersByUid } from "$lib/api-admin";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
|
||||
import type { HappydnsUser, HappydnsUserQuota } from "$lib/api-admin";
|
||||
|
||||
interface UserQuotaCardProps {
|
||||
user: HappydnsUser;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
let { user, uid }: UserQuotaCardProps = $props();
|
||||
|
||||
let maxChecksPerDay = $state(0);
|
||||
let retentionDays = $state(0);
|
||||
let inactivityPauseDays = $state(0);
|
||||
let schedulingPaused = $state(false);
|
||||
let updatedAt = $state<string | undefined>(undefined);
|
||||
|
||||
let loading = $state(false);
|
||||
let errorMessage = $state("");
|
||||
|
||||
$effect(() => {
|
||||
const q: HappydnsUserQuota = user?.quota ?? {};
|
||||
maxChecksPerDay = q.max_checks_per_day ?? 0;
|
||||
retentionDays = q.retention_days ?? 0;
|
||||
inactivityPauseDays = q.inactivity_pause_days ?? 0;
|
||||
schedulingPaused = q.scheduling_paused ?? false;
|
||||
updatedAt = q.updated_at;
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
errorMessage = "";
|
||||
|
||||
try {
|
||||
const body: any = {
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
last_seen: user.last_seen,
|
||||
settings: user.settings,
|
||||
quota: {
|
||||
max_checks_per_day: Number(maxChecksPerDay) || 0,
|
||||
retention_days: Number(retentionDays) || 0,
|
||||
inactivity_pause_days: Number(inactivityPauseDays) || 0,
|
||||
scheduling_paused: schedulingPaused,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await putUsersByUid({ path: { uid }, body });
|
||||
const updated = (res?.data as HappydnsUser | undefined)?.quota;
|
||||
if (updated?.updated_at) updatedAt = updated.updated_at;
|
||||
|
||||
toasts.addToast({
|
||||
message: "Quota updated successfully",
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = "Failed to update quota: " + error;
|
||||
toasts.addErrorToast({ message: errorMessage, timeout: 10000 });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<Icon name="speedometer2" class="me-2"></Icon>
|
||||
Admin Quota
|
||||
</h5>
|
||||
{#if updatedAt}
|
||||
<small class="text-muted">
|
||||
Updated {new Date(updatedAt).toLocaleString()}
|
||||
</small>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p class="text-muted small">
|
||||
These limits are controlled by administrators and cannot be modified
|
||||
by the user. A value of <code>0</code> means "use the system default".
|
||||
</p>
|
||||
|
||||
{#if errorMessage}
|
||||
<Alert color="danger" dismissible fade>{errorMessage}</Alert>
|
||||
{/if}
|
||||
|
||||
<Form on:submit={handleSubmit}>
|
||||
<FormGroup>
|
||||
<Label for="schedulingPaused" class="form-check-label">
|
||||
<Input
|
||||
type="checkbox"
|
||||
id="schedulingPaused"
|
||||
bind:checked={schedulingPaused}
|
||||
/>
|
||||
Pause scheduler for this user
|
||||
</Label>
|
||||
<FormText>
|
||||
Admin kill switch — when enabled, no checks will run for this
|
||||
user regardless of their plans.
|
||||
</FormText>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="retentionDays">Retention (days)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="retentionDays"
|
||||
min="0"
|
||||
bind:value={retentionDays}
|
||||
/>
|
||||
<FormText>
|
||||
Maximum age of stored check executions. Older entries are
|
||||
pruned by the janitor according to the tiered retention policy.
|
||||
</FormText>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="maxChecksPerDay">Max checks per day</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="maxChecksPerDay"
|
||||
min="0"
|
||||
bind:value={maxChecksPerDay}
|
||||
/>
|
||||
<FormText>
|
||||
Daily cap on the number of executions the scheduler may launch
|
||||
for this user (enforced later).
|
||||
</FormText>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label for="inactivityPauseDays">
|
||||
Inactivity pause (days)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="inactivityPauseDays"
|
||||
bind:value={inactivityPauseDays}
|
||||
/>
|
||||
<FormText>
|
||||
The scheduler stops running checks after this many days
|
||||
without login. Use a negative value to disable.
|
||||
</FormText>
|
||||
</FormGroup>
|
||||
|
||||
<Button color="primary" type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
<Spinner size="sm" class="me-2" />
|
||||
{:else}
|
||||
<Icon name="check-circle" class="me-2"></Icon>
|
||||
{/if}
|
||||
Save Quota
|
||||
</Button>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
41
web/package-lock.json
generated
41
web/package-lock.json
generated
|
|
@ -12,6 +12,9 @@
|
|||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
|
|
@ -431,6 +434,12 @@
|
|||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
|
|
@ -1795,6 +1804,28 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
|
|
@ -1905,6 +1936,16 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@
|
|||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
|
|
|
|||
442
web/src/lib/api/checkers.ts
Normal file
442
web/src/lib/api/checkers.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
getCheckers,
|
||||
getCheckersByCheckerId,
|
||||
getCheckersByCheckerIdOptions,
|
||||
putCheckersByCheckerIdOptions,
|
||||
getDomainsByDomainCheckers,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutions,
|
||||
postDomainsByDomainCheckersByCheckerIdExecutions,
|
||||
deleteDomainsByDomainCheckersByCheckerIdExecutions,
|
||||
deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdMetrics,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport,
|
||||
getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults,
|
||||
getDomainsByDomainCheckersByCheckerIdMetrics,
|
||||
getDomainsByDomainCheckersByCheckerIdOptions,
|
||||
putDomainsByDomainCheckersByCheckerIdOptions,
|
||||
getDomainsByDomainCheckersByCheckerIdPlans,
|
||||
postDomainsByDomainCheckersByCheckerIdPlans,
|
||||
putDomainsByDomainCheckersByCheckerIdPlansByPlanId,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions,
|
||||
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions,
|
||||
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions,
|
||||
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdMetrics,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdMetrics,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions,
|
||||
putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans,
|
||||
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans,
|
||||
putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import type {
|
||||
CheckerCheckerDefinition,
|
||||
CheckerCheckerOptions,
|
||||
CheckerCheckMetric,
|
||||
HappydnsCheckEvaluation,
|
||||
HappydnsCheckPlan,
|
||||
HappydnsCheckPlanWritable,
|
||||
HappydnsCheckerOptions,
|
||||
HappydnsCheckerOptionsPositional,
|
||||
HappydnsCheckerRunRequest,
|
||||
HappydnsCheckerStatus,
|
||||
HappydnsExecution,
|
||||
HappydnsObservationSnapshot,
|
||||
} from "$lib/api-base/types.gen";
|
||||
|
||||
// Workaround: hey-api/openapi-ts drops the `data` field from HappydnsObservationSnapshot
|
||||
// because swagger generates `additionalProperties: {type: object}` which the codegen cannot handle.
|
||||
// The API does return a `data` field of type Record<string, unknown>.
|
||||
export type ObservationSnapshotWithData = HappydnsObservationSnapshot & {
|
||||
readonly data: Record<string, unknown>;
|
||||
};
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
||||
// Global (non-scoped) checker functions
|
||||
|
||||
export async function listCheckers(): Promise<Record<string, CheckerCheckerDefinition>> {
|
||||
return unwrapSdkResponse(await getCheckers()) as Record<string, CheckerCheckerDefinition>;
|
||||
}
|
||||
|
||||
export async function getCheckStatus(checkerId: string): Promise<CheckerCheckerDefinition> {
|
||||
return unwrapSdkResponse(
|
||||
await getCheckersByCheckerId({ path: { checkerId } }),
|
||||
) as CheckerCheckerDefinition;
|
||||
}
|
||||
|
||||
export async function getCheckOptions(checkerId: string): Promise<HappydnsCheckerOptionsPositional[]> {
|
||||
return (unwrapSdkResponse(
|
||||
await getCheckersByCheckerIdOptions({ path: { checkerId } }),
|
||||
) as HappydnsCheckerOptionsPositional[]) ?? [];
|
||||
}
|
||||
|
||||
export async function updateCheckOptions(
|
||||
checkerId: string,
|
||||
options: HappydnsCheckerOptions,
|
||||
): Promise<HappydnsCheckerOptions> {
|
||||
return unwrapSdkResponse(
|
||||
await putCheckersByCheckerIdOptions({ path: { checkerId }, body: options as CheckerCheckerOptions }),
|
||||
) as HappydnsCheckerOptions;
|
||||
}
|
||||
|
||||
// Scope-aware helpers
|
||||
|
||||
export interface CheckerScope {
|
||||
domainId: string;
|
||||
zoneId?: string;
|
||||
subdomain?: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
function isServiceScope(scope: CheckerScope): scope is CheckerScope & { zoneId: string; subdomain: string; serviceId: string } {
|
||||
return !!(scope.zoneId && scope.subdomain !== undefined && scope.serviceId);
|
||||
}
|
||||
|
||||
export async function listScopedCheckers(
|
||||
scope: CheckerScope,
|
||||
): Promise<HappydnsCheckerStatus[]> {
|
||||
if (isServiceScope(scope)) {
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckers({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId },
|
||||
}),
|
||||
) as HappydnsCheckerStatus[]) ?? [];
|
||||
}
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckers({ path: { domain: scope.domainId } }),
|
||||
) as HappydnsCheckerStatus[]) ?? [];
|
||||
}
|
||||
|
||||
export async function listScopedExecutions(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
options?: { includePlanned?: boolean; limit?: number },
|
||||
): Promise<HappydnsExecution[]> {
|
||||
const query = {
|
||||
...(options?.includePlanned ? { include_planned: true } : {}),
|
||||
...(options?.limit ? { limit: options.limit } : {}),
|
||||
};
|
||||
if (isServiceScope(scope)) {
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
query,
|
||||
}),
|
||||
) as HappydnsExecution[]) ?? [];
|
||||
}
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
query,
|
||||
}),
|
||||
) as HappydnsExecution[]) ?? [];
|
||||
}
|
||||
|
||||
export async function triggerScopedCheck(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
request?: HappydnsCheckerRunRequest,
|
||||
): Promise<HappydnsExecution> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
body: request,
|
||||
}),
|
||||
) as HappydnsExecution;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
body: request,
|
||||
}),
|
||||
) as HappydnsExecution;
|
||||
}
|
||||
|
||||
export async function getScopedExecution(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
): Promise<HappydnsExecution> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId },
|
||||
}),
|
||||
) as HappydnsExecution;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({
|
||||
path: { domain: scope.domainId, checkerId, executionId },
|
||||
}),
|
||||
) as HappydnsExecution;
|
||||
}
|
||||
|
||||
export async function deleteScopedExecution(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
): Promise<boolean> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionId({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainCheckersByCheckerIdExecutionsByExecutionId({
|
||||
path: { domain: scope.domainId, checkerId, executionId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllScopedExecutions(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
): Promise<boolean> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return unwrapEmptyResponse(
|
||||
await deleteDomainsByDomainCheckersByCheckerIdExecutions({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScopedExecutionResults(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
): Promise<HappydnsCheckEvaluation> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdResults({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId },
|
||||
}),
|
||||
) as HappydnsCheckEvaluation;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdResults({
|
||||
path: { domain: scope.domainId, checkerId, executionId },
|
||||
}),
|
||||
) as HappydnsCheckEvaluation;
|
||||
}
|
||||
|
||||
export async function getScopedExecutionObservations(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
): Promise<ObservationSnapshotWithData> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservations({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId },
|
||||
} as any),
|
||||
) as ObservationSnapshotWithData;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservations({
|
||||
path: { domain: scope.domainId, checkerId, executionId },
|
||||
} as any),
|
||||
) as ObservationSnapshotWithData;
|
||||
}
|
||||
|
||||
export async function getScopedCheckOptions(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
): Promise<HappydnsCheckerOptionsPositional[]> {
|
||||
if (isServiceScope(scope)) {
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
}),
|
||||
) as HappydnsCheckerOptionsPositional[]) ?? [];
|
||||
}
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdOptions({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
}),
|
||||
) as HappydnsCheckerOptionsPositional[]) ?? [];
|
||||
}
|
||||
|
||||
export async function updateScopedCheckOptions(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
options: HappydnsCheckerOptions,
|
||||
): Promise<HappydnsCheckerOptions> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdOptions({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
body: options,
|
||||
}),
|
||||
) as HappydnsCheckerOptions;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await putDomainsByDomainCheckersByCheckerIdOptions({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
body: options,
|
||||
}),
|
||||
) as HappydnsCheckerOptions;
|
||||
}
|
||||
|
||||
export async function getScopedCheckPlans(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
): Promise<HappydnsCheckPlan[]> {
|
||||
if (isServiceScope(scope)) {
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
}),
|
||||
) as HappydnsCheckPlan[]) ?? [];
|
||||
}
|
||||
return (unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdPlans({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
}),
|
||||
) as HappydnsCheckPlan[]) ?? [];
|
||||
}
|
||||
|
||||
export async function createScopedCheckPlan(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
|
||||
): Promise<HappydnsCheckPlan> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlans({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
body: plan as HappydnsCheckPlanWritable,
|
||||
}),
|
||||
) as HappydnsCheckPlan;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainCheckersByCheckerIdPlans({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
body: plan as HappydnsCheckPlanWritable,
|
||||
}),
|
||||
) as HappydnsCheckPlan;
|
||||
}
|
||||
|
||||
export async function updateScopedCheckPlan(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
planId: string,
|
||||
plan: HappydnsCheckPlan | HappydnsCheckPlanWritable,
|
||||
): Promise<HappydnsCheckPlan> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdPlansByPlanId({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, planId },
|
||||
body: plan as HappydnsCheckPlanWritable,
|
||||
}),
|
||||
) as HappydnsCheckPlan;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await putDomainsByDomainCheckersByCheckerIdPlansByPlanId({
|
||||
path: { domain: scope.domainId, checkerId, planId },
|
||||
body: plan as HappydnsCheckPlanWritable,
|
||||
}),
|
||||
) as HappydnsCheckPlan;
|
||||
}
|
||||
|
||||
// --- Metrics types and API functions ---
|
||||
|
||||
export type CheckMetric = CheckerCheckMetric;
|
||||
|
||||
export async function getScopedCheckerMetrics(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
limit: number = 100,
|
||||
): Promise<CheckMetric[]> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdMetrics({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId },
|
||||
query: { limit },
|
||||
}),
|
||||
) as CheckMetric[];
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdMetrics({
|
||||
path: { domain: scope.domainId, checkerId },
|
||||
query: { limit },
|
||||
}),
|
||||
) as CheckMetric[];
|
||||
}
|
||||
|
||||
export async function getScopedExecutionMetrics(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
): Promise<CheckMetric[]> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdMetrics({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId },
|
||||
}),
|
||||
) as CheckMetric[];
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdMetrics({
|
||||
path: { domain: scope.domainId, checkerId, executionId },
|
||||
}),
|
||||
) as CheckMetric[];
|
||||
}
|
||||
|
||||
// HTML report functions
|
||||
|
||||
export async function getScopedExecutionHTMLReport(
|
||||
scope: CheckerScope,
|
||||
checkerId: string,
|
||||
executionId: string,
|
||||
obsKey: string,
|
||||
): Promise<string> {
|
||||
if (isServiceScope(scope)) {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport({
|
||||
path: { domain: scope.domainId, zoneid: scope.zoneId, subdomain: scope.subdomain, serviceid: scope.serviceId, checkerId, executionId, obsKey },
|
||||
}),
|
||||
) as string;
|
||||
}
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainCheckersByCheckerIdExecutionsByExecutionIdObservationsByObsKeyReport({
|
||||
path: { domain: scope.domainId, checkerId, executionId, obsKey },
|
||||
}),
|
||||
) as string;
|
||||
}
|
||||
|
|
@ -27,21 +27,25 @@ import {
|
|||
deleteDomainsByDomainId,
|
||||
getDomainsByDomainIdLogs,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import type { HappydnsDomainUpdateInput } from "$lib/api-base/types.gen";
|
||||
import type {
|
||||
HappydnsDomainUpdateInput,
|
||||
HappydnsDomainWithCheckStatus,
|
||||
HappydnsDomainWithZoneMetadata,
|
||||
} from "$lib/api-base/types.gen";
|
||||
import type { Domain, DomainLog } from "$lib/model/domain";
|
||||
import type { Provider } from "$lib/model/provider";
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
||||
export async function listDomains(): Promise<Array<Domain>> {
|
||||
return unwrapSdkResponse(await getDomains()) as Array<Domain>;
|
||||
export async function listDomains(): Promise<Array<HappydnsDomainWithCheckStatus>> {
|
||||
return unwrapSdkResponse(await getDomains()) as Array<HappydnsDomainWithCheckStatus>;
|
||||
}
|
||||
|
||||
export async function getDomain(id: string): Promise<Domain> {
|
||||
export async function getDomain(id: string): Promise<HappydnsDomainWithZoneMetadata> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainId({
|
||||
path: { domainId: id },
|
||||
}),
|
||||
) as Domain;
|
||||
) as HappydnsDomainWithZoneMetadata;
|
||||
}
|
||||
|
||||
export async function addDomain(domain: string, provider: Provider | undefined): Promise<Domain> {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,14 @@
|
|||
<Icon name="search" class="me-2" />
|
||||
{$t("menu.dns-resolver")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active={page.route &&
|
||||
(page.route.id == "/checkers" ||
|
||||
page.route.id?.startsWith("/checkers/"))}
|
||||
href="/checkers"
|
||||
>
|
||||
{$t("menu.checkers")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
|
||||
<Icon name="gear" class="me-2" />
|
||||
|
|
|
|||
194
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
194
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import type { CheckMetric } from "$lib/api/checkers";
|
||||
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
Filler,
|
||||
);
|
||||
|
||||
interface Props {
|
||||
metrics: CheckMetric[];
|
||||
}
|
||||
|
||||
let { metrics }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart | null = null;
|
||||
|
||||
const COLORS = [
|
||||
"#0d6efd",
|
||||
"#dc3545",
|
||||
"#198754",
|
||||
"#ffc107",
|
||||
"#6610f2",
|
||||
"#0dcaf0",
|
||||
"#fd7e14",
|
||||
"#d63384",
|
||||
];
|
||||
|
||||
function buildChart() {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
if (!canvas || !metrics?.length) return;
|
||||
|
||||
// Group metrics into series by (name, labels)
|
||||
type SeriesKey = string;
|
||||
interface Series {
|
||||
name: string;
|
||||
unit: string;
|
||||
labels: Record<string, string>;
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
const seriesMap = new Map<SeriesKey, Series>();
|
||||
const seriesOrder: SeriesKey[] = [];
|
||||
|
||||
for (const m of metrics) {
|
||||
const labelStr = m.labels ? Object.entries(m.labels).sort().map(([k, v]) => `${k}=${v}`).join(",") : "";
|
||||
const key = `${m.name}{${labelStr}}`;
|
||||
if (!seriesMap.has(key)) {
|
||||
seriesMap.set(key, {
|
||||
name: m.name,
|
||||
unit: m.unit ?? "",
|
||||
labels: m.labels ?? {},
|
||||
points: [],
|
||||
});
|
||||
seriesOrder.push(key);
|
||||
}
|
||||
seriesMap.get(key)!.points.push({
|
||||
x: new Date(m.timestamp).getTime(),
|
||||
y: m.value,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort points by time within each series
|
||||
for (const series of seriesMap.values()) {
|
||||
series.points.sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
// Determine unique units for multi-axis support
|
||||
const units = [...new Set(Array.from(seriesMap.values()).map((s) => s.unit))];
|
||||
const hasRightAxis = units.length > 1;
|
||||
const rightUnit = hasRightAxis ? units[1] : null;
|
||||
|
||||
const datasets = seriesOrder.map((key, i) => {
|
||||
const series = seriesMap.get(key)!;
|
||||
const labelParts = Object.entries(series.labels).map(([k, v]) => `${k}=${v}`);
|
||||
const displayLabel = labelParts.length > 0
|
||||
? `${series.name} (${labelParts.join(", ")})`
|
||||
: series.name;
|
||||
|
||||
return {
|
||||
label: displayLabel,
|
||||
data: series.points,
|
||||
borderColor: COLORS[i % COLORS.length],
|
||||
backgroundColor: COLORS[i % COLORS.length] + "20",
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
tension: 0.3,
|
||||
yAxisID: hasRightAxis && series.unit === rightUnit ? "y1" : "y",
|
||||
};
|
||||
});
|
||||
|
||||
const scales: Record<string, any> = {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: { tooltipFormat: "PPpp" },
|
||||
title: { display: false },
|
||||
},
|
||||
y: {
|
||||
type: "linear" as const,
|
||||
position: "left" as const,
|
||||
title: { display: true, text: units[0] || "" },
|
||||
beginAtZero: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (hasRightAxis && rightUnit) {
|
||||
scales.y1 = {
|
||||
type: "linear" as const,
|
||||
position: "right" as const,
|
||||
title: { display: true, text: rightUnit },
|
||||
beginAtZero: true,
|
||||
grid: { drawOnChartArea: false },
|
||||
};
|
||||
}
|
||||
|
||||
chart = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
scales,
|
||||
plugins: {
|
||||
legend: { position: "bottom" },
|
||||
tooltip: { mode: "index", intersect: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (metrics && canvas) {
|
||||
buildChart();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chart-container" style="position: relative; height: 350px; width: 100%;">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
190
web/src/lib/components/checkers/CheckerConfigPage.svelte
Normal file
190
web/src/lib/components/checkers/CheckerConfigPage.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Alert, Button, Card, Col, Icon, Row } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { checkers } from "$lib/stores/checkers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import type {
|
||||
HappydnsCheckPlan,
|
||||
HappydnsCheckPlanWritable,
|
||||
HappydnsCheckerOptionsPositional,
|
||||
} from "$lib/api-base/types.gen";
|
||||
import type { CheckerScope } from "$lib/api/checkers";
|
||||
import {
|
||||
getCheckStatus,
|
||||
getScopedCheckOptions,
|
||||
updateScopedCheckOptions,
|
||||
} from "$lib/api/checkers";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import CheckerScheduleCard from "./CheckerScheduleCard.svelte";
|
||||
import CheckerRulesCard from "./CheckerRulesCard.svelte";
|
||||
import CheckerOptionsPanel from "./CheckerOptionsPanel.svelte";
|
||||
|
||||
interface Props {
|
||||
scope: CheckerScope;
|
||||
checksBase: string;
|
||||
checkerId: string;
|
||||
domainName: string;
|
||||
editableGroups: (status: any) => { label: string; opts: any[] }[];
|
||||
readOnlyGroups: (status: any) => { key: string; label: string; opts: any[] }[];
|
||||
showSchedule?: boolean;
|
||||
}
|
||||
|
||||
let { scope, checksBase, checkerId, domainName, editableGroups, readOnlyGroups, showSchedule = true }: Props = $props();
|
||||
|
||||
let checkStatusPromise = $derived(getCheckStatus(checkerId));
|
||||
let checkOptionsPromise = $derived(getScopedCheckOptions(scope, checkerId));
|
||||
|
||||
let resolvedStatus = $state<any>(null);
|
||||
let optionValues = $state<Record<string, unknown>>({});
|
||||
let inheritedValues = $state<Record<string, unknown>>({});
|
||||
let savingOptions = $state(false);
|
||||
|
||||
let plan = $state<HappydnsCheckPlanWritable>({
|
||||
enabled: {},
|
||||
interval: 3600,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
checkStatusPromise.then((status) => {
|
||||
resolvedStatus = status;
|
||||
if (status?.rules && Object.keys(plan.enabled ?? {}).length === 0) {
|
||||
const enabled: Record<string, boolean> = {};
|
||||
for (const rule of status.rules) {
|
||||
if (rule.name) enabled[rule.name] = true;
|
||||
}
|
||||
plan.enabled = enabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
checkOptionsPromise.then((positionals: HappydnsCheckerOptionsPositional[]) => {
|
||||
const current =
|
||||
positionals.length > 0 ? (positionals[positionals.length - 1]?.options ?? {}) : {};
|
||||
const inherited: Record<string, unknown> = {};
|
||||
for (let i = 0; i < positionals.length - 1; i++) {
|
||||
for (const [k, v] of Object.entries(positionals[i].options ?? {})) {
|
||||
inherited[k] = v;
|
||||
}
|
||||
}
|
||||
optionValues = { ...current };
|
||||
inheritedValues = inherited;
|
||||
});
|
||||
});
|
||||
|
||||
async function saveOptions() {
|
||||
savingOptions = true;
|
||||
try {
|
||||
await updateScopedCheckOptions(scope, checkerId, optionValues);
|
||||
checkOptionsPromise = getScopedCheckOptions(scope, checkerId);
|
||||
toasts.addToast({
|
||||
message: $t("checkers.messages.options-updated"),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checkers.messages.update-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
savingOptions = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{resolvedStatus?.name ?? checkerId} - {domainName} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill mt-1 mb-5">
|
||||
<PageTitle title={resolvedStatus?.name ?? checkerId} domain={domainName}>
|
||||
{#if $checkers && (!$checkers[checkerId]?.availability || $checkers[checkerId].availability.applyToDomain || $checkers[checkerId].availability.applyToZone)}
|
||||
<Button
|
||||
color="info"
|
||||
href={`${checksBase}/${encodeURIComponent(checkerId)}/executions`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("checkers.list.view-results")}
|
||||
</Button>
|
||||
{/if}
|
||||
</PageTitle>
|
||||
|
||||
{#await checkStatusPromise}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t("checkers.loading-info")}
|
||||
</p>
|
||||
</Card>
|
||||
{:then status}
|
||||
{#if status}
|
||||
{@const editable = editableGroups(status)}
|
||||
{@const readOnly = readOnlyGroups(status)}
|
||||
<Row class="mb-4">
|
||||
{#if showSchedule}
|
||||
<Col md={6}>
|
||||
<CheckerScheduleCard {scope} {checkerId} bind:plan />
|
||||
|
||||
{#if status.rules && status.rules.length > 0}
|
||||
<CheckerRulesCard
|
||||
rules={status.rules}
|
||||
bind:optionValues
|
||||
{inheritedValues}
|
||||
saving={savingOptions}
|
||||
onsave={saveOptions}
|
||||
bind:plan
|
||||
/>
|
||||
{/if}
|
||||
</Col>
|
||||
{/if}
|
||||
|
||||
<Col md={6}>
|
||||
<CheckerOptionsPanel
|
||||
{checkOptionsPromise}
|
||||
editableGroups={editable}
|
||||
readOnlyGroups={readOnly}
|
||||
bind:optionValues
|
||||
{inheritedValues}
|
||||
saving={savingOptions}
|
||||
onsave={saveOptions}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checkers.checker-info-not-found")}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checkers.error-loading-checker", { error: error.message })}
|
||||
</Alert>
|
||||
{/await}
|
||||
</div>
|
||||
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