Compare commits

...

30 commits

Author SHA1 Message Date
e5a8c55630 checker: also consider WIP zone for tidy, scheduler, and auto-fill
Some checks are pending
continuous-integration/drone/push Build is pending
Tidy and scheduler now check both the WIP zone ([0]) and the latest
published zone ([1]) so that services being drafted are not cleaned up
or ignored by the scheduler.  Auto-fill searches WIP first for the
best user experience when configuring new services.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:34:25 +07:00
eaae8a3c70 fix: use latest published zone instead of oldest in checker subsystem
ZoneHistory is ordered [WIP, newest-published, ..., oldest-published].
The tidy, scheduler, and auto-fill code was using ZoneHistory[len-1]
(the oldest zone) instead of ZoneHistory[1] (the latest published).

This caused the scheduler to enumerate services from the oldest zone
snapshot, tidy to check service existence against outdated data, and
auto-fill to resolve from the wrong zone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:34:25 +07:00
3e41a09de9 checker: pause scheduling for paused or inactive users
Add a job-level gate to the scheduler. When set, the gate is consulted
on every popped job; if it returns false, the job is skipped and
re-enqueued for its next interval without invoking the engine.

A new UserGater builds such a gate from a user resolver and an
inactivity threshold:

  - users with UserQuota.SchedulingPaused are always blocked (admin
    kill switch);
  - users whose LastSeen is older than their effective inactivity
    horizon (UserQuota.InactivityPauseDays, falling back to a system
    default) are blocked until they log in again;
  - lookups are cached for 5 minutes so the scheduler hot path stays
    cheap, with an Invalidate hook for use on user updates.

This addresses the "free trial then forgotten" failure mode described
in the design notes.
2026-04-16 02:34:25 +07:00
f626e814c7 checker: add Janitor goroutine to enforce retention policy
The Janitor periodically walks every CheckPlan, loads its executions,
and deletes the ones that the tiered RetentionPolicy says to drop.

Per-user overrides are honoured: if a user's UserQuota.RetentionDays
is set, that horizon replaces the system default for the user's plans.
User lookups are cached per sweep to avoid repeated storage hits.

The janitor is the long-tail counterpart of the (still TODO) cheap
hard cap that will be applied at execution-creation time. It runs
immediately on Start() and then every configured interval (default 6h).
2026-04-16 02:33:36 +07:00
f54a1bea70 checker: keep 1 report per hour after the first day
Insert an hourly tier between the full-detail window and the daily
bucket so users still get sub-day resolution for the first week:

  0..1 day  -> all
  1..7 days -> 1 per hour
  7..30     -> 2 per day
  ...
2026-04-16 02:33:36 +07:00
b10a421a83 checker: add tiered RetentionPolicy
Introduce a pure RetentionPolicy.Decide function that partitions check
executions into keep/drop sets according to a tiered policy:

  - 0..7 days   -> every execution
  - 7..30 days  -> 2 per day per (checker, target)
  - 30..D/2     -> 1 per week per (checker, target)
  - D/2..D days -> 1 per month per (checker, target)
  - > D days    -> dropped

The function is intentionally storage-agnostic so the upcoming janitor
goroutine can call it on any execution slice and so it can be unit
tested directly. All thresholds are configurable to allow per-user
overrides via UserQuota.
2026-04-16 02:33:36 +07:00
83bd89a69d model: add UserQuota struct for admin-controlled per-user limits
Introduce a UserQuota field on the User model to hold admin-controlled
limits and flags that the user cannot modify. Only checker-related
fields are defined for now (max checks per day, retention days,
inactivity pause days, scheduling kill switch); future paid-plan
attributes will be added here later.

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

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

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

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

Adds API endpoint GET .../observations/:obsKey/report, frontend stores
for view mode switching (HTML/JSON), and wires the sidebar toggle buttons.
2026-04-16 02:32:02 +07:00
25c1ff41c7 checkers: add frontend UI components and routes
Add all checker UI pages and components:
- Checker list, config, schedule, and rules pages
- Execution list, detail, results, and rules pages
- Sidebar components for domain/service checker status
- Run check modal with option overrides and rule selection
- Domain-scoped and service-scoped check routes
- Admin pages for checker configuration and scheduler management
- Header navigation link for checkers section
2026-04-16 02:32:02 +07:00
6b25424ad5 checkers: add frontend API client, stores, and utilities
Add the frontend infrastructure for the checker UI:
- API client with scoped helpers for domain/service-level operations
- Svelte stores for checker state (currentExecution, currentCheckInfo)
- Utility functions for status colors, icons, i18n keys, date formatting
- Shared helpers: withInheritedPlaceholders, downloadBlob, collectAllOptionDocs
- English translations for all checker UI strings
- Zone model and form types extended for checker support
2026-04-16 02:31:07 +07:00
48cc4eca02 checkers: add API controllers, routes, and app wiring
Wire up the checker system to the HTTP layer:
- API controllers for checker operations, options, plans, and results
- Scoped routes at domain and service level
- Admin controllers for checker config and scheduler management
- App initialization: create usecases, start/stop scheduler
- Zone controller updated to include per-service check status
2026-04-16 02:31:07 +07:00
3f3157a355 checkers: add usecases, engine, and scheduler
Implement the checker business logic:
- CheckerOptionsUsecase: scope-based option resolution, validation,
  auto-fill from execution context (domain, zone, service)
- CheckPlanUsecase: CRUD for user scheduling configurations
- CheckStatusUsecase: aggregated status queries, execution history
- CheckerEngine: full execution pipeline (observe, evaluate, aggregate)
- Scheduler: background job executor with auto-discovery, min-heap
  queue, worker pool, and jitter-based scheduling
2026-04-16 02:31:07 +07:00
618314e15a checkers: add storage interfaces, implementations, and tidy
Add the persistence layer for the checker system:
- Storage interfaces (CheckPlanStorage, CheckerOptionsStorage,
  CheckEvaluationStorage, ExecutionStorage, ObservationSnapshotStorage,
  SchedulerStateStorage) in the usecase/checker package
- KV-based implementations for LevelDB/Oracle NoSQL/InMemory backends
- Integrate checker storage into the main Storage interface
- Add tidy methods for checker entities (plans, configurations,
  evaluations, executions, snapshots, observation cache) and
  secondary index cleanup
2026-04-16 02:29:09 +07:00
0530cb2198 checkers: add map-based option validation for checker fields
Add ValidateMapValues() to the forms package for validating
checker option maps against field documentation (required fields,
allowed choices, type checking).
2026-04-16 02:29:09 +07:00
1c6b9cd127 checkers: load external checker plugins from .so files
All checks were successful
continuous-integration/drone/push Build is passing
Scan -plugins-directory paths at startup, open each .so via plugin.Open,
look up the NewCheckerPlugin symbol from checker-sdk-go, and register the
returned definition and observation provider in the global checker
registries. A pluginLoader indirection keeps the door open for future
plugin kinds.
2026-04-16 02:29:09 +07:00
7651f57890 checkers: introduce checker subsystem foundation
Add the checker-sdk-go dependency and build the core checker
infrastructure:
- Domain model types: CheckTarget, CheckPlan, Execution,
  CheckEvaluation, CheckerDefinition, CheckerOptions,
  ObservationSnapshot, and associated interfaces
- Observation collection engine with concurrent per-key gathering
- Checker and observation provider registries (wrapping checker-sdk-go)
- WorstStatusAggregator for combining rule evaluation results
2026-04-16 02:29:09 +07:00
10775fe36c fix: give ErrAuthUserNotFound a distinct message from ErrUserNotFound
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
Both sentinels had the identical message "user not found", making them
indistinguishable in logs and error responses. Rename the auth variant
to "auth user not found" so debugging and error matching are unambiguous.
2026-04-16 02:14:55 +07:00
1977508108 tidy: delete User record when cleaning up unverified AuthUsers
When TidyUsers removes an AuthUser with unverified email and no login
after 7 days, the corresponding User record was left orphaned in the
database. Now DeleteUser is called before dropping the AuthUser.
2026-04-16 02:14:55 +07:00
exyone
9a3f834129 Improve Chinese translation and fix English translation errors (manually translated)
Add Esperanto, Manchu, and Tibetan translations (AI-assisted with manual review)
2026-04-16 02:14:18 +07:00
168 changed files with 24284 additions and 600 deletions

View file

