Compare commits
30 commits
3c8df24ebc
...
e5a8c55630
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a8c55630 | |||
| eaae8a3c70 | |||
| 3e41a09de9 | |||
| f626e814c7 | |||
| f54a1bea70 | |||
| b10a421a83 | |||
| 83bd89a69d | |||
| c9013f3469 | |||
| e846b8e5eb | |||
| ae225155c6 | |||
| 4271149130 | |||
| 98b623730c | |||
| 605b69f29f | |||
| 236e564ba4 | |||
| fe8ed708b2 | |||
| e920237c79 | |||
| 46ec2512c3 | |||
| b979881337 | |||
| d46637b474 | |||
| 25c1ff41c7 | |||
| 6b25424ad5 | |||
| 48cc4eca02 | |||
| 3f3157a355 | |||
| 618314e15a | |||
| 0530cb2198 | |||
| 1c6b9cd127 | |||
| 7651f57890 | |||
| 10775fe36c | |||
| 1977508108 | |||
|
|
9a3f834129 |
168 changed files with 24284 additions and 600 deletions
148
README.zh-cn.md
148
README.zh-cn.md
|
|
@ -1,28 +1,51 @@
|
|||
happyDomain
|
||||
===========
|
||||
|
||||
> 中文译者:[Exyone](https://www.exyone.me/)
|
||||
[](./LICENSE)
|
||||
[](https://hub.docker.com/r/happydomain/happydomain)
|
||||
[](https://matrix.to/#/%23happyDNS:matrix.org)
|
||||
[](https://try.happydomain.org/)
|
||||
[](https://github.com/happyDomain/happydomain)
|
||||
[](https://github.com/happyDomain/happydomain/releases)
|
||||
|
||||
由于软件架构限制,当前 happyDomain 仅支持简体中文,繁体中文用户请安装浏览器拓展插件以进行简繁转换。造成困扰,敬请谅解。
|
||||
> **中文译者:** [exyone](https://www.exyone.me/) · [exyone.dev@icloud.com](mailto:exyone.dev@icloud.com)
|
||||
|
||||
happyDomain 是一款免费的 Web 应用,可集中管理来自不同注册商和托管商的域名。
|
||||
|
||||

|
||||
|
||||
它由 Golang 编写的 HTTP REST API(主要基于 https://stackexchange.github.io/dnscontrol/ 和 https://github.com/miekg/dns)与 [Svelte](https://svelte.dev/) 构建的精美 Web 界面组成。
|
||||
作为单一无状态的 Linux 二进制文件运行,支持多种数据库(当前支持 LevelDB,更多选项即将推出)。
|
||||
**官网:** [happydomain.org](https://www.happydomain.org/) | **演示:** [try.happydomain.org](https://try.happydomain.org/)
|
||||
|
||||
**主要特性:**
|
||||
由于软件架构限制,当前 happyDomain 仅支持简体中文,繁体中文用户请安装浏览器扩展进行简繁转换。造成困扰,敬请谅解。
|
||||
|
||||
它由 Golang 编写的 HTTP REST API(主要基于 [DNSControl](https://dnscontrol.org/) 和 [CoreDNS 库](https://github.com/miekg/dns))与 [Svelte](https://svelte.dev/) 构建的精美 Web 界面组成。作为单一无状态的 Linux 二进制文件运行,支持多种数据库后端。
|
||||
|
||||
目录
|
||||
----
|
||||
|
||||
- [主要特性](#主要特性)
|
||||
- [使用 Docker 快速开始](#使用-docker-快速开始)
|
||||
- [从二进制文件安装](#从二进制文件安装)
|
||||
- [配置说明](#使用-happydomain)
|
||||
- [从源码构建](#从源码构建)
|
||||
- [开发环境](#开发环境)
|
||||
- [参与贡献](#参与贡献)
|
||||
- [AI 使用声明](#ai-使用声明)
|
||||
- [许可证](#许可证)
|
||||
|
||||
|
||||
主要特性
|
||||
--------
|
||||
|
||||
* 高性能 Web 界面,响应迅速
|
||||
* 支持多域名管理
|
||||
* 支持 60+ DNS 提供商(含动态 DNS、RFC 2136),得益于 [DNSControl](https://stackexchange.github.io/dnscontrol/)
|
||||
* 多域名统一管理
|
||||
* 支持 60+ DNS 提供商(含动态 DNS、RFC 2136),得益于 [DNSControl](https://dnscontrol.org/)
|
||||
* 支持最新资源记录类型,得益于 [CoreDNS 库](https://github.com/miekg/dns)
|
||||
* 区域编辑器支持差异对比,部署前轻松审查变更
|
||||
* 保留部署变更历史记录
|
||||
* 上下文帮助
|
||||
* 支持多用户认证或单用户无认证模式
|
||||
* 兼容外部认证(OpenId Connect 或 JWT 令牌:Auth0 等)
|
||||
* 兼容外部认证(OpenID Connect 或 JWT 令牌:Auth0 等)
|
||||
|
||||
**happyDomain 已可投入使用,但仍需不断完善:这是一个精心打造的概念验证版本,您的反馈将助力其不断进化!**
|
||||
|
||||
|
|
@ -31,10 +54,10 @@ happyDomain 是一款免费的 Web 应用,可集中管理来自不同注册商
|
|||
[无论使用体验如何,我们都期待您的反馈!](https://feedback.happydomain.org/) 您如何看待我们简化域名管理的方式?您的初步印象有助于我们根据**您的实际期望**来指引项目方向。
|
||||
|
||||
|
||||
使用 Docker
|
||||
------------
|
||||
使用 Docker 快速开始
|
||||
--------------------
|
||||
|
||||
我们是由 Docker 赞助的开源项目!因此您可以轻松使用 Docker/podman/kubernetes/... 来试用或部署应用。
|
||||
我们是由 Docker 赞助的开源项目!因此您可以轻松使用 Docker/Podman/Kubernetes 等容器平台来试用或部署应用。
|
||||
|
||||
使用 `docker compose` 启动 happyDomain:
|
||||
|
||||
|
|
@ -56,17 +79,17 @@ docker run -e HAPPYDOMAIN_NO_AUTH=1 -p 8081:8081 happydomain/happydomain
|
|||
|
||||
|
||||
从二进制文件安装
|
||||
-------------------
|
||||
----------------
|
||||
|
||||
预编译二进制文件下载地址:<https://get.happydomain.org/>
|
||||
|
||||
选择目录(最新版本或 master 分支),然后选择与您的操作系统和 CPU 架构对应的二进制文件。
|
||||
选择目录(最新版本或 `master` 分支),然后选择与您的操作系统和 CPU 架构对应的二进制文件。
|
||||
|
||||
|
||||
使用 happyDomain
|
||||
---------------
|
||||
----------------
|
||||
|
||||
二进制文件附带默认配置,可直接启动。在终端中运行以下命令即可:
|
||||
二进制文件附带合理的默认配置,可直接启动。在终端中运行以下命令即可:
|
||||
|
||||
```bash
|
||||
./happyDomain
|
||||
|
|
@ -88,7 +111,7 @@ docker run -e HAPPYDOMAIN_NO_AUTH=1 -p 8081:8081 happydomain/happydomain
|
|||
|
||||
```
|
||||
-storage-engine value
|
||||
在 [inmemory leveldb oracle-nosql postgresql] 中选择存储引擎 (默认 leveldb)
|
||||
在 [inmemory leveldb oracle-nosql postgresql] 中选择存储引擎 (默认 leveldb)
|
||||
```
|
||||
|
||||
#### LevelDB
|
||||
|
|
@ -97,7 +120,7 @@ LevelDB 是轻量级嵌入式键值存储(类似 SQLite,无需额外守护
|
|||
|
||||
```
|
||||
-leveldb-path string
|
||||
LevelDB 数据库路径 (默认 "happydomain.db")
|
||||
LevelDB 数据库路径 (默认 "happydomain.db")
|
||||
```
|
||||
|
||||
默认在二进制文件所在目录创建 `happydomain.db` 目录。可更改为更有意义或更持久的路径。
|
||||
|
|
@ -114,19 +137,19 @@ happyDomain 以键值存储模式使用 PostgreSQL,将所有数据存储在包
|
|||
|
||||
```
|
||||
-postgres-database string
|
||||
PostgreSQL 数据库名称 (默认 "happydomain")
|
||||
PostgreSQL 数据库名称 (默认 "happydomain")
|
||||
-postgres-host string
|
||||
PostgreSQL 服务器主机名 (默认 "localhost")
|
||||
PostgreSQL 服务器主机名 (默认 "localhost")
|
||||
-postgres-password string
|
||||
PostgreSQL 密码
|
||||
PostgreSQL 密码
|
||||
-postgres-port int
|
||||
PostgreSQL 服务器端口 (默认 5432)
|
||||
PostgreSQL 服务器端口 (默认 5432)
|
||||
-postgres-ssl-mode string
|
||||
PostgreSQL SSL 模式 (disable, require, verify-ca, verify-full) (默认 "disable")
|
||||
PostgreSQL SSL 模式 (disable, require, verify-ca, verify-full) (默认 "disable")
|
||||
-postgres-table string
|
||||
键值存储的 PostgreSQL 表名 (默认 "happydomain_kv")
|
||||
键值存储的 PostgreSQL 表名 (默认 "happydomain_kv")
|
||||
-postgres-user string
|
||||
PostgreSQL 用户名 (默认 "happydomain")
|
||||
PostgreSQL 用户名 (默认 "happydomain")
|
||||
```
|
||||
|
||||
#### Oracle NoSQL Database
|
||||
|
|
@ -139,28 +162,27 @@ Oracle NoSQL Database 是来自 Oracle Cloud Infrastructure (OCI) 的全托管
|
|||
|
||||
```
|
||||
-oci-compartment string
|
||||
NoSQL 数据库所在的 OCI 隔间 ID
|
||||
NoSQL 数据库所在的 OCI 隔间 ID
|
||||
-oci-fingerprint string
|
||||
OCI 用户 API 密钥指纹
|
||||
OCI 用户 API 密钥指纹
|
||||
-oci-private-key-file string
|
||||
给定用户的 OCI 私钥文件路径
|
||||
给定用户的 OCI 私钥文件路径
|
||||
-oci-region string
|
||||
NoSQL 数据库所在的 OCI 区域 (默认 "us-phoenix-1")
|
||||
NoSQL 数据库所在的 OCI 区域 (默认 "us-phoenix-1")
|
||||
-oci-table string
|
||||
存储值的表名 (默认 "happydomain")
|
||||
存储值的表名 (默认 "happydomain")
|
||||
-oci-tenancy string
|
||||
NoSQL 数据库所在的 OCI 租户 ID
|
||||
NoSQL 数据库所在的 OCI 租户 ID
|
||||
-oci-user string
|
||||
访问 NoSQL 数据库的 OCI 用户 ID
|
||||
访问 NoSQL 数据库的 OCI 用户 ID
|
||||
```
|
||||
|
||||
#### 数据库管理系统
|
||||
|
||||
MySQL/Mariadb 等 DBMS 已不再支持,亦无相关计划。
|
||||
MySQL/MariaDB 等 DBMS 已不再支持,亦无相关计划。
|
||||
|
||||
|
||||
持久化配置
|
||||
-------------------
|
||||
### 持久化配置
|
||||
|
||||
二进制文件会自动查找以下配置文件:
|
||||
|
||||
|
|
@ -178,7 +200,7 @@ MySQL/Mariadb 等 DBMS 已不再支持,亦无相关计划。
|
|||
|
||||
#### 配置文件格式
|
||||
|
||||
注释行必须以 # 开头,不支持行尾注释。
|
||||
注释行必须以 `#` 开头,不支持行尾注释。
|
||||
|
||||
每行放置配置选项名称和期望值,用 `=` 分隔。例如:
|
||||
|
||||
|
|
@ -209,8 +231,8 @@ OVH 没有简单的 API 密钥或凭据,需通过 Web 流程获取密钥。
|
|||
[连接 OVH,请按以下说明操作](https://help.happydomain.org/en/introduction/deploy/ovh)。
|
||||
|
||||
|
||||
构建
|
||||
--------
|
||||
从源码构建
|
||||
----------
|
||||
|
||||
### 依赖项
|
||||
|
||||
|
|
@ -245,7 +267,7 @@ go build -tags swagger,web ./cmd/happyDomain
|
|||
|
||||
|
||||
开发环境
|
||||
-----------------------
|
||||
--------
|
||||
|
||||
若要为前端做贡献,而非每次修改后都重新生成前端资源(使用 `go generate`),可使用开发工具:
|
||||
|
||||
|
|
@ -261,4 +283,52 @@ go build -tags swagger,web ./cmd/happyDomain
|
|||
cd web; npm run dev
|
||||
```
|
||||
|
||||
此设置不使用集成到 go 二进制文件中的静态资源,而是将所有静态资源请求转发至 node 服务器,实现动态重载等功能。
|
||||
此设置不使用集成到 Go 二进制文件中的静态资源,而是将所有静态资源请求转发至 node 服务器,实现动态重载等功能。
|
||||
|
||||
|
||||
参与贡献
|
||||
--------
|
||||
|
||||
欢迎参与贡献!您可以通过以下方式帮助我们:
|
||||
|
||||
- **报告问题:** 在您喜欢的代码托管平台提交 Issue:[GitHub](https://github.com/happyDomain/happydomain/issues)、[GitLab](https://gitlab.com/happyDomain/happydomain/-/issues)、[Framagit](https://framagit.org/happyDomain/happydomain/-/issues)、[Codeberg](https://codeberg.org/happyDomain/happyDomain/issues),我们响应迅速。
|
||||
- **分享反馈:** [告诉我们您的想法](https://feedback.happydomain.org/),您的意见将指引项目发展方向。
|
||||
|
||||
|
||||
AI 使用声明
|
||||
-----------
|
||||
|
||||
关于项目开发中 AI 的使用,我们收到过一些询问。我们的项目涉及域名管理,这是一个敏感领域,错误可能导致真实的服务中断,因此有必要说明 AI 在开发过程中的使用方式。
|
||||
|
||||
AI 作为辅助工具用于:
|
||||
|
||||
- 代码质量验证和漏洞搜索
|
||||
- 清理和改进文档、注释和代码
|
||||
- 开发过程中的辅助
|
||||
- 人工审查后对 PR 和提交的二次检查
|
||||
|
||||
AI 不用于:
|
||||
|
||||
- 编写完整的功能或组件
|
||||
- "氛围编程"(vibe coding)方式
|
||||
- 未经人工逐行验证的代码
|
||||
- 没有测试的代码
|
||||
|
||||
项目具备:
|
||||
|
||||
- 带有测试和代码检查的 CI/CD 流水线自动化,确保代码质量
|
||||
- 经验丰富的开发者审核
|
||||
|
||||
因此 AI 只是开发者的助手和提高生产力的工具,确保代码质量。实际工作由开发者完成。
|
||||
|
||||
我们不区分糟糕的人工代码和 AI 氛围代码。任何代码要合并都有严格要求,以保持代码库的可维护性。即使是人工手写的代码,也不能保证被合并。氛围代码不被允许,此类 PR 会被拒绝。
|
||||
|
||||
*灵感来源于 [Databasus AI 声明](https://github.com/databasus/databasus#ai-disclaimer)。*
|
||||
|
||||
|
||||
许可证
|
||||
------
|
||||
|
||||
happyDomain 采用 [GNU Affero General Public License v3.0](./LICENSE) (AGPL-3.0) 许可证。
|
||||
|
||||
同时提供商业许可证,如有需要请联系我们。
|
||||
|
|
|
|||
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.5.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
|
||||
|
|
|
|||
177
go.sum
177
go.sum
|
|
@ -1,25 +1,29 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
|
||||
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
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.4.0 h1:MDUnzdIy+o4yXQkcGl6QRXVprwlERmIJ9nuO7cspUBs=
|
||||
git.happydns.org/checker-sdk-go v0.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-sdk-go v0.5.0 h1:wpFIK/vxanrAYf1OlewSnSCYc7KOJKdu88uUWB7HIQI=
|
||||
git.happydns.org/checker-sdk-go v0.5.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=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
|
||||
|
|
@ -36,8 +40,6 @@ github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+W
|
|||
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
|
|
@ -47,8 +49,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
|
|||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 h1:DQ1+lDdBve+u+aovjh4wV6sYnvZKH0Hx8GaQOi4vYl8=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4/go.mod h1:eauGmjfZG874MOAEPVeqg21mZCbTOLW+tFe8F7NpfnY=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2 h1:BPaX0lnXTZ9TniICiiK/0iJqzeGJ2ibvB4DjAqLMBSM=
|
||||
github.com/CloudyKit/jet/v6 v6.3.2/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.3.3 h1:McILJSbJ5nOcT0MI0aBYhEuufCF329YbqKwFIN0RjCI=
|
||||
|
|
@ -60,8 +60,6 @@ github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk=
|
|||
github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
|
|
@ -76,8 +74,6 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYb
|
|||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
|
|
@ -87,68 +83,36 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
|
|||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17 h1:Fw2SIR63jhfLpFZr6955zU3g9V8ouHC/pRpmmiHmIFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.17/go.mod h1:x9PRRtbCQ/gv1ziQPXFB7nQwQgVLQ+FSvPIkVAhRcYY=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19 h1:I1uSW0oydwLZWp4IDjGqAJY+EoNFylgNxxcXeOSioVk=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53domains v1.34.19/go.mod h1:1qCxun61Kq+S1e790tY+MpOKQ29DoOt2Fdx8Efgmo2g=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
|
|
@ -165,20 +129,15 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
|
|||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
|
|
@ -196,12 +155,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepmap/oapi-codegen v1.16.3 h1:GT9G86SbQtT1r8ZB+4Cybi9VGdu1P5ieNvNdEoCSbrA=
|
||||
github.com/deepmap/oapi-codegen v1.16.3/go.mod h1:JD6ErqeX0nYnhdciLc61Konj3NBASREMlkHOgHn8WAM=
|
||||
github.com/digitalocean/godo v1.176.0 h1:P379vPO5TUre+bUHPEsdSAbl5vIrRRhP91tMIEPoWYU=
|
||||
github.com/digitalocean/godo v1.176.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w=
|
||||
github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0 h1:U4ENaNCe5aUFHLiF7lj2NNpLPzFY3YIriu/UzrdfUbg=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.1.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0 h1:nNgtqKrt1K1BIWIpKTCL2qCiQcfYUxzsyRGIKLYEYH0=
|
||||
github.com/dnsimple/dnsimple-go/v8 v8.2.0/go.mod h1:61MdYHRL+p2TBBUVEkxo1n4iRF6s3R9fZcvQvyt5du8=
|
||||
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
|
||||
|
|
@ -231,8 +186,6 @@ github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d
|
|||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
|
|
@ -249,56 +202,33 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
|
||||
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
|
|
@ -311,8 +241,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
|
|||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
|
|
@ -321,8 +249,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
|||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U=
|
||||
github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXKkTfoE=
|
||||
|
|
@ -333,7 +259,6 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8
|
|||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
|
|
@ -376,12 +301,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
|
|
@ -408,14 +329,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU
|
|||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
|
||||
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL5O9iq5QEtvo=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0 h1:PMnuOA8pL8aHLLPp6nnnCTo2Xk2tqu4dAfYsC3bWdT0=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.37.0/go.mod h1:zaDOCKmpnI86ftoCpUpaiYaw9Wew1ib1AcXTh96deYI=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 h1:J+U6+eUjIsBhefolFdZW5hQNJbkMj+7msxZrv56Cg2g=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192 h1:xgKdmcALGqLGBrBG8stMli0k+irufCeNPenn76Y4U9o=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.192/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
|
||||
|
|
@ -458,8 +375,6 @@ github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwf
|
|||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
|
@ -474,8 +389,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
|
|
@ -493,8 +406,6 @@ github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJn
|
|||
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
|
|
@ -504,8 +415,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||
|
|
@ -549,15 +458,10 @@ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:Ff
|
|||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
||||
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0 h1:EsbFVvcV+uid9SoQnFQbTKS6FgqsM9NtoKmUIovKsbk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.109.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c=
|
||||
|
|
@ -573,6 +477,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=
|
||||
|
|
@ -580,8 +486,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
|
||||
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
|
||||
|
|
@ -646,20 +550,14 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
|||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tdewolff/minify/v2 v2.24.9 h1:W6A570F9N6MuZtg9mdHXD93piZZIWJaGpbAw9Narrfw=
|
||||
github.com/tdewolff/minify/v2 v2.24.9/go.mod h1:9F66jUzl/Pdf6Q5x0RXFUsI/8N1kjBb3ILg9ABSWoOI=
|
||||
github.com/tdewolff/minify/v2 v2.24.12 h1:YXJxVJmz7vxgnEv1v8J/EI4x+Uw4MMohcRFK7TFOjmk=
|
||||
github.com/tdewolff/minify/v2 v2.24.12/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro=
|
||||
github.com/tdewolff/parse/v2 v2.8.8 h1:l3yOJ4OUKq1sKeQQxZ7P2yZ6daW/Oq4IDxL98uTOpPI=
|
||||
github.com/tdewolff/parse/v2 v2.8.8/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0=
|
||||
github.com/tdewolff/parse/v2 v2.8.11/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=
|
||||
github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/transip/gotransip/v6 v6.26.2 h1:pnbDXrkFevOngpi6ertLw6e57lOW+Rk3djJ9AewmJ94=
|
||||
github.com/transip/gotransip/v6 v6.26.2/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
|
|
@ -676,7 +574,6 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg=
|
||||
github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
|
|
@ -718,26 +615,16 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
|||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
|
|
@ -748,14 +635,10 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
|||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -777,8 +660,6 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
|||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
|
|
@ -795,8 +676,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -819,8 +698,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
|
@ -859,7 +736,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
|
@ -890,8 +766,6 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -908,8 +782,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -919,36 +791,27 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
|
|||
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
|
||||
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
|
|
|
|||
64
internal/api-admin/controller/check_controller.go
Normal file
64
internal/api-admin/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminCheckerController handles admin checker-related API endpoints.
|
||||
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
|
||||
// (non-positional) map scoped to nil (global/admin) level.
|
||||
type AdminCheckerController struct {
|
||||
*apicontroller.CheckerController
|
||||
}
|
||||
|
||||
// NewAdminCheckerController creates a new AdminCheckerController.
|
||||
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
|
||||
return &AdminCheckerController{
|
||||
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
|
||||
//
|
||||
// @Summary Get admin-level checker options
|
||||
// @Tags admin,checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerOptions
|
||||
// @Router /checkers/{checkerId}/options [get]
|
||||
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ func NewDomainController(
|
|||
func (dc *DomainController) ListDomains(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
if user != nil {
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
|
||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
|
||||
apidc.GetDomains(c)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
100
internal/api-admin/controller/scheduler_controller.go
Normal file
100
internal/api-admin/controller/scheduler_controller.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
)
|
||||
|
||||
// AdminSchedulerController handles admin scheduler API endpoints.
|
||||
type AdminSchedulerController struct {
|
||||
scheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
// NewAdminSchedulerController creates a new AdminSchedulerController.
|
||||
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
|
||||
return &AdminSchedulerController{scheduler: scheduler}
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns the current scheduler status.
|
||||
//
|
||||
// @Summary Get scheduler status
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Router /scheduler [get]
|
||||
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// EnableScheduler starts the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Enable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/enable [post]
|
||||
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// DisableScheduler stops the scheduler and returns updated status.
|
||||
//
|
||||
// @Summary Disable the scheduler
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} checkerUC.SchedulerStatus
|
||||
// @Failure 500 {object} object
|
||||
// @Router /scheduler/disable [post]
|
||||
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
|
||||
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s.scheduler.GetStatus())
|
||||
}
|
||||
|
||||
// RescheduleUpcoming rebuilds the job queue and returns the new count.
|
||||
//
|
||||
// @Summary Rebuild the scheduler queue
|
||||
// @Tags admin-scheduler
|
||||
// @Produce json
|
||||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Router /scheduler/reschedule-upcoming [post]
|
||||
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
|
||||
n := s.scheduler.RebuildQueue()
|
||||
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ package controller
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
|
@ -168,7 +169,20 @@ func (uc *UserController) UpdateUser(c *gin.Context) {
|
|||
}
|
||||
uu.Id = user.Id
|
||||
|
||||
happydns.ApiResponse(c, uu, uc.store.CreateOrUpdateUser(uu))
|
||||
updated, err := uc.userService.UpdateUser(uu.Id, func(u *happydns.User) {
|
||||
// Stamp quota update time if quota fields changed.
|
||||
if uu.Quota != u.Quota {
|
||||
uu.Quota.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
u.Email = uu.Email
|
||||
u.CreatedAt = uu.CreatedAt
|
||||
u.LastSeen = uu.LastSeen
|
||||
u.Settings = uu.Settings
|
||||
u.Quota = uu.Quota
|
||||
})
|
||||
|
||||
happydns.ApiResponse(c, updated, err)
|
||||
}
|
||||
|
||||
// deleteUser removes a specific user from the database.
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
|
|||
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
|
||||
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
|
||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
|
||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
|
||||
apizc.GetZone(c)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
internal/api-admin/route/checker.go
Normal file
51
internal/api-admin/route/checker.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckerOptionsUC == nil {
|
||||
return
|
||||
}
|
||||
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
|
||||
|
||||
apiCheckersRoutes := router.Group("/checkers")
|
||||
apiCheckersRoutes.GET("", cc.ListCheckers)
|
||||
|
||||
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
|
||||
apiCheckerRoutes.Use(cc.CheckerHandler)
|
||||
apiCheckerRoutes.GET("", cc.GetChecker)
|
||||
|
||||
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
|
||||
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
|
||||
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
|
||||
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
|
||||
|
||||
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
|
||||
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
|
||||
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
api "git.happydns.org/happyDomain/internal/api/route"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -41,14 +42,18 @@ type Dependencies struct {
|
|||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
CheckScheduler *checkerUC.Scheduler
|
||||
}
|
||||
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) {
|
||||
apiRoutes := router.Group("/api")
|
||||
|
||||
declareBackupRoutes(cfg, apiRoutes, s)
|
||||
declareCheckersRoutes(apiRoutes, dep)
|
||||
declareDomainRoutes(apiRoutes, dep, s)
|
||||
declareProviderRoutes(apiRoutes, dep, s)
|
||||
declareSchedulerRoutes(apiRoutes, dep)
|
||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||
declareUserAuthsRoutes(apiRoutes, dep, s)
|
||||
declareUsersRoutes(apiRoutes, dep, s)
|
||||
|
|
|
|||
41
internal/api-admin/route/scheduler.go
Normal file
41
internal/api-admin/route/scheduler.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
if dep.CheckScheduler == nil {
|
||||
return
|
||||
}
|
||||
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
|
||||
|
||||
schedulerRoute := router.Group("/scheduler")
|
||||
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
|
||||
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
|
||||
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
|
||||
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
|
||||
}
|
||||
244
internal/api/controller/checker.go
Normal file
244
internal/api/controller/checker.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerController handles checker-related API endpoints.
|
||||
type CheckerController struct {
|
||||
engine happydns.CheckerEngine
|
||||
OptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
planUC *checkerUC.CheckPlanUsecase
|
||||
statusUC *checkerUC.CheckStatusUsecase
|
||||
plannedProvider checkerUC.PlannedJobProvider
|
||||
}
|
||||
|
||||
// NewCheckerController creates a new CheckerController.
|
||||
func NewCheckerController(
|
||||
engine happydns.CheckerEngine,
|
||||
optionsUC *checkerUC.CheckerOptionsUsecase,
|
||||
planUC *checkerUC.CheckPlanUsecase,
|
||||
statusUC *checkerUC.CheckStatusUsecase,
|
||||
plannedProvider checkerUC.PlannedJobProvider,
|
||||
) *CheckerController {
|
||||
return &CheckerController{
|
||||
engine: engine,
|
||||
OptionsUC: optionsUC,
|
||||
planUC: planUC,
|
||||
statusUC: statusUC,
|
||||
plannedProvider: plannedProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// StatusUC returns the CheckStatusUsecase for use by other controllers.
|
||||
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
|
||||
return cc.statusUC
|
||||
}
|
||||
|
||||
// targetFromContext builds a CheckTarget from middleware context values.
|
||||
func targetFromContext(c *gin.Context) happydns.CheckTarget {
|
||||
user := middleware.MyUser(c)
|
||||
target := happydns.CheckTarget{}
|
||||
if user != nil {
|
||||
target.UserId = user.Id.String()
|
||||
}
|
||||
if domain, exists := c.Get("domain"); exists {
|
||||
d := domain.(*happydns.Domain)
|
||||
target.DomainId = d.Id.String()
|
||||
}
|
||||
if sid, exists := c.Get("serviceid"); exists {
|
||||
id := sid.(happydns.Identifier)
|
||||
target.ServiceId = id.String()
|
||||
if z, zExists := c.Get("zone"); zExists {
|
||||
zone := z.(*happydns.Zone)
|
||||
if _, svc := zone.FindService(id); svc != nil {
|
||||
target.ServiceType = svc.Type
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// --- Global checker routes ---
|
||||
|
||||
// ListCheckers returns all registered checker definitions.
|
||||
//
|
||||
// @Summary List available checkers
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]checker.CheckerDefinition
|
||||
// @Router /checkers [get]
|
||||
func (cc *CheckerController) ListCheckers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
|
||||
}
|
||||
|
||||
// GetChecker returns a specific checker definition.
|
||||
//
|
||||
// @Summary Get a checker definition
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} checker.CheckerDefinition
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /checkers/{checkerId} [get]
|
||||
func (cc *CheckerController) GetChecker(c *gin.Context) {
|
||||
def, _ := c.Get("checker")
|
||||
c.JSON(http.StatusOK, def)
|
||||
}
|
||||
|
||||
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
|
||||
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
|
||||
checkerID := c.Param("checkerId")
|
||||
def := checkerPkg.FindChecker(checkerID)
|
||||
if def == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
|
||||
return
|
||||
}
|
||||
c.Set("checker", def)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// --- Scoped routes (domain/service) ---
|
||||
|
||||
// ListAvailableChecks lists all checkers with their latest status for a target.
|
||||
//
|
||||
// @Summary List available checks with status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {array} happydns.CheckerStatus
|
||||
// @Router /domains/{domain}/checkers [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
|
||||
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
|
||||
target := targetFromContext(c)
|
||||
|
||||
result, err := cc.statusUC.ListCheckerStatuses(target)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TriggerCheck manually triggers a checker execution.
|
||||
// By default the check runs asynchronously and returns an Execution (HTTP 202).
|
||||
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
|
||||
//
|
||||
// @Summary Trigger a manual check
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param sync query bool false "Run synchronously"
|
||||
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.CheckEvaluation
|
||||
// @Success 202 {object} happydns.Execution
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
|
||||
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
|
||||
cname := c.Param("checkerId")
|
||||
|
||||
var req happydns.CheckerRunRequest
|
||||
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
target := targetFromContext(c)
|
||||
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a temporary plan from enabled rules if provided.
|
||||
var plan *happydns.CheckPlan
|
||||
if len(req.EnabledRules) > 0 {
|
||||
plan = &happydns.CheckPlan{
|
||||
CheckerID: cname,
|
||||
Target: target,
|
||||
Enabled: req.EnabledRules,
|
||||
}
|
||||
}
|
||||
|
||||
exec, err := cc.engine.CreateExecution(cname, target, plan)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("sync") == "true" {
|
||||
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, eval)
|
||||
} else {
|
||||
go func() {
|
||||
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
|
||||
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
|
||||
}
|
||||
}()
|
||||
c.JSON(http.StatusAccepted, exec)
|
||||
}
|
||||
}
|
||||
|
||||
// GetExecutionStatus returns the status of an execution.
|
||||
//
|
||||
// @Summary Get execution status
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param executionId path string true "Execution ID"
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string true "Zone identifier"
|
||||
// @Param subdomain path string true "Subdomain"
|
||||
// @Param serviceid path string true "Service identifier"
|
||||
// @Success 200 {object} happydns.Execution
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
|
||||
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
|
||||
exec := c.MustGet("execution").(*happydns.Execution)
|
||||
c.JSON(http.StatusOK, exec)
|
||||
}
|
||||
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 {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot create check plan: %w", err))
|
||||
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 {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("cannot update check plan: %w", err))
|
||||
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)
|
||||
}
|
||||
307
internal/api/controller/checker_results.go
Normal file
307
internal/api/controller/checker_results.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// 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"
|
||||
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 := getLimitParam(c, 0)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("GetWorstServiceStatuses: %s", err.Error())
|
||||
} else {
|
||||
result.ServicesCheckStatus = statusByService
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetZoneSubdomain returns the services associated with a given subdomain.
|
||||
|
|
|
|||
114
internal/api/route/checker.go
Normal file
114
internal/api/route/checker.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// declareCheckerOptionsRoutes registers the options sub-routes on a checker group.
|
||||
func declareCheckerOptionsRoutes(checkerID *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
checkerID.GET("/options", cc.GetCheckerOptions)
|
||||
checkerID.POST("/options", cc.AddCheckerOptions)
|
||||
checkerID.PUT("/options", cc.ChangeCheckerOptions)
|
||||
checkerID.GET("/options/:optname", cc.GetCheckerOption)
|
||||
checkerID.PUT("/options/:optname", cc.SetCheckerOption)
|
||||
}
|
||||
|
||||
// DeclareCheckerRoutes registers global checker routes under /api/checkers.
|
||||
// Returns the controller so it can be reused for scoped routes.
|
||||
func DeclareCheckerRoutes(
|
||||
apiRoutes *gin.RouterGroup,
|
||||
engine happydns.CheckerEngine,
|
||||
optionsUC *checkerUC.CheckerOptionsUsecase,
|
||||
planUC *checkerUC.CheckPlanUsecase,
|
||||
statusUC *checkerUC.CheckStatusUsecase,
|
||||
plannedProvider checkerUC.PlannedJobProvider,
|
||||
) *controller.CheckerController {
|
||||
cc := controller.NewCheckerController(engine, optionsUC, planUC, statusUC, plannedProvider)
|
||||
|
||||
// Global: /api/checkers
|
||||
checkers := apiRoutes.Group("/checkers")
|
||||
checkers.GET("", cc.ListCheckers)
|
||||
checkers.GET("/metrics", cc.GetUserMetrics)
|
||||
|
||||
checkerID := checkers.Group("/:checkerId")
|
||||
checkerID.Use(cc.CheckerHandler)
|
||||
checkerID.GET("", cc.GetChecker)
|
||||
|
||||
declareCheckerOptionsRoutes(checkerID, cc)
|
||||
|
||||
return cc
|
||||
}
|
||||
|
||||
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
|
||||
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
checkers := scopedRouter.Group("/checkers")
|
||||
checkers.GET("", cc.ListAvailableChecks)
|
||||
checkers.GET("/metrics", cc.GetDomainMetrics)
|
||||
|
||||
checkerID := checkers.Group("/:checkerId")
|
||||
checkerID.Use(cc.CheckerHandler)
|
||||
|
||||
declareCheckerOptionsRoutes(checkerID, cc)
|
||||
|
||||
// Plans (schedules).
|
||||
checkerID.GET("/plans", cc.ListCheckPlans)
|
||||
checkerID.POST("/plans", cc.CreateCheckPlan)
|
||||
|
||||
planID := checkerID.Group("/plans/:planId")
|
||||
planID.Use(cc.PlanHandler)
|
||||
planID.GET("", cc.GetCheckPlan)
|
||||
planID.PUT("", cc.UpdateCheckPlan)
|
||||
planID.DELETE("", cc.DeleteCheckPlan)
|
||||
|
||||
// Per-checker metrics.
|
||||
checkerID.GET("/metrics", cc.GetCheckerMetrics)
|
||||
|
||||
// Executions.
|
||||
executions := checkerID.Group("/executions")
|
||||
executions.GET("", cc.ListExecutions)
|
||||
executions.POST("", cc.TriggerCheck)
|
||||
executions.DELETE("", cc.DeleteCheckerExecutions)
|
||||
|
||||
executionID := executions.Group("/:executionId")
|
||||
executionID.Use(cc.ExecutionHandler)
|
||||
executionID.GET("", cc.GetExecutionStatus)
|
||||
executionID.DELETE("", cc.DeleteExecution)
|
||||
|
||||
// Metrics (under execution).
|
||||
executionID.GET("/metrics", cc.GetExecutionMetrics)
|
||||
|
||||
// Observations (under execution).
|
||||
executionID.GET("/observations", cc.GetExecutionObservations)
|
||||
executionID.GET("/observations/:obsKey", cc.GetExecutionObservation)
|
||||
executionID.GET("/observations/:obsKey/report", cc.GetExecutionHTMLReport)
|
||||
|
||||
// Results (under execution).
|
||||
executionID.GET("/results", cc.GetExecutionResults)
|
||||
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -39,11 +40,14 @@ func DeclareDomainRoutes(
|
|||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase,
|
||||
) {
|
||||
dc := controller.NewDomainController(
|
||||
domainUC,
|
||||
remoteZoneImporter,
|
||||
zoneImporter,
|
||||
checkStatusUC,
|
||||
)
|
||||
|
||||
router.GET("/domains", dc.GetDomains)
|
||||
|
|
@ -61,6 +65,11 @@ func DeclareDomainRoutes(
|
|||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||
|
||||
// Mount domain-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
|
||||
}
|
||||
|
||||
DeclareZoneRoutes(
|
||||
apiDomainsRoutes,
|
||||
zoneUC,
|
||||
|
|
@ -68,5 +77,6 @@ func DeclareDomainRoutes(
|
|||
zoneCorrApplier,
|
||||
zoneServiceUC,
|
||||
serviceUC,
|
||||
cc,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ package route
|
|||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Dependencies holds all use cases required to register the public API routes.
|
||||
// It is a plain struct — no methods, no interface — constructed once in app.go.
|
||||
// It is a plain struct - no methods, no interface - constructed once in app.go.
|
||||
type Dependencies struct {
|
||||
Authentication happydns.AuthenticationUsecase
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
|
|
@ -50,6 +52,12 @@ type Dependencies struct {
|
|||
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
|
||||
ZoneImporter happydns.ZoneImporterUsecase
|
||||
ZoneService happydns.ZoneServiceUsecase
|
||||
|
||||
CheckerEngine happydns.CheckerEngine
|
||||
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
CheckPlanUC *checkerUC.CheckPlanUsecase
|
||||
CheckStatusUC *checkerUC.CheckStatusUsecase
|
||||
PlannedProvider checkerUC.PlannedJobProvider
|
||||
}
|
||||
|
||||
// @title happyDomain API
|
||||
|
|
@ -105,6 +113,19 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
}
|
||||
apiAuthRoutes.Use(middleware.AuthRequired())
|
||||
|
||||
// Initialize checker controller if checker engine is available.
|
||||
var cc *controller.CheckerController
|
||||
if dep.CheckerEngine != nil {
|
||||
cc = DeclareCheckerRoutes(
|
||||
apiAuthRoutes,
|
||||
dep.CheckerEngine,
|
||||
dep.CheckerOptionsUC,
|
||||
dep.CheckPlanUC,
|
||||
dep.CheckStatusUC,
|
||||
dep.PlannedProvider,
|
||||
)
|
||||
}
|
||||
|
||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||
DeclareDomainRoutes(
|
||||
apiAuthRoutes,
|
||||
|
|
@ -116,6 +137,8 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
dep.ZoneCorrectionApplier,
|
||||
dep.ZoneService,
|
||||
dep.Service,
|
||||
cc,
|
||||
dep.CheckStatusUC,
|
||||
)
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes(
|
|||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
zoneUC happydns.ZoneUsecase,
|
||||
cc *controller.CheckerController,
|
||||
) {
|
||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
||||
|
||||
|
|
@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes(
|
|||
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
|
||||
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
|
||||
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
|
||||
|
||||
// Mount service-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -36,11 +37,18 @@ func DeclareZoneRoutes(
|
|||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
) {
|
||||
var checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
if cc != nil {
|
||||
checkStatusUC = cc.StatusUC()
|
||||
}
|
||||
|
||||
zc := controller.NewZoneController(
|
||||
zoneUC,
|
||||
domainUC,
|
||||
zoneCorrApplier,
|
||||
checkStatusUC,
|
||||
)
|
||||
|
||||
apiZonesRoutes := router.Group("/zone/:zoneid")
|
||||
|
|
@ -65,6 +73,7 @@ func DeclareZoneRoutes(
|
|||
zoneServiceUC,
|
||||
serviceUC,
|
||||
zoneUC,
|
||||
cc,
|
||||
)
|
||||
|
||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||
|
|
|
|||
|
|
@ -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,14 @@ type Usecases struct {
|
|||
zoneService happydns.ZoneServiceUsecase
|
||||
|
||||
orchestrator *orchestrator.Orchestrator
|
||||
|
||||
checkerEngine happydns.CheckerEngine
|
||||
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
|
||||
checkerPlanUC *checkerUC.CheckPlanUsecase
|
||||
checkerStatusUC *checkerUC.CheckStatusUsecase
|
||||
checkerScheduler *checkerUC.Scheduler
|
||||
checkerJanitor *checkerUC.Janitor
|
||||
checkerUserGater *checkerUC.UserGater
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -93,6 +102,9 @@ func NewApp(cfg *happydns.Options) *App {
|
|||
app.initStorageEngine()
|
||||
app.initNewsletter()
|
||||
app.initInsights()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -108,6 +120,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
|||
|
||||
app.initMailer()
|
||||
app.initNewsletter()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.setupRouter()
|
||||
|
|
@ -224,12 +239,13 @@ func (app *App) initUsecases() {
|
|||
app.store,
|
||||
)
|
||||
|
||||
app.usecases.user = userUC.NewUserUsecases(
|
||||
userService := userUC.NewUserUsecases(
|
||||
app.store,
|
||||
app.newsletter,
|
||||
authUserService,
|
||||
sessionService,
|
||||
)
|
||||
app.usecases.user = userService
|
||||
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
|
||||
app.usecases.authUser = authUserService
|
||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||
|
|
@ -246,6 +262,44 @@ func (app *App) initUsecases() {
|
|||
providerAdminService,
|
||||
zoneService.UpdateZoneUC,
|
||||
)
|
||||
|
||||
// Checker system.
|
||||
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
|
||||
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
|
||||
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
|
||||
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
|
||||
app.usecases.checkerOptionsUC,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
)
|
||||
// Build the user-level gate so paused or long-inactive users do not
|
||||
// get checked. The same user resolver is reused by the janitor for
|
||||
// per-user retention overrides.
|
||||
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
|
||||
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store, app.usecases.checkerUserGater.Allow)
|
||||
|
||||
// Invalidate the scheduler's user gate cache whenever a user is updated
|
||||
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
|
||||
userService.SetOnUserChanged(func(id happydns.Identifier) {
|
||||
app.usecases.checkerUserGater.Invalidate(id.String())
|
||||
})
|
||||
|
||||
// Retention janitor.
|
||||
app.usecases.checkerJanitor = checkerUC.NewJanitor(
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
|
||||
app.cfg.CheckerJanitorInterval,
|
||||
)
|
||||
|
||||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -291,6 +345,12 @@ func (app *App) setupRouter() {
|
|||
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
|
||||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
|
||||
CheckerEngine: app.usecases.checkerEngine,
|
||||
CheckerOptionsUC: app.usecases.checkerOptionsUC,
|
||||
CheckPlanUC: app.usecases.checkerPlanUC,
|
||||
CheckStatusUC: app.usecases.checkerStatusUC,
|
||||
PlannedProvider: app.usecases.checkerScheduler,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
|
|
@ -308,6 +368,14 @@ func (app *App) Start() {
|
|||
go app.insights.Run()
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Start(context.Background())
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Start(context.Background())
|
||||
}
|
||||
|
||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
|
|
@ -321,6 +389,14 @@ func (app *App) Stop() {
|
|||
log.Fatal("Server Shutdown:", err)
|
||||
}
|
||||
|
||||
if app.usecases.checkerScheduler != nil {
|
||||
app.usecases.checkerScheduler.Stop()
|
||||
}
|
||||
|
||||
if app.usecases.checkerJanitor != nil {
|
||||
app.usecases.checkerJanitor.Stop()
|
||||
}
|
||||
|
||||
// Close storage
|
||||
if app.store != nil {
|
||||
app.store.Close()
|
||||
|
|
|
|||
238
internal/app/plugins.go
Normal file
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()
|
||||
}
|
||||
}
|
||||
120
internal/checker/provider_http.go
Normal file
120
internal/checker/provider_http.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// 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
|
||||
|
||||
// maxResponseBodySize is the maximum number of bytes read from a successful
|
||||
// response body. This prevents a misbehaving endpoint from causing OOM.
|
||||
const maxResponseBodySize = 10 << 20 // 10 MiB
|
||||
|
||||
// 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(io.LimitReader(resp.Body, maxResponseBodySize)).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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,13 @@ func ValidateStructValues(data any) error {
|
|||
}
|
||||
|
||||
v := reflect.Indirect(reflect.ValueOf(data))
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
}
|
||||
t := v.Type()
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
sf := t.Field(i)
|
||||
|
|
@ -127,6 +133,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 {
|
||||
|
|
|
|||
201
internal/forms/field_test.go
Normal file
201
internal/forms/field_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructValues_NilPointer(t *testing.T) {
|
||||
type S struct {
|
||||
Name string `happydomain:"required"`
|
||||
}
|
||||
// Typed nil pointer must not panic.
|
||||
if err := ValidateStructValues((*S)(nil)); err != nil {
|
||||
t.Fatalf("expected nil error for typed nil pointer, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructValues_NonStruct(t *testing.T) {
|
||||
// Non-struct values must not panic.
|
||||
if err := ValidateStructValues("hello"); err != nil {
|
||||
t.Fatalf("expected nil error for non-struct, got %v", err)
|
||||
}
|
||||
if err := ValidateStructValues(42); err != nil {
|
||||
t.Fatalf("expected nil error for non-struct, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
226
internal/storage/kvtpl/check_evaluation.go
Normal file
226
internal/storage/kvtpl/check_evaluation.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// 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"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckeval-plan|%s|", planID.String()), s.GetEvaluation)
|
||||
}
|
||||
|
||||
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) {
|
||||
return listByIndexSorted(
|
||||
s,
|
||||
fmt.Sprintf("chckeval-chkr|%s|%s|", checkerID, target.String()),
|
||||
s.GetEvaluation,
|
||||
func(a, b *happydns.CheckEvaluation) bool { return a.EvaluatedAt.After(b.EvaluatedAt) },
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
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) evalExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetEvaluation(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyEvaluationIndexes() error {
|
||||
// Tidy chckeval-plan|{planId}|{evalId} indexes.
|
||||
s.tidyTwoPartIndex("chckeval-plan|", "evaluation plan", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}, s.evalExists)
|
||||
|
||||
// Tidy chckeval-chkr|{checkerID}|{target}|{evalId} indexes.
|
||||
s.tidyLastSegmentIndex("chckeval-chkr|", "evaluation checker", s.evalExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearEvaluations() error {
|
||||
// Delete secondary indexes (chckeval-plan|..., chckeval-chkr|...).
|
||||
if err := s.clearByPrefix("chckeval-"); 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
|
||||
}
|
||||
222
internal/storage/kvtpl/check_plan.go
Normal file
222
internal/storage/kvtpl/check_plan.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// 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"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func planTargetIndexKey(target happydns.CheckTarget, planId string) string {
|
||||
return fmt.Sprintf("chckpln-tgt|%s|%s", target.String(), planId)
|
||||
}
|
||||
|
||||
func planCheckerIndexKey(checkerID string, planId string) string {
|
||||
return fmt.Sprintf("chckpln-chkr|%s|%s", checkerID, planId)
|
||||
}
|
||||
|
||||
func planUserIndexKey(userId string, planId string) string {
|
||||
return fmt.Sprintf("chckpln-user|%s|%s", userId, planId)
|
||||
}
|
||||
|
||||
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) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-tgt|%s|", target.String()), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckPlansByChecker(checkerID string) ([]*happydns.CheckPlan, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-chkr|%s|", checkerID), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckPlansByUser(userId happydns.Identifier) ([]*happydns.CheckPlan, error) {
|
||||
return listByIndex(s, fmt.Sprintf("chckpln-user|%s|", userId.String()), s.GetCheckPlan)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if err := s.db.Put(key, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.putCheckPlanIndexes(plan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
old, err := s.GetCheckPlan(plan.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up stale target index if target changed.
|
||||
oldTargetKey := planTargetIndexKey(old.Target, old.Id.String())
|
||||
newTargetKey := planTargetIndexKey(plan.Target, plan.Id.String())
|
||||
if oldTargetKey != newTargetKey {
|
||||
if err := s.db.Delete(oldTargetKey); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale target index %s: %v\n", oldTargetKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale checker index if checker changed.
|
||||
oldCheckerKey := planCheckerIndexKey(old.CheckerID, old.Id.String())
|
||||
newCheckerKey := planCheckerIndexKey(plan.CheckerID, plan.Id.String())
|
||||
if oldCheckerKey != newCheckerKey {
|
||||
if err := s.db.Delete(oldCheckerKey); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale checker index %s: %v\n", oldCheckerKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale user index if user changed.
|
||||
if old.Target.UserId != "" && old.Target.UserId != plan.Target.UserId {
|
||||
if err := s.db.Delete(planUserIndexKey(old.Target.UserId, old.Id.String())); err != nil {
|
||||
log.Printf("UpdateCheckPlan: failed to delete stale user index for user %s: %v\n", old.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.putCheckPlanIndexes(plan)
|
||||
}
|
||||
|
||||
func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
|
||||
if err := s.db.Put(planTargetIndexKey(plan.Target, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Put(planCheckerIndexKey(plan.CheckerID, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if plan.Target.UserId != "" {
|
||||
if err := s.db.Put(planUserIndexKey(plan.Target.UserId, plan.Id.String()), true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
|
||||
plan, err := s.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Delete(planTargetIndexKey(plan.Target, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete target index: %v\n", err)
|
||||
}
|
||||
|
||||
if err := s.db.Delete(planCheckerIndexKey(plan.CheckerID, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete checker index: %v\n", err)
|
||||
}
|
||||
|
||||
if plan.Target.UserId != "" {
|
||||
if err := s.db.Delete(planUserIndexKey(plan.Target.UserId, planID.String())); err != nil {
|
||||
log.Printf("DeleteCheckPlan: failed to delete user index for user %s: %v\n", plan.Target.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Delete(fmt.Sprintf("chckpln|%s", planID.String()))
|
||||
}
|
||||
|
||||
// deleteCheckPlanSecondaryIndexesByPlanID scans all plan index prefixes to
|
||||
// remove any entry for the given plan ID. Used when the primary record is
|
||||
// already gone and we don't know which target/checker/user it belonged to.
|
||||
func (s *KVStorage) deleteCheckPlanSecondaryIndexesByPlanID(planId happydns.Identifier) {
|
||||
suffix := "|" + planId.String()
|
||||
for _, prefix := range []string{"chckpln-tgt|", "chckpln-chkr|", "chckpln-user|"} {
|
||||
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("deleteCheckPlanSecondaryIndexesByPlanID: failed to delete %s: %v\n", iter.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) checkPlanExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyCheckPlanIndexes() error {
|
||||
// Tidy chckpln-tgt|{target}|{planId} indexes.
|
||||
s.tidyLastSegmentIndex("chckpln-tgt|", "plan target", s.checkPlanExists)
|
||||
|
||||
// Tidy chckpln-chkr|{checkerID}|{planId} indexes.
|
||||
s.tidyLastSegmentIndex("chckpln-chkr|", "plan checker", s.checkPlanExists)
|
||||
|
||||
// Tidy chckpln-user|{userId}|{planId} indexes.
|
||||
s.tidyTwoPartIndex("chckpln-user|", "plan user", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetUser(id)
|
||||
return err == nil
|
||||
}, s.checkPlanExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearCheckPlans() error {
|
||||
// Delete secondary indexes.
|
||||
if err := s.clearByPrefix("chckpln-"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete primary records.
|
||||
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
|
||||
}
|
||||
354
internal/storage/kvtpl/execution.go
Normal file
354
internal/storage/kvtpl/execution.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
// 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"
|
||||
"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) {
|
||||
return listByIndex(s, fmt.Sprintf("chckexec-plan|%s|", planID.String()), s.GetExecution)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return listByIndexSorted(
|
||||
s,
|
||||
prefix,
|
||||
s.GetExecution,
|
||||
func(a, b *happydns.Execution) bool { return a.StartedAt.After(b.StartedAt) },
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KVStorage) execExists(id happydns.Identifier) bool {
|
||||
_, err := s.GetExecution(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) TidyExecutionIndexes() error {
|
||||
// Tidy chckexec-plan|{planId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-plan|", "execution plan", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetCheckPlan(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
// Tidy chckexec-chkr|{checkerID}|{target}|{execId} indexes.
|
||||
s.tidyLastSegmentIndex("chckexec-chkr|", "execution checker", s.execExists)
|
||||
|
||||
// Tidy chckexec-user|{userId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-user|", "execution user", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetUser(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
// Tidy chckexec-domain|{domainId}|{execId} indexes.
|
||||
s.tidyTwoPartIndex("chckexec-domain|", "execution domain", func(id happydns.Identifier) bool {
|
||||
_, err := s.GetDomain(id)
|
||||
return err == nil
|
||||
}, s.execExists)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearExecutions() error {
|
||||
// Delete secondary indexes (chckexec-plan|..., chckexec-chkr|..., chckexec-user|..., chckexec-domain|...).
|
||||
if err := s.clearByPrefix("chckexec-"); 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,13 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type KVStorage struct {
|
||||
|
|
@ -38,3 +44,130 @@ 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:])
|
||||
}
|
||||
|
||||
// listByIndex scans a secondary index prefix, resolves each entity by its
|
||||
// last key segment, and returns the collected results.
|
||||
func listByIndex[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error)) ([]*T, error) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var results []*T
|
||||
for iter.Next() {
|
||||
id, err := lastKeySegment(iter.Key())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entity, err := getEntity(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, entity)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// listByIndexSorted is like listByIndex but sorts results and applies a limit.
|
||||
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int) ([]*T, error) {
|
||||
results, err := listByIndex(s, prefix, getEntity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return less(results[i], results[j])
|
||||
})
|
||||
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// tidyTwoPartIndex removes stale secondary index entries of the form
|
||||
// prefix{ownerId}|{entityId}. 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, entityExists 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
|
||||
}
|
||||
|
||||
entityId, err := happydns.NewIdentifierFromString(parts[1])
|
||||
if err != nil {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if validateOwner != nil && !validateOwner(ownerId) {
|
||||
log.Printf("Deleting stale %s index (%s %s not found): %s\n", label, label, parts[0], key)
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if !entityExists(entityId) {
|
||||
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, parts[1], key)
|
||||
_ = s.db.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tidyLastSegmentIndex removes stale index entries where the entity ID is the
|
||||
// last "|"-separated segment. Used for multi-part indexes like
|
||||
// prefix{checkerID}|{target}|{entityId}.
|
||||
func (s *KVStorage) tidyLastSegmentIndex(prefix, label string, entityExists func(happydns.Identifier) bool) {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
lastPipe := strings.LastIndex(key, "|")
|
||||
if lastPipe < 0 {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
idStr := key[lastPipe+1:]
|
||||
|
||||
id, err := happydns.NewIdentifierFromString(idStr)
|
||||
if err != nil {
|
||||
_ = s.db.Delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if !entityExists(id) {
|
||||
log.Printf("Deleting stale %s index (entity %s not found): %s\n", label, idStr, key)
|
||||
_ = s.db.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearByPrefix deletes all KV entries matching the given prefix.
|
||||
func (s *KVStorage) clearByPrefix(prefix string) error {
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
for iter.Next() {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,12 +61,13 @@ func (lu *loginUsecase) CompleteAuthentication(uinfo happydns.UserInfo) (*happyd
|
|||
return nil, fmt.Errorf("unable to create user account: %w", err)
|
||||
}
|
||||
} else if (uinfo.GetEmail() != "" && user.Email != uinfo.GetEmail()) || time.Since(user.LastSeen) > time.Hour*12 {
|
||||
if uinfo.GetEmail() != "" {
|
||||
user.Email = uinfo.GetEmail()
|
||||
}
|
||||
user.LastSeen = time.Now()
|
||||
|
||||
err = lu.store.CreateOrUpdateUser(user)
|
||||
email := uinfo.GetEmail()
|
||||
user, err = lu.userService.UpdateUser(user.Id, func(u *happydns.User) {
|
||||
if email != "" {
|
||||
u.Email = email
|
||||
}
|
||||
u.LastSeen = time.Now()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("has a correct JWT, user has been found, but an error occured when trying to update the user's information: %w", err)
|
||||
}
|
||||
|
|
|
|||
135
internal/usecase/checker/check_plan_usecase.go
Normal file
135
internal/usecase/checker/check_plan_usecase.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// targetMatchesResource verifies that every non-empty field in scope
|
||||
// matches the corresponding field in resource. Returns false if any
|
||||
// scope-specified field does not match, indicating the resource belongs
|
||||
// to a different user/domain/service than the caller's scope.
|
||||
func targetMatchesResource(scope, resource happydns.CheckTarget) bool {
|
||||
if scope.UserId != "" && scope.UserId != resource.UserId {
|
||||
return false
|
||||
}
|
||||
if scope.DomainId != "" && scope.DomainId != resource.DomainId {
|
||||
return false
|
||||
}
|
||||
if scope.ServiceId != "" && scope.ServiceId != resource.ServiceId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CheckPlanUsecase handles business logic for check plans.
|
||||
type CheckPlanUsecase struct {
|
||||
store CheckPlanStorage
|
||||
}
|
||||
|
||||
// NewCheckPlanUsecase creates a new CheckPlanUsecase.
|
||||
func NewCheckPlanUsecase(store CheckPlanStorage) *CheckPlanUsecase {
|
||||
return &CheckPlanUsecase{store: store}
|
||||
}
|
||||
|
||||
// ListCheckPlansByTarget returns all check plans matching the given target.
|
||||
func (u *CheckPlanUsecase) ListCheckPlansByTarget(target happydns.CheckTarget) ([]*happydns.CheckPlan, error) {
|
||||
return u.store.ListCheckPlansByTarget(target)
|
||||
}
|
||||
|
||||
// ListCheckPlansByTargetAndChecker returns all check plans matching both the
|
||||
// given target and the given checkerID, filtering in a single pass to avoid
|
||||
// fetching then discarding unrelated plans.
|
||||
func (u *CheckPlanUsecase) ListCheckPlansByTargetAndChecker(target happydns.CheckTarget, checkerID string) ([]*happydns.CheckPlan, error) {
|
||||
plans, err := u.store.ListCheckPlansByTarget(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := plans[:0]
|
||||
for _, p := range plans {
|
||||
if p.CheckerID == checkerID {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// CreateCheckPlan validates that the checker exists and persists the plan.
|
||||
func (u *CheckPlanUsecase) CreateCheckPlan(plan *happydns.CheckPlan) error {
|
||||
if checkerPkg.FindChecker(plan.CheckerID) == nil {
|
||||
return fmt.Errorf("checker %q not found", plan.CheckerID)
|
||||
}
|
||||
return u.store.CreateCheckPlan(plan)
|
||||
}
|
||||
|
||||
// GetCheckPlan retrieves a check plan by ID and verifies it belongs to the given scope.
|
||||
func (u *CheckPlanUsecase) GetCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) (*happydns.CheckPlan, error) {
|
||||
plan, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, plan.Target) {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// UpdateCheckPlan fetches the existing plan, verifies scope ownership,
|
||||
// validates the checker exists, preserves Id and Target (immutable),
|
||||
// and persists the merged result.
|
||||
func (u *CheckPlanUsecase) UpdateCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier, updated *happydns.CheckPlan) (*happydns.CheckPlan, error) {
|
||||
existing, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !targetMatchesResource(scope, existing.Target) {
|
||||
return nil, happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
|
||||
if checkerPkg.FindChecker(updated.CheckerID) == nil {
|
||||
return nil, fmt.Errorf("checker %q not found", updated.CheckerID)
|
||||
}
|
||||
|
||||
updated.Id = existing.Id
|
||||
updated.Target = existing.Target
|
||||
|
||||
if err := u.store.UpdateCheckPlan(updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DeleteCheckPlan deletes a check plan by ID after verifying scope ownership.
|
||||
func (u *CheckPlanUsecase) DeleteCheckPlan(scope happydns.CheckTarget, planID happydns.Identifier) error {
|
||||
plan, err := u.store.GetCheckPlan(planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetMatchesResource(scope, plan.Target) {
|
||||
return happydns.ErrCheckPlanNotFound
|
||||
}
|
||||
return u.store.DeleteCheckPlan(planID)
|
||||
}
|
||||
387
internal/usecase/checker/check_plan_usecase_test.go
Normal file
387
internal/usecase/checker/check_plan_usecase_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
// 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) TidyCheckPlanIndexes() error { 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) (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)
|
||||
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)
|
||||
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))
|
||||
}
|
||||
}
|
||||
642
internal/usecase/checker/checker_options_usecase.go
Normal file
642
internal/usecase/checker/checker_options_usecase.go
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
// 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"
|
||||
"sync"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/forms"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// fieldMetaCache caches the result of computeFieldMeta per CheckerDefinition.
|
||||
// Checker definitions are immutable after init-time registration, so the cache
|
||||
// never needs invalidation.
|
||||
var fieldMetaCache sync.Map // *happydns.CheckerDefinition -> checkerFieldMeta
|
||||
|
||||
// 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
|
||||
// and persists the result. 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) {
|
||||
merged, err := u.MergeCheckerOptions(checkerName, userId, domainId, serviceId, newOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merged, 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 returns cached field metadata for a checker definition.
|
||||
// The result is computed once per definition and cached for the process lifetime.
|
||||
func computeFieldMeta(def *happydns.CheckerDefinition) checkerFieldMeta {
|
||||
if cached, ok := fieldMetaCache.Load(def); ok {
|
||||
return cached.(checkerFieldMeta)
|
||||
}
|
||||
meta := buildFieldMeta(def)
|
||||
fieldMetaCache.Store(def, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
// buildFieldMeta scans all option groups and rules of a checker definition
|
||||
// and returns the consolidated field metadata.
|
||||
func buildFieldMeta(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 WIP zone ([0]) for auto-fill context, so the user can
|
||||
// configure checkers for services they are currently working on.
|
||||
if len(domain.ZoneHistory) == 0 {
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
zone, err := u.autoFillStore.GetZone(domain.ZoneHistory[0])
|
||||
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 WIP first, then latest published, then older history.
|
||||
if serviceId := happydns.TargetIdentifier(target.ServiceId); serviceId != nil {
|
||||
for i := 0; i < len(domain.ZoneHistory); i++ {
|
||||
z := zone
|
||||
if i > 0 {
|
||||
z, err = u.autoFillStore.GetZone(domain.ZoneHistory[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for subdomain, services := range z.Services {
|
||||
for _, svc := range services {
|
||||
if svc.Id.Equals(*serviceId) {
|
||||
ctx[happydns.AutoFillSubdomain] = string(subdomain)
|
||||
ctx[happydns.AutoFillServiceType] = svc.Type
|
||||
ctx[happydns.AutoFillService] = svc
|
||||
return ctx, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// resolveAutoFill looks up the checker definition, scans its fields for AutoFill
|
||||
// attributes, builds the execution context from storage, and returns a map of
|
||||
// field ID to resolved value. Returns an empty map (not nil) when there is
|
||||
// nothing to fill.
|
||||
func (u *CheckerOptionsUsecase) resolveAutoFill(
|
||||
checkerName string,
|
||||
target happydns.CheckTarget,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
def := checkerPkg.FindChecker(checkerName)
|
||||
if def == nil {
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
|
||||
autoFillFields := computeFieldMeta(def).autoFillIds
|
||||
if len(autoFillFields) == 0 {
|
||||
return make(happydns.CheckerOptions), nil
|
||||
}
|
||||
|
||||
ctx, err := u.buildAutoFillContext(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(happydns.CheckerOptions, len(autoFillFields))
|
||||
for fieldId, autoFillKey := range autoFillFields {
|
||||
if val, ok := ctx[autoFillKey]; ok {
|
||||
result[fieldId] = val
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BuildMergedCheckerOptionsWithAutoFill merges stored options, runtime overrides,
|
||||
// and auto-fill values. Auto-fill values are applied last and always win.
|
||||
func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
|
||||
checkerName string,
|
||||
userId *happydns.Identifier,
|
||||
domainId *happydns.Identifier,
|
||||
serviceId *happydns.Identifier,
|
||||
runOpts happydns.CheckerOptions,
|
||||
) (happydns.CheckerOptions, error) {
|
||||
positionals, err := u.store.GetCheckerConfiguration(checkerName, userId, domainId, serviceId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
def := checkerPkg.FindChecker(checkerName)
|
||||
|
||||
// Merge stored options from least to most specific, respecting NoOverride.
|
||||
var meta checkerFieldMeta
|
||||
if def != nil {
|
||||
meta = computeFieldMeta(def)
|
||||
}
|
||||
|
||||
storedOpts := make(happydns.CheckerOptions)
|
||||
for _, p := range positionals {
|
||||
for k, v := range p.Options {
|
||||
if meta.noOverrideIds[k] {
|
||||
if _, exists := storedOpts[k]; exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
storedOpts[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Apply runtime overrides on top.
|
||||
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
|
||||
|
||||
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
|
||||
for id := range meta.noOverrideIds {
|
||||
if v, ok := storedOpts[id]; ok {
|
||||
merged[id] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve auto-fill values (always win).
|
||||
if def != nil && len(meta.autoFillIds) > 0 {
|
||||
target := happydns.CheckTarget{
|
||||
UserId: happydns.FormatIdentifier(userId),
|
||||
DomainId: happydns.FormatIdentifier(domainId),
|
||||
ServiceId: happydns.FormatIdentifier(serviceId),
|
||||
}
|
||||
ctx, err := u.buildAutoFillContext(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for fieldId, autoFillKey := range meta.autoFillIds {
|
||||
if val, ok := ctx[autoFillKey]; ok {
|
||||
merged[fieldId] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
1654
internal/usecase/checker/checker_options_usecase_test.go
Normal file
File diff suppressed because it is too large
Load diff
23
internal/usecase/checker/doc.go
Normal file
23
internal/usecase/checker/doc.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package checker provides the usecase layer for the checker/monitoring system.
|
||||
package checker // import "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
241
internal/usecase/checker/janitor.go
Normal file
241
internal/usecase/checker/janitor.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// JanitorUserResolver resolves a user from a CheckTarget so the janitor can
|
||||
// honour per-user retention overrides stored in UserQuota.
|
||||
type JanitorUserResolver interface {
|
||||
GetUser(id happydns.Identifier) (*happydns.User, error)
|
||||
}
|
||||
|
||||
// Janitor periodically prunes old check executions and evaluations according
|
||||
// to the tiered RetentionPolicy. It is the long-tail enforcement counterpart
|
||||
// of the cheap hard cap applied at execution-creation time.
|
||||
type Janitor struct {
|
||||
planStore CheckPlanStorage
|
||||
execStore ExecutionStorage
|
||||
evalStore CheckEvaluationStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
userResolver JanitorUserResolver
|
||||
defaultPolicy RetentionPolicy
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy
|
||||
// is applied to executions of users that did not customize their retention
|
||||
// horizon via UserQuota. evalStore and snapStore may be nil if evaluation
|
||||
// pruning is not desired.
|
||||
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, evalStore CheckEvaluationStorage, snapStore ObservationSnapshotStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
return &Janitor{
|
||||
planStore: planStore,
|
||||
execStore: execStore,
|
||||
evalStore: evalStore,
|
||||
snapStore: snapStore,
|
||||
userResolver: userResolver,
|
||||
defaultPolicy: defaultPolicy,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the janitor loop in a goroutine. It runs an immediate sweep
|
||||
// once the loop is up.
|
||||
func (j *Janitor) Start(ctx context.Context) {
|
||||
j.mu.Lock()
|
||||
if j.running {
|
||||
j.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
j.cancel = cancel
|
||||
j.done = make(chan struct{})
|
||||
j.running = true
|
||||
j.mu.Unlock()
|
||||
|
||||
go j.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the janitor and waits for the current sweep to finish.
|
||||
func (j *Janitor) Stop() {
|
||||
j.mu.Lock()
|
||||
cancel := j.cancel
|
||||
done := j.done
|
||||
j.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
j.mu.Lock()
|
||||
j.running = false
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
func (j *Janitor) loop(ctx context.Context) {
|
||||
defer close(j.done)
|
||||
|
||||
// Run immediately, then on the configured interval.
|
||||
j.RunOnce(ctx)
|
||||
|
||||
ticker := time.NewTicker(j.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
j.RunOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce performs a single sweep over all check plans, applying the per-user
|
||||
// retention policy to both executions and evaluations. Returns the total
|
||||
// number of records deleted (executions + evaluations).
|
||||
func (j *Janitor) RunOnce(ctx context.Context) int {
|
||||
iter, err := j.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list check plans: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
now := time.Now()
|
||||
deleted := 0
|
||||
|
||||
// Cache user policies to avoid resolving the same user repeatedly.
|
||||
policyByUser := map[string]RetentionPolicy{}
|
||||
|
||||
for iter.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return deleted
|
||||
default:
|
||||
}
|
||||
|
||||
plan := iter.Item()
|
||||
if plan == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policy := j.policyForTarget(plan.Target, policyByUser)
|
||||
hardCutoff := now.AddDate(0, 0, -policy.RetentionDays)
|
||||
|
||||
// Prune executions using the tiered retention policy.
|
||||
execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
|
||||
} else if len(execs) > 0 {
|
||||
// All executions share the same (CheckerID, Target) since they come
|
||||
// from a single plan, so Decide's internal grouping is a no-op here.
|
||||
_, drop := policy.Decide(execs, now)
|
||||
|
||||
for _, id := range drop {
|
||||
if err := j.execStore.DeleteExecution(id); err != nil {
|
||||
log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
// Prune evaluations older than the hard cutoff.
|
||||
if j.evalStore != nil {
|
||||
deleted += j.pruneEvaluations(plan.Id, hardCutoff)
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
log.Printf("Janitor: iterator error while walking check plans: %v", err)
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("Janitor: pruned %d records", deleted)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
// pruneEvaluations deletes evaluations for the given plan that are older than
|
||||
// the cutoff, along with their associated snapshots.
|
||||
func (j *Janitor) pruneEvaluations(planID happydns.Identifier, cutoff time.Time) int {
|
||||
evals, err := j.evalStore.ListEvaluationsByPlan(planID)
|
||||
if err != nil {
|
||||
log.Printf("Janitor: failed to list evaluations for plan %s: %v", planID.String(), err)
|
||||
return 0
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, eval := range evals {
|
||||
if eval.EvaluatedAt.Before(cutoff) {
|
||||
// Delete the associated snapshot first.
|
||||
if j.snapStore != nil && !eval.SnapshotID.IsEmpty() {
|
||||
if err := j.snapStore.DeleteSnapshot(eval.SnapshotID); err != nil {
|
||||
log.Printf("Janitor: failed to delete snapshot %s: %v", eval.SnapshotID.String(), err)
|
||||
}
|
||||
}
|
||||
if err := j.evalStore.DeleteEvaluation(eval.Id); err != nil {
|
||||
log.Printf("Janitor: failed to delete evaluation %s: %v", eval.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
|
||||
uid := target.UserId
|
||||
if uid == "" || j.userResolver == nil {
|
||||
return j.defaultPolicy
|
||||
}
|
||||
if p, ok := cache[uid]; ok {
|
||||
return p
|
||||
}
|
||||
policy := j.defaultPolicy
|
||||
id, err := happydns.NewIdentifierFromString(uid)
|
||||
if err == nil {
|
||||
if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
|
||||
if user.Quota.RetentionDays > 0 {
|
||||
policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
cache[uid] = policy
|
||||
return policy
|
||||
}
|
||||
668
internal/usecase/checker/janitor_test.go
Normal file
668
internal/usecase/checker/janitor_test.go
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// --- mock execution store for janitor tests ---
|
||||
|
||||
type mockExecStore struct {
|
||||
mu sync.Mutex
|
||||
execs map[string][]*happydns.Execution // planID (base64) -> executions
|
||||
errs map[string]error // planID (base64) -> error
|
||||
}
|
||||
|
||||
func newMockExecStore() *mockExecStore {
|
||||
return &mockExecStore{
|
||||
execs: make(map[string][]*happydns.Execution),
|
||||
errs: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockExecStore) addExec(planID happydns.Identifier, exec *happydns.Execution) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := planID.String()
|
||||
s.execs[key] = append(s.execs[key], exec)
|
||||
}
|
||||
|
||||
func (s *mockExecStore) setListError(planID happydns.Identifier, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.errs[planID.String()] = err
|
||||
}
|
||||
|
||||
func (s *mockExecStore) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := planID.String()
|
||||
if err, ok := s.errs[key]; ok {
|
||||
return nil, err
|
||||
}
|
||||
return s.execs[key], nil
|
||||
}
|
||||
|
||||
func (s *mockExecStore) DeleteExecution(execID happydns.Identifier) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for planKey, execs := range s.execs {
|
||||
for i, e := range execs {
|
||||
if e.Id.Equals(execID) {
|
||||
s.execs[planKey] = append(execs[:i], execs[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("execution %s not found", execID.String())
|
||||
}
|
||||
|
||||
// Unused interface methods.
|
||||
func (s *mockExecStore) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int) ([]*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockExecStore) CreateExecution(*happydns.Execution) error { return nil }
|
||||
func (s *mockExecStore) UpdateExecution(*happydns.Execution) error { return nil }
|
||||
func (s *mockExecStore) DeleteExecutionsByChecker(string, happydns.CheckTarget) error { return nil }
|
||||
func (s *mockExecStore) TidyExecutionIndexes() error { return nil }
|
||||
func (s *mockExecStore) ClearExecutions() error { return nil }
|
||||
|
||||
// --- mock user resolver ---
|
||||
|
||||
type mockUserResolver struct {
|
||||
users map[string]*happydns.User
|
||||
}
|
||||
|
||||
func (r *mockUserResolver) GetUser(id happydns.Identifier) (*happydns.User, error) {
|
||||
if u, ok := r.users[id.String()]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user %s not found", id.String())
|
||||
}
|
||||
|
||||
// --- counting wrapper ---
|
||||
|
||||
type countingUserResolver struct {
|
||||
inner JanitorUserResolver
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (r *countingUserResolver) GetUser(id happydns.Identifier) (*happydns.User, error) {
|
||||
*r.calls++
|
||||
return r.inner.GetUser(id)
|
||||
}
|
||||
|
||||
// --- failing plan store ---
|
||||
|
||||
type failingPlanStore struct {
|
||||
mockPlanStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *failingPlanStore) ListAllCheckPlans() (happydns.Iterator[happydns.CheckPlan], error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func makePlan(id string, userID string) *happydns.CheckPlan {
|
||||
return &happydns.CheckPlan{
|
||||
Id: happydns.Identifier(id),
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{
|
||||
UserId: userID,
|
||||
DomainId: "example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeExec(id string, age time.Duration, now time.Time) *happydns.Execution {
|
||||
return &happydns.Execution{
|
||||
Id: happydns.Identifier(id),
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{DomainId: "example.com"},
|
||||
StartedAt: now.Add(-age),
|
||||
}
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestJanitor_RunOnce_NoPlans(t *testing.T) {
|
||||
ps := &mockPlanStore{}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
|
||||
deleted := j.RunOnce(context.Background())
|
||||
if deleted != 0 {
|
||||
t.Fatalf("expected 0 deletions, got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_NoExecutions(t *testing.T) {
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
|
||||
deleted := j.RunOnce(context.Background())
|
||||
if deleted != 0 {
|
||||
t.Fatalf("expected 0 deletions, got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_PrunesExpiredExecutions(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
|
||||
// One recent execution (1 hour old) and one expired (100 days old with a 30-day policy).
|
||||
es.addExec(plan.Id, makeExec("recent", 1*time.Hour, now))
|
||||
es.addExec(plan.Id, makeExec("old", 100*24*time.Hour, now))
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion, got %d", deleted)
|
||||
}
|
||||
|
||||
// Verify the old execution was deleted.
|
||||
remaining, _ := es.ListExecutionsByPlan(plan.Id)
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("expected 1 remaining execution, got %d", len(remaining))
|
||||
}
|
||||
if !remaining[0].Id.Equals(happydns.Identifier("recent")) {
|
||||
t.Fatalf("expected 'recent' to survive, got %s", remaining[0].Id.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_PerUserRetentionOverride(t *testing.T) {
|
||||
now := time.Now()
|
||||
userID := happydns.Identifier("user1")
|
||||
plan := makePlan("plan1", userID.String())
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
|
||||
// Execution 20 days old. System default is 30 days (would keep), but user override is 10 days (should drop).
|
||||
es.addExec(plan.Id, makeExec("exec1", 20*24*time.Hour, now))
|
||||
|
||||
resolver := &mockUserResolver{
|
||||
users: map[string]*happydns.User{
|
||||
userID.String(): {
|
||||
Id: userID,
|
||||
Quota: happydns.UserQuota{RetentionDays: 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion (user retention=10d), got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_UserCacheAvoidsRepeatedLookups(t *testing.T) {
|
||||
now := time.Now()
|
||||
userID := happydns.Identifier("user1")
|
||||
|
||||
// Two plans for the same user.
|
||||
plan1 := makePlan("plan1", userID.String())
|
||||
plan2 := makePlan("plan2", userID.String())
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
|
||||
es := newMockExecStore()
|
||||
|
||||
es.addExec(plan1.Id, makeExec("e1", 20*24*time.Hour, now))
|
||||
es.addExec(plan2.Id, makeExec("e2", 20*24*time.Hour, now))
|
||||
|
||||
calls := 0
|
||||
resolver := &countingUserResolver{
|
||||
inner: &mockUserResolver{
|
||||
users: map[string]*happydns.User{
|
||||
userID.String(): {
|
||||
Id: userID,
|
||||
Quota: happydns.UserQuota{RetentionDays: 10},
|
||||
},
|
||||
},
|
||||
},
|
||||
calls: &calls,
|
||||
}
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(30), time.Hour)
|
||||
j.RunOnce(context.Background())
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected user resolver to be called once (cached), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_NilUserResolverUsesDefault(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "user1")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
|
||||
// 20 days old with a 30-day default policy: should be kept.
|
||||
es.addExec(plan.Id, makeExec("exec1", 20*24*time.Hour, now))
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 0 {
|
||||
t.Fatalf("expected 0 deletions (within default 30d retention), got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_ListPlanError(t *testing.T) {
|
||||
ps := &failingPlanStore{err: errors.New("storage down")}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
|
||||
deleted := j.RunOnce(context.Background())
|
||||
if deleted != 0 {
|
||||
t.Fatalf("expected 0 on plan listing error, got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_ListExecErrorContinues(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan1 := makePlan("plan1", "")
|
||||
plan2 := makePlan("plan2", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
|
||||
es := newMockExecStore()
|
||||
|
||||
// plan1 returns an error; plan2 has a deletable execution.
|
||||
es.setListError(plan1.Id, errors.New("corrupt index"))
|
||||
es.addExec(plan2.Id, makeExec("old", 100*24*time.Hour, now))
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion (plan1 error should be skipped), got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_ContextCancellation(t *testing.T) {
|
||||
now := time.Now()
|
||||
var plans []*happydns.CheckPlan
|
||||
es := newMockExecStore()
|
||||
|
||||
// Create many plans with expired executions.
|
||||
for i := 0; i < 100; i++ {
|
||||
id := fmt.Sprintf("plan%d", i)
|
||||
plan := makePlan(id, "")
|
||||
plans = append(plans, plan)
|
||||
es.addExec(plan.Id, makeExec(fmt.Sprintf("exec%d", i), 100*24*time.Hour, now))
|
||||
}
|
||||
ps := &mockPlanStore{plans: plans}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(ctx)
|
||||
|
||||
// Should have stopped early - not all 100 should be deleted.
|
||||
if deleted >= 100 {
|
||||
t.Fatalf("expected early exit from cancellation, but all %d were deleted", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_StartStop(t *testing.T) {
|
||||
ps := &mockPlanStore{}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), 50*time.Millisecond)
|
||||
|
||||
ctx := context.Background()
|
||||
j.Start(ctx)
|
||||
|
||||
// Let it run a couple of ticks.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
j.Stop()
|
||||
|
||||
// Verify it actually stopped by checking that Stop doesn't hang.
|
||||
}
|
||||
|
||||
func TestJanitor_DoubleStartIsNoop(t *testing.T) {
|
||||
ps := &mockPlanStore{}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
|
||||
ctx := context.Background()
|
||||
j.Start(ctx)
|
||||
j.Start(ctx) // should not panic or start a second goroutine
|
||||
|
||||
j.Stop()
|
||||
}
|
||||
|
||||
func TestJanitor_StopBeforeStartIsNoop(t *testing.T) {
|
||||
ps := &mockPlanStore{}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
|
||||
// Should not panic or hang.
|
||||
j.Stop()
|
||||
}
|
||||
|
||||
func TestJanitor_DefaultInterval(t *testing.T) {
|
||||
ps := &mockPlanStore{}
|
||||
es := newMockExecStore()
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), 0)
|
||||
|
||||
if j.interval != 6*time.Hour {
|
||||
t.Fatalf("expected default interval 6h, got %v", j.interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_MultiplePlansMultipleUsers(t *testing.T) {
|
||||
now := time.Now()
|
||||
user1 := happydns.Identifier("user1")
|
||||
user2 := happydns.Identifier("user2")
|
||||
|
||||
plan1 := makePlan("plan1", user1.String())
|
||||
plan2 := makePlan("plan2", user2.String())
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan1, plan2}}
|
||||
es := newMockExecStore()
|
||||
|
||||
// user1 has retention=10d, exec at 15 days -> should be pruned.
|
||||
es.addExec(plan1.Id, makeExec("u1_exec", 15*24*time.Hour, now))
|
||||
|
||||
// user2 has retention=30d, exec at 15 days -> should be kept.
|
||||
es.addExec(plan2.Id, makeExec("u2_exec", 15*24*time.Hour, now))
|
||||
|
||||
resolver := &mockUserResolver{
|
||||
users: map[string]*happydns.User{
|
||||
user1.String(): {Id: user1, Quota: happydns.UserQuota{RetentionDays: 10}},
|
||||
user2.String(): {Id: user2, Quota: happydns.UserQuota{RetentionDays: 30}},
|
||||
},
|
||||
}
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, resolver, DefaultRetentionPolicy(365), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion (user1 only), got %d", deleted)
|
||||
}
|
||||
|
||||
remaining1, _ := es.ListExecutionsByPlan(plan1.Id)
|
||||
if len(remaining1) != 0 {
|
||||
t.Fatalf("expected user1's exec to be deleted, got %d remaining", len(remaining1))
|
||||
}
|
||||
|
||||
remaining2, _ := es.ListExecutionsByPlan(plan2.Id)
|
||||
if len(remaining2) != 1 {
|
||||
t.Fatalf("expected user2's exec to be kept, got %d remaining", len(remaining2))
|
||||
}
|
||||
}
|
||||
|
||||
// --- mock evaluation store for janitor tests ---
|
||||
|
||||
type mockEvalStore struct {
|
||||
mu sync.Mutex
|
||||
evals map[string][]*happydns.CheckEvaluation // planID (base64) -> evaluations
|
||||
}
|
||||
|
||||
func newMockEvalStore() *mockEvalStore {
|
||||
return &mockEvalStore{
|
||||
evals: make(map[string][]*happydns.CheckEvaluation),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockEvalStore) addEval(planID happydns.Identifier, eval *happydns.CheckEvaluation) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := planID.String()
|
||||
s.evals[key] = append(s.evals[key], eval)
|
||||
}
|
||||
|
||||
func (s *mockEvalStore) ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.evals[planID.String()], nil
|
||||
}
|
||||
|
||||
func (s *mockEvalStore) DeleteEvaluation(evalID happydns.Identifier) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for planKey, evals := range s.evals {
|
||||
for i, e := range evals {
|
||||
if e.Id.Equals(evalID) {
|
||||
s.evals[planKey] = append(evals[:i], evals[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("evaluation %s not found", evalID.String())
|
||||
}
|
||||
|
||||
// Unused interface methods.
|
||||
func (s *mockEvalStore) ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockEvalStore) ListEvaluationsByChecker(string, happydns.CheckTarget, int) ([]*happydns.CheckEvaluation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockEvalStore) GetEvaluation(happydns.Identifier) (*happydns.CheckEvaluation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockEvalStore) GetLatestEvaluation(happydns.Identifier) (*happydns.CheckEvaluation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockEvalStore) CreateEvaluation(*happydns.CheckEvaluation) error { return nil }
|
||||
func (s *mockEvalStore) DeleteEvaluationsByChecker(string, happydns.CheckTarget) error { return nil }
|
||||
func (s *mockEvalStore) TidyEvaluationIndexes() error { return nil }
|
||||
func (s *mockEvalStore) ClearEvaluations() error { return nil }
|
||||
|
||||
// --- mock snapshot store for janitor tests ---
|
||||
|
||||
type mockSnapStore struct {
|
||||
mu sync.Mutex
|
||||
deleted []string // snapshot IDs that were deleted
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func newMockSnapStore() *mockSnapStore {
|
||||
return &mockSnapStore{}
|
||||
}
|
||||
|
||||
func (s *mockSnapStore) DeleteSnapshot(snapID happydns.Identifier) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failNext {
|
||||
s.failNext = false
|
||||
return fmt.Errorf("snapshot %s delete failed", snapID.String())
|
||||
}
|
||||
s.deleted = append(s.deleted, snapID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockSnapStore) deletedCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.deleted)
|
||||
}
|
||||
|
||||
// Unused interface methods.
|
||||
func (s *mockSnapStore) ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockSnapStore) GetSnapshot(happydns.Identifier) (*happydns.ObservationSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockSnapStore) CreateSnapshot(*happydns.ObservationSnapshot) error { return nil }
|
||||
func (s *mockSnapStore) ClearSnapshots() error { return nil }
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func makeEval(id string, snapID string, age time.Duration, now time.Time, planID happydns.Identifier) *happydns.CheckEvaluation {
|
||||
pid := planID
|
||||
return &happydns.CheckEvaluation{
|
||||
Id: happydns.Identifier(id),
|
||||
PlanID: &pid,
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{DomainId: "example.com"},
|
||||
SnapshotID: happydns.Identifier(snapID),
|
||||
EvaluatedAt: now.Add(-age),
|
||||
}
|
||||
}
|
||||
|
||||
// --- evaluation pruning tests ---
|
||||
|
||||
func TestJanitor_RunOnce_PrunesExpiredEvaluations(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
evs := newMockEvalStore()
|
||||
ss := newMockSnapStore()
|
||||
|
||||
evs.addEval(plan.Id, makeEval("recent_eval", "snap1", 1*time.Hour, now, plan.Id))
|
||||
evs.addEval(plan.Id, makeEval("old_eval", "snap2", 100*24*time.Hour, now, plan.Id))
|
||||
|
||||
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion, got %d", deleted)
|
||||
}
|
||||
|
||||
remaining, _ := evs.ListEvaluationsByPlan(plan.Id)
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("expected 1 remaining evaluation, got %d", len(remaining))
|
||||
}
|
||||
if !remaining[0].Id.Equals(happydns.Identifier("recent_eval")) {
|
||||
t.Fatalf("expected 'recent_eval' to survive, got %s", remaining[0].Id.String())
|
||||
}
|
||||
|
||||
if ss.deletedCount() != 1 {
|
||||
t.Fatalf("expected 1 snapshot deleted, got %d", ss.deletedCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_PrunesBothExecutionsAndEvaluations(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
evs := newMockEvalStore()
|
||||
ss := newMockSnapStore()
|
||||
|
||||
es.addExec(plan.Id, makeExec("old_exec", 100*24*time.Hour, now))
|
||||
evs.addEval(plan.Id, makeEval("old_eval", "snap1", 100*24*time.Hour, now, plan.Id))
|
||||
|
||||
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 2 {
|
||||
t.Fatalf("expected 2 deletions (1 exec + 1 eval), got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_EvalPruningRespectsPerUserRetention(t *testing.T) {
|
||||
now := time.Now()
|
||||
userID := happydns.Identifier("user1")
|
||||
plan := makePlan("plan1", userID.String())
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
evs := newMockEvalStore()
|
||||
ss := newMockSnapStore()
|
||||
|
||||
// Evaluation 20 days old. System default is 30 days (would keep), but user override is 10 days (should drop).
|
||||
evs.addEval(plan.Id, makeEval("eval1", "snap1", 20*24*time.Hour, now, plan.Id))
|
||||
|
||||
resolver := &mockUserResolver{
|
||||
users: map[string]*happydns.User{
|
||||
userID.String(): {
|
||||
Id: userID,
|
||||
Quota: happydns.UserQuota{RetentionDays: 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
j := NewJanitor(ps, es, evs, ss, resolver, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion (user retention=10d), got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_NilEvalStoreSkipsEvalPruning(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
|
||||
es.addExec(plan.Id, makeExec("old", 100*24*time.Hour, now))
|
||||
|
||||
j := NewJanitor(ps, es, nil, nil, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
// Should only delete the execution, not panic on nil evalStore.
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion, got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJanitor_RunOnce_SnapshotDeleteFailureContinues(t *testing.T) {
|
||||
now := time.Now()
|
||||
plan := makePlan("plan1", "")
|
||||
ps := &mockPlanStore{plans: []*happydns.CheckPlan{plan}}
|
||||
es := newMockExecStore()
|
||||
evs := newMockEvalStore()
|
||||
ss := newMockSnapStore()
|
||||
ss.failNext = true
|
||||
|
||||
evs.addEval(plan.Id, makeEval("old_eval", "snap1", 100*24*time.Hour, now, plan.Id))
|
||||
|
||||
j := NewJanitor(ps, es, evs, ss, nil, DefaultRetentionPolicy(30), time.Hour)
|
||||
deleted := j.RunOnce(context.Background())
|
||||
|
||||
// Evaluation should still be deleted even if snapshot deletion fails.
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected 1 deletion despite snapshot failure, got %d", deleted)
|
||||
}
|
||||
}
|
||||
196
internal/usecase/checker/retention.go
Normal file
196
internal/usecase/checker/retention.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// RetentionPolicy describes how check executions are thinned out as they age.
|
||||
//
|
||||
// The policy is intentionally tiered: users care about full detail for recent
|
||||
// runs, but only need sparse historical samples to spot long-term trends.
|
||||
//
|
||||
// Default behaviour, given a RetentionDays of D:
|
||||
//
|
||||
// age window | kept
|
||||
// ------------------------- | ------------------------------------------
|
||||
// 0 .. 1 day | every execution
|
||||
// 1 .. 7 days | up to 1 execution per hour per (checker,target)
|
||||
// 7 .. 30 days | up to 2 executions per day per (checker,target)
|
||||
// 30 .. D/2 days | up to 1 execution per week per (checker,target)
|
||||
// D/2 .. D days | up to 1 execution per month per (checker,target)
|
||||
// > D days | dropped
|
||||
//
|
||||
// All thresholds and bucket counts are configurable so the policy can be
|
||||
// tuned per-user via the admin UserQuota.
|
||||
type RetentionPolicy struct {
|
||||
// RetentionDays is the hard cap on age. Executions older than this are
|
||||
// always dropped. Must be > 0.
|
||||
RetentionDays int
|
||||
|
||||
// FullDetailDays: every execution kept under this age.
|
||||
FullDetailDays int
|
||||
// HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep
|
||||
// PerHourKept executions per UTC hour per (checker,target).
|
||||
HourlyBucketDays int
|
||||
PerHourKept int
|
||||
// DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep
|
||||
// PerDayKept executions per UTC day per (checker,target).
|
||||
DailyBucketDays int
|
||||
PerDayKept int
|
||||
// WeeklyBucketDays: between DailyBucketDays and WeeklyBucketDays, keep
|
||||
// PerWeekKept executions per ISO week per (checker,target).
|
||||
WeeklyBucketDays int
|
||||
PerWeekKept int
|
||||
// Beyond WeeklyBucketDays and up to RetentionDays, keep PerMonthKept
|
||||
// executions per calendar month per (checker,target).
|
||||
PerMonthKept int
|
||||
}
|
||||
|
||||
// DefaultRetentionPolicy returns the standard tiered policy for the given
|
||||
// retention horizon.
|
||||
func DefaultRetentionPolicy(retentionDays int) RetentionPolicy {
|
||||
if retentionDays <= 0 {
|
||||
retentionDays = 365
|
||||
}
|
||||
return RetentionPolicy{
|
||||
RetentionDays: retentionDays,
|
||||
FullDetailDays: min(1, retentionDays),
|
||||
HourlyBucketDays: min(7, retentionDays),
|
||||
PerHourKept: 1,
|
||||
DailyBucketDays: min(30, retentionDays),
|
||||
PerDayKept: 2,
|
||||
WeeklyBucketDays: min(max(retentionDays/2, 31), retentionDays),
|
||||
PerWeekKept: 1,
|
||||
PerMonthKept: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Decide partitions executions into the ones to keep and the ones to drop
|
||||
// according to the policy. The function is pure: it does not touch storage.
|
||||
//
|
||||
// Executions are grouped by (CheckerID, Target) and ordered most-recent-first
|
||||
// inside each group, so the newest execution in a bucket is the one preserved.
|
||||
func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) {
|
||||
if len(executions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Clamp bucket counts: a zero or negative value would silently drop
|
||||
// every execution in that tier, which is almost certainly a
|
||||
// misconfiguration rather than intent.
|
||||
if p.PerHourKept < 1 {
|
||||
p.PerHourKept = 1
|
||||
}
|
||||
if p.PerDayKept < 1 {
|
||||
p.PerDayKept = 1
|
||||
}
|
||||
if p.PerWeekKept < 1 {
|
||||
p.PerWeekKept = 1
|
||||
}
|
||||
if p.PerMonthKept < 1 {
|
||||
p.PerMonthKept = 1
|
||||
}
|
||||
|
||||
// Group by (checker, target).
|
||||
groups := map[string][]*happydns.Execution{}
|
||||
for _, e := range executions {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
key := e.CheckerID + "|" + e.Target.String()
|
||||
groups[key] = append(groups[key], e)
|
||||
}
|
||||
|
||||
hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
|
||||
fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
|
||||
hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays)
|
||||
dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays)
|
||||
weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays)
|
||||
|
||||
for _, group := range groups {
|
||||
// Most recent first.
|
||||
sort.Slice(group, func(i, j int) bool {
|
||||
return group[i].StartedAt.After(group[j].StartedAt)
|
||||
})
|
||||
|
||||
hourBuckets := map[string]int{}
|
||||
dayBuckets := map[string]int{}
|
||||
weekBuckets := map[string]int{}
|
||||
monthBuckets := map[string]int{}
|
||||
|
||||
for _, e := range group {
|
||||
t := e.StartedAt
|
||||
switch {
|
||||
case t.Before(hardCutoff):
|
||||
drop = append(drop, e.Id)
|
||||
case !t.Before(fullCutoff):
|
||||
// 0 .. FullDetailDays: keep everything.
|
||||
keep = append(keep, e.Id)
|
||||
case !t.Before(hourlyCutoff):
|
||||
k := t.UTC().Format("2006-01-02T15")
|
||||
if hourBuckets[k] < p.PerHourKept {
|
||||
hourBuckets[k]++
|
||||
keep = append(keep, e.Id)
|
||||
} else {
|
||||
drop = append(drop, e.Id)
|
||||
}
|
||||
case !t.Before(dailyCutoff):
|
||||
k := t.UTC().Format("2006-01-02")
|
||||
if dayBuckets[k] < p.PerDayKept {
|
||||
dayBuckets[k]++
|
||||
keep = append(keep, e.Id)
|
||||
} else {
|
||||
drop = append(drop, e.Id)
|
||||
}
|
||||
case !t.Before(weeklyCutoff):
|
||||
y, w := t.UTC().ISOWeek()
|
||||
k := isoWeekKey(y, w)
|
||||
if weekBuckets[k] < p.PerWeekKept {
|
||||
weekBuckets[k]++
|
||||
keep = append(keep, e.Id)
|
||||
} else {
|
||||
drop = append(drop, e.Id)
|
||||
}
|
||||
default:
|
||||
k := t.UTC().Format("2006-01")
|
||||
if monthBuckets[k] < p.PerMonthKept {
|
||||
monthBuckets[k]++
|
||||
keep = append(keep, e.Id)
|
||||
} else {
|
||||
drop = append(drop, e.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keep, drop
|
||||
}
|
||||
|
||||
func isoWeekKey(year, week int) string {
|
||||
return fmt.Sprintf("%d-W%02d", year, week)
|
||||
}
|
||||
262
internal/usecase/checker/retention_test.go
Normal file
262
internal/usecase/checker/retention_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func mkExec(id string, age time.Duration, now time.Time) *happydns.Execution {
|
||||
return &happydns.Execution{
|
||||
Id: happydns.Identifier(id),
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{DomainId: "example.com"},
|
||||
StartedAt: now.Add(-age),
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_Empty(t *testing.T) {
|
||||
p := DefaultRetentionPolicy(365)
|
||||
keep, drop := p.Decide(nil, time.Now())
|
||||
if len(keep) != 0 || len(drop) != 0 {
|
||||
t.Fatalf("expected empty results, got keep=%d drop=%d", len(keep), len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_FullDetailWindow(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 20 executions in the first 20 minutes, all inside 0..1 day window.
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 20; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(drop) != 0 {
|
||||
t.Fatalf("expected no drops in <1d window, got %d", len(drop))
|
||||
}
|
||||
if len(keep) != 20 {
|
||||
t.Fatalf("expected 20 keeps, got %d", len(keep))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_HourlyBucket(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 6 executions in the same hour ~3 days ago (inside hourly window).
|
||||
var execs []*happydns.Execution
|
||||
base := 3*24*time.Hour + 30*time.Minute
|
||||
for i := 0; i < 6; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != p.PerHourKept {
|
||||
t.Fatalf("expected %d keeps in hourly bucket, got %d", p.PerHourKept, len(keep))
|
||||
}
|
||||
if len(drop) != 6-p.PerHourKept {
|
||||
t.Fatalf("expected %d drops, got %d", 6-p.PerHourKept, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_DailyBucket(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 10 executions on the same day, ~10 days ago (inside daily window).
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 10; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), 10*24*time.Hour+time.Duration(i)*time.Hour, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != p.PerDayKept {
|
||||
t.Fatalf("expected %d keeps in daily bucket, got %d", p.PerDayKept, len(keep))
|
||||
}
|
||||
if len(drop) != 10-p.PerDayKept {
|
||||
t.Fatalf("expected %d drops, got %d", 10-p.PerDayKept, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_WeeklyBucket(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 8 executions in the same ISO week, ~60 days ago (inside weekly window).
|
||||
var execs []*happydns.Execution
|
||||
base := 60 * 24 * time.Hour
|
||||
for i := 0; i < 8; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Hour, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != p.PerWeekKept {
|
||||
t.Fatalf("expected %d keeps in weekly bucket, got %d", p.PerWeekKept, len(keep))
|
||||
}
|
||||
if len(drop) != 8-p.PerWeekKept {
|
||||
t.Fatalf("expected %d drops, got %d", 8-p.PerWeekKept, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_MonthlyBucket(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 6 executions in the same calendar month, ~300 days ago (inside monthly window,
|
||||
// beyond weekly window which is 365/2 = 182 days).
|
||||
var execs []*happydns.Execution
|
||||
base := 300 * 24 * time.Hour
|
||||
for i := 0; i < 6; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), base+time.Duration(i)*time.Hour, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != p.PerMonthKept {
|
||||
t.Fatalf("expected %d keeps in monthly bucket, got %d", p.PerMonthKept, len(keep))
|
||||
}
|
||||
if len(drop) != 6-p.PerMonthKept {
|
||||
t.Fatalf("expected %d drops, got %d", 6-p.PerMonthKept, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_ZeroBucketCountsClamped(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
p.PerDayKept = 0
|
||||
|
||||
// 5 executions ~10 days ago (daily bucket).
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 5; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("e%d", i), 10*24*time.Hour+time.Duration(i)*time.Hour, now))
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
// Clamped to 1, so exactly 1 kept.
|
||||
if len(keep) != 1 {
|
||||
t.Fatalf("expected 1 keep after clamping PerDayKept=0 to 1, got %d", len(keep))
|
||||
}
|
||||
if len(drop) != 4 {
|
||||
t.Fatalf("expected 4 drops, got %d", len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_HardCutoff(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(30)
|
||||
|
||||
execs := []*happydns.Execution{
|
||||
mkExec("recent", 1*24*time.Hour, now),
|
||||
mkExec("old", 100*24*time.Hour, now),
|
||||
}
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
if len(keep) != 1 || string(keep[0]) != "recent" {
|
||||
t.Fatalf("expected 'recent' to be kept, got %v", keep)
|
||||
}
|
||||
if len(drop) != 1 || string(drop[0]) != "old" {
|
||||
t.Fatalf("expected 'old' to be dropped, got %v", drop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_SmallRetentionCollapseTiers(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(3)
|
||||
|
||||
// With retentionDays=3, tiers collapse:
|
||||
// FullDetailDays=1, HourlyBucketDays=3, DailyBucketDays=3,
|
||||
// WeeklyBucketDays=3 - only full-detail and hourly tiers are reachable.
|
||||
|
||||
var execs []*happydns.Execution
|
||||
// 3 executions inside full-detail window (< 1 day).
|
||||
for i := 0; i < 3; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("recent%d", i), time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
// 4 executions in the same hour, ~2 days ago (hourly tier).
|
||||
base := 2*24*time.Hour + 30*time.Minute
|
||||
for i := 0; i < 4; i++ {
|
||||
execs = append(execs, mkExec(fmt.Sprintf("hourly%d", i), base+time.Duration(i)*time.Minute, now))
|
||||
}
|
||||
// 1 execution beyond retention (5 days ago).
|
||||
execs = append(execs, mkExec("expired", 5*24*time.Hour, now))
|
||||
|
||||
keep, drop := p.Decide(execs, now)
|
||||
// 3 full-detail + 1 hourly kept + 3 hourly dropped + 1 expired dropped
|
||||
if len(keep) != 3+p.PerHourKept {
|
||||
t.Fatalf("expected %d keeps, got %d", 3+p.PerHourKept, len(keep))
|
||||
}
|
||||
if len(drop) != 4-p.PerHourKept+1 {
|
||||
t.Fatalf("expected %d drops, got %d", 4-p.PerHourKept+1, len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_BoundaryFullDetailToHourly(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// Execution exactly at the full-detail boundary (age == exactly 1 day).
|
||||
// !t.Before(fullCutoff) is true when t == fullCutoff, so this lands in full-detail.
|
||||
exactBoundary := mkExec("boundary", 24*time.Hour, now)
|
||||
// Execution 1 second past the boundary (age == 1 day + 1s) lands in hourly.
|
||||
pastBoundary := mkExec("past", 24*time.Hour+time.Second, now)
|
||||
|
||||
keep, drop := p.Decide([]*happydns.Execution{exactBoundary, pastBoundary}, now)
|
||||
// Both should be kept (one as full-detail, one as hourly).
|
||||
if len(keep) != 2 {
|
||||
t.Fatalf("expected 2 keeps, got %d (keep=%v, drop=%v)", len(keep), keep, drop)
|
||||
}
|
||||
if len(drop) != 0 {
|
||||
t.Fatalf("expected 0 drops, got %d", len(drop))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecide_GroupedByTarget(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
p := DefaultRetentionPolicy(365)
|
||||
|
||||
// 5 executions same day, 10 days ago, two different targets.
|
||||
mk := func(id, dom string) *happydns.Execution {
|
||||
return &happydns.Execution{
|
||||
Id: happydns.Identifier(id),
|
||||
CheckerID: "ping",
|
||||
Target: happydns.CheckTarget{DomainId: dom},
|
||||
StartedAt: now.Add(-10 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
var execs []*happydns.Execution
|
||||
for i := 0; i < 5; i++ {
|
||||
execs = append(execs, mk(fmt.Sprintf("a%d", i), "a.example"))
|
||||
execs = append(execs, mk(fmt.Sprintf("b%d", i), "b.example"))
|
||||
}
|
||||
|
||||
keep, _ := p.Decide(execs, now)
|
||||
// PerDayKept per group => 2 * 2 groups = 4
|
||||
if len(keep) != 2*p.PerDayKept {
|
||||
t.Fatalf("expected %d keeps, got %d", 2*p.PerDayKept, len(keep))
|
||||
}
|
||||
}
|
||||
823
internal/usecase/checker/scheduler.go
Normal file
823
internal/usecase/checker/scheduler.go
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
minSpacing = 2 * time.Second
|
||||
maxCatchUpWindow = 10 * time.Minute
|
||||
defaultInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// SchedulerJob represents a single scheduled checker execution.
|
||||
type SchedulerJob struct {
|
||||
CheckerID string `json:"checkerID"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
PlanID *happydns.Identifier `json:"planID" swaggertype:"string"`
|
||||
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
index int // heap index
|
||||
}
|
||||
|
||||
// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun.
|
||||
type SchedulerQueue []*SchedulerJob
|
||||
|
||||
func (q SchedulerQueue) Len() int { return len(q) }
|
||||
func (q SchedulerQueue) Less(i, j int) bool { return q[i].NextRun.Before(q[j].NextRun) }
|
||||
func (q SchedulerQueue) Swap(i, j int) {
|
||||
q[i], q[j] = q[j], q[i]
|
||||
q[i].index = i
|
||||
q[j].index = j
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Push(x any) {
|
||||
n := len(*q)
|
||||
job := x.(*SchedulerJob)
|
||||
job.index = n
|
||||
*q = append(*q, job)
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Pop() any {
|
||||
old := *q
|
||||
n := len(old)
|
||||
job := old[n-1]
|
||||
old[n-1] = nil
|
||||
job.index = -1
|
||||
*q = old[:n-1]
|
||||
return job
|
||||
}
|
||||
|
||||
func (q *SchedulerQueue) Peek() *SchedulerJob {
|
||||
if len(*q) == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*q)[0]
|
||||
}
|
||||
|
||||
// SchedulerStatus holds a snapshot of the scheduler's current state.
|
||||
type SchedulerStatus struct {
|
||||
Running bool `json:"running"`
|
||||
JobCount int `json:"job_count"`
|
||||
NextJobs []*SchedulerJob `json:"next_jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Scheduler manages periodic execution of checkers.
|
||||
type Scheduler struct {
|
||||
queue SchedulerQueue
|
||||
jobKeys map[string]bool
|
||||
engine happydns.CheckerEngine
|
||||
planStore CheckPlanStorage
|
||||
domainStore DomainLister
|
||||
zoneStore ZoneGetter
|
||||
stateStore SchedulerStateStorage
|
||||
cancel context.CancelFunc
|
||||
wake chan struct{}
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
maxConcurrency int
|
||||
|
||||
// gate, if set, is consulted before launching each job. Returning false
|
||||
// causes the scheduler to skip (and reschedule) the job, e.g. when the
|
||||
// owning user is paused or has been inactive for too long.
|
||||
gate func(target happydns.CheckTarget) bool
|
||||
}
|
||||
|
||||
// NewScheduler creates a new Scheduler. The optional gate function, if
|
||||
// non-nil, is consulted before launching each job; returning false causes
|
||||
// the scheduler to skip (and reschedule) the job.
|
||||
func NewScheduler(
|
||||
engine happydns.CheckerEngine,
|
||||
maxConcurrency int,
|
||||
planStore CheckPlanStorage,
|
||||
domainStore DomainLister,
|
||||
zoneStore ZoneGetter,
|
||||
stateStore SchedulerStateStorage,
|
||||
gate func(target happydns.CheckTarget) bool,
|
||||
) *Scheduler {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
return &Scheduler{
|
||||
engine: engine,
|
||||
planStore: planStore,
|
||||
domainStore: domainStore,
|
||||
zoneStore: zoneStore,
|
||||
stateStore: stateStore,
|
||||
jobKeys: make(map[string]bool),
|
||||
wake: make(chan struct{}, 1),
|
||||
maxConcurrency: maxConcurrency,
|
||||
gate: gate,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scheduler loop in a goroutine.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.mu.Lock()
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
s.done = make(chan struct{})
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
s.mu.Unlock()
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Stop halts the scheduler and waits for in-flight workers to finish.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns a snapshot of the scheduler's current state.
|
||||
func (s *Scheduler) GetStatus() SchedulerStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
status := SchedulerStatus{
|
||||
Running: s.running,
|
||||
JobCount: s.queue.Len(),
|
||||
}
|
||||
|
||||
n := min(20, s.queue.Len())
|
||||
if n > 0 {
|
||||
tmp := make(SchedulerQueue, s.queue.Len())
|
||||
copy(tmp, s.queue)
|
||||
for i, job := range tmp {
|
||||
cp := *job
|
||||
cp.index = i
|
||||
tmp[i] = &cp
|
||||
}
|
||||
status.NextJobs = make([]*SchedulerJob, 0, n)
|
||||
for range n {
|
||||
status.NextJobs = append(status.NextJobs, heap.Pop(&tmp).(*SchedulerJob))
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// SetEnabled starts or stops the scheduler. The provided ctx is used as the
|
||||
// parent context for the new scheduler loop when enabled is true.
|
||||
func (s *Scheduler) SetEnabled(ctx context.Context, enabled bool) error {
|
||||
s.mu.RLock()
|
||||
wasRunning := s.running
|
||||
s.mu.RUnlock()
|
||||
|
||||
if wasRunning {
|
||||
s.Stop()
|
||||
}
|
||||
if enabled {
|
||||
s.Start(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildQueue rebuilds the scheduler queue and returns the new job count.
|
||||
func (s *Scheduler) RebuildQueue() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.buildQueue()
|
||||
s.spreadOverdueJobs()
|
||||
return s.queue.Len()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run(ctx context.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Scheduler: panic in run loop: %v", r)
|
||||
}
|
||||
s.wg.Wait()
|
||||
close(s.done)
|
||||
}()
|
||||
|
||||
sem := make(chan struct{}, s.maxConcurrency)
|
||||
|
||||
for {
|
||||
ready, cancelled := s.waitForNextJob(ctx)
|
||||
if cancelled {
|
||||
return
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.queue.Len() == 0 {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
job := heap.Pop(&s.queue).(*SchedulerJob)
|
||||
gate := s.gate
|
||||
s.mu.Unlock()
|
||||
|
||||
// Honour the user-level gate before doing any work.
|
||||
if gate != nil && !gate(job.Target) {
|
||||
// log.Printf("Scheduler: skipping checker %s on %s (gated by user policy)", job.CheckerID, job.Target.String())
|
||||
s.rescheduleJob(job)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find plan if applicable.
|
||||
var plan *happydns.CheckPlan
|
||||
if job.PlanID != nil {
|
||||
p, err := s.planStore.GetCheckPlan(*job.PlanID)
|
||||
if err == nil {
|
||||
plan = p
|
||||
}
|
||||
}
|
||||
|
||||
if !s.acquireWorkerSlot(ctx, sem, job) {
|
||||
return
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.executeJob(ctx, job, plan, sem)
|
||||
|
||||
// Advance to next cycle and re-enqueue.
|
||||
s.rescheduleJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForNextJob blocks until the next job is due, the queue is woken, or the
|
||||
// context is cancelled. It returns (true, false) when a job is ready to run,
|
||||
// (false, false) when the loop should re-evaluate (wake or queue rebuild), and
|
||||
// (false, true) when the context is done.
|
||||
func (s *Scheduler) waitForNextJob(ctx context.Context) (ready, cancelled bool) {
|
||||
s.mu.RLock()
|
||||
qLen := s.queue.Len()
|
||||
s.mu.RUnlock()
|
||||
|
||||
if qLen == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, true
|
||||
case <-s.wake:
|
||||
return false, false
|
||||
case <-time.After(1 * time.Minute):
|
||||
s.mu.Lock()
|
||||
s.buildQueue()
|
||||
s.mu.Unlock()
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return true, false
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return false, true
|
||||
case <-s.wake:
|
||||
timer.Stop()
|
||||
return false, false
|
||||
case <-timer.C:
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
|
||||
// acquireWorkerSlot blocks until a concurrency slot is available or the context
|
||||
// is cancelled. Returns false when the context is done.
|
||||
func (s *Scheduler) acquireWorkerSlot(ctx context.Context, sem chan struct{}, job *SchedulerJob) bool {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return true
|
||||
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{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob runs a single checker execution in its own goroutine.
|
||||
// The caller must have incremented s.wg and acquired a slot from sem.
|
||||
func (s *Scheduler) executeJob(ctx context.Context, job *SchedulerJob, plan *happydns.CheckPlan, sem chan struct{}) {
|
||||
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", job.CheckerID, job.Target.String(), r)
|
||||
}
|
||||
}()
|
||||
log.Printf("Scheduler: running checker %s on %s", job.CheckerID, job.Target.String())
|
||||
exec, err := s.engine.CreateExecution(job.CheckerID, job.Target, plan)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: checker %s on %s failed to create execution: %v", job.CheckerID, job.Target.String(), err)
|
||||
return
|
||||
}
|
||||
_, err = s.engine.RunExecution(ctx, exec, plan, nil)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: checker %s on %s failed: %v", job.CheckerID, job.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rescheduleJob advances job.NextRun past the current time, adds jitter,
|
||||
// and pushes the job back onto the scheduler queue.
|
||||
func (s *Scheduler) rescheduleJob(job *SchedulerJob) {
|
||||
now := time.Now()
|
||||
for job.NextRun.Before(now) {
|
||||
job.NextRun = job.NextRun.Add(job.Interval)
|
||||
}
|
||||
job.NextRun = job.NextRun.Add(computeJitter(job.CheckerID, job.Target.String(), job.NextRun, job.Interval))
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
s.mu.Lock()
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) buildQueue() {
|
||||
s.queue = s.queue[:0]
|
||||
s.jobKeys = make(map[string]bool)
|
||||
|
||||
var lastRun time.Time
|
||||
if s.stateStore != nil {
|
||||
if t, err := s.stateStore.GetLastSchedulerRun(); err != nil {
|
||||
log.Printf("Scheduler: failed to read last run time: %v", err)
|
||||
} else {
|
||||
lastRun = t
|
||||
}
|
||||
}
|
||||
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
plans, err := s.loadAllPlans()
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load plans, skipping queue build: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
disabledSet, planMap := buildPlanIndex(plans)
|
||||
|
||||
// Collect checkers by scope for efficient iteration.
|
||||
var domainCheckers, serviceCheckers []struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
domainCheckers = append(domainCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
if def.Availability.ApplyToService {
|
||||
serviceCheckers = append(serviceCheckers, struct {
|
||||
id string
|
||||
def *happydns.CheckerDefinition
|
||||
}{checkerID, def})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discovery: enumerate all domains and schedule applicable checkers.
|
||||
domains := s.loadAllDomains()
|
||||
for _, domain := range domains {
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
for _, c := range domainCheckers {
|
||||
s.enqueueJob(c.id, c.def, domainTarget, disabledSet, planMap, lastRun)
|
||||
}
|
||||
|
||||
// Service-level discovery: load the latest zone and match services.
|
||||
if len(serviceCheckers) > 0 {
|
||||
services := s.loadDomainServices(domain)
|
||||
for _, svc := range services {
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
|
||||
|
||||
for _, c := range serviceCheckers {
|
||||
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainChange incrementally adds scheduler jobs for a domain
|
||||
// without rebuilding the entire queue. Call this after a domain is
|
||||
// created or its zone is imported/published.
|
||||
func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
|
||||
checkers := checkerPkg.GetCheckers()
|
||||
|
||||
// Load plans relevant to this domain.
|
||||
uid := domain.Owner
|
||||
did := domain.Id
|
||||
domainTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
|
||||
|
||||
plans, err := s.planStore.ListCheckPlansByTarget(domainTarget)
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: NotifyDomainChange: failed to load plans: %v", err)
|
||||
}
|
||||
disabledSet, planMap := buildPlanIndex(plans)
|
||||
|
||||
// Load services outside the lock to avoid holding the mutex during I/O.
|
||||
services := s.loadDomainServices(domain)
|
||||
|
||||
// Build the set of desired job keys for this domain so we can detect stale entries.
|
||||
wantKeys := make(map[string]bool)
|
||||
didStr := did.String()
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
key := checkerID + "|" + domainTarget.String()
|
||||
if !disabledSet[key] {
|
||||
wantKeys[key] = true
|
||||
}
|
||||
}
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
|
||||
key := checkerID + "|" + svcTarget.String()
|
||||
if !disabledSet[key] {
|
||||
wantKeys[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var added, removed int
|
||||
s.mu.Lock()
|
||||
|
||||
// Remove stale jobs for this domain that are no longer wanted.
|
||||
for i := 0; i < len(s.queue); {
|
||||
job := s.queue[i]
|
||||
if job.Target.DomainId == didStr {
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
if !wantKeys[key] {
|
||||
delete(s.jobKeys, key)
|
||||
s.queue[i] = s.queue[len(s.queue)-1]
|
||||
s.queue[len(s.queue)-1] = nil
|
||||
s.queue = s.queue[:len(s.queue)-1]
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if removed > 0 {
|
||||
heap.Init(&s.queue)
|
||||
}
|
||||
|
||||
// Add new jobs for this domain.
|
||||
for checkerID, def := range checkers {
|
||||
if def.Availability.ApplyToDomain {
|
||||
if s.enqueueJob(checkerID, def, domainTarget, disabledSet, planMap, time.Time{}) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
if def.Availability.ApplyToService {
|
||||
for _, svc := range services {
|
||||
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
|
||||
continue
|
||||
}
|
||||
sid := svc.Id
|
||||
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: sid.String(), ServiceType: svc.Type}
|
||||
if s.enqueueJob(checkerID, def, svcTarget, disabledSet, planMap, time.Time{}) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if added > 0 || removed > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainChange(%s): added %d jobs, removed %d stale jobs", domain.DomainName, added, removed)
|
||||
// Wake the run loop so it re-evaluates the queue head.
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDomainRemoved removes all scheduler jobs for the given domain.
|
||||
func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
|
||||
s.mu.Lock()
|
||||
n := 0
|
||||
for i := 0; i < len(s.queue); {
|
||||
job := s.queue[i]
|
||||
if job.Target.DomainId == domainID.String() {
|
||||
key := job.CheckerID + "|" + job.Target.String()
|
||||
delete(s.jobKeys, key)
|
||||
// Swap with last and shrink.
|
||||
s.queue[i] = s.queue[len(s.queue)-1]
|
||||
s.queue[len(s.queue)-1] = nil
|
||||
s.queue = s.queue[:len(s.queue)-1]
|
||||
n++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
heap.Init(&s.queue)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if n > 0 {
|
||||
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
|
||||
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
|
||||
disabledSet = make(map[string]bool)
|
||||
planMap = make(map[string]*happydns.CheckPlan)
|
||||
for _, p := range plans {
|
||||
key := p.CheckerID + "|" + p.Target.String()
|
||||
planMap[key] = p
|
||||
if p.IsFullyDisabled() {
|
||||
disabledSet[key] = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// enqueueJob creates and pushes a scheduler job if the key is not already
|
||||
// present and not disabled. When lastActive is zero (e.g. NotifyDomainChange),
|
||||
// the job is scheduled at now + jitter; otherwise offset-based grid scheduling
|
||||
// is used. Must be called with s.mu held. Returns true if a job was added.
|
||||
func (s *Scheduler) enqueueJob(checkerID string, def *happydns.CheckerDefinition, target happydns.CheckTarget, disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan, lastActive time.Time) bool {
|
||||
targetStr := target.String()
|
||||
key := checkerID + "|" + targetStr
|
||||
if s.jobKeys[key] || disabledSet[key] {
|
||||
return false
|
||||
}
|
||||
|
||||
plan := planMap[key]
|
||||
interval := s.effectiveInterval(def, plan)
|
||||
|
||||
var nextRun time.Time
|
||||
if lastActive.IsZero() {
|
||||
now := time.Now()
|
||||
nextRun = now.Add(computeJitter(checkerID, targetStr, now, interval))
|
||||
} else {
|
||||
offset := computeOffset(checkerID, targetStr, interval)
|
||||
nextRun = computeNextRun(interval, offset, lastActive)
|
||||
}
|
||||
|
||||
job := &SchedulerJob{
|
||||
CheckerID: checkerID,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
NextRun: nextRun,
|
||||
}
|
||||
if plan != nil {
|
||||
job.PlanID = &plan.Id
|
||||
}
|
||||
heap.Push(&s.queue, job)
|
||||
s.jobKeys[key] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllPlans() ([]*happydns.CheckPlan, error) {
|
||||
iter, err := s.planStore.ListAllCheckPlans()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var plans []*happydns.CheckPlan
|
||||
for iter.Next() {
|
||||
plans = append(plans, iter.Item())
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadAllDomains() []*happydns.Domain {
|
||||
if s.domainStore == nil {
|
||||
return nil
|
||||
}
|
||||
iter, err := s.domainStore.ListAllDomains()
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to list domains for auto-discovery: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var domains []*happydns.Domain
|
||||
for iter.Next() {
|
||||
d := iter.Item()
|
||||
domains = append(domains, d)
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
|
||||
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect services from the WIP zone ([0]) and the latest published
|
||||
// zone ([1]). This lets the scheduler pick up new services the user
|
||||
// is configuring while still covering what is live.
|
||||
seen := make(map[string]struct{})
|
||||
var services []*happydns.ServiceMessage
|
||||
for _, idx := range []int{0, 1} {
|
||||
if idx >= len(domain.ZoneHistory) {
|
||||
break
|
||||
}
|
||||
zone, err := s.zoneStore.GetZone(domain.ZoneHistory[idx])
|
||||
if err != nil {
|
||||
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx], domain.DomainName, err)
|
||||
continue
|
||||
}
|
||||
for _, svcs := range zone.Services {
|
||||
for _, svc := range svcs {
|
||||
key := svc.Id.String()
|
||||
if _, dup := seen[key]; !dup {
|
||||
seen[key] = struct{}{}
|
||||
services = append(services, svc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
730
internal/usecase/checker/scheduler_test.go
Normal file
730
internal/usecase/checker/scheduler_test.go
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
// 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) TidyCheckPlanIndexes() error { return nil }
|
||||
func (s *mockPlanStore) ClearCheckPlans() error { return nil }
|
||||
|
||||
// --- mock domain lister ---
|
||||
|
||||
type mockDomainLister struct {
|
||||
domains []*happydns.Domain
|
||||
}
|
||||
|
||||
func (d *mockDomainLister) ListAllDomains() (happydns.Iterator[happydns.Domain], error) {
|
||||
return &sliceIterator[happydns.Domain]{items: d.domains}, nil
|
||||
}
|
||||
|
||||
// --- mock zone getter ---
|
||||
|
||||
type mockZoneGetter struct {
|
||||
zones map[string]*happydns.ZoneMessage
|
||||
}
|
||||
|
||||
func (z *mockZoneGetter) GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error) {
|
||||
zm, ok := z.zones[id.String()]
|
||||
if !ok {
|
||||
return nil, happydns.ErrZoneNotFound
|
||||
}
|
||||
return zm, nil
|
||||
}
|
||||
|
||||
// --- mock state store ---
|
||||
|
||||
type mockStateStore struct {
|
||||
mu sync.Mutex
|
||||
lastRun time.Time
|
||||
}
|
||||
|
||||
func (s *mockStateStore) GetLastSchedulerRun() (time.Time, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastRun, nil
|
||||
}
|
||||
|
||||
func (s *mockStateStore) SetLastSchedulerRun(t time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastRun = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- sliceIterator ---
|
||||
|
||||
type sliceIterator[T any] struct {
|
||||
items []*T
|
||||
idx int
|
||||
cur *T
|
||||
}
|
||||
|
||||
func (it *sliceIterator[T]) Next() bool {
|
||||
if it.idx >= len(it.items) {
|
||||
return false
|
||||
}
|
||||
it.cur = it.items[it.idx]
|
||||
it.idx++
|
||||
return true
|
||||
}
|
||||
func (it *sliceIterator[T]) NextWithError() bool { return it.Next() }
|
||||
func (it *sliceIterator[T]) Item() *T { return it.cur }
|
||||
func (it *sliceIterator[T]) DropItem() error { return nil }
|
||||
func (it *sliceIterator[T]) Key() string { return "" }
|
||||
func (it *sliceIterator[T]) Raw() any { return nil }
|
||||
func (it *sliceIterator[T]) Err() error { return nil }
|
||||
func (it *sliceIterator[T]) Close() {}
|
||||
|
||||
// --- helper to build a scheduler with mock deps ---
|
||||
|
||||
func newTestScheduler(engine happydns.CheckerEngine, domains []*happydns.Domain) (*Scheduler, *mockPlanStore, *mockStateStore) {
|
||||
ps := &mockPlanStore{}
|
||||
dl := &mockDomainLister{domains: domains}
|
||||
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
|
||||
ss := &mockStateStore{}
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, nil)
|
||||
return sched, ps, ss
|
||||
}
|
||||
|
||||
// --- computeNextRun tests (preserved from original) ---
|
||||
|
||||
func TestComputeNextRun_ZeroLastActive(t *testing.T) {
|
||||
interval := 1 * time.Hour
|
||||
offset := 10 * time.Minute
|
||||
|
||||
nextRun := computeNextRun(interval, offset, time.Time{})
|
||||
now := time.Now()
|
||||
|
||||
if !nextRun.After(now) {
|
||||
t.Errorf("expected nextRun (%v) to be in the future (now=%v)", nextRun, now)
|
||||
}
|
||||
if nextRun.After(now.Add(interval)) {
|
||||
t.Errorf("expected nextRun (%v) to be within one interval from now (%v)", nextRun, now.Add(interval))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRun_RecentLastActive_NoRerun(t *testing.T) {
|
||||
interval := 1 * time.Hour
|
||||
offset := computeOffset("test-checker", "test-target", interval)
|
||||
now := time.Now()
|
||||
|
||||
// lastActive is very recent; the current slot was already executed.
|
||||
lastActive := now.Add(-1 * time.Minute)
|
||||
|
||||
nextRun := computeNextRun(interval, offset, lastActive)
|
||||
|
||||
if !nextRun.After(now) {
|
||||
t.Errorf("expected nextRun (%v) to be in the future when lastActive is recent (now=%v)", nextRun, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRun_OldLastActive_CatchUp(t *testing.T) {
|
||||
interval := 1 * time.Hour
|
||||
offset := 0 * time.Minute
|
||||
now := time.Now()
|
||||
|
||||
// lastActive is several hours ago; there should be a missed slot.
|
||||
lastActive := now.Add(-3 * time.Hour)
|
||||
|
||||
nextRun := computeNextRun(interval, offset, lastActive)
|
||||
|
||||
// The missed slot should be scheduled at now (catch-up).
|
||||
if nextRun.After(now.Add(1 * time.Second)) {
|
||||
t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now)
|
||||
}
|
||||
if nextRun.Before(now.Add(-1 * time.Second)) {
|
||||
t.Errorf("expected nextRun (%v) to be approximately now (%v) for catch-up", nextRun, now)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scheduler lifecycle tests ---
|
||||
|
||||
func TestScheduler_StartStop(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sched.Start(ctx)
|
||||
|
||||
status := sched.GetStatus()
|
||||
if !status.Running {
|
||||
t.Error("expected scheduler to be running after Start")
|
||||
}
|
||||
|
||||
sched.Stop()
|
||||
|
||||
status = sched.GetStatus()
|
||||
if status.Running {
|
||||
t.Error("expected scheduler to be stopped after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_StopIdempotent(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
// Stop without Start should not panic.
|
||||
sched.Stop()
|
||||
sched.Stop()
|
||||
}
|
||||
|
||||
func TestScheduler_SetEnabled(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Start via SetEnabled.
|
||||
sched.SetEnabled(ctx, true)
|
||||
status := sched.GetStatus()
|
||||
if !status.Running {
|
||||
t.Error("expected scheduler to be running after SetEnabled(true)")
|
||||
}
|
||||
|
||||
// Stop via SetEnabled.
|
||||
sched.SetEnabled(ctx, false)
|
||||
status = sched.GetStatus()
|
||||
if status.Running {
|
||||
t.Error("expected scheduler to be stopped after SetEnabled(false)")
|
||||
}
|
||||
|
||||
// Restart via SetEnabled (this verifies the fixed context bug).
|
||||
sched.SetEnabled(ctx, true)
|
||||
status = sched.GetStatus()
|
||||
if !status.Running {
|
||||
t.Fatal("expected scheduler to be running after re-enable via SetEnabled(true)")
|
||||
}
|
||||
|
||||
// Give it a moment and verify it's still running (not exited due to cancelled context).
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
status = sched.GetStatus()
|
||||
if !status.Running {
|
||||
t.Error("scheduler exited prematurely after re-enable; likely using a cancelled context")
|
||||
}
|
||||
|
||||
sched.Stop()
|
||||
}
|
||||
|
||||
func TestScheduler_Gate(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
domain := &happydns.Domain{
|
||||
Id: did,
|
||||
Owner: uid,
|
||||
DomainName: "gate-test.example.",
|
||||
}
|
||||
|
||||
var gated atomic.Int32
|
||||
ps := &mockPlanStore{}
|
||||
dl := &mockDomainLister{domains: []*happydns.Domain{domain}}
|
||||
zg := &mockZoneGetter{zones: make(map[string]*happydns.ZoneMessage)}
|
||||
ss := &mockStateStore{}
|
||||
sched := NewScheduler(engine, 2, ps, dl, zg, ss, func(target happydns.CheckTarget) bool {
|
||||
gated.Add(1)
|
||||
return false // block all jobs
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sched.Start(ctx)
|
||||
defer sched.Stop()
|
||||
|
||||
// Wait briefly for the scheduler to attempt to run jobs.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// The gate should have been called but no executions should have run.
|
||||
if engine.executionCount() > 0 {
|
||||
t.Error("expected no executions when gate blocks all jobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_GetStatus_Empty(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
status := sched.GetStatus()
|
||||
if status.Running {
|
||||
t.Error("expected not running before Start")
|
||||
}
|
||||
if status.JobCount != 0 {
|
||||
t.Errorf("expected 0 jobs, got %d", status.JobCount)
|
||||
}
|
||||
if len(status.NextJobs) != 0 {
|
||||
t.Errorf("expected 0 next jobs, got %d", len(status.NextJobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_RebuildQueue(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
domain := &happydns.Domain{
|
||||
Id: did,
|
||||
Owner: uid,
|
||||
DomainName: "rebuild.example.",
|
||||
}
|
||||
|
||||
sched, _, _ := newTestScheduler(engine, []*happydns.Domain{domain})
|
||||
|
||||
count := sched.RebuildQueue()
|
||||
if count == 0 {
|
||||
// No checkers registered, so 0 is expected.
|
||||
// This test verifies RebuildQueue doesn't panic.
|
||||
}
|
||||
|
||||
status := sched.GetStatus()
|
||||
if status.JobCount != count {
|
||||
t.Errorf("expected JobCount %d, got %d", count, status.JobCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_NotifyDomainRemoved(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
domain := &happydns.Domain{
|
||||
Id: did,
|
||||
Owner: uid,
|
||||
DomainName: "remove-test.example.",
|
||||
}
|
||||
|
||||
sched, _, _ := newTestScheduler(engine, []*happydns.Domain{domain})
|
||||
|
||||
// Build the queue so jobs exist.
|
||||
sched.mu.Lock()
|
||||
sched.buildQueue()
|
||||
initialCount := sched.queue.Len()
|
||||
sched.mu.Unlock()
|
||||
|
||||
// Remove the domain.
|
||||
sched.NotifyDomainRemoved(did)
|
||||
|
||||
sched.mu.RLock()
|
||||
afterCount := sched.queue.Len()
|
||||
sched.mu.RUnlock()
|
||||
|
||||
if initialCount > 0 && afterCount >= initialCount {
|
||||
t.Errorf("expected jobs to decrease after domain removal, was %d, now %d", initialCount, afterCount)
|
||||
}
|
||||
|
||||
// Verify no jobs reference the removed domain.
|
||||
sched.mu.RLock()
|
||||
for _, job := range sched.queue {
|
||||
if job.Target.DomainId == did.String() {
|
||||
t.Errorf("found job referencing removed domain %s", did)
|
||||
}
|
||||
}
|
||||
sched.mu.RUnlock()
|
||||
}
|
||||
|
||||
func TestScheduler_GetPlannedJobsForChecker(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
// Manually push a job into the queue.
|
||||
sched.mu.Lock()
|
||||
job := &SchedulerJob{
|
||||
CheckerID: "test-checker",
|
||||
Target: target,
|
||||
Interval: time.Hour,
|
||||
NextRun: time.Now().Add(time.Hour),
|
||||
}
|
||||
heap.Push(&sched.queue, job)
|
||||
sched.mu.Unlock()
|
||||
|
||||
jobs := sched.GetPlannedJobsForChecker("test-checker", target)
|
||||
if len(jobs) != 1 {
|
||||
t.Fatalf("expected 1 planned job, got %d", len(jobs))
|
||||
}
|
||||
if jobs[0].CheckerID != "test-checker" {
|
||||
t.Errorf("expected checker ID test-checker, got %s", jobs[0].CheckerID)
|
||||
}
|
||||
|
||||
// Different checker should return empty.
|
||||
jobs2 := sched.GetPlannedJobsForChecker("other-checker", target)
|
||||
if len(jobs2) != 0 {
|
||||
t.Errorf("expected 0 planned jobs for other checker, got %d", len(jobs2))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Queue tests ---
|
||||
|
||||
func TestSchedulerQueue_HeapOrder(t *testing.T) {
|
||||
q := &SchedulerQueue{}
|
||||
heap.Init(q)
|
||||
|
||||
now := time.Now()
|
||||
heap.Push(q, &SchedulerJob{CheckerID: "c", NextRun: now.Add(3 * time.Hour)})
|
||||
heap.Push(q, &SchedulerJob{CheckerID: "a", NextRun: now.Add(1 * time.Hour)})
|
||||
heap.Push(q, &SchedulerJob{CheckerID: "b", NextRun: now.Add(2 * time.Hour)})
|
||||
|
||||
first := heap.Pop(q).(*SchedulerJob)
|
||||
if first.CheckerID != "a" {
|
||||
t.Errorf("expected first popped job to be 'a', got %s", first.CheckerID)
|
||||
}
|
||||
second := heap.Pop(q).(*SchedulerJob)
|
||||
if second.CheckerID != "b" {
|
||||
t.Errorf("expected second popped job to be 'b', got %s", second.CheckerID)
|
||||
}
|
||||
third := heap.Pop(q).(*SchedulerJob)
|
||||
if third.CheckerID != "c" {
|
||||
t.Errorf("expected third popped job to be 'c', got %s", third.CheckerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerQueue_Peek(t *testing.T) {
|
||||
q := &SchedulerQueue{}
|
||||
heap.Init(q)
|
||||
|
||||
if q.Peek() != nil {
|
||||
t.Error("expected Peek on empty queue to return nil")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
heap.Push(q, &SchedulerJob{CheckerID: "x", NextRun: now.Add(time.Hour)})
|
||||
heap.Push(q, &SchedulerJob{CheckerID: "y", NextRun: now.Add(time.Minute)})
|
||||
|
||||
peeked := q.Peek()
|
||||
if peeked.CheckerID != "y" {
|
||||
t.Errorf("expected Peek to return earliest job 'y', got %s", peeked.CheckerID)
|
||||
}
|
||||
// Peek should not remove the item.
|
||||
if q.Len() != 2 {
|
||||
t.Errorf("expected queue length 2 after Peek, got %d", q.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// --- spreadOverdueJobs tests ---
|
||||
|
||||
func TestSpreadOverdueJobs(t *testing.T) {
|
||||
engine := &mockEngine{}
|
||||
sched, _, _ := newTestScheduler(engine, nil)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Add overdue jobs.
|
||||
sched.mu.Lock()
|
||||
for i := 0; i < 5; i++ {
|
||||
heap.Push(&sched.queue, &SchedulerJob{
|
||||
CheckerID: "overdue",
|
||||
Target: happydns.CheckTarget{UserId: "u", DomainId: "d"},
|
||||
Interval: time.Hour,
|
||||
NextRun: now.Add(-time.Duration(i+1) * time.Hour),
|
||||
})
|
||||
}
|
||||
sched.spreadOverdueJobs()
|
||||
sched.mu.Unlock()
|
||||
|
||||
// All jobs should now be in the future (or at now).
|
||||
sched.mu.RLock()
|
||||
for _, job := range sched.queue {
|
||||
if job.NextRun.Before(now.Add(-time.Second)) {
|
||||
t.Errorf("expected job to be rescheduled to now or later, got %v", job.NextRun)
|
||||
}
|
||||
}
|
||||
sched.mu.RUnlock()
|
||||
}
|
||||
|
||||
// --- effectiveInterval tests ---
|
||||
|
||||
func TestEffectiveInterval_Defaults(t *testing.T) {
|
||||
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
|
||||
|
||||
// No interval spec, no plan -> defaultInterval.
|
||||
def := &happydns.CheckerDefinition{}
|
||||
got := sched.effectiveInterval(def, nil)
|
||||
if got != defaultInterval {
|
||||
t.Errorf("expected %v, got %v", defaultInterval, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveInterval_DefDefault(t *testing.T) {
|
||||
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
|
||||
|
||||
def := &happydns.CheckerDefinition{
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Default: 2 * time.Hour,
|
||||
Min: 1 * time.Hour,
|
||||
Max: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
got := sched.effectiveInterval(def, nil)
|
||||
if got != 2*time.Hour {
|
||||
t.Errorf("expected 2h, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveInterval_PlanOverride(t *testing.T) {
|
||||
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
|
||||
|
||||
def := &happydns.CheckerDefinition{
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Default: 2 * time.Hour,
|
||||
Min: 1 * time.Hour,
|
||||
Max: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
interval := 6 * time.Hour
|
||||
plan := &happydns.CheckPlan{Interval: &interval}
|
||||
got := sched.effectiveInterval(def, plan)
|
||||
if got != 6*time.Hour {
|
||||
t.Errorf("expected 6h, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveInterval_ClampMin(t *testing.T) {
|
||||
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
|
||||
|
||||
def := &happydns.CheckerDefinition{
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Default: 2 * time.Hour,
|
||||
Min: 1 * time.Hour,
|
||||
Max: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
interval := 10 * time.Minute // below min
|
||||
plan := &happydns.CheckPlan{Interval: &interval}
|
||||
got := sched.effectiveInterval(def, plan)
|
||||
if got != 1*time.Hour {
|
||||
t.Errorf("expected clamped to 1h, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveInterval_ClampMax(t *testing.T) {
|
||||
sched, _, _ := newTestScheduler(&mockEngine{}, nil)
|
||||
|
||||
def := &happydns.CheckerDefinition{
|
||||
Interval: &happydns.CheckIntervalSpec{
|
||||
Default: 2 * time.Hour,
|
||||
Min: 1 * time.Hour,
|
||||
Max: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
interval := 24 * time.Hour // above max
|
||||
plan := &happydns.CheckPlan{Interval: &interval}
|
||||
got := sched.effectiveInterval(def, plan)
|
||||
if got != 12*time.Hour {
|
||||
t.Errorf("expected clamped to 12h, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildPlanIndex tests ---
|
||||
|
||||
func TestBuildPlanIndex(t *testing.T) {
|
||||
target := happydns.CheckTarget{UserId: "u1", DomainId: "d1"}
|
||||
plans := []*happydns.CheckPlan{
|
||||
{
|
||||
CheckerID: "c1",
|
||||
Target: target,
|
||||
Enabled: map[string]bool{"r1": false, "r2": false},
|
||||
},
|
||||
{
|
||||
CheckerID: "c2",
|
||||
Target: target,
|
||||
Enabled: map[string]bool{"r1": true},
|
||||
},
|
||||
}
|
||||
|
||||
disabled, planMap := buildPlanIndex(plans)
|
||||
|
||||
key1 := "c1|" + target.String()
|
||||
key2 := "c2|" + target.String()
|
||||
|
||||
if !disabled[key1] {
|
||||
t.Error("expected c1 to be in disabled set")
|
||||
}
|
||||
if disabled[key2] {
|
||||
t.Error("expected c2 to NOT be in disabled set")
|
||||
}
|
||||
if planMap[key1] != plans[0] {
|
||||
t.Error("expected planMap to contain c1 plan")
|
||||
}
|
||||
if planMap[key2] != plans[1] {
|
||||
t.Error("expected planMap to contain c2 plan")
|
||||
}
|
||||
}
|
||||
|
||||
// --- computeJitter tests ---
|
||||
|
||||
func TestComputeJitter_Deterministic(t *testing.T) {
|
||||
now := time.Now()
|
||||
interval := time.Hour
|
||||
|
||||
j1 := computeJitter("c1", "t1", now, interval)
|
||||
j2 := computeJitter("c1", "t1", now, interval)
|
||||
|
||||
if j1 != j2 {
|
||||
t.Errorf("expected deterministic jitter, got %v and %v", j1, j2)
|
||||
}
|
||||
|
||||
// Different inputs should (usually) produce different jitter.
|
||||
j3 := computeJitter("c2", "t1", now, interval)
|
||||
// Not guaranteed to differ, but very likely.
|
||||
_ = j3
|
||||
}
|
||||
|
||||
func TestComputeJitter_BoundedByInterval(t *testing.T) {
|
||||
now := time.Now()
|
||||
interval := time.Hour
|
||||
maxJitter := interval / 20
|
||||
|
||||
j := computeJitter("c1", "t1", now, interval)
|
||||
if j < 0 || j >= maxJitter {
|
||||
t.Errorf("expected jitter in [0, %v), got %v", maxJitter, j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeJitter_ZeroInterval(t *testing.T) {
|
||||
j := computeJitter("c1", "t1", time.Now(), 0)
|
||||
if j != 0 {
|
||||
t.Errorf("expected 0 jitter for zero interval, got %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// --- computeOffset tests ---
|
||||
|
||||
func TestComputeOffset_Deterministic(t *testing.T) {
|
||||
interval := time.Hour
|
||||
o1 := computeOffset("c1", "t1", interval)
|
||||
o2 := computeOffset("c1", "t1", interval)
|
||||
if o1 != o2 {
|
||||
t.Errorf("expected deterministic offset, got %v and %v", o1, o2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeOffset_WithinInterval(t *testing.T) {
|
||||
interval := time.Hour
|
||||
o := computeOffset("c1", "t1", interval)
|
||||
if o < 0 || o >= interval {
|
||||
t.Errorf("expected offset in [0, %v), got %v", interval, o)
|
||||
}
|
||||
}
|
||||
129
internal/usecase/checker/storage.go
Normal file
129
internal/usecase/checker/storage.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// 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
|
||||
TidyCheckPlanIndexes() error
|
||||
ClearCheckPlans() error
|
||||
}
|
||||
|
||||
// CheckerOptionsStorage provides persistence for checker options at different levels.
|
||||
type CheckerOptionsStorage interface {
|
||||
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptionsPositional], error)
|
||||
ListCheckerConfiguration(checkerName string) ([]*happydns.CheckerOptionsPositional, error)
|
||||
GetCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
|
||||
UpdateCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier, opts happydns.CheckerOptions) error
|
||||
DeleteCheckerConfiguration(checkerName string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) error
|
||||
ClearCheckerConfigurations() error
|
||||
}
|
||||
|
||||
// CheckEvaluationStorage provides persistence for check evaluation results.
|
||||
type CheckEvaluationStorage interface {
|
||||
ListAllEvaluations() (happydns.Iterator[happydns.CheckEvaluation], error)
|
||||
ListEvaluationsByPlan(planID happydns.Identifier) ([]*happydns.CheckEvaluation, error)
|
||||
ListEvaluationsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.CheckEvaluation, error)
|
||||
GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error)
|
||||
GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error)
|
||||
CreateEvaluation(eval *happydns.CheckEvaluation) error
|
||||
DeleteEvaluation(evalID happydns.Identifier) error
|
||||
DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error
|
||||
TidyEvaluationIndexes() error
|
||||
ClearEvaluations() error
|
||||
}
|
||||
|
||||
// ExecutionStorage provides persistence for execution records.
|
||||
type ExecutionStorage interface {
|
||||
ListAllExecutions() (happydns.Iterator[happydns.Execution], error)
|
||||
ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error)
|
||||
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error)
|
||||
ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error)
|
||||
ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error)
|
||||
GetExecution(execID happydns.Identifier) (*happydns.Execution, error)
|
||||
CreateExecution(exec *happydns.Execution) error
|
||||
UpdateExecution(exec *happydns.Execution) error
|
||||
DeleteExecution(execID happydns.Identifier) error
|
||||
DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error
|
||||
TidyExecutionIndexes() error
|
||||
ClearExecutions() error
|
||||
}
|
||||
|
||||
// PlannedJobProvider exposes upcoming scheduler jobs from the in-memory queue.
|
||||
type PlannedJobProvider interface {
|
||||
GetPlannedJobsForChecker(checkerID string, target happydns.CheckTarget) []*SchedulerJob
|
||||
}
|
||||
|
||||
// ObservationSnapshotStorage provides persistence for observation snapshots.
|
||||
type ObservationSnapshotStorage interface {
|
||||
ListAllSnapshots() (happydns.Iterator[happydns.ObservationSnapshot], error)
|
||||
GetSnapshot(snapID happydns.Identifier) (*happydns.ObservationSnapshot, error)
|
||||
CreateSnapshot(snap *happydns.ObservationSnapshot) error
|
||||
DeleteSnapshot(snapID happydns.Identifier) error
|
||||
ClearSnapshots() error
|
||||
}
|
||||
|
||||
// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
|
||||
// to the snapshot that holds the most recent data.
|
||||
type ObservationCacheStorage interface {
|
||||
ListAllCachedObservations() (happydns.Iterator[happydns.ObservationCacheEntry], error)
|
||||
GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
|
||||
PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
|
||||
}
|
||||
119
internal/usecase/checker/user_gate.go
Normal file
119
internal/usecase/checker/user_gate.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// UserGater builds a Scheduler gate function that filters out check jobs
|
||||
// belonging to users that are paused or have been inactive for too long.
|
||||
//
|
||||
// Lookups are cached for a short TTL so the scheduler hot path does not hit
|
||||
// storage on every job pop.
|
||||
type UserGater struct {
|
||||
resolver JanitorUserResolver
|
||||
defaultInactivityDays int
|
||||
cacheTTL time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]gateCacheEntry
|
||||
}
|
||||
|
||||
type gateCacheEntry struct {
|
||||
allow bool
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewUserGater creates a UserGater. defaultInactivityDays is used for users
|
||||
// whose UserQuota.InactivityPauseDays is zero. A negative effective value
|
||||
// disables inactivity-based pausing for that user.
|
||||
func NewUserGater(resolver JanitorUserResolver, defaultInactivityDays int) *UserGater {
|
||||
return &UserGater{
|
||||
resolver: resolver,
|
||||
defaultInactivityDays: defaultInactivityDays,
|
||||
cacheTTL: 5 * time.Minute,
|
||||
cache: map[string]gateCacheEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// Allow returns true if the scheduler should run jobs for the given target.
|
||||
func (g *UserGater) Allow(target happydns.CheckTarget) bool {
|
||||
uid := target.UserId
|
||||
if uid == "" || g.resolver == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
if e, ok := g.cache[uid]; ok && time.Now().Before(e.expires) {
|
||||
g.mu.Unlock()
|
||||
return e.allow
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
allow := g.compute(uid)
|
||||
|
||||
g.mu.Lock()
|
||||
g.cache[uid] = gateCacheEntry{allow: allow, expires: time.Now().Add(g.cacheTTL)}
|
||||
g.mu.Unlock()
|
||||
|
||||
return allow
|
||||
}
|
||||
|
||||
// Invalidate drops any cached decision for the given user. Call this when a
|
||||
// user's quota or LastSeen changes (e.g. on login or admin update).
|
||||
func (g *UserGater) Invalidate(userID string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.cache, userID)
|
||||
}
|
||||
|
||||
func (g *UserGater) compute(uid string) bool {
|
||||
id, err := happydns.NewIdentifierFromString(uid)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
user, err := g.resolver.GetUser(id)
|
||||
if err != nil || user == nil {
|
||||
// Be conservative: allow rather than silently dropping work.
|
||||
return true
|
||||
}
|
||||
if user.Quota.SchedulingPaused {
|
||||
return false
|
||||
}
|
||||
|
||||
days := user.Quota.InactivityPauseDays
|
||||
if days == 0 {
|
||||
days = g.defaultInactivityDays
|
||||
}
|
||||
if days <= 0 {
|
||||
return true
|
||||
}
|
||||
if user.LastSeen.IsZero() {
|
||||
return true
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
return user.LastSeen.After(cutoff)
|
||||
}
|
||||
261
internal/usecase/checker/user_gate_test.go
Normal file
261
internal/usecase/checker/user_gate_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// mockUserResolver is declared in janitor_test.go (same package).
|
||||
|
||||
func newGateResolver() *mockUserResolver {
|
||||
return &mockUserResolver{users: make(map[string]*happydns.User)}
|
||||
}
|
||||
|
||||
func addGateUser(r *mockUserResolver, quota happydns.UserQuota, lastSeen time.Time) string {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
r.users[uid.String()] = &happydns.User{
|
||||
Id: uid,
|
||||
LastSeen: lastSeen,
|
||||
Quota: quota,
|
||||
}
|
||||
return uid.String()
|
||||
}
|
||||
|
||||
// --- Allow tests ---
|
||||
|
||||
func TestUserGater_ActiveUser(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Now())
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected active user to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_SchedulingPaused(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if g.Allow(target) {
|
||||
t.Error("expected paused user to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_InactiveUser(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(0, 0, -100))
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if g.Allow(target) {
|
||||
t.Error("expected inactive user (100 days) to be blocked with 90-day threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_InactiveUserWithinThreshold(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(0, 0, -30))
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected user seen 30 days ago to be allowed with 90-day threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_PerUserInactivityOverride(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
// User has custom 14-day inactivity threshold, last seen 20 days ago.
|
||||
uid := addGateUser(r, happydns.UserQuota{InactivityPauseDays: 14}, time.Now().AddDate(0, 0, -20))
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if g.Allow(target) {
|
||||
t.Error("expected user with 14-day override to be blocked after 20 days")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_NegativeInactivityDaysDisablesCheck(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
// User opts out of inactivity pause with negative value, last seen 1 year ago.
|
||||
uid := addGateUser(r, happydns.UserQuota{InactivityPauseDays: -1}, time.Now().AddDate(-1, 0, 0))
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected negative InactivityPauseDays to disable inactivity check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_ZeroDefaultInactivityDisablesCheck(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(-1, 0, 0))
|
||||
|
||||
g := NewUserGater(r, 0) // system default disabled
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected zero defaultInactivityDays to disable inactivity check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_NegativeDefaultInactivityDisablesCheck(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Now().AddDate(-1, 0, 0))
|
||||
|
||||
g := NewUserGater(r, -1)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected negative defaultInactivityDays to disable inactivity check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_ZeroLastSeenAllowed(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{}, time.Time{})
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected zero LastSeen to be allowed (user never logged in yet)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_UnknownUserAllowed(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid.String()}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected unknown user to be allowed (fail-open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_EmptyUserIdAllowed(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: ""}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected empty UserId to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_NilResolverAllowed(t *testing.T) {
|
||||
g := NewUserGater(nil, 90)
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String()}
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected nil resolver to allow all targets")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cache tests ---
|
||||
|
||||
func TestUserGater_CacheHit(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
// First call populates cache.
|
||||
if g.Allow(target) {
|
||||
t.Fatal("expected paused user to be blocked")
|
||||
}
|
||||
|
||||
// Remove user from resolver; cached result should still apply.
|
||||
delete(r.users, uid)
|
||||
|
||||
if g.Allow(target) {
|
||||
t.Error("expected cached blocked result to persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_Invalidate(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
// Populate cache with blocked result.
|
||||
if g.Allow(target) {
|
||||
t.Fatal("expected paused user to be blocked")
|
||||
}
|
||||
|
||||
// Admin unpauses the user.
|
||||
r.users[uid].Quota.SchedulingPaused = false
|
||||
|
||||
// Without invalidation, cache still blocks.
|
||||
if g.Allow(target) {
|
||||
t.Fatal("expected cache to still block before invalidation")
|
||||
}
|
||||
|
||||
// Invalidate and re-check.
|
||||
g.Invalidate(uid)
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected user to be allowed after invalidation and unpause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGater_CacheExpiry(t *testing.T) {
|
||||
r := newGateResolver()
|
||||
uid := addGateUser(r, happydns.UserQuota{SchedulingPaused: true}, time.Now())
|
||||
|
||||
g := NewUserGater(r, 90)
|
||||
g.cacheTTL = 10 * time.Millisecond // very short TTL for testing
|
||||
target := happydns.CheckTarget{UserId: uid}
|
||||
|
||||
// Populate cache.
|
||||
if g.Allow(target) {
|
||||
t.Fatal("expected paused user to be blocked")
|
||||
}
|
||||
|
||||
// Unpause and wait for cache expiry.
|
||||
r.users[uid].Quota.SchedulingPaused = false
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
if !g.Allow(target) {
|
||||
t.Error("expected cache to expire and re-evaluate to allowed")
|
||||
}
|
||||
}
|
||||
|
|
@ -42,11 +42,12 @@ type DomainExistenceTester interface {
|
|||
}
|
||||
|
||||
type Service struct {
|
||||
store DomainStorage
|
||||
providerService ProviderGetter
|
||||
getZone *zoneUC.GetZoneUsecase
|
||||
domainExistence DomainExistenceTester
|
||||
domainLogAppender domainLogUC.DomainLogAppender
|
||||
store DomainStorage
|
||||
providerService ProviderGetter
|
||||
getZone *zoneUC.GetZoneUsecase
|
||||
domainExistence DomainExistenceTester
|
||||
domainLogAppender domainLogUC.DomainLogAppender
|
||||
schedulerNotifier happydns.SchedulerDomainNotifier
|
||||
}
|
||||
|
||||
func NewService(
|
||||
|
|
@ -65,6 +66,12 @@ func NewService(
|
|||
}
|
||||
}
|
||||
|
||||
// SetSchedulerNotifier sets the optional scheduler notifier for incremental
|
||||
// queue updates on domain creation/deletion.
|
||||
func (s *Service) SetSchedulerNotifier(notifier happydns.SchedulerDomainNotifier) {
|
||||
s.schedulerNotifier = notifier
|
||||
}
|
||||
|
||||
// CreateDomain creates a new domain for the given user.
|
||||
func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, input *happydns.DomainCreationInput) (*happydns.Domain, error) {
|
||||
uz, err := happydns.NewDomain(user, input.DomainName, input.ProviderId)
|
||||
|
|
@ -93,6 +100,10 @@ func (s *Service) CreateDomain(ctx context.Context, user *happydns.User, input *
|
|||
s.domainLogAppender.AppendDomainLog(uz, happydns.NewDomainLog(user, happydns.LOG_INFO, fmt.Sprintf("Domain name %s added.", uz.DomainName)))
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyDomainChange(uz)
|
||||
}
|
||||
|
||||
return uz, nil
|
||||
}
|
||||
|
||||
|
|
@ -194,5 +205,9 @@ func (s *Service) DeleteDomain(domainID happydns.Identifier) error {
|
|||
}
|
||||
}
|
||||
|
||||
if s.schedulerNotifier != nil {
|
||||
s.schedulerNotifier.NotifyDomainRemoved(domainID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,259 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tu.store.TidyCheckPlanIndexes()
|
||||
}
|
||||
|
||||
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 {
|
||||
// Check both the WIP zone ([0]) and the latest published
|
||||
// zone ([1]) so we keep configs for services that are
|
||||
// either being worked on or currently live.
|
||||
found := false
|
||||
for _, idx := range []int{0, 1} {
|
||||
if idx >= len(domain.ZoneHistory) {
|
||||
break
|
||||
}
|
||||
zone, err := tu.store.GetZone(domain.ZoneHistory[idx])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, svcs := range zone.Services {
|
||||
for _, svc := range svcs {
|
||||
if svc.Id.Equals(*cfg.ServiceId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
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 +437,45 @@ func (tu *tidyUpUsecase) TidySessions() error {
|
|||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidySnapshots() error {
|
||||
// Collect all snapshot IDs referenced by evaluations.
|
||||
evalIter, err := tu.store.ListAllEvaluations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer evalIter.Close()
|
||||
|
||||
referencedSnapshots := make(map[string]struct{})
|
||||
for evalIter.Next() {
|
||||
eval := evalIter.Item()
|
||||
if !eval.SnapshotID.IsEmpty() {
|
||||
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err = evalIter.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete snapshots not referenced by any evaluation.
|
||||
iter, err := tu.store.ListAllSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
for iter.Next() {
|
||||
snap := iter.Item()
|
||||
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
|
||||
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (tu *tidyUpUsecase) TidyUsers() error {
|
||||
iter, err := tu.store.ListAllAuthUsers()
|
||||
if err != nil {
|
||||
|
|
@ -182,6 +488,9 @@ func (tu *tidyUpUsecase) TidyUsers() error {
|
|||
|
||||
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
|
||||
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
|
||||
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
|
||||
return err
|
||||
}
|
||||
if err = iter.DropItem(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
108
internal/usecase/tidy_usecase_test.go
Normal file
108
internal/usecase/tidy_usecase_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
|
||||
|
||||
// Create a snapshot and a cache entry pointing to it.
|
||||
snap := &happydns.ObservationSnapshot{
|
||||
Target: target,
|
||||
CollectedAt: time.Now(),
|
||||
Data: map[happydns.ObservationKey]json.RawMessage{
|
||||
"obs_a": json.RawMessage(`{"x":1}`),
|
||||
},
|
||||
}
|
||||
if err := store.CreateSnapshot(snap); err != nil {
|
||||
t.Fatalf("CreateSnapshot() error: %v", err)
|
||||
}
|
||||
|
||||
validEntry := &happydns.ObservationCacheEntry{
|
||||
SnapshotID: snap.Id,
|
||||
CollectedAt: snap.CollectedAt,
|
||||
}
|
||||
if err := store.PutCachedObservation(target, "obs_a", validEntry); err != nil {
|
||||
t.Fatalf("PutCachedObservation() error: %v", err)
|
||||
}
|
||||
|
||||
// Create a stale cache entry pointing to a non-existent snapshot.
|
||||
staleSnapID, _ := happydns.NewRandomIdentifier()
|
||||
staleEntry := &happydns.ObservationCacheEntry{
|
||||
SnapshotID: staleSnapID,
|
||||
CollectedAt: time.Now().Add(-time.Hour),
|
||||
}
|
||||
if err := store.PutCachedObservation(target, "obs_stale", staleEntry); err != nil {
|
||||
t.Fatalf("PutCachedObservation() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify both entries exist before tidy.
|
||||
if _, err := store.GetCachedObservation(target, "obs_a"); err != nil {
|
||||
t.Fatalf("expected valid cache entry to exist: %v", err)
|
||||
}
|
||||
if _, err := store.GetCachedObservation(target, "obs_stale"); err != nil {
|
||||
t.Fatalf("expected stale cache entry to exist: %v", err)
|
||||
}
|
||||
|
||||
// Run tidy.
|
||||
tu := usecase.NewTidyUpUsecase(store)
|
||||
if err := tu.TidyObservationCache(); err != nil {
|
||||
t.Fatalf("TidyObservationCache() error: %v", err)
|
||||
}
|
||||
|
||||
// Valid entry should still exist.
|
||||
if _, err := store.GetCachedObservation(target, "obs_a"); err != nil {
|
||||
t.Errorf("expected valid cache entry to survive tidy: %v", err)
|
||||
}
|
||||
|
||||
// Stale entry should be removed.
|
||||
if _, err := store.GetCachedObservation(target, "obs_stale"); err == nil {
|
||||
t.Error("expected stale cache entry to be removed by tidy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidyObservationCache_EmptyCache(t *testing.T) {
|
||||
store, err := inmemory.Instantiate()
|
||||
if err != nil {
|
||||
t.Fatalf("Instantiate() returned error: %v", err)
|
||||
}
|
||||
|
||||
tu := usecase.NewTidyUpUsecase(store)
|
||||
if err := tu.TidyObservationCache(); err != nil {
|
||||
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ type Service struct {
|
|||
newsletter happydns.NewsletterSubscriptor
|
||||
authUser happydns.AuthUserUsecase
|
||||
closeUserSessions happydns.SessionCloserUsecase
|
||||
onUserChanged func(happydns.Identifier)
|
||||
}
|
||||
|
||||
func NewUserUsecases(
|
||||
|
|
@ -51,6 +52,13 @@ func NewUserUsecases(
|
|||
}
|
||||
}
|
||||
|
||||
// SetOnUserChanged installs a callback invoked after any successful user
|
||||
// update (via UpdateUser). This is used to invalidate caches that depend on
|
||||
// user state, such as the scheduler's UserGater.
|
||||
func (s *Service) SetOnUserChanged(fn func(happydns.Identifier)) {
|
||||
s.onUserChanged = fn
|
||||
}
|
||||
|
||||
// CreateUser creates a new user with the given information.
|
||||
func (s *Service) CreateUser(uinfo happydns.UserInfo) (*happydns.User, error) {
|
||||
if uinfo.GetEmail() == "" {
|
||||
|
|
@ -89,32 +97,44 @@ func (s *Service) GetUserByEmail(email string) (*happydns.User, error) {
|
|||
}
|
||||
|
||||
// UpdateUser updates a user using the provided update function.
|
||||
func (s *Service) UpdateUser(id happydns.Identifier, updateFn func(*happydns.User)) error {
|
||||
func (s *Service) UpdateUser(id happydns.Identifier, updateFn func(*happydns.User)) (*happydns.User, error) {
|
||||
user, err := s.store.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updateFn(user)
|
||||
|
||||
if !user.Id.Equals(id) {
|
||||
return happydns.ValidationError{Msg: "you cannot change the user identifier"}
|
||||
return nil, happydns.ValidationError{Msg: "you cannot change the user identifier"}
|
||||
}
|
||||
|
||||
if err := s.store.CreateOrUpdateUser(user); err != nil {
|
||||
return happydns.InternalError{
|
||||
return nil, happydns.InternalError{
|
||||
Err: fmt.Errorf("failed to update user: %w", err),
|
||||
UserMessage: "Sorry, we are currently unable to update your user. Please retry later.",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
if s.onUserChanged != nil {
|
||||
s.onUserChanged(id)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ChangeUserSettings updates the settings for a user.
|
||||
func (s *Service) ChangeUserSettings(user *happydns.User, newSettings happydns.UserSettings) error {
|
||||
user.Settings = newSettings
|
||||
return s.store.CreateOrUpdateUser(user)
|
||||
if err := s.store.CreateOrUpdateUser(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.onUserChanged != nil {
|
||||
s.onUserChanged(user.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user by their identifier.
|
||||
|
|
|
|||
|
|
@ -272,14 +272,17 @@ func Test_UpdateUser(t *testing.T) {
|
|||
}
|
||||
|
||||
// Update the user
|
||||
err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
updated, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Email = "updated@example.com"
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Email != "updated@example.com" {
|
||||
t.Errorf("returned user should have updated email, got %s", updated.Email)
|
||||
}
|
||||
|
||||
// Verify the user was updated
|
||||
// Verify the user was updated in storage
|
||||
updatedUser, err := service.GetUser(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving updated user: %v", err)
|
||||
|
|
@ -303,7 +306,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
|
|||
}
|
||||
|
||||
// Try to change the user ID
|
||||
err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
_, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Id = happydns.Identifier([]byte("new-id"))
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -319,7 +322,7 @@ func Test_UpdateUser_PreventIdChange(t *testing.T) {
|
|||
func Test_UpdateUser_NotFound(t *testing.T) {
|
||||
service, _, _, _ := createTestService(t)
|
||||
|
||||
err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
|
||||
_, err := service.UpdateUser(happydns.Identifier([]byte("nonexistent")), func(u *happydns.User) {
|
||||
u.Email = "updated@example.com"
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -364,6 +367,89 @@ func Test_ChangeUserSettings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_ChangeUserSettings_PreservesQuota(t *testing.T) {
|
||||
service, mem, _, _ := createTestService(t)
|
||||
|
||||
// Create a user with quota set
|
||||
user := &happydns.User{
|
||||
Id: happydns.Identifier([]byte("user-123")),
|
||||
Email: "test@example.com",
|
||||
Settings: *happydns.DefaultUserSettings(),
|
||||
Quota: happydns.UserQuota{
|
||||
MaxChecksPerDay: 42,
|
||||
RetentionDays: 30,
|
||||
SchedulingPaused: true,
|
||||
},
|
||||
}
|
||||
if err := mem.CreateOrUpdateUser(user); err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Change settings (should not touch quota)
|
||||
err := service.ChangeUserSettings(user, happydns.UserSettings{Language: "de"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify quota is untouched in storage
|
||||
storedUser, err := service.GetUser(user.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving user: %v", err)
|
||||
}
|
||||
if storedUser.Quota.MaxChecksPerDay != 42 {
|
||||
t.Errorf("expected MaxChecksPerDay 42 after settings change, got %d", storedUser.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if storedUser.Quota.RetentionDays != 30 {
|
||||
t.Errorf("expected RetentionDays 30 after settings change, got %d", storedUser.Quota.RetentionDays)
|
||||
}
|
||||
if !storedUser.Quota.SchedulingPaused {
|
||||
t.Error("expected SchedulingPaused to remain true after settings change")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_UpdateUser_Quota(t *testing.T) {
|
||||
service, mem, _, _ := createTestService(t)
|
||||
|
||||
userID := happydns.Identifier([]byte("user-123"))
|
||||
user := &happydns.User{
|
||||
Id: userID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
if err := mem.CreateOrUpdateUser(user); err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Update quota through UpdateUser (simulates admin path)
|
||||
_, err := service.UpdateUser(userID, func(u *happydns.User) {
|
||||
u.Quota = happydns.UserQuota{
|
||||
MaxChecksPerDay: 100,
|
||||
RetentionDays: 60,
|
||||
InactivityPauseDays: -1,
|
||||
SchedulingPaused: true,
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
storedUser, err := service.GetUser(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error retrieving user: %v", err)
|
||||
}
|
||||
if storedUser.Quota.MaxChecksPerDay != 100 {
|
||||
t.Errorf("expected MaxChecksPerDay 100, got %d", storedUser.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if storedUser.Quota.RetentionDays != 60 {
|
||||
t.Errorf("expected RetentionDays 60, got %d", storedUser.Quota.RetentionDays)
|
||||
}
|
||||
if storedUser.Quota.InactivityPauseDays != -1 {
|
||||
t.Errorf("expected InactivityPauseDays -1, got %d", storedUser.Quota.InactivityPauseDays)
|
||||
}
|
||||
if !storedUser.Quota.SchedulingPaused {
|
||||
t.Error("expected SchedulingPaused true")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_DeleteUser(t *testing.T) {
|
||||
service, mem, _, sessionCloser := createTestService(t)
|
||||
|
||||
|
|
|
|||
251
model/checker.go
Normal file
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("auth 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."
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ func TestPredefinedErrors(t *testing.T) {
|
|||
{
|
||||
name: "ErrAuthUserNotFound",
|
||||
err: happydns.ErrAuthUserNotFound,
|
||||
msg: "user not found",
|
||||
msg: "auth user not found",
|
||||
},
|
||||
{
|
||||
name: "ErrDomainNotFound",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -63,5 +67,5 @@ type UserUsecase interface {
|
|||
GenerateUserAvatar(*User, int, io.Writer) error
|
||||
GetUser(Identifier) (*User, error)
|
||||
GetUserByEmail(string) (*User, error)
|
||||
UpdateUser(Identifier, func(*User)) error
|
||||
UpdateUser(Identifier, func(*User)) (*User, error)
|
||||
}
|
||||
|
|
|
|||
51
model/user_quota.go
Normal file
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"`
|
||||
}
|
||||
193
model/user_quota_test.go
Normal file
193
model/user_quota_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestUserQuotaZeroValues(t *testing.T) {
|
||||
q := happydns.UserQuota{}
|
||||
|
||||
if q.MaxChecksPerDay != 0 {
|
||||
t.Errorf("zero UserQuota should have MaxChecksPerDay 0, got %d", q.MaxChecksPerDay)
|
||||
}
|
||||
if q.RetentionDays != 0 {
|
||||
t.Errorf("zero UserQuota should have RetentionDays 0, got %d", q.RetentionDays)
|
||||
}
|
||||
if q.InactivityPauseDays != 0 {
|
||||
t.Errorf("zero UserQuota should have InactivityPauseDays 0, got %d", q.InactivityPauseDays)
|
||||
}
|
||||
if q.SchedulingPaused {
|
||||
t.Error("zero UserQuota should have SchedulingPaused false")
|
||||
}
|
||||
if !q.UpdatedAt.IsZero() {
|
||||
t.Error("zero UserQuota should have zero UpdatedAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserQuotaJSON_RoundTrip(t *testing.T) {
|
||||
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
original := happydns.UserQuota{
|
||||
MaxChecksPerDay: 100,
|
||||
RetentionDays: 30,
|
||||
InactivityPauseDays: 14,
|
||||
SchedulingPaused: true,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal UserQuota: %v", err)
|
||||
}
|
||||
|
||||
var decoded happydns.UserQuota
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal UserQuota: %v", err)
|
||||
}
|
||||
|
||||
if decoded.MaxChecksPerDay != original.MaxChecksPerDay {
|
||||
t.Errorf("MaxChecksPerDay = %d; want %d", decoded.MaxChecksPerDay, original.MaxChecksPerDay)
|
||||
}
|
||||
if decoded.RetentionDays != original.RetentionDays {
|
||||
t.Errorf("RetentionDays = %d; want %d", decoded.RetentionDays, original.RetentionDays)
|
||||
}
|
||||
if decoded.InactivityPauseDays != original.InactivityPauseDays {
|
||||
t.Errorf("InactivityPauseDays = %d; want %d", decoded.InactivityPauseDays, original.InactivityPauseDays)
|
||||
}
|
||||
if decoded.SchedulingPaused != original.SchedulingPaused {
|
||||
t.Errorf("SchedulingPaused = %v; want %v", decoded.SchedulingPaused, original.SchedulingPaused)
|
||||
}
|
||||
if !decoded.UpdatedAt.Equal(original.UpdatedAt) {
|
||||
t.Errorf("UpdatedAt = %v; want %v", decoded.UpdatedAt, original.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserQuotaJSON_OmitEmpty(t *testing.T) {
|
||||
q := happydns.UserQuota{}
|
||||
|
||||
data, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal zero UserQuota: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal to map: %v", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{"max_checks_per_day", "retention_days", "inactivity_pause_days", "scheduling_paused"} {
|
||||
if _, ok := m[field]; ok {
|
||||
t.Errorf("zero-value field %q should be omitted from JSON, but was present", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserQuotaJSON_PartialDecode(t *testing.T) {
|
||||
raw := `{"retention_days": 7, "scheduling_paused": true}`
|
||||
|
||||
var q happydns.UserQuota
|
||||
if err := json.Unmarshal([]byte(raw), &q); err != nil {
|
||||
t.Fatalf("failed to unmarshal partial JSON: %v", err)
|
||||
}
|
||||
|
||||
if q.RetentionDays != 7 {
|
||||
t.Errorf("RetentionDays = %d; want 7", q.RetentionDays)
|
||||
}
|
||||
if !q.SchedulingPaused {
|
||||
t.Error("SchedulingPaused should be true")
|
||||
}
|
||||
if q.MaxChecksPerDay != 0 {
|
||||
t.Errorf("MaxChecksPerDay should default to 0, got %d", q.MaxChecksPerDay)
|
||||
}
|
||||
if q.InactivityPauseDays != 0 {
|
||||
t.Errorf("InactivityPauseDays should default to 0, got %d", q.InactivityPauseDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserQuotaJSON_NegativeInactivityPauseDays(t *testing.T) {
|
||||
q := happydns.UserQuota{InactivityPauseDays: -1}
|
||||
|
||||
data, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded happydns.UserQuota
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.InactivityPauseDays != -1 {
|
||||
t.Errorf("InactivityPauseDays = %d; want -1", decoded.InactivityPauseDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserWithQuotaJSON_RoundTrip(t *testing.T) {
|
||||
user := happydns.User{
|
||||
Id: happydns.Identifier{0x01, 0x02},
|
||||
Email: "test@example.com",
|
||||
Quota: happydns.UserQuota{
|
||||
MaxChecksPerDay: 50,
|
||||
RetentionDays: 90,
|
||||
SchedulingPaused: false,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal User with Quota: %v", err)
|
||||
}
|
||||
|
||||
var decoded happydns.User
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal User with Quota: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Quota.MaxChecksPerDay != 50 {
|
||||
t.Errorf("Quota.MaxChecksPerDay = %d; want 50", decoded.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if decoded.Quota.RetentionDays != 90 {
|
||||
t.Errorf("Quota.RetentionDays = %d; want 90", decoded.Quota.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserWithEmptyQuotaJSON(t *testing.T) {
|
||||
raw := `{"id":"AQID","email":"test@example.com","created_at":"0001-01-01T00:00:00Z","last_seen":"0001-01-01T00:00:00Z","settings":{}}`
|
||||
|
||||
var user happydns.User
|
||||
if err := json.Unmarshal([]byte(raw), &user); err != nil {
|
||||
t.Fatalf("failed to unmarshal User without quota field: %v", err)
|
||||
}
|
||||
|
||||
if user.Quota.MaxChecksPerDay != 0 {
|
||||
t.Errorf("missing quota should default MaxChecksPerDay to 0, got %d", user.Quota.MaxChecksPerDay)
|
||||
}
|
||||
if user.Quota.SchedulingPaused {
|
||||
t.Error("missing quota should default SchedulingPaused to false")
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
420
web-admin/src/routes/checkers/[checkerId]/+page.svelte
Normal file
420
web-admin/src/routes/checkers/[checkerId]/+page.svelte
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
<!--
|
||||
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, getOrphanedOptionKeys, filterValidOptions } 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[]) {
|
||||
saving = true;
|
||||
try {
|
||||
await putCheckersByCheckerIdOptions({
|
||||
path: { checkerId },
|
||||
body: filterValidOptions(optionValues, adminOpts),
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
</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 = getOrphanedOptionKeys(optionValues, 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>
|
||||
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