@ -1,28 +1,51 @@
happyDomain
===========
> 中文译者:[Exyone](https://www.exyone.me/)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](./LICENSE)
[![Docker Hub](https://img.shields.io/docker/pulls/happydomain/happydomain)](https://hub.docker.com/r/happydomain/happydomain)
[![Matrix](https://img.shields.io/badge/matrix-%23000000?style=for-the-badge&logo=matrix&logoColor=white)](https://matrix.to/#/%23happyDNS:matrix.org)
[![在线体验](https://img.shields.io/badge/在线体验-brightgreen?style=for-the-badge)](https://try.happydomain.org/)
[![GitHub Stars](https://img.shields.io/github/stars/happyDomain/happydomain)](https://github.com/happyDomain/happydomain)
[![最新版本](https://img.shields.io/github/v/release/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 应用,可集中管理来自不同注册商和托管商的域名。
![happyDomain 截图](./docs/header.webp)
它由 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) 许可证。
同时提供商业许可证,如有需要请联系我们。

View file

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

32
checkers/ping.go Normal file
View file

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

33
checkers/zonemaster.go Normal file
View file

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

View file

@ -30,6 +30,7 @@ import (
"github.com/earthboundkid/versioninfo/v2"
"github.com/fatih/color"
_ "git.happydns.org/happyDomain/checkers"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/app"
"git.happydns.org/happyDomain/internal/config"

View file

@ -0,0 +1,149 @@
# Building a happyDomain Checker Plugin
This page documents how to ship a **checker** as an in-process Go plugin
that happyDomain loads at startup. Checker plugins extend happyDomain with
automated diagnostics on zones, domains, services or users.
If you've never built a happyDomain plugin before, read
[`checker-dummy`](https://git.happydns.org/checker-dummy) first; it is the
reference implementation that this page mirrors.
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
> and runs with the same privileges. happyDomain refuses to load plugins from
> a directory that is group- or world-writable; keep your plugin directory
> owned and writable only by the happyDomain user.
---
## What a checker plugin must export
happyDomain's loader looks for a single exported symbol named
`NewCheckerPlugin` with this exact signature:
```go
func NewCheckerPlugin() (
*checker.CheckerDefinition,
checker.ObservationProvider,
error,
)
```
where `checker` is `git.happydns.org/checker-sdk-go/checker` (see
[Licensing](#licensing) below for why the SDK lives in a separate module).
- `*CheckerDefinition` describes the checker: ID, name, version, options
documentation, rules, optional aggregator, scheduling interval, and
whether the checker exposes HTML reports or metrics. The `ID` field is
the persistent key: pick something stable and namespaced
(`com.example.dnssec-freshness`, not `dnssec`).
- `ObservationProvider` is the data-collection half of the checker. It
exposes a `Key()` (the observation key the rules will look up) and a
`Collect(ctx, opts)` method that returns the raw observation payload.
happyDomain serialises the result to JSON and caches it per
`ObservationContext`.
- Return a non-nil `error` if your plugin cannot initialise (missing
environment variable, broken cgo dependency, …); the host will log it and
skip the file rather than aborting startup.
### Registration and collisions
The loader calls `RegisterExternalizableChecker` and
`RegisterObservationProvider` from the SDK registry. Pick globally unique
identifiers: if your checker ID or observation key collides with a built-in
or another plugin, the duplicate is ignored.
The same `.so` may export both `NewCheckerPlugin` and (e.g.)
`NewProviderPlugin`. The loader runs every known plugin loader against
every file, so a single binary can ship a checker, a provider and a service
at once.
---
## Minimal example
```go
// Command plugin is the happyDomain plugin entrypoint for the dummy checker.
//
// Build with:
// go build -buildmode=plugin -o checker-dummy.so ./plugin
package main
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type dummyProvider struct{}
func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" }
func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
return map[string]string{"hello": "world"}, nil
}
// NewCheckerPlugin is the symbol resolved by happyDomain at startup.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: "com.example.dummy",
Name: "Dummy checker",
Version: "0.1.0",
ObservationKeys: []sdk.ObservationKey{"dummy.observation"},
// Add Rules / Aggregator / Options here in a real plugin.
}
return def, dummyProvider{}, nil
}
```
Build and deploy:
```bash
go build -buildmode=plugin -o checker-dummy.so ./plugin
sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/
sudo systemctl restart happydomain
```
happyDomain will log:
```
Plugin com.example.dummy (.../checker-dummy.so) loaded
```
---
## Build constraints and platform support
Go's `plugin` package is unforgiving:
- The plugin **must be built with the same Go version** as happyDomain
itself, including the same toolchain patch level.
- It **must use the same versions of every shared dependency**. Vendor the
exact module versions happyDomain ships, or pin them in your `go.mod`
with `replace` directives.
- `CGO_ENABLED=1` is required.
- `GOOS`/`GOARCH` must match the host binary.
If any of these don't match, `plugin.Open` will fail with a (sometimes
cryptic) error like *"plugin was built with a different version of package
…"*. The host will log it and skip the file.
Go's `plugin` package only works on **linux**, **darwin** and **freebsd**.
On other platforms (Windows, plan9, …) happyDomain is built without plugin
support and `--plugins-directory` is silently ignored apart from a warning
log line at startup.
---
## Licensing
Checker plugins import only `git.happydns.org/checker-sdk-go/checker`,
which is licensed under **Apache-2.0**. This is intentional: the
checker SDK is a small, stable public API for third-party checkers,
deliberately split out of the AGPL-3.0 happyDomain core so that
permissively-licensed checker plugins are possible.
You may therefore distribute your checker `.so` under any license compatible
with Apache-2.0. Note that this only covers checker plugins; provider and
service plugins still link against AGPL code and remain subject to the
AGPL-3.0 reciprocity rules described in their respective documentation
([provider](provider-plugin.md), [service](service-plugin.md)).

View file

@ -26,5 +26,5 @@ package main
//go:generate go run tools/gen_rr_typescript.go web/src/lib/dns_rr.ts
//go:generate go run tools/gen_service_specs.go -o web/src/lib/services_specs.ts
//go:generate go run tools/gen_dns_type_mapping.go -o internal/usecase/service_specs_dns_types.go
//go:generate swag init --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
//go:generate swag init --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go
//go:generate swag init --parseDependency --exclude internal/api-admin/ --generalInfo internal/api/route/route.go
//go:generate swag init --parseDependency --output docs-admin --exclude internal/api/ --generalInfo internal/api-admin/route/route.go

5
go.mod
View file

@ -5,6 +5,10 @@ go 1.25.0
toolchain go1.26.2
require (
git.happydns.org/checker-matrix v0.0.0-20260407211824-2bb91d33d489
git.happydns.org/checker-ping v0.0.0-20260407194626-a2ebf17774fc
git.happydns.org/checker-sdk-go v0.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
View file

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

View file

@ -0,0 +1,64 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminCheckerController handles admin checker-related API endpoints.
// It embeds CheckerController and overrides GetCheckerOptions to return a flat
// (non-positional) map scoped to nil (global/admin) level.
type AdminCheckerController struct {
*apicontroller.CheckerController
}
// NewAdminCheckerController creates a new AdminCheckerController.
func NewAdminCheckerController(optionsUC *checkerUC.CheckerOptionsUsecase) *AdminCheckerController {
return &AdminCheckerController{
CheckerController: apicontroller.NewCheckerController(nil, optionsUC, nil, nil, nil),
}
}
// GetCheckerOptions returns admin-level options (nil scope) for a checker as a flat map.
//
// @Summary Get admin-level checker options
// @Tags admin,checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [get]
func (cc *AdminCheckerController) GetCheckerOptions(c *gin.Context) {
checkerID := c.Param("checkerId")
opts, err := cc.OptionsUC.GetCheckerOptions(checkerID, nil, nil, nil)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}

View file

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

View file

@ -0,0 +1,100 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
)
// AdminSchedulerController handles admin scheduler API endpoints.
type AdminSchedulerController struct {
scheduler *checkerUC.Scheduler
}
// NewAdminSchedulerController creates a new AdminSchedulerController.
func NewAdminSchedulerController(scheduler *checkerUC.Scheduler) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler status.
//
// @Summary Get scheduler status
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Router /scheduler [get]
func (s *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// EnableScheduler starts the scheduler and returns updated status.
//
// @Summary Enable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/enable [post]
func (s *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), true); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// DisableScheduler stops the scheduler and returns updated status.
//
// @Summary Disable the scheduler
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} checkerUC.SchedulerStatus
// @Failure 500 {object} object
// @Router /scheduler/disable [post]
func (s *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := s.scheduler.SetEnabled(c.Request.Context(), false); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, s.scheduler.GetStatus())
}
// RescheduleUpcoming rebuilds the job queue and returns the new count.
//
// @Summary Rebuild the scheduler queue
// @Tags admin-scheduler
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {object} map[string]int
// @Router /scheduler/reschedule-upcoming [post]
func (s *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n := s.scheduler.RebuildQueue()
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

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

View file

@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
apizc.GetZone(c)
}

View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareCheckersRoutes(router *gin.RouterGroup, dep Dependencies) {
if dep.CheckerOptionsUC == nil {
return
}
cc := controller.NewAdminCheckerController(dep.CheckerOptionsUC)
apiCheckersRoutes := router.Group("/checkers")
apiCheckersRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiCheckersRoutes.Group("/:checkerId")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetChecker)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options")
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOptions)
apiCheckerOptionsRoutes.POST("", cc.AddCheckerOptions)
apiCheckerOptionsRoutes.PUT("", cc.ChangeCheckerOptions)
apiCheckerOptionRoutes := apiCheckerOptionsRoutes.Group("/:optname")
apiCheckerOptionRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -26,6 +26,7 @@ import (
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/storage"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
@ -41,14 +42,18 @@ type Dependencies struct {
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
CheckScheduler *checkerUC.Scheduler
}
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, dep Dependencies) {
apiRoutes := router.Group("/api")
declareBackupRoutes(cfg, apiRoutes, s)
declareCheckersRoutes(apiRoutes, dep)
declareDomainRoutes(apiRoutes, dep, s)
declareProviderRoutes(apiRoutes, dep, s)
declareSchedulerRoutes(apiRoutes, dep)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dep, s)
declareUsersRoutes(apiRoutes, dep, s)

View file

@ -0,0 +1,41 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
if dep.CheckScheduler == nil {
return
}
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
schedulerRoute := router.Group("/scheduler")
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
}

View file

@ -0,0 +1,244 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles checker-related API endpoints.
type CheckerController struct {
engine happydns.CheckerEngine
OptionsUC *checkerUC.CheckerOptionsUsecase
planUC *checkerUC.CheckPlanUsecase
statusUC *checkerUC.CheckStatusUsecase
plannedProvider checkerUC.PlannedJobProvider
}
// NewCheckerController creates a new CheckerController.
func NewCheckerController(
engine happydns.CheckerEngine,
optionsUC *checkerUC.CheckerOptionsUsecase,
planUC *checkerUC.CheckPlanUsecase,
statusUC *checkerUC.CheckStatusUsecase,
plannedProvider checkerUC.PlannedJobProvider,
) *CheckerController {
return &CheckerController{
engine: engine,
OptionsUC: optionsUC,
planUC: planUC,
statusUC: statusUC,
plannedProvider: plannedProvider,
}
}
// StatusUC returns the CheckStatusUsecase for use by other controllers.
func (cc *CheckerController) StatusUC() *checkerUC.CheckStatusUsecase {
return cc.statusUC
}
// targetFromContext builds a CheckTarget from middleware context values.
func targetFromContext(c *gin.Context) happydns.CheckTarget {
user := middleware.MyUser(c)
target := happydns.CheckTarget{}
if user != nil {
target.UserId = user.Id.String()
}
if domain, exists := c.Get("domain"); exists {
d := domain.(*happydns.Domain)
target.DomainId = d.Id.String()
}
if sid, exists := c.Get("serviceid"); exists {
id := sid.(happydns.Identifier)
target.ServiceId = id.String()
if z, zExists := c.Get("zone"); zExists {
zone := z.(*happydns.Zone)
if _, svc := zone.FindService(id); svc != nil {
target.ServiceType = svc.Type
}
}
}
return target
}
// --- Global checker routes ---
// ListCheckers returns all registered checker definitions.
//
// @Summary List available checkers
// @Tags checkers
// @Produce json
// @Success 200 {object} map[string]checker.CheckerDefinition
// @Router /checkers [get]
func (cc *CheckerController) ListCheckers(c *gin.Context) {
c.JSON(http.StatusOK, checkerPkg.GetCheckers())
}
// GetChecker returns a specific checker definition.
//
// @Summary Get a checker definition
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} checker.CheckerDefinition
// @Failure 404 {object} happydns.ErrorResponse
// @Router /checkers/{checkerId} [get]
func (cc *CheckerController) GetChecker(c *gin.Context) {
def, _ := c.Get("checker")
c.JSON(http.StatusOK, def)
}
// CheckerHandler is a middleware that validates the checkerId path parameter and sets "checker" in context.
func (cc *CheckerController) CheckerHandler(c *gin.Context) {
checkerID := c.Param("checkerId")
def := checkerPkg.FindChecker(checkerID)
if def == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Checker not found"})
return
}
c.Set("checker", def)
c.Next()
}
// --- Scoped routes (domain/service) ---
// ListAvailableChecks lists all checkers with their latest status for a target.
//
// @Summary List available checks with status
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerStatus
// @Router /domains/{domain}/checkers [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers [get]
func (cc *CheckerController) ListAvailableChecks(c *gin.Context) {
target := targetFromContext(c)
result, err := cc.statusUC.ListCheckerStatuses(target)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, result)
}
// TriggerCheck manually triggers a checker execution.
// By default the check runs asynchronously and returns an Execution (HTTP 202).
// Pass ?sync=true to block until the check completes and return a CheckEvaluation (HTTP 200).
//
// @Summary Trigger a manual check
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param sync query bool false "Run synchronously"
// @Param body body happydns.CheckerRunRequest false "Run request with options and enabled rules"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.CheckEvaluation
// @Success 202 {object} happydns.Execution
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [post]
func (cc *CheckerController) TriggerCheck(c *gin.Context) {
cname := c.Param("checkerId")
var req happydns.CheckerRunRequest
// Body is optional; io.EOF means no body was sent, which is valid (no custom options or rules).
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
target := targetFromContext(c)
if err := cc.OptionsUC.ValidateOptions(cname, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), req.Options, true); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Build a temporary plan from enabled rules if provided.
var plan *happydns.CheckPlan
if len(req.EnabledRules) > 0 {
plan = &happydns.CheckPlan{
CheckerID: cname,
Target: target,
Enabled: req.EnabledRules,
}
}
exec, err := cc.engine.CreateExecution(cname, target, plan)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if c.Query("sync") == "true" {
eval, err := cc.engine.RunExecution(c.Request.Context(), exec, plan, req.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, eval)
} else {
go func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
}
}()
c.JSON(http.StatusAccepted, exec)
}
}
// GetExecutionStatus returns the status of an execution.
//
// @Summary Get execution status
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} happydns.Execution
// @Failure 404 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [get]
func (cc *CheckerController) GetExecutionStatus(c *gin.Context) {
exec := c.MustGet("execution").(*happydns.Execution)
c.JSON(http.StatusOK, exec)
}

View file

@ -0,0 +1,177 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// respondWithMetrics writes metrics as a JSON array.
func respondWithMetrics(c *gin.Context, metrics []happydns.CheckMetric) {
if metrics == nil {
metrics = []happydns.CheckMetric{}
}
c.JSON(http.StatusOK, metrics)
}
const maxLimit = 1000
func getLimitParam(c *gin.Context, defaultLimit int) int {
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
if parsed > maxLimit {
return maxLimit
}
return parsed
}
}
return defaultLimit
}
// GetUserMetrics returns metrics across all checkers for the authenticated user.
//
// @Summary Get all user metrics
// @Description Returns metrics from all recent executions for the authenticated user as a JSON array.
// @Tags checkers
// @Produce json
// @Param limit query int false "Maximum number of executions to extract metrics from (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /checkers/metrics [get]
func (cc *CheckerController) GetUserMetrics(c *gin.Context) {
target := targetFromContext(c)
userID := happydns.TargetIdentifier(target.UserId)
if userID == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "Not authenticated"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByUser(*userID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetDomainMetrics returns metrics for a domain and its service children.
//
// @Summary Get domain metrics
// @Description Returns metrics from recent executions for a domain and all its services as a JSON array.
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/metrics [get]
func (cc *CheckerController) GetDomainMetrics(c *gin.Context) {
target := targetFromContext(c)
domainID := happydns.TargetIdentifier(target.DomainId)
if domainID == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Domain context required"})
return
}
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByDomain(*domainID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetCheckerMetrics returns metrics for a specific checker on a target.
//
// @Summary Get checker metrics
// @Description Returns metrics from recent executions of a specific checker on a target as a JSON array.
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Param limit query int false "Maximum number of executions (default: 100)"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/metrics [get]
func (cc *CheckerController) GetCheckerMetrics(c *gin.Context) {
checkerID := c.Param("checkerId")
target := targetFromContext(c)
limit := getLimitParam(c, 100)
metrics, err := cc.statusUC.GetMetricsByChecker(checkerID, target, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}
// GetExecutionMetrics returns metrics for a single execution.
//
// @Summary Get execution metrics
// @Description Returns metrics extracted from a single execution's observation snapshot as a JSON array.
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param executionId path string true "Execution ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string false "Zone identifier"
// @Param subdomain path string false "Subdomain"
// @Param serviceid path string false "Service identifier"
// @Success 200 {array} checker.CheckMetric
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/metrics [get]
func (cc *CheckerController) GetExecutionMetrics(c *gin.Context) {
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
return
}
target := targetFromContext(c)
exec, err := cc.statusUC.GetExecution(target, execID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
return
}
metrics, err := cc.statusUC.GetMetricsByExecution(target, exec.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
respondWithMetrics(c, metrics)
}

View file

@ -0,0 +1,223 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// GetCheckerOptions returns layered options for a checker, from least to most specific scope.
// The scope is determined by the route context (user-only at /api/checkers, domain/service at scoped routes).
//
// @Summary Get checker options by scope
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckerOptionsPositional
// @Router /checkers/{checkerId}/options [get]
// @Router /domains/{domain}/checkers/{checkerId}/options [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [get]
func (cc *CheckerController) GetCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
positionals, err := cc.OptionsUC.GetCheckerOptionsPositional(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if positionals == nil {
positionals = []*happydns.CheckerOptionsPositional{}
}
// Append auto-fill resolved values so the frontend can display them.
autoFillOpts, err := cc.OptionsUC.GetAutoFillOptions(checkerID, target)
if err == nil && autoFillOpts != nil {
positionals = append(positionals, &happydns.CheckerOptionsPositional{
CheckName: checkerID,
UserId: happydns.TargetIdentifier(target.UserId),
DomainId: happydns.TargetIdentifier(target.DomainId),
ServiceId: happydns.TargetIdentifier(target.ServiceId),
Options: autoFillOpts,
})
}
c.JSON(http.StatusOK, positionals)
}
// AddCheckerOptions partially merges options at the current scope.
//
// @Summary Merge checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to merge"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [post]
// @Router /domains/{domain}/checkers/{checkerId}/options [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [post]
func (cc *CheckerController) AddCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
merged, err := cc.OptionsUC.MergeCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), merged, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if _, err := cc.OptionsUC.AddCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, merged)
}
// ChangeCheckerOptions fully replaces options at the current scope.
//
// @Summary Replace checker options
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param options body checker.CheckerOptions true "Options to set"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} checker.CheckerOptions
// @Router /checkers/{checkerId}/options [put]
// @Router /domains/{domain}/checkers/{checkerId}/options [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options [put]
func (cc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var opts happydns.CheckerOptions
if err := c.ShouldBindJSON(&opts); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), opts); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// GetCheckerOption returns a single option value at the current scope.
//
// @Summary Get a single checker option
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [get]
func (cc *CheckerController) GetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
val, err := cc.OptionsUC.GetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
if val == nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Option not set"})
return
}
c.JSON(http.StatusOK, val)
}
// SetCheckerOption sets a single option value at the current scope.
//
// @Summary Set a single checker option
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param optname path string true "Option name"
// @Param value body any true "Option value"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {object} any
// @Router /checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/checkers/{checkerId}/options/{optname} [put]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/options/{optname} [put]
func (cc *CheckerController) SetCheckerOption(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
optname := c.Param("optname")
var value any
if err := c.ShouldBindJSON(&value); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Validate the full merged options after inserting the key.
existing, err := cc.OptionsUC.GetCheckerOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId))
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
existing[optname] = value
if err := cc.OptionsUC.ValidateOptions(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), existing, false); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if err := cc.OptionsUC.SetCheckerOption(checkerID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), optname, value); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, value)
}

View file

@ -0,0 +1,196 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// PlanHandler is a middleware that validates the planId path parameter,
// checks target scope, and sets "plan" in context.
func (cc *CheckerController) PlanHandler(c *gin.Context) {
planID, err := happydns.NewIdentifierFromString(c.Param("planId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid plan ID"})
return
}
plan, err := cc.planUC.GetCheckPlan(targetFromContext(c), planID)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Check plan not found"})
return
}
c.Set("plan", plan)
c.Next()
}
// ListCheckPlans returns all check plans for a domain.
//
// @Summary List check plans for a domain
// @Tags checkers
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 200 {array} happydns.CheckPlan
// @Router /domains/{domain}/checkers/{checkerId}/plans [get]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [get]
func (cc *CheckerController) ListCheckPlans(c *gin.Context) {
target := targetFromContext(c)
checkerID := c.Param("checkerId")
plans, err := cc.planUC.ListCheckPlansByTargetAndChecker(target, checkerID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, plans)
}
// CreateCheckPlan creates a new check plan.
//
// @Summary Create a check plan
// @Tags checkers
// @Accept json
// @Produce json
// @Param checkerId path string true "Checker ID"
// @Param plan body happydns.CheckPlan true "Check plan to create"
// @Param domain path string true "Domain identifier"
// @Param zoneid path string true "Zone identifier"
// @Param subdomain path string true "Subdomain"
// @Param serviceid path string true "Service identifier"
// @Success 201 {object} happydns.CheckPlan
// @Failure 400 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checkers/{checkerId}/plans [post]
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/plans [post]
func (cc *CheckerController) CreateCheckPlan(c *gin.Context) {
target := targetFromContext(c)
var plan happydns.CheckPlan
if err := c.ShouldBindJSON(&plan); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
plan.Target = target
plan.CheckerID = c.Param("checkerId")
if err := cc.planUC.CreateCheckPlan(&plan); err != nil {
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)
}

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,7 @@ import (
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/internal/helpers"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -38,13 +39,15 @@ type ZoneController struct {
domainService happydns.DomainUsecase
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
zoneService happydns.ZoneUsecase
checkStatusUC *checkerUC.CheckStatusUsecase
}
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *ZoneController {
return &ZoneController{
domainService: domainService,
zoneCorrectionService: zoneCorrectionService,
zoneService: zoneService,
checkStatusUC: checkStatusUC,
}
}
@ -59,14 +62,27 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Success 200 {object} happydns.Zone
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId} [get]
func (zc *ZoneController) GetZone(c *gin.Context) {
zone := c.MustGet("zone").(*happydns.Zone)
c.JSON(http.StatusOK, zone)
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
if zc.checkStatusUC != nil {
user := middleware.MyUser(c)
domain := c.MustGet("domain").(*happydns.Domain)
statusByService, err := zc.checkStatusUC.GetWorstServiceStatuses(user.Id, domain.Id)
if err != nil {
log.Printf("GetWorstServiceStatuses: %s", err.Error())
} else {
result.ServicesCheckStatus = statusByService
}
}
c.JSON(http.StatusOK, result)
}
// GetZoneSubdomain returns the services associated with a given subdomain.

View file

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

View file

@ -26,6 +26,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -39,11 +40,14 @@ func DeclareDomainRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkStatusUC,
)
router.GET("/domains", dc.GetDomains)
@ -61,6 +65,11 @@ func DeclareDomainRoutes(
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
// Mount domain-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
}
DeclareZoneRoutes(
apiDomainsRoutes,
zoneUC,
@ -68,5 +77,6 @@ func DeclareDomainRoutes(
zoneCorrApplier,
zoneServiceUC,
serviceUC,
cc,
)
}

View file

@ -24,12 +24,14 @@ package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
// Dependencies holds all use cases required to register the public API routes.
// It is a plain struct — no methods, no interface — constructed once in app.go.
// It is a plain struct - no methods, no interface - constructed once in app.go.
type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
@ -50,6 +52,12 @@ type Dependencies struct {
ZoneCorrectionApplier happydns.ZoneCorrectionApplierUsecase
ZoneImporter happydns.ZoneImporterUsecase
ZoneService happydns.ZoneServiceUsecase
CheckerEngine happydns.CheckerEngine
CheckerOptionsUC *checkerUC.CheckerOptionsUsecase
CheckPlanUC *checkerUC.CheckPlanUsecase
CheckStatusUC *checkerUC.CheckStatusUsecase
PlannedProvider checkerUC.PlannedJobProvider
}
// @title happyDomain API
@ -105,6 +113,19 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
}
apiAuthRoutes.Use(middleware.AuthRequired())
// Initialize checker controller if checker engine is available.
var cc *controller.CheckerController
if dep.CheckerEngine != nil {
cc = DeclareCheckerRoutes(
apiAuthRoutes,
dep.CheckerEngine,
dep.CheckerOptionsUC,
dep.CheckPlanUC,
dep.CheckStatusUC,
dep.PlannedProvider,
)
}
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(
apiAuthRoutes,
@ -116,6 +137,8 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.ZoneCorrectionApplier,
dep.ZoneService,
dep.Service,
cc,
dep.CheckStatusUC,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

@ -36,6 +36,7 @@ func DeclareZoneServiceRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
cc *controller.CheckerController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
@ -47,4 +48,9 @@ func DeclareZoneServiceRoutes(
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
// Mount service-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
}
}

View file

@ -26,6 +26,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
happydns "git.happydns.org/happyDomain/model"
)
@ -36,11 +37,18 @@ func DeclareZoneRoutes(
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
) {
var checkStatusUC *checkerUC.CheckStatusUsecase
if cc != nil {
checkStatusUC = cc.StatusUC()
}
zc := controller.NewZoneController(
zoneUC,
domainUC,
zoneCorrApplier,
checkStatusUC,
)
apiZonesRoutes := router.Group("/zone/:zoneid")
@ -65,6 +73,7 @@ func DeclareZoneRoutes(
zoneServiceUC,
serviceUC,
zoneUC,
cc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)

View file

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

View file

@ -38,6 +38,7 @@ import (
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -69,6 +70,14 @@ type Usecases struct {
zoneService happydns.ZoneServiceUsecase
orchestrator *orchestrator.Orchestrator
checkerEngine happydns.CheckerEngine
checkerOptionsUC *checkerUC.CheckerOptionsUsecase
checkerPlanUC *checkerUC.CheckPlanUsecase
checkerStatusUC *checkerUC.CheckStatusUsecase
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
}
type App struct {
@ -93,6 +102,9 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
@ -108,6 +120,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
@ -224,12 +239,13 @@ func (app *App) initUsecases() {
app.store,
)
app.usecases.user = userUC.NewUserUsecases(
userService := userUC.NewUserUsecases(
app.store,
app.newsletter,
authUserService,
sessionService,
)
app.usecases.user = userService
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
@ -246,6 +262,44 @@ func (app *App) initUsecases() {
providerAdminService,
zoneService.UpdateZoneUC,
)
// Checker system.
app.usecases.checkerOptionsUC = checkerUC.NewCheckerOptionsUsecase(app.store, app.store)
app.usecases.checkerPlanUC = checkerUC.NewCheckPlanUsecase(app.store)
app.usecases.checkerStatusUC = checkerUC.NewCheckStatusUsecase(app.store, app.store, app.store, app.store)
app.usecases.checkerEngine = checkerUC.NewCheckerEngine(
app.usecases.checkerOptionsUC,
app.store,
app.store,
app.store,
app.store,
)
// Build the user-level gate so paused or long-inactive users do not
// get checked. The same user resolver is reused by the janitor for
// per-user retention overrides.
app.usecases.checkerUserGater = checkerUC.NewUserGater(app.store, app.cfg.CheckerInactivityPauseDays)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store, app.usecases.checkerUserGater.Allow)
// Invalidate the scheduler's user gate cache whenever a user is updated
// (e.g. login refreshing LastSeen, admin toggling SchedulingPaused).
userService.SetOnUserChanged(func(id happydns.Identifier) {
app.usecases.checkerUserGater.Invalidate(id.String())
})
// Retention janitor.
app.usecases.checkerJanitor = checkerUC.NewJanitor(
app.store,
app.store,
app.store,
app.store,
app.store,
checkerUC.DefaultRetentionPolicy(app.cfg.CheckerRetentionDays),
app.cfg.CheckerJanitorInterval,
)
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
}
func (app *App) setupRouter() {
@ -291,6 +345,12 @@ func (app *App) setupRouter() {
ZoneCorrectionApplier: app.usecases.orchestrator.ZoneCorrectionApplier,
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
ZoneService: app.usecases.zoneService,
CheckerEngine: app.usecases.checkerEngine,
CheckerOptionsUC: app.usecases.checkerOptionsUC,
CheckPlanUC: app.usecases.checkerPlanUC,
CheckStatusUC: app.usecases.checkerStatusUC,
PlannedProvider: app.usecases.checkerScheduler,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
@ -308,6 +368,14 @@ func (app *App) Start() {
go app.insights.Run()
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Start(context.Background())
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Start(context.Background())
}
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -321,6 +389,14 @@ func (app *App) Stop() {
log.Fatal("Server Shutdown:", err)
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Stop()
}
if app.usecases.checkerJanitor != nil {
app.usecases.checkerJanitor.Stop()
}
// Close storage
if app.store != nil {
app.store.Close()

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

@ -0,0 +1,238 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build linux || darwin || freebsd
package app
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"plugin"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
)
// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders.
// It exists so that loaders can be unit-tested with a fake instead of
// requiring a real .so file built via `go build -buildmode=plugin`.
type pluginSymbols interface {
Lookup(symName string) (plugin.Symbol, error)
}
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error: a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p pluginSymbols, fname string) (found bool, err error)
// safeCall invokes fn while recovering from any panic raised by plugin code.
// A panicking factory must not take the whole server down at startup; the
// recovered value is converted to an error so the caller can log/skip the
// offending plugin like any other failure.
func safeCall(symbol string, fname string, fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("plugin %q panicked in %s: %v", fname, symbol, r)
}
}()
return fn()
}
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckerPlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
// built against checker-sdk-go (see ../../checker-dummy/README.md).
func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckerPlugin")
if err != nil {
// Symbol not present in this .so, not an error.
return false, nil
}
factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym)
}
var (
def *sdk.CheckerDefinition
provider sdk.ObservationProvider
)
if err := safeCall("NewCheckerPlugin", fname, func() error {
var ferr error
def, provider, ferr = factory()
return ferr
}); err != nil {
return true, err
}
if def == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition")
}
if provider == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider")
}
checker.RegisterObservationProvider(provider)
checker.RegisterExternalizableChecker(def)
log.Printf("Plugin %s (%s) loaded", def.ID, fname)
return true, nil
}
// checkPluginDirectoryPermissions refuses to load plugins from a directory
// that any non-owner can write to. Loading a .so file is arbitrary code
// execution as the happyDomain process, so a world- or group-writable
// plugin directory is treated as a fatal misconfiguration: any local user
// (or any process sharing the group) able to drop a file there could take
// over the server. Operators who genuinely need shared deployment should
// stage plugins elsewhere and rsync them into a directory owned and
// writable only by the happyDomain user.
func checkPluginDirectoryPermissions(directory string) error {
// Use Lstat to detect symlinks: a symlink could be silently redirected
// to an attacker-controlled directory, bypassing the permission check
// on the original path.
linfo, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("unable to stat plugins directory %q: %s", directory, err)
}
if linfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("plugins directory %q is a symbolic link; refusing to follow it", directory)
}
if !linfo.IsDir() {
return fmt.Errorf("plugins path %q is not a directory", directory)
}
mode := linfo.Mode().Perm()
if mode&0o022 != 0 {
return fmt.Errorf("plugins directory %q is group- or world-writable (mode %#o); refusing to load plugins from it", directory, mode)
}
return nil
}
// checkPluginFilePermissions refuses to load a .so file that is group- or
// world-writable. Even inside a properly locked-down directory, a writable
// plugin binary could be replaced by a malicious actor sharing the group.
// Symlinks are followed: the permission check applies to the resolved target,
// which allows the common pattern of symlinking to versioned binaries
// (e.g. checker-foo.so -> checker-foo-v1.2.so) for atomic upgrades.
// The directory-level symlink ban already prevents attackers from redirecting
// the scan root itself.
func checkPluginFilePermissions(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("unable to stat plugin file %q: %s", path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("plugin %q is not a regular file (or resolves to a non-regular file)", path)
}
mode := info.Mode().Perm()
if mode&0o022 != 0 {
return fmt.Errorf("plugin file %q is group- or world-writable (mode %#o)", path, mode)
}
return nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories and loads
// every .so file found as a Go plugin. A directory that cannot be read is a
// fatal configuration error; individual plugin failures are logged and
// skipped so that one bad .so does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
if err := checkPluginDirectoryPermissions(directory); err != nil {
return err
}
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
if err := checkPluginFilePermissions(fname); err != nil {
log.Printf("Skipping plugin %q: %s", fname, err)
continue
}
if err := loadPlugin(fname); err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is
// logged, because the file might be a valid plugin for a future version of
// happyDomain. Loader errors for one plugin kind do not prevent the other
// kinds in the same .so from being attempted: a single .so is allowed to
// expose more than one plugin type, and a failure to register (e.g.) the
// service half should not silently drop the checker half. All loader errors
// encountered are joined and returned together.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
var (
anyFound bool
errs []error
)
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if found {
anyFound = true
}
if err != nil {
errs = append(errs, err)
}
}
if !anyFound && len(errs) == 0 {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,143 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build linux || darwin || freebsd
package app
import (
"context"
"errors"
"plugin"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// dummyCheckerProvider is a minimal ObservationProvider used by the tests
// below. It is intentionally trivial: the loader tests only care that
// registration succeeds, not what the provider actually collects.
type dummyCheckerProvider struct {
key happydns.ObservationKey
}
func (d *dummyCheckerProvider) Key() happydns.ObservationKey { return d.key }
func (d *dummyCheckerProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
return nil, nil
}
func newDummyCheckerFactory(id string) func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: id,
Name: "Dummy checker",
}
return def, &dummyCheckerProvider{key: happydns.ObservationKey("dummy-" + id)}, nil
}
}
func TestLoadCheckerPlugin_SymbolMissing(t *testing.T) {
found, err := loadCheckerPlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewCheckerPlugin": 42, // not a function
}}
found, err := loadCheckerPlugin(fs, "wrongtype.so")
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryError(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, nil, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "factoryerr.so")
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_NilDefinition(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, &dummyCheckerProvider{key: "k"}, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nildef.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil CheckerDefinition") {
t.Fatalf("expected nil-definition error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_NilProvider(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return &sdk.CheckerDefinition{ID: "x"}, nil, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nilprov.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ObservationProvider") {
t.Fatalf("expected nil-provider error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryPanics(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "panic.so")
if !found || err == nil {
t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") {
t.Errorf("expected wrapped panic error, got %v", err)
}
}
func TestLoadCheckerPlugin_Success(t *testing.T) {
factory := newDummyCheckerFactory("dummy-success")
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected success, got (%v, %v)", found, err)
}
if got := checker.FindChecker("dummy-success"); got == nil {
t.Errorf("expected checker %q to be registered", "dummy-success")
}
if got := sdk.FindObservationProvider(happydns.ObservationKey("dummy-dummy-success")); got == nil {
t.Errorf("expected observation provider %q to be registered", "dummy-dummy-success")
}
}

View file

@ -0,0 +1,37 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build !linux && !darwin && !freebsd
package app
import "log"
// initPlugins is a no-op on platforms where Go's plugin package is not
// supported (Windows, plan9, …). If the operator configured plugin
// directories anyway we log a clear warning rather than silently ignoring
// them, so the misconfiguration is visible at startup.
func (a *App) initPlugins() error {
if len(a.cfg.PluginsDirectories) > 0 {
log.Printf("Warning: plugin loading is not supported on this platform; ignoring %d configured plugin directories", len(a.cfg.PluginsDirectories))
}
return nil
}

View file

@ -0,0 +1,172 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build linux || darwin || freebsd
package app
import (
"fmt"
"os"
"path/filepath"
"plugin"
"testing"
)
// fakeSymbols is a pluginSymbols implementation backed by a static map. It
// lets the loader tests exercise their behaviour without having to compile a
// real .so file via `go build -buildmode=plugin`.
type fakeSymbols struct {
syms map[string]plugin.Symbol
}
func (f *fakeSymbols) Lookup(name string) (plugin.Symbol, error) {
if s, ok := f.syms[name]; ok {
return s, nil
}
return nil, fmt.Errorf("symbol %q not found", name)
}
// TestLoadPlugin_NoRecognisedSymbols verifies that when a .so file exports
// none of the known plugin symbols, every loader returns (false, nil), i.e.
// the file is silently skipped rather than reported as an error. loadPlugin
// itself logs a warning in that situation; we exercise the inner loop here
// because the outer call requires plugin.Open and a real .so file.
func TestLoadPlugin_NoRecognisedSymbols(t *testing.T) {
fs := &fakeSymbols{}
for _, loader := range pluginLoaders {
found, err := loader(fs, "empty.so")
if found || err != nil {
t.Fatalf("loader returned (%v, %v) for empty symbol set, expected (false, nil)", found, err)
}
}
}
func TestCheckPluginDirectoryPermissions(t *testing.T) {
dir := t.TempDir()
// A freshly-created TempDir is owner-only on every platform we run on,
// so this must be accepted.
if err := os.Chmod(dir, 0o750); err != nil {
t.Fatalf("chmod 0750: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err != nil {
t.Errorf("expected 0750 directory to be accepted, got %v", err)
}
// World-writable: must be refused.
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod 0777: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0777 directory to be refused")
}
// Group-writable: must also be refused.
if err := os.Chmod(dir, 0o770); err != nil {
t.Fatalf("chmod 0770: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0770 directory to be refused")
}
// Restore permissions so t.TempDir cleanup can remove the directory.
_ = os.Chmod(dir, 0o700)
// Non-existent path: must be refused.
if err := checkPluginDirectoryPermissions(filepath.Join(dir, "does-not-exist")); err == nil {
t.Errorf("expected missing directory to be refused")
}
// Symlink to a valid directory: must be refused.
target := t.TempDir()
link := filepath.Join(dir, "symlink-plugins")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginDirectoryPermissions(link); err == nil {
t.Errorf("expected symlink directory to be refused")
}
}
func TestCheckPluginFilePermissions(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "test.so")
if err := os.WriteFile(f, []byte("fake"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
// Owner-writable, not group/world-writable: accepted.
if err := checkPluginFilePermissions(f); err != nil {
t.Errorf("expected 0644 file to be accepted, got %v", err)
}
// Group-writable: refused.
if err := os.Chmod(f, 0o664); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := checkPluginFilePermissions(f); err == nil {
t.Errorf("expected 0664 file to be refused")
}
// World-writable: refused.
if err := os.Chmod(f, 0o646); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := checkPluginFilePermissions(f); err == nil {
t.Errorf("expected 0646 file to be refused")
}
// Non-existent: refused.
if err := checkPluginFilePermissions(filepath.Join(dir, "nope.so")); err == nil {
t.Errorf("expected missing file to be refused")
}
// Symlink to a safe regular file: accepted (we follow the link and
// check the target's permissions, not the link itself).
regular := filepath.Join(dir, "real.so")
if err := os.WriteFile(regular, []byte("real"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
link := filepath.Join(dir, "link.so")
if err := os.Symlink(regular, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginFilePermissions(link); err != nil {
t.Errorf("expected symlink to safe file to be accepted, got %v", err)
}
// Symlink to a writable target: refused.
writable := filepath.Join(dir, "writable.so")
if err := os.WriteFile(writable, []byte("bad"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(writable, 0o666); err != nil {
t.Fatalf("chmod: %v", err)
}
linkBad := filepath.Join(dir, "link-bad.so")
if err := os.Symlink(writable, linkBad); err != nil {
t.Fatalf("symlink: %v", err)
}
if err := checkPluginFilePermissions(linkBad); err == nil {
t.Errorf("expected symlink to writable file to be refused")
}
}

View file

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

View file

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

View file

@ -0,0 +1,323 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// observation.go implements the observation subsystem, which is the data
// collection layer for the checker framework. An observation represents a
// piece of raw data gathered about a check target (e.g. DNS records, HTTP
// headers, TLS certificate details). Observations are identified by an
// ObservationKey and collected on demand by registered ObservationProviders.
//
// The ObservationContext provides lazy-loading, cached, thread-safe access to
// observations: the first checker that requests a given observation triggers
// its collection, and subsequent checkers reuse the cached result. This
// design decouples data collection from evaluation: checkers declare which
// observations they need, and the context ensures each is collected at most
// once per check run. Observations can also be persisted as snapshots and
// reused across runs when freshness requirements allow.
//
// Observation providers may optionally implement reporting interfaces
// (CheckerHTMLReporter, CheckerMetricsReporter) to produce human-readable
// reports or extract time-series metrics from collected data.
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
//
// Concurrency model: the outer mu protects only the cache/errors/inflight
// maps and is held for short critical sections. Provider collection runs
// *without* mu held, so two calls to Get for *different* keys can collect
// concurrently. Two calls for the *same* key are deduplicated: the first
// installs an inflight channel, runs the collection, then closes the
// channel; the others wait on it and read the cached result afterwards.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
inflight map[happydns.ObservationKey]chan struct{}
mu sync.Mutex
cacheLookup ObservationCacheLookup // nil = no DB cache
freshness time.Duration // 0 = always collect
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
}
// NewObservationContext creates a new ObservationContext for the given target and options.
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
inflight: make(map[happydns.ObservationKey]chan struct{}),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// SetProviderOverride registers a per-context provider that takes precedence
// over the global registry for the given observation key. This is used to
// substitute local providers with HTTP-backed ones when an endpoint is configured.
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
oc.mu.Lock()
defer oc.mu.Unlock()
if oc.providerOverride == nil {
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
}
oc.providerOverride[key] = p
}
// getProvider returns the observation provider for the given key, checking
// per-context overrides first, then falling back to the global registry.
// Safe to call without holding oc.mu - it acquires the lock internally.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
oc.mu.Lock()
override := oc.providerOverride
oc.mu.Unlock()
if override != nil {
if p, ok := override[key]; ok {
return p
}
}
return sdk.FindObservationProvider(key)
}
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key are deduplicated; concurrent
// calls for different keys collect in parallel.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
for {
oc.mu.Lock()
if raw, ok := oc.cache[key]; ok {
oc.mu.Unlock()
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.Unlock()
return err
}
if ch, ok := oc.inflight[key]; ok {
// Another goroutine is already collecting this key. Release
// the lock, wait for it to finish, then re-check the cache.
oc.mu.Unlock()
select {
case <-ch:
case <-ctx.Done():
return ctx.Err()
}
continue
}
// We are the leader for this key. Install the inflight channel
// before releasing the lock so concurrent callers wait on us.
ch := make(chan struct{})
oc.inflight[key] = ch
oc.mu.Unlock()
raw, collectErr := oc.collect(ctx, key)
// Collection errors are cached for the lifetime of this
// ObservationContext (i.e. a single execution run). This is
// intentional: within one run the same transient failure would
// keep recurring, and retrying would slow down the pipeline.
// A new execution creates a fresh context, giving the provider
// another chance.
oc.mu.Lock()
if collectErr != nil {
oc.errors[key] = collectErr
} else {
oc.cache[key] = raw
}
delete(oc.inflight, key)
close(ch)
oc.mu.Unlock()
if collectErr != nil {
return collectErr
}
return json.Unmarshal(raw, dest)
}
}
// collect runs the DB-cache lookup and provider collection for a single key
// without holding oc.mu, so collections for different keys can run in
// parallel. Callers are responsible for installing the result into the cache
// or errors map and signalling waiters.
func (oc *ObservationContext) collect(ctx context.Context, key happydns.ObservationKey) (json.RawMessage, error) {
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
return raw, nil
}
}
}
provider := oc.getProvider(key)
if provider == nil {
return nil, fmt.Errorf("no observation provider registered for key %q", key)
}
val, err := provider.Collect(ctx, oc.opts)
if err != nil {
return nil, err
}
raw, err := json.Marshal(val)
if err != nil {
return nil, fmt.Errorf("observation %q: marshal failed: %w", key, err)
}
return json.RawMessage(raw), nil
}
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.Lock()
defer oc.mu.Unlock()
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
return data
}
// Provider registration is startup-only (see comments on the registries in
// internal/service/registry.go and internal/provider/registry.go), so the
// "any provider implements X reporter" question has a fixed answer for the
// process lifetime. We compute it once on first call and cache it.
var (
htmlReporterOnce sync.Once
htmlReporterCached bool
metricsReporterOnce sync.Once
metricsReporterCached bool
)
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
func HasHTMLReporter() bool {
htmlReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
htmlReporterCached = true
return
}
}
})
return htmlReporterCached
}
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(sdk.FindObservationProvider(key), key, raw)
}
// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(oc.getProvider(key), key, raw)
}
func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
if provider == nil {
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
}
hr, ok := provider.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
func HasMetricsReporter() bool {
metricsReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
metricsReporterCached = true
return
}
}
})
return metricsReporterCached
}
// GetMetrics extracts metrics for the given observation key and raw JSON data.
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(sdk.FindObservationProvider(key), key, raw, collectedAt)
}
// GetMetricsCtx is like GetMetrics but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(oc.getProvider(key), key, raw, collectedAt)
}
func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
if provider == nil {
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
}
mr, ok := provider.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
metrics, err := mr.ExtractMetrics(raw, collectedAt)
return metrics, true, err
}
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
var errs []error
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil {
errs = append(errs, fmt.Errorf("observation %q: %w", key, err))
continue
}
if !supported {
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, errors.Join(errs...)
}

View file

@ -0,0 +1,168 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// blockingProvider is an ObservationProvider whose Collect blocks on the
// release channel until the test signals it. It records how many concurrent
// Collect calls are in flight at any moment.
type blockingProvider struct {
key happydns.ObservationKey
release chan struct{}
calls int32
}
func (b *blockingProvider) Key() happydns.ObservationKey { return b.key }
func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(&b.calls, 1)
defer atomic.AddInt32(&b.calls, -1)
select {
case <-b.release:
return map[string]string{string(b.key): "ok"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls
// for distinct observation keys can run their Collect concurrently, i.e.
// the per-context lock is not held across provider.Collect.
func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) {
release := make(chan struct{})
defer close(release)
pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release}
pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(pa.key, pa)
oc.SetProviderOverride(pb.key, pb)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
results := make([]error, 2)
for i, key := range []happydns.ObservationKey{pa.key, pb.key} {
wg.Add(1)
go func(idx int, k happydns.ObservationKey) {
defer wg.Done()
var dst map[string]string
results[idx] = oc.Get(ctx, k, &dst)
}(i, key)
}
// Wait until both providers are blocked inside Collect simultaneously.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 {
break
}
time.Sleep(5 * time.Millisecond)
}
if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 {
t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b)
}
// Release both Collects and wait for the Get calls to return.
release <- struct{}{}
release <- struct{}{}
wg.Wait()
for i, err := range results {
if err != nil {
t.Errorf("Get %d returned error: %v", i, err)
}
}
}
// TestObservationContext_DedupesSameKey verifies that concurrent Get calls
// for the *same* key only invoke provider.Collect once.
func TestObservationContext_DedupesSameKey(t *testing.T) {
release := make(chan struct{})
var collectCount int32
prov := &countingProvider{
key: happydns.ObservationKey("test-dedup"),
release: release,
count: &collectCount,
}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(prov.key, prov)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
const N = 8
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
var dst map[string]string
if err := oc.Get(ctx, prov.key, &dst); err != nil {
t.Errorf("Get error: %v", err)
}
}()
}
// Wait for at least one collect to be in flight, then release it.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 {
time.Sleep(5 * time.Millisecond)
}
close(release)
wg.Wait()
if got := atomic.LoadInt32(&collectCount); got != 1 {
t.Errorf("expected exactly 1 Collect call, got %d", got)
}
}
type countingProvider struct {
key happydns.ObservationKey
release chan struct{}
count *int32
}
func (c *countingProvider) Key() happydns.ObservationKey { return c.key }
func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(c.count, 1)
select {
case <-c.release:
return map[string]string{"k": "v"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}

View file

@ -0,0 +1,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
}

View file

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

View file

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

View file

@ -24,6 +24,8 @@ package config // import "git.happydns.org/happyDomain/config"
import (
"flag"
"fmt"
"runtime"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@ -45,6 +47,10 @@ func declareFlags(o *happydns.Options) {
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
flag.IntVar(&o.CheckerRetentionDays, "checker-retention-days", 365, "System-wide default retention horizon for check execution history (overridable per user)")
flag.DurationVar(&o.CheckerJanitorInterval, "checker-janitor-interval", 6*time.Hour, "How often the checker retention janitor runs")
flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
@ -60,6 +66,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -25,8 +25,27 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
// stringSlice is a flag.Value that accumulates string values across repeated
// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b).
type stringSlice struct {
Values *[]string
}
func (s *stringSlice) String() string {
if s.Values == nil {
return ""
}
return strings.Join(*s.Values, ",")
}
func (s *stringSlice) Set(value string) error {
*s.Values = append(*s.Values, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

View file

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

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

View file

@ -23,6 +23,7 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -40,6 +41,13 @@ type ProviderAndDomainStorage interface {
type Storage interface {
authuser.AuthUserStorage
checker.CheckPlanStorage
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage

View file

@ -0,0 +1,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
}

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

View file

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

View file

@ -0,0 +1,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
}

View file

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

View file

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

View file

@ -0,0 +1,44 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"time"
"git.happydns.org/happyDomain/model"
)
const schedulerLastRunKey = "scheduler-lastrun"
func (s *KVStorage) GetLastSchedulerRun() (time.Time, error) {
var t time.Time
err := s.db.Get(schedulerLastRunKey, &t)
if errors.Is(err, happydns.ErrNotFound) {
return time.Time{}, nil
}
return t, err
}
func (s *KVStorage) SetLastSchedulerRun(t time.Time) error {
return s.db.Put(schedulerLastRunKey, t)
}

View file

@ -22,7 +22,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
}

View file

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

View file

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

View file

@ -0,0 +1,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
}

View file

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

View file

@ -0,0 +1,839 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/storage/inmemory"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
func setupStatusUC(t *testing.T) (*checkerUC.CheckStatusUsecase, *planStore, storage.Storage) {
t.Helper()
checker.RegisterChecker(&happydns.CheckerDefinition{
ID: "status_test_checker",
Name: "Status Test Checker",
Availability: happydns.CheckerAvailability{
ApplyToDomain: true,
},
Rules: []happydns.CheckRule{
&testCheckRule{name: "rule_x", status: happydns.StatusOK},
&testCheckRule{name: "rule_y", status: happydns.StatusWarn},
},
})
ps := newPlanStore()
ms, err := inmemory.Instantiate()
if err != nil {
t.Fatalf("Instantiate() returned error: %v", err)
}
uc := checkerUC.NewCheckStatusUsecase(ps, ms, ms, ms)
return uc, ps, ms
}
func TestCheckStatusUsecase_ListCheckerStatuses(t *testing.T) {
uc, _, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
if len(statuses) == 0 {
t.Fatal("expected at least one checker status")
}
// All should be enabled by default (no plans).
for _, s := range statuses {
if !s.Enabled {
t.Errorf("expected checker %s to be enabled by default", s.ID)
}
}
}
func TestCheckStatusUsecase_ListCheckerStatuses_WithPlan(t *testing.T) {
uc, ps, _ := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create a plan that fully disables the checker.
plan := &happydns.CheckPlan{
CheckerID: "status_test_checker",
Target: target,
Enabled: map[string]bool{"rule_x": false, "rule_y": false},
}
if err := ps.CreateCheckPlan(plan); err != nil {
t.Fatalf("CreateCheckPlan() error: %v", err)
}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
found := false
for _, s := range statuses {
if s.ID == "status_test_checker" {
found = true
if s.Enabled {
t.Error("expected status_test_checker to be disabled when all rules are off")
}
if s.Plan == nil {
t.Error("expected Plan to be set")
}
if s.EnabledRules["rule_x"] {
t.Error("expected rule_x to be disabled")
}
if s.EnabledRules["rule_y"] {
t.Error("expected rule_y to be disabled")
}
}
}
if !found {
t.Error("status_test_checker not found in statuses")
}
}
func TestCheckStatusUsecase_ListCheckerStatuses_WithEvaluation(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String()}
// Create an execution for the checker.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK, Message: "all good"},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
statuses, err := uc.ListCheckerStatuses(target)
if err != nil {
t.Fatalf("ListCheckerStatuses() error: %v", err)
}
for _, s := range statuses {
if s.ID == "status_test_checker" {
if s.LatestExecution == nil {
t.Error("expected LatestExecution to be set")
} else if s.LatestExecution.Result.Status != happydns.StatusOK {
t.Errorf("expected latest execution result status OK, got %s", s.LatestExecution.Result.Status)
}
}
}
}
func TestCheckStatusUsecase_GetExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
exec := &happydns.Execution{
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
got, err := uc.GetExecution(happydns.CheckTarget{}, exec.Id)
if err != nil {
t.Fatalf("GetExecution() error: %v", err)
}
if got.Status != happydns.ExecutionDone {
t.Errorf("expected status Done, got %d", got.Status)
}
}
func TestCheckStatusUsecase_GetExecutionNotFound(t *testing.T) {
uc, _, _ := setupStatusUC(t)
fakeID, _ := happydns.NewRandomIdentifier()
_, err := uc.GetExecution(happydns.CheckTarget{}, fakeID)
if err == nil {
t.Fatal("expected error for nonexistent execution")
}
}
func TestCheckStatusUsecase_GetExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Access with a different user scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
_, err := uc.GetExecution(wrongScope, exec.Id)
if err == nil {
t.Fatal("expected error when scope doesn't match execution target")
}
}
func TestCheckStatusUsecase_DeleteExecution(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
if err := uc.DeleteExecution(target, exec.Id); err != nil {
t.Fatalf("DeleteExecution() error: %v", err)
}
_, err := uc.GetExecution(target, exec.Id)
if err == nil {
t.Fatal("expected error after deletion")
}
}
func TestCheckStatusUsecase_DeleteExecution_ScopeMismatch(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
uid2, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Delete with wrong scope should fail.
wrongScope := happydns.CheckTarget{UserId: uid2.String()}
if err := uc.DeleteExecution(wrongScope, exec.Id); err == nil {
t.Fatal("expected error when scope doesn't match")
}
// Original should still exist.
_, err := uc.GetExecution(target, exec.Id)
if err != nil {
t.Fatalf("execution should still exist after failed delete: %v", err)
}
}
func TestCheckStatusUsecase_DeleteExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 3; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
if err := uc.DeleteExecutionsByChecker("status_test_checker", target); err != nil {
t.Fatalf("DeleteExecutionsByChecker() error: %v", err)
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 0)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) != 0 {
t.Errorf("expected 0 executions after bulk delete, got %d", len(execs))
}
}
func TestCheckStatusUsecase_ListExecutionsByChecker(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: uid.String(), DomainId: "d1"}
for i := 0; i < 5; i++ {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: target,
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
execs, err := uc.ListExecutionsByChecker("status_test_checker", target, 3)
if err != nil {
t.Fatalf("ListExecutionsByChecker() error: %v", err)
}
if len(execs) > 3 {
t.Errorf("expected at most 3 executions with limit, got %d", len(execs))
}
}
func TestCheckStatusUsecase_GetWorstDomainStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did1, _ := happydns.NewRandomIdentifier()
did2, _ := happydns.NewRandomIdentifier()
// Domain 1: one OK and one WARN execution.
for _, status := range []happydns.Status{happydns.StatusOK, happydns.StatusWarn} {
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: status},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
}
// Domain 2: only OK.
exec := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstDomainStatuses(uid)
if err != nil {
t.Fatalf("GetWorstDomainStatuses() error: %v", err)
}
// Domain 1 should have worst status WARN.
if s, ok := worst[did1.String()]; !ok {
t.Error("expected domain 1 in results")
} else if *s != happydns.StatusWarn {
t.Errorf("expected worst status WARN for domain 1, got %v", *s)
}
// Domain 2 should have worst status OK.
if s, ok := worst[did2.String()]; !ok {
t.Error("expected domain 2 in results")
} else if *s != happydns.StatusOK {
t.Errorf("expected worst status OK for domain 2, got %v", *s)
}
}
func TestCheckStatusUsecase_GetWorstServiceStatuses(t *testing.T) {
uc, _, ms := setupStatusUC(t)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
sid1, _ := happydns.NewRandomIdentifier()
sid2, _ := happydns.NewRandomIdentifier()
// Service 1: CRIT execution.
exec1 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid1.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusCrit},
}
if err := ms.CreateExecution(exec1); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
// Service 2: OK execution.
exec2 := &happydns.Execution{
CheckerID: "status_test_checker",
Target: happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid2.String()},
StartedAt: time.Now(),
Status: happydns.ExecutionDone,
Result: happydns.CheckState{Status: happydns.StatusOK},
}
if err := ms.CreateExecution(exec2); err != nil {
t.Fatalf("CreateExecution() error: %v", err)
}
worst, err := uc.GetWorstServiceStatuses(uid, did)
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")
}
}

View file

@ -0,0 +1,241 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// checkerEngine implements the happydns.CheckerEngine interface.
type checkerEngine struct {
optionsUC *CheckerOptionsUsecase
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
func NewCheckerEngine(
optionsUC *CheckerOptionsUsecase,
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
cacheStore: cacheStore,
}
}
// CreateExecution validates the checker and creates a pending Execution record.
func (e *checkerEngine) CreateExecution(checkerID string, target happydns.CheckTarget, plan *happydns.CheckPlan) (*happydns.Execution, error) {
if checkerPkg.FindChecker(checkerID) == nil {
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, checkerID)
}
// Determine trigger info.
trigger := happydns.TriggerInfo{Type: happydns.TriggerManual}
var planID *happydns.Identifier
if plan != nil {
planID = &plan.Id
trigger.PlanID = planID
trigger.Type = happydns.TriggerSchedule
}
// Create execution record.
exec := &happydns.Execution{
CheckerID: checkerID,
PlanID: planID,
Target: target,
Trigger: trigger,
StartedAt: time.Now(),
Status: happydns.ExecutionPending,
}
if err := e.execStore.CreateExecution(exec); err != nil {
return nil, fmt.Errorf("creating execution: %w", err)
}
return exec, nil
}
// RunExecution takes an existing execution and runs the checker pipeline.
func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Execution, plan *happydns.CheckPlan, runOpts happydns.CheckerOptions) (*happydns.CheckEvaluation, error) {
log.Printf("CheckerEngine: running checker %s on %s", exec.CheckerID, exec.Target.String())
def := checkerPkg.FindChecker(exec.CheckerID)
if def == nil {
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
exec.Error = fmt.Sprintf("checker not found: %s", exec.CheckerID)
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return nil, fmt.Errorf("%w: %s", happydns.ErrCheckerNotFound, exec.CheckerID)
}
// Mark as running.
exec.Status = happydns.ExecutionRunning
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
// Run the pipeline and handle failure.
result, eval, err := e.runPipeline(ctx, def, exec.Target, plan, exec.PlanID, runOpts)
if err != nil {
log.Printf("CheckerEngine: checker %s on %s failed: %v", exec.CheckerID, exec.Target.String(), err)
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
exec.Error = err.Error()
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return nil, err
}
// Mark as done.
endTime := time.Now()
exec.Status = happydns.ExecutionDone
exec.EndedAt = &endTime
exec.Result = result
exec.EvaluationID = &eval.Id
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
return eval, nil
}
func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDefinition, target happydns.CheckTarget, plan *happydns.CheckPlan, planID *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckState, *happydns.CheckEvaluation, error) {
// Resolve options (stored + run + auto-fill).
mergedOpts, err := e.optionsUC.BuildMergedCheckerOptionsWithAutoFill(def.ID, happydns.TargetIdentifier(target.UserId), happydns.TargetIdentifier(target.DomainId), happydns.TargetIdentifier(target.ServiceId), runOpts)
if err != nil {
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
}
// Build observation cache lookup for cross-checker reuse.
var cacheLookup checkerPkg.ObservationCacheLookup
if e.cacheStore != nil {
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
entry, err := e.cacheStore.GetCachedObservation(target, key)
if err != nil {
return nil, time.Time{}, err
}
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
if err != nil {
return nil, time.Time{}, err
}
raw, ok := snap.Data[key]
if !ok {
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
}
return raw, entry.CollectedAt, nil
}
}
var freshness time.Duration
if plan != nil && plan.Interval != nil {
freshness = *plan.Interval
} else if plan != nil && def.Interval != nil {
freshness = def.Interval.Default
}
// Create observation context for lazy data collection.
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
// If an endpoint is configured, override observation providers with HTTP transport.
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
for _, key := range def.ObservationKeys {
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
}
}
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
for _, rule := range def.Rules {
if plan != nil && !plan.IsRuleEnabled(rule.Name()) {
continue
}
state := rule.Evaluate(ctx, obsCtx, mergedOpts)
if state.Code == "" {
state.Code = rule.Name()
}
states = append(states, state)
}
// Aggregate results.
aggregator := def.Aggregator
if aggregator == nil {
aggregator = checkerPkg.WorstStatusAggregator{}
}
result := aggregator.Aggregate(states)
// Persist observation snapshot.
snap := &happydns.ObservationSnapshot{
Target: target,
CollectedAt: time.Now(),
Data: obsCtx.Data(),
}
if err := e.snapStore.CreateSnapshot(snap); err != nil {
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
}
// Update observation cache pointers for cross-checker reuse.
if e.cacheStore != nil {
for key := range snap.Data {
if err := e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
}); err != nil {
log.Printf("warning: failed to cache observation %q for target %s: %v", key, target.String(), err)
}
}
}
// Persist evaluation.
eval := &happydns.CheckEvaluation{
PlanID: planID,
CheckerID: def.ID,
Target: target,
SnapshotID: snap.Id,
EvaluatedAt: time.Now(),
States: states,
}
if err := e.evalStore.CreateEvaluation(eval); err != nil {
return happydns.CheckState{}, nil, fmt.Errorf("creating evaluation: %w", err)
}
return result, eval, nil
}

View file

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

View file

@ -0,0 +1,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
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,241 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"log"
"sync"
"time"
"git.happydns.org/happyDomain/model"
)
// JanitorUserResolver resolves a user from a CheckTarget so the janitor can
// honour per-user retention overrides stored in UserQuota.
type JanitorUserResolver interface {
GetUser(id happydns.Identifier) (*happydns.User, error)
}
// Janitor periodically prunes old check executions and evaluations according
// to the tiered RetentionPolicy. It is the long-tail enforcement counterpart
// of the cheap hard cap applied at execution-creation time.
type Janitor struct {
planStore CheckPlanStorage
execStore ExecutionStorage
evalStore CheckEvaluationStorage
snapStore ObservationSnapshotStorage
userResolver JanitorUserResolver
defaultPolicy RetentionPolicy
interval time.Duration
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
running bool
}
// NewJanitor builds a Janitor that runs every `interval`. The defaultPolicy
// is applied to executions of users that did not customize their retention
// horizon via UserQuota. evalStore and snapStore may be nil if evaluation
// pruning is not desired.
func NewJanitor(planStore CheckPlanStorage, execStore ExecutionStorage, evalStore CheckEvaluationStorage, snapStore ObservationSnapshotStorage, userResolver JanitorUserResolver, defaultPolicy RetentionPolicy, interval time.Duration) *Janitor {
if interval <= 0 {
interval = 6 * time.Hour
}
return &Janitor{
planStore: planStore,
execStore: execStore,
evalStore: evalStore,
snapStore: snapStore,
userResolver: userResolver,
defaultPolicy: defaultPolicy,
interval: interval,
}
}
// Start launches the janitor loop in a goroutine. It runs an immediate sweep
// once the loop is up.
func (j *Janitor) Start(ctx context.Context) {
j.mu.Lock()
if j.running {
j.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(ctx)
j.cancel = cancel
j.done = make(chan struct{})
j.running = true
j.mu.Unlock()
go j.loop(ctx)
}
// Stop halts the janitor and waits for the current sweep to finish.
func (j *Janitor) Stop() {
j.mu.Lock()
cancel := j.cancel
done := j.done
j.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
j.mu.Lock()
j.running = false
j.mu.Unlock()
}
func (j *Janitor) loop(ctx context.Context) {
defer close(j.done)
// Run immediately, then on the configured interval.
j.RunOnce(ctx)
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
j.RunOnce(ctx)
}
}
}
// RunOnce performs a single sweep over all check plans, applying the per-user
// retention policy to both executions and evaluations. Returns the total
// number of records deleted (executions + evaluations).
func (j *Janitor) RunOnce(ctx context.Context) int {
iter, err := j.planStore.ListAllCheckPlans()
if err != nil {
log.Printf("Janitor: failed to list check plans: %v", err)
return 0
}
defer iter.Close()
now := time.Now()
deleted := 0
// Cache user policies to avoid resolving the same user repeatedly.
policyByUser := map[string]RetentionPolicy{}
for iter.Next() {
select {
case <-ctx.Done():
return deleted
default:
}
plan := iter.Item()
if plan == nil {
continue
}
policy := j.policyForTarget(plan.Target, policyByUser)
hardCutoff := now.AddDate(0, 0, -policy.RetentionDays)
// Prune executions using the tiered retention policy.
execs, err := j.execStore.ListExecutionsByPlan(plan.Id)
if err != nil {
log.Printf("Janitor: failed to list executions for plan %s: %v", plan.Id.String(), err)
} else if len(execs) > 0 {
// All executions share the same (CheckerID, Target) since they come
// from a single plan, so Decide's internal grouping is a no-op here.
_, drop := policy.Decide(execs, now)
for _, id := range drop {
if err := j.execStore.DeleteExecution(id); err != nil {
log.Printf("Janitor: failed to delete execution %s: %v", id.String(), err)
continue
}
deleted++
}
}
// Prune evaluations older than the hard cutoff.
if j.evalStore != nil {
deleted += j.pruneEvaluations(plan.Id, hardCutoff)
}
}
if err := iter.Err(); err != nil {
log.Printf("Janitor: iterator error while walking check plans: %v", err)
}
if deleted > 0 {
log.Printf("Janitor: pruned %d records", deleted)
}
return deleted
}
// pruneEvaluations deletes evaluations for the given plan that are older than
// the cutoff, along with their associated snapshots.
func (j *Janitor) pruneEvaluations(planID happydns.Identifier, cutoff time.Time) int {
evals, err := j.evalStore.ListEvaluationsByPlan(planID)
if err != nil {
log.Printf("Janitor: failed to list evaluations for plan %s: %v", planID.String(), err)
return 0
}
deleted := 0
for _, eval := range evals {
if eval.EvaluatedAt.Before(cutoff) {
// Delete the associated snapshot first.
if j.snapStore != nil && !eval.SnapshotID.IsEmpty() {
if err := j.snapStore.DeleteSnapshot(eval.SnapshotID); err != nil {
log.Printf("Janitor: failed to delete snapshot %s: %v", eval.SnapshotID.String(), err)
}
}
if err := j.evalStore.DeleteEvaluation(eval.Id); err != nil {
log.Printf("Janitor: failed to delete evaluation %s: %v", eval.Id.String(), err)
continue
}
deleted++
}
}
return deleted
}
func (j *Janitor) policyForTarget(target happydns.CheckTarget, cache map[string]RetentionPolicy) RetentionPolicy {
uid := target.UserId
if uid == "" || j.userResolver == nil {
return j.defaultPolicy
}
if p, ok := cache[uid]; ok {
return p
}
policy := j.defaultPolicy
id, err := happydns.NewIdentifierFromString(uid)
if err == nil {
if user, err := j.userResolver.GetUser(id); err == nil && user != nil {
if user.Quota.RetentionDays > 0 {
policy = DefaultRetentionPolicy(user.Quota.RetentionDays)
}
}
}
cache[uid] = policy
return policy
}

View file

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

View file

@ -0,0 +1,196 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
// RetentionPolicy describes how check executions are thinned out as they age.
//
// The policy is intentionally tiered: users care about full detail for recent
// runs, but only need sparse historical samples to spot long-term trends.
//
// Default behaviour, given a RetentionDays of D:
//
// age window | kept
// ------------------------- | ------------------------------------------
// 0 .. 1 day | every execution
// 1 .. 7 days | up to 1 execution per hour per (checker,target)
// 7 .. 30 days | up to 2 executions per day per (checker,target)
// 30 .. D/2 days | up to 1 execution per week per (checker,target)
// D/2 .. D days | up to 1 execution per month per (checker,target)
// > D days | dropped
//
// All thresholds and bucket counts are configurable so the policy can be
// tuned per-user via the admin UserQuota.
type RetentionPolicy struct {
// RetentionDays is the hard cap on age. Executions older than this are
// always dropped. Must be > 0.
RetentionDays int
// FullDetailDays: every execution kept under this age.
FullDetailDays int
// HourlyBucketDays: between FullDetailDays and HourlyBucketDays, keep
// PerHourKept executions per UTC hour per (checker,target).
HourlyBucketDays int
PerHourKept int
// DailyBucketDays: between HourlyBucketDays and DailyBucketDays, keep
// PerDayKept executions per UTC day per (checker,target).
DailyBucketDays int
PerDayKept int
// WeeklyBucketDays: between DailyBucketDays and WeeklyBucketDays, keep
// PerWeekKept executions per ISO week per (checker,target).
WeeklyBucketDays int
PerWeekKept int
// Beyond WeeklyBucketDays and up to RetentionDays, keep PerMonthKept
// executions per calendar month per (checker,target).
PerMonthKept int
}
// DefaultRetentionPolicy returns the standard tiered policy for the given
// retention horizon.
func DefaultRetentionPolicy(retentionDays int) RetentionPolicy {
if retentionDays <= 0 {
retentionDays = 365
}
return RetentionPolicy{
RetentionDays: retentionDays,
FullDetailDays: min(1, retentionDays),
HourlyBucketDays: min(7, retentionDays),
PerHourKept: 1,
DailyBucketDays: min(30, retentionDays),
PerDayKept: 2,
WeeklyBucketDays: min(max(retentionDays/2, 31), retentionDays),
PerWeekKept: 1,
PerMonthKept: 1,
}
}
// Decide partitions executions into the ones to keep and the ones to drop
// according to the policy. The function is pure: it does not touch storage.
//
// Executions are grouped by (CheckerID, Target) and ordered most-recent-first
// inside each group, so the newest execution in a bucket is the one preserved.
func (p RetentionPolicy) Decide(executions []*happydns.Execution, now time.Time) (keep, drop []happydns.Identifier) {
if len(executions) == 0 {
return nil, nil
}
// Clamp bucket counts: a zero or negative value would silently drop
// every execution in that tier, which is almost certainly a
// misconfiguration rather than intent.
if p.PerHourKept < 1 {
p.PerHourKept = 1
}
if p.PerDayKept < 1 {
p.PerDayKept = 1
}
if p.PerWeekKept < 1 {
p.PerWeekKept = 1
}
if p.PerMonthKept < 1 {
p.PerMonthKept = 1
}
// Group by (checker, target).
groups := map[string][]*happydns.Execution{}
for _, e := range executions {
if e == nil {
continue
}
key := e.CheckerID + "|" + e.Target.String()
groups[key] = append(groups[key], e)
}
hardCutoff := now.AddDate(0, 0, -p.RetentionDays)
fullCutoff := now.AddDate(0, 0, -p.FullDetailDays)
hourlyCutoff := now.AddDate(0, 0, -p.HourlyBucketDays)
dailyCutoff := now.AddDate(0, 0, -p.DailyBucketDays)
weeklyCutoff := now.AddDate(0, 0, -p.WeeklyBucketDays)
for _, group := range groups {
// Most recent first.
sort.Slice(group, func(i, j int) bool {
return group[i].StartedAt.After(group[j].StartedAt)
})
hourBuckets := map[string]int{}
dayBuckets := map[string]int{}
weekBuckets := map[string]int{}
monthBuckets := map[string]int{}
for _, e := range group {
t := e.StartedAt
switch {
case t.Before(hardCutoff):
drop = append(drop, e.Id)
case !t.Before(fullCutoff):
// 0 .. FullDetailDays: keep everything.
keep = append(keep, e.Id)
case !t.Before(hourlyCutoff):
k := t.UTC().Format("2006-01-02T15")
if hourBuckets[k] < p.PerHourKept {
hourBuckets[k]++
keep = append(keep, e.Id)
} else {
drop = append(drop, e.Id)
}
case !t.Before(dailyCutoff):
k := t.UTC().Format("2006-01-02")
if dayBuckets[k] < p.PerDayKept {
dayBuckets[k]++
keep = append(keep, e.Id)
} else {
drop = append(drop, e.Id)
}
case !t.Before(weeklyCutoff):
y, w := t.UTC().ISOWeek()
k := isoWeekKey(y, w)
if weekBuckets[k] < p.PerWeekKept {
weekBuckets[k]++
keep = append(keep, e.Id)
} else {
drop = append(drop, e.Id)
}
default:
k := t.UTC().Format("2006-01")
if monthBuckets[k] < p.PerMonthKept {
monthBuckets[k]++
keep = append(keep, e.Id)
} else {
drop = append(drop, e.Id)
}
}
}
}
return keep, drop
}
func isoWeekKey(year, week int) string {
return fmt.Sprintf("%d-W%02d", year, week)
}

View file

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

View file

@ -0,0 +1,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)
}

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

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

View file

@ -0,0 +1,119 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"sync"
"time"
"git.happydns.org/happyDomain/model"
)
// UserGater builds a Scheduler gate function that filters out check jobs
// belonging to users that are paused or have been inactive for too long.
//
// Lookups are cached for a short TTL so the scheduler hot path does not hit
// storage on every job pop.
type UserGater struct {
resolver JanitorUserResolver
defaultInactivityDays int
cacheTTL time.Duration
mu sync.Mutex
cache map[string]gateCacheEntry
}
type gateCacheEntry struct {
allow bool
expires time.Time
}
// NewUserGater creates a UserGater. defaultInactivityDays is used for users
// whose UserQuota.InactivityPauseDays is zero. A negative effective value
// disables inactivity-based pausing for that user.
func NewUserGater(resolver JanitorUserResolver, defaultInactivityDays int) *UserGater {
return &UserGater{
resolver: resolver,
defaultInactivityDays: defaultInactivityDays,
cacheTTL: 5 * time.Minute,
cache: map[string]gateCacheEntry{},
}
}
// Allow returns true if the scheduler should run jobs for the given target.
func (g *UserGater) Allow(target happydns.CheckTarget) bool {
uid := target.UserId
if uid == "" || g.resolver == nil {
return true
}
g.mu.Lock()
if e, ok := g.cache[uid]; ok && time.Now().Before(e.expires) {
g.mu.Unlock()
return e.allow
}
g.mu.Unlock()
allow := g.compute(uid)
g.mu.Lock()
g.cache[uid] = gateCacheEntry{allow: allow, expires: time.Now().Add(g.cacheTTL)}
g.mu.Unlock()
return allow
}
// Invalidate drops any cached decision for the given user. Call this when a
// user's quota or LastSeen changes (e.g. on login or admin update).
func (g *UserGater) Invalidate(userID string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.cache, userID)
}
func (g *UserGater) compute(uid string) bool {
id, err := happydns.NewIdentifierFromString(uid)
if err != nil {
return true
}
user, err := g.resolver.GetUser(id)
if err != nil || user == nil {
// Be conservative: allow rather than silently dropping work.
return true
}
if user.Quota.SchedulingPaused {
return false
}
days := user.Quota.InactivityPauseDays
if days == 0 {
days = g.defaultInactivityDays
}
if days <= 0 {
return true
}
if user.LastSeen.IsZero() {
return true
}
cutoff := time.Now().AddDate(0, 0, -days)
return user.LastSeen.After(cutoff)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,21 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
}
func (tu *tidyUpUsecase) TidyAll() error {
for _, tidy := range []func() error{tu.TidySessions, tu.TidyAuthUsers, tu.TidyUsers, tu.TidyProviders, tu.TidyDomains, tu.TidyZones, tu.TidyDomainLogs} {
for _, tidy := range []func() error{
tu.TidySessions,
tu.TidyAuthUsers,
tu.TidyUsers,
tu.TidyProviders,
tu.TidyDomains,
tu.TidyZones,
tu.TidyDomainLogs,
tu.TidyCheckPlans,
tu.TidyCheckerConfigurations,
tu.TidyExecutions,
tu.TidyCheckEvaluations,
tu.TidySnapshots,
tu.TidyObservationCache,
} {
if err := tidy(); err != nil {
return err
}
@ -72,6 +86,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
}

View file

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

View file

@ -35,6 +35,7 @@ type Service struct {
newsletter happydns.NewsletterSubscriptor
authUser happydns.AuthUserUsecase
closeUserSessions happydns.SessionCloserUsecase
onUserChanged func(happydns.Identifier)
}
func NewUserUsecases(
@ -51,6 +52,13 @@ func NewUserUsecases(
}
}
// SetOnUserChanged installs a callback invoked after any successful user
// update (via UpdateUser). This is used to invalidate caches that depend on
// user state, such as the scheduler's UserGater.
func (s *Service) SetOnUserChanged(fn func(happydns.Identifier)) {
s.onUserChanged = fn
}
// CreateUser creates a new user with the given information.
func (s *Service) CreateUser(uinfo happydns.UserInfo) (*happydns.User, error) {
if uinfo.GetEmail() == "" {
@ -89,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.

View file

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

251
model/checker.go Normal file
View file

@ -0,0 +1,251 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
import (
"context"
"encoding/json"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// The types and helpers needed by external checker plugins live in the
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
// aliases so the rest of the happyDomain codebase keeps relying on this model.
//
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
// defined in this file because they describe orchestration state that is
// internal to the happyDomain server and never crosses the plugin boundary.
// --- Re-exports from checker-sdk-go ---
type CheckScopeType = sdk.CheckScopeType
const (
CheckScopeAdmin = sdk.CheckScopeAdmin
CheckScopeUser = sdk.CheckScopeUser
CheckScopeDomain = sdk.CheckScopeDomain
CheckScopeZone = sdk.CheckScopeZone
CheckScopeService = sdk.CheckScopeService
)
const (
AutoFillDomainName = sdk.AutoFillDomainName
AutoFillSubdomain = sdk.AutoFillSubdomain
AutoFillZone = sdk.AutoFillZone
AutoFillServiceType = sdk.AutoFillServiceType
AutoFillService = sdk.AutoFillService
)
type (
CheckTarget = sdk.CheckTarget
CheckerAvailability = sdk.CheckerAvailability
CheckerOptions = sdk.CheckerOptions
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
Status = sdk.Status
CheckState = sdk.CheckState
CheckMetric = sdk.CheckMetric
ObservationKey = sdk.ObservationKey
CheckIntervalSpec = sdk.CheckIntervalSpec
ObservationProvider = sdk.ObservationProvider
CheckRuleInfo = sdk.CheckRuleInfo
CheckRule = sdk.CheckRule
CheckRuleWithOptions = sdk.CheckRuleWithOptions
ObservationGetter = sdk.ObservationGetter
CheckAggregator = sdk.CheckAggregator
CheckerHTMLReporter = sdk.CheckerHTMLReporter
CheckerMetricsReporter = sdk.CheckerMetricsReporter
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
CheckerDefinition = sdk.CheckerDefinition
OptionsValidator = sdk.OptionsValidator
ExternalCollectRequest = sdk.ExternalCollectRequest
ExternalCollectResponse = sdk.ExternalCollectResponse
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
ExternalReportRequest = sdk.ExternalReportRequest
)
const (
StatusOK = sdk.StatusOK
StatusInfo = sdk.StatusInfo
StatusUnknown = sdk.StatusUnknown
StatusWarn = sdk.StatusWarn
StatusCrit = sdk.StatusCrit
StatusError = sdk.StatusError
)
// --- Helpers for converting between target identifier strings and *Identifier ---
// TargetIdentifier parses a target identifier string into an *Identifier.
// Returns nil if the string is empty or cannot be parsed.
func TargetIdentifier(s string) *Identifier {
if s == "" {
return nil
}
id, err := NewIdentifierFromString(s)
if err != nil {
return nil
}
return &id
}
// FormatIdentifier returns the string representation of id, or "" if nil.
func FormatIdentifier(id *Identifier) string {
if id == nil {
return ""
}
return id.String()
}
// --- Host-only types (orchestration state) ---
// CheckerRunRequest is the JSON body for manually triggering a checker.
type CheckerRunRequest struct {
Options CheckerOptions `json:"options,omitempty"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// CheckerOptionsPositional stores options with their positional key components.
type CheckerOptionsPositional struct {
CheckName string `json:"checkName"`
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
Options CheckerOptions `json:"options"`
}
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
}
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
func (p *CheckPlan) IsFullyDisabled() bool {
if len(p.Enabled) == 0 {
return false
}
for _, v := range p.Enabled {
if v {
return false
}
}
return true
}
// IsRuleEnabled returns whether a specific rule is enabled.
// A nil or empty map means all rules are enabled. A missing key means enabled.
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
if len(p.Enabled) == 0 {
return true
}
v, ok := p.Enabled[ruleName]
if !ok {
return true
}
return v
}
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
type CheckerStatus struct {
*CheckerDefinition
LatestExecution *Execution `json:"latestExecution,omitempty"`
Plan *CheckPlan `json:"plan,omitempty"`
Enabled bool `json:"enabled"`
EnabledRules map[string]bool `json:"enabledRules"`
}
// CheckEvaluation is the result of running a checker on observed data.
type CheckEvaluation struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
CheckerID string `json:"checkerId" binding:"required"`
Target CheckTarget `json:"target" binding:"required"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
States []CheckState `json:"states" binding:"required" readonly:"true"`
}
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
type ExecutionStatus int
const (
ExecutionPending ExecutionStatus = iota
ExecutionRunning
ExecutionDone
ExecutionFailed
)
// TriggerType represents what initiated an execution.
type TriggerType int
const (
TriggerManual TriggerType = iota
TriggerSchedule
)
// TriggerInfo describes the trigger for an execution.
type TriggerInfo struct {
Type TriggerType `json:"type"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
}
// Execution represents a single run of a checker pipeline.
type Execution struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
Error string `json:"error,omitempty" readonly:"true"`
Result CheckState `json:"result" readonly:"true"`
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
}
// CheckerEngine orchestrates the full checker pipeline.
type CheckerEngine interface {
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
}

163
model/checker_test.go Normal file
View file

@ -0,0 +1,163 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns_test
import (
"testing"
happydns "git.happydns.org/happyDomain/model"
)
func TestCheckPlan_IsFullyDisabled(t *testing.T) {
tests := []struct {
name string
enabled map[string]bool
want bool
}{
{"nil map", nil, false},
{"empty map", map[string]bool{}, false},
{"all false", map[string]bool{"a": false, "b": false}, true},
{"one true", map[string]bool{"a": false, "b": true}, false},
{"all true", map[string]bool{"a": true, "b": true}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &happydns.CheckPlan{Enabled: tt.enabled}
if got := p.IsFullyDisabled(); got != tt.want {
t.Errorf("IsFullyDisabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckPlan_IsRuleEnabled(t *testing.T) {
tests := []struct {
name string
enabled map[string]bool
rule string
want bool
}{
{"nil map", nil, "any", true},
{"empty map", map[string]bool{}, "any", true},
{"rule explicitly enabled", map[string]bool{"r1": true}, "r1", true},
{"rule explicitly disabled", map[string]bool{"r1": false}, "r1", false},
{"rule missing from map", map[string]bool{"r1": false}, "r2", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := &happydns.CheckPlan{Enabled: tt.enabled}
if got := p.IsRuleEnabled(tt.rule); got != tt.want {
t.Errorf("IsRuleEnabled(%q) = %v, want %v", tt.rule, got, tt.want)
}
})
}
}
func TestTargetIdentifier(t *testing.T) {
if got := happydns.TargetIdentifier(""); got != nil {
t.Errorf("TargetIdentifier(\"\") = %v, want nil", got)
}
if got := happydns.TargetIdentifier("not-valid-hex"); got != nil {
t.Errorf("TargetIdentifier(\"not-valid-hex\") = %v, want nil", got)
}
id, err := happydns.NewRandomIdentifier()
if err != nil {
t.Fatalf("NewRandomIdentifier: %v", err)
}
s := id.String()
got := happydns.TargetIdentifier(s)
if got == nil {
t.Fatalf("TargetIdentifier(%q) = nil, want non-nil", s)
}
if !got.Equals(id) {
t.Errorf("TargetIdentifier(%q) = %v, want %v", s, got, id)
}
}
func TestFormatIdentifier(t *testing.T) {
if got := happydns.FormatIdentifier(nil); got != "" {
t.Errorf("FormatIdentifier(nil) = %q, want empty", got)
}
id, err := happydns.NewRandomIdentifier()
if err != nil {
t.Fatalf("NewRandomIdentifier: %v", err)
}
got := happydns.FormatIdentifier(&id)
if got != id.String() {
t.Errorf("FormatIdentifier(&id) = %q, want %q", got, id.String())
}
}
func TestFieldFromCheckerOption(t *testing.T) {
opt := happydns.CheckerOptionDocumentation{
Id: "myopt",
Type: "string",
Label: "My Option",
Placeholder: "enter value",
Default: "default-val",
Choices: []string{"a", "b"},
Required: true,
Secret: true,
Hide: true,
Textarea: true,
Description: "help text",
}
f := happydns.FieldFromCheckerOption(opt)
if f.Id != opt.Id {
t.Errorf("Id = %q, want %q", f.Id, opt.Id)
}
if f.Type != opt.Type {
t.Errorf("Type = %q, want %q", f.Type, opt.Type)
}
if f.Label != opt.Label {
t.Errorf("Label = %q, want %q", f.Label, opt.Label)
}
if f.Placeholder != opt.Placeholder {
t.Errorf("Placeholder = %q, want %q", f.Placeholder, opt.Placeholder)
}
if f.Default != opt.Default {
t.Errorf("Default = %v, want %v", f.Default, opt.Default)
}
if len(f.Choices) != len(opt.Choices) {
t.Errorf("Choices len = %d, want %d", len(f.Choices), len(opt.Choices))
}
if f.Required != opt.Required {
t.Errorf("Required = %v, want %v", f.Required, opt.Required)
}
if f.Secret != opt.Secret {
t.Errorf("Secret = %v, want %v", f.Secret, opt.Secret)
}
if f.Hide != opt.Hide {
t.Errorf("Hide = %v, want %v", f.Hide, opt.Hide)
}
if f.Textarea != opt.Textarea {
t.Errorf("Textarea = %v, want %v", f.Textarea, opt.Textarea)
}
if f.Description != opt.Description {
t.Errorf("Description = %q, want %q", f.Description, opt.Description)
}
}

View file

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

View file

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

View file

@ -27,15 +27,20 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("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."

View file

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

View file

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

View file

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

View file

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

51
model/user_quota.go Normal file
View file

@ -0,0 +1,51 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
import "time"
// UserQuota holds admin-controlled per-user limits and flags. These fields are
// never modifiable by the user; they can only be updated through the admin API.
//
// Only checker-related fields are defined for now. Future paid-plan attributes
// (plan tier, domain caps, payment metadata, ...) will be added here later.
type UserQuota struct {
// MaxChecksPerDay caps the number of checker executions per day for this
// user. 0 means "use the system default".
MaxChecksPerDay int `json:"max_checks_per_day,omitempty"`
// RetentionDays is the maximum age (in days) of checker executions kept in
// storage for this user. 0 means "use the system default".
RetentionDays int `json:"retention_days,omitempty"`
// InactivityPauseDays is the number of days without login after which the
// scheduler stops running checks for this user. 0 means "use the system
// default". A negative value disables the inactivity pause for this user.
InactivityPauseDays int `json:"inactivity_pause_days,omitempty"`
// SchedulingPaused, when true, completely disables the scheduler for this
// user (admin kill switch).
SchedulingPaused bool `json:"scheduling_paused,omitempty"`
// UpdatedAt records the last time these quotas were modified.
UpdatedAt time.Time `json:"updated_at,omitzero" format:"date-time"`
}

193
model/user_quota_test.go Normal file
View file

@ -0,0 +1,193 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns_test
import (
"encoding/json"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
func TestUserQuotaZeroValues(t *testing.T) {
q := happydns.UserQuota{}
if q.MaxChecksPerDay != 0 {
t.Errorf("zero UserQuota should have MaxChecksPerDay 0, got %d", q.MaxChecksPerDay)
}
if q.RetentionDays != 0 {
t.Errorf("zero UserQuota should have RetentionDays 0, got %d", q.RetentionDays)
}
if q.InactivityPauseDays != 0 {
t.Errorf("zero UserQuota should have InactivityPauseDays 0, got %d", q.InactivityPauseDays)
}
if q.SchedulingPaused {
t.Error("zero UserQuota should have SchedulingPaused false")
}
if !q.UpdatedAt.IsZero() {
t.Error("zero UserQuota should have zero UpdatedAt")
}
}
func TestUserQuotaJSON_RoundTrip(t *testing.T) {
now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC)
original := happydns.UserQuota{
MaxChecksPerDay: 100,
RetentionDays: 30,
InactivityPauseDays: 14,
SchedulingPaused: true,
UpdatedAt: now,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("failed to marshal UserQuota: %v", err)
}
var decoded happydns.UserQuota
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal UserQuota: %v", err)
}
if decoded.MaxChecksPerDay != original.MaxChecksPerDay {
t.Errorf("MaxChecksPerDay = %d; want %d", decoded.MaxChecksPerDay, original.MaxChecksPerDay)
}
if decoded.RetentionDays != original.RetentionDays {
t.Errorf("RetentionDays = %d; want %d", decoded.RetentionDays, original.RetentionDays)
}
if decoded.InactivityPauseDays != original.InactivityPauseDays {
t.Errorf("InactivityPauseDays = %d; want %d", decoded.InactivityPauseDays, original.InactivityPauseDays)
}
if decoded.SchedulingPaused != original.SchedulingPaused {
t.Errorf("SchedulingPaused = %v; want %v", decoded.SchedulingPaused, original.SchedulingPaused)
}
if !decoded.UpdatedAt.Equal(original.UpdatedAt) {
t.Errorf("UpdatedAt = %v; want %v", decoded.UpdatedAt, original.UpdatedAt)
}
}
func TestUserQuotaJSON_OmitEmpty(t *testing.T) {
q := happydns.UserQuota{}
data, err := json.Marshal(q)
if err != nil {
t.Fatalf("failed to marshal zero UserQuota: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("failed to unmarshal to map: %v", err)
}
for _, field := range []string{"max_checks_per_day", "retention_days", "inactivity_pause_days", "scheduling_paused"} {
if _, ok := m[field]; ok {
t.Errorf("zero-value field %q should be omitted from JSON, but was present", field)
}
}
}
func TestUserQuotaJSON_PartialDecode(t *testing.T) {
raw := `{"retention_days": 7, "scheduling_paused": true}`
var q happydns.UserQuota
if err := json.Unmarshal([]byte(raw), &q); err != nil {
t.Fatalf("failed to unmarshal partial JSON: %v", err)
}
if q.RetentionDays != 7 {
t.Errorf("RetentionDays = %d; want 7", q.RetentionDays)
}
if !q.SchedulingPaused {
t.Error("SchedulingPaused should be true")
}
if q.MaxChecksPerDay != 0 {
t.Errorf("MaxChecksPerDay should default to 0, got %d", q.MaxChecksPerDay)
}
if q.InactivityPauseDays != 0 {
t.Errorf("InactivityPauseDays should default to 0, got %d", q.InactivityPauseDays)
}
}
func TestUserQuotaJSON_NegativeInactivityPauseDays(t *testing.T) {
q := happydns.UserQuota{InactivityPauseDays: -1}
data, err := json.Marshal(q)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded happydns.UserQuota
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.InactivityPauseDays != -1 {
t.Errorf("InactivityPauseDays = %d; want -1", decoded.InactivityPauseDays)
}
}
func TestUserWithQuotaJSON_RoundTrip(t *testing.T) {
user := happydns.User{
Id: happydns.Identifier{0x01, 0x02},
Email: "test@example.com",
Quota: happydns.UserQuota{
MaxChecksPerDay: 50,
RetentionDays: 90,
SchedulingPaused: false,
},
}
data, err := json.Marshal(user)
if err != nil {
t.Fatalf("failed to marshal User with Quota: %v", err)
}
var decoded happydns.User
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal User with Quota: %v", err)
}
if decoded.Quota.MaxChecksPerDay != 50 {
t.Errorf("Quota.MaxChecksPerDay = %d; want 50", decoded.Quota.MaxChecksPerDay)
}
if decoded.Quota.RetentionDays != 90 {
t.Errorf("Quota.RetentionDays = %d; want 90", decoded.Quota.RetentionDays)
}
}
func TestUserWithEmptyQuotaJSON(t *testing.T) {
raw := `{"id":"AQID","email":"test@example.com","created_at":"0001-01-01T00:00:00Z","last_seen":"0001-01-01T00:00:00Z","settings":{}}`
var user happydns.User
if err := json.Unmarshal([]byte(raw), &user); err != nil {
t.Fatalf("failed to unmarshal User without quota field: %v", err)
}
if user.Quota.MaxChecksPerDay != 0 {
t.Errorf("missing quota should default MaxChecksPerDay to 0, got %d", user.Quota.MaxChecksPerDay)
}
if user.Quota.SchedulingPaused {
t.Error("missing quota should default SchedulingPaused to false")
}
}

View file

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

View file

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

View file

@ -0,0 +1,131 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getCheckers } from "$lib/api-base";
import { availabilityBadges } from "$lib/utils";
let checkersQ = $state(getCheckers());
let searchQuery = $state("");
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Checkers
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead"> Manage all checkers </span>
{#await checkersQ then checkersR}
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
</InputGroup>
</Col>
</Row>
{#await checkersQ}
Please wait...
{:then checkersR}
{@const checkers = checkersR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !checkers || Object.keys(checkers).length == 0}
<tr>
<td colspan="3" class="text-center text-muted py-2">
No checkers available
</td>
</tr>
{:else}
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerId, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerId}</strong></td>
<td>
{#if availabilityBadges(checkerInfo.availability).length > 0}
{#each availabilityBadges(checkerInfo.availability) as badge}
<Badge color={badge.color} class="me-1">{badge.label}</Badge>
{/each}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerId}"
class="btn btn-sm btn-primary"
>
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checkers: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,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>

View file

@ -0,0 +1,262 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import {
getScheduler,
postSchedulerEnable,
postSchedulerDisable,
postSchedulerRescheduleUpcoming,
} from "$lib/api-admin";
import type { CheckerSchedulerStatus } from "$lib/api-admin";
import { formatDuration, formatRelative } from "$lib/utils/datetime";
let status = $state<CheckerSchedulerStatus | null>(null);
let loading = $state(true);
let toggling = $state(false);
let rescheduling = $state(false);
let error = $state<string | null>(null);
async function fetchStatus() {
loading = true;
error = null;
try {
const { data, error: err } = await getScheduler();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
loading = false;
}
}
async function toggleScheduler() {
if (!status) return;
toggling = true;
error = null;
try {
const fn = status.running ? postSchedulerDisable : postSchedulerEnable;
const { data, error: err } = await fn();
if (err) throw new Error(String(err));
status = data ?? null;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
toggling = false;
}
}
async function rebuildQueue() {
rescheduling = true;
error = null;
try {
const { error: err } = await postSchedulerRescheduleUpcoming();
if (err) throw new Error(String(err));
await fetchStatus();
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
rescheduling = false;
}
}
onMount(fetchStatus);
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="clock-history"></Icon>
Scheduler
</h1>
<p class="text-muted lead">Monitor and control the checker scheduler</p>
</Col>
</Row>
{#if error}
<Card color="danger" body class="mb-4">
<Icon name="exclamation-triangle-fill"></Icon>
{error}
</Card>
{/if}
{#if loading}
<div class="d-flex align-items-center gap-2">
<Spinner size="sm" />
<span>Loading scheduler status...</span>
</div>
{:else if status}
<Card class="mb-4">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<span>
<Icon name="info-circle-fill"></Icon>
Scheduler Status
</span>
<div class="d-flex gap-2">
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
<Icon name="arrow-clockwise"></Icon> Refresh
</Button>
<Button
size="sm"
color={status.running ? "warning" : "success"}
disabled={toggling}
onclick={toggleScheduler}
>
{#if toggling}
<Spinner size="sm" />
{:else if status.running}
<Icon name="stop-fill"></Icon> Stop
{:else}
<Icon name="play-fill"></Icon> Start
{/if}
</Button>
<Button
size="sm"
color="primary"
outline
disabled={rescheduling}
onclick={rebuildQueue}
>
{#if rescheduling}
<Spinner size="sm" />
{:else}
<Icon name="calendar2-check"></Icon> Rebuild queue
{/if}
</Button>
</div>
</div>
</CardHeader>
<CardBody>
<div class="d-flex gap-4 align-items-center">
<div>
<small class="text-muted d-block">Status</small>
{#if status.running}
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
{:else}
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
{/if}
</div>
<div>
<small class="text-muted d-block">Jobs in queue</small>
<strong>{status.job_count ?? 0}</strong>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<Icon name="list-ol"></Icon>
Next scheduled jobs
<Badge color="secondary" class="ms-2">{status.next_jobs?.length ?? 0}</Badge>
</CardHeader>
<CardBody class="p-0">
<div class="table-responsive">
<Table hover class="mb-0">
<thead>
<tr>
<th>Checker</th>
<th>Target</th>
<th>Interval</th>
<th>Next run</th>
</tr>
</thead>
<tbody>
{#if !status.next_jobs || status.next_jobs.length === 0}
<tr>
<td colspan="4" class="text-center text-muted py-3">
No jobs scheduled
</td>
</tr>
{:else}
{#each status.next_jobs as job}
<tr>
<td>
<code>{job.checkerID ?? "—"}</code>
</td>
<td>
{#if job.target?.domainId}
<Badge
href={"/domains/" + job.target?.domainId}
color="info"
class="me-1"
>
domain
</Badge>
{/if}
{#if job.target?.serviceId}
<Badge
href={"/service/" + job.target?.serviceId}
color="warning"
class="me-1"
>
service
</Badge>
{/if}
{#if job.target?.userId}
<Badge
href={"/users/" + job.target?.userId}
color="secondary"
class="me-1"
>
user
</Badge>
{/if}
{#if !job.target?.domainId && !job.target?.serviceId && !job.target?.userId}
<span class="text-muted"></span>
{/if}
</td>
<td>{formatDuration(job.interval)}</td>
<td>
<span title={job.nextRun}
>{formatRelative(job.nextRun)}</span
>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
</CardBody>
</Card>
{/if}
</Container>

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