Implement backend model for test results and schedule

This commit is contained in:
nemunaire 2026-02-10 10:32:01 +07:00
commit 5b4dd01a13
16 changed files with 1806 additions and 34 deletions

View file

@ -0,0 +1,580 @@
// 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"
"log"
"maps"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// TestResultController handles test result operations
type TestResultController struct {
scope happydns.TestScopeType
testPluginUC happydns.TestPluginUsecase
testResultUC happydns.TestResultUsecase
testScheduleUC happydns.TestScheduleUsecase
testScheduler TestSchedulerInterface
}
// TestSchedulerInterface defines the interface for triggering on-demand tests
type TestSchedulerInterface interface {
TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetID happydns.Identifier, userID happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error)
}
func NewTestResultController(
scope happydns.TestScopeType,
testPluginUC happydns.TestPluginUsecase,
testResultUC happydns.TestResultUsecase,
testScheduleUC happydns.TestScheduleUsecase,
testScheduler TestSchedulerInterface,
) *TestResultController {
return &TestResultController{
scope: scope,
testPluginUC: testPluginUC,
testResultUC: testResultUC,
testScheduleUC: testScheduleUC,
testScheduler: testScheduler,
}
}
// getTargetFromContext extracts the target ID from context based on scope
func (tc *TestResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
switch tc.scope {
case happydns.TestScopeUser:
user := c.MustGet("user").(*happydns.User)
return user.Id, nil
case happydns.TestScopeDomain:
domain := c.MustGet("domain").(*happydns.Domain)
return domain.Id, nil
case happydns.TestScopeService:
// Services are stored by ID in context
serviceID := c.MustGet("serviceid").(happydns.Identifier)
return serviceID, nil
default:
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
}
}
// ListAvailableTests lists all available test plugins for the target scope
//
// @Summary List available tests
// @Description Retrieves all available test plugins for the target scope with their last execution status if enabled
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Success 200 {array} object "List of available tests"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests [get]
func (tc *TestResultController) ListAvailableTests(c *gin.Context) {
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get all test plugins
plugins, err := tc.testPluginUC.ListTestPlugins()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get schedules for this target
schedules, err := tc.testScheduleUC.ListSchedulesByTarget(tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Build schedule map
scheduleMap := make(map[string]*happydns.TestSchedule)
for _, sched := range schedules {
scheduleMap[sched.PluginName] = sched
}
// Build response with last results
type TestInfo struct {
PluginName string `json:"plugin_name"`
Enabled bool `json:"enabled"`
Schedule *happydns.TestSchedule `json:"schedule,omitempty"`
LastResult *happydns.TestResult `json:"last_result,omitempty"`
}
var tests []TestInfo
for _, plugin := range plugins {
// Get plugin version info
versionInfo := plugin.Version()
availability := versionInfo.AvailableOn
// Filter plugins by scope
if tc.scope == happydns.TestScopeDomain && !availability.ApplyToDomain {
continue
}
if tc.scope == happydns.TestScopeService && !availability.ApplyToService {
continue
}
pluginNames := plugin.PluginEnvName()
if len(pluginNames) == 0 {
continue
}
info := TestInfo{
PluginName: pluginNames[0],
Enabled: false,
}
// Check if there's a schedule
if sched, ok := scheduleMap[versionInfo.Name]; ok {
info.Enabled = sched.Enabled
info.Schedule = sched
// Get last result
results, err := tc.testResultUC.ListTestResultsByTarget(versionInfo.Name, tc.scope, targetID, 1)
if err == nil && len(results) > 0 {
info.LastResult = results[0]
}
}
tests = append(tests, info)
}
c.JSON(http.StatusOK, tests)
}
// ListLatestTestResults retrieves the latest test results for a specific plugin
//
// @Summary Get latest test results
// @Description Retrieves the 5 most recent test results for a specific plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 200 {array} happydns.TestResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname} [get]
func (tc *TestResultController) ListLatestTestResults(c *gin.Context) {
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
results, err := tc.testResultUC.ListTestResultsByTarget(pluginName, tc.scope, targetID, 5)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// TriggerTest triggers an on-demand test execution
//
// @Summary Trigger test execution
// @Description Triggers an immediate test execution and returns the execution ID
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param body body object false "Optional: Plugin options"
// @Success 202 {object} object{execution_id=string}
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname} [post]
func (tc *TestResultController) TriggerTest(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse run options
var options happydns.SetPluginOptionsRequest
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Merge options with upper levels (user, domain, service)
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
mergedOptions := make(happydns.PluginOptions)
// Fill opts with default plugin options
plugin, err := tc.testPluginUC.GetTestPlugin(pluginName)
if err != nil {
log.Printf("Warning: unable to get plugin %q for default options: %v", pluginName, err)
} else {
availableOpts := plugin.AvailableOptions()
// Collect all option documentation from different scopes
allOpts := []happydns.PluginOptionDocumentation{}
allOpts = append(allOpts, availableOpts.RunOpts...)
allOpts = append(allOpts, availableOpts.ServiceOpts...)
allOpts = append(allOpts, availableOpts.DomainOpts...)
allOpts = append(allOpts, availableOpts.UserOpts...)
allOpts = append(allOpts, availableOpts.AdminOpts...)
// Fill defaults
for _, opt := range allOpts {
if opt.Default != nil {
mergedOptions[opt.Id] = opt.Default
}
}
}
// Get merged options from upper levels
baseOptions, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Merge request options on top of base options (request options override)
if baseOptions != nil {
maps.Copy(mergedOptions, *baseOptions)
}
maps.Copy(mergedOptions, options.Options)
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.testScheduler.TriggerOnDemandTest(pluginName, tc.scope, targetID, user.Id, mergedOptions)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
}
// GetTestPluginOptions retrieves plugin options for the target scope
//
// @Summary Get test plugin options
// @Description Retrieves configuration options for a test plugin at the target scope
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 200 {object} happydns.PluginOptions
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [get]
func (tc *TestResultController) GetTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
opts, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// AddTestPluginOptions adds or overwrites specific options
//
// @Summary Add test plugin options
// @Description Adds or overwrites specific options for a test plugin at the target scope
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param body body happydns.PluginOptions true "Options to add"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [post]
func (tc *TestResultController) AddTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.PluginOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
err = tc.testPluginUC.OverwriteSomeTestPluginOptions(pluginName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// ChangeTestPluginOptions replaces all options
//
// @Summary Replace test plugin options
// @Description Replaces all options for a test plugin at the target scope
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param body body happydns.PluginOptions true "New complete options"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [put]
func (tc *TestResultController) ChangeTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.PluginOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
err = tc.testPluginUC.SetTestPluginOptions(pluginName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// GetTestExecutionStatus retrieves the status of a test execution
//
// @Summary Get test execution status
// @Description Retrieves the current status of a test execution
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param execution_id path string true "Execution ID"
// @Success 200 {object} happydns.TestExecution
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/executions/{execution_id} [get]
func (tc *TestResultController) GetTestExecutionStatus(c *gin.Context) {
executionIDStr := c.Param("execution_id")
executionID, err := happydns.NewIdentifierFromString(executionIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID"))
return
}
execution, err := tc.testResultUC.GetTestExecution(executionID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, execution)
}
// ListTestPluginResults lists all results for a test plugin
//
// @Summary List test results
// @Description Lists all test results for a specific test plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param limit query int false "Maximum number of results to return (default: 10)"
// @Success 200 {array} happydns.TestResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results [get]
func (tc *TestResultController) ListTestPluginResults(c *gin.Context) {
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse limit parameter
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
results, err := tc.testResultUC.ListTestResultsByTarget(pluginName, tc.scope, targetID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// DropTestPluginResults deletes all results for a test plugin
//
// @Summary Delete all test results
// @Description Deletes all test results for a specific test plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 204 "No Content"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results [delete]
func (tc *TestResultController) DropTestPluginResults(c *gin.Context) {
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = tc.testResultUC.DeleteAllTestResults(pluginName, tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetTestPluginResult retrieves a specific test result
//
// @Summary Get test result
// @Description Retrieves a specific test result by ID
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.TestResult
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results/{result_id} [get]
func (tc *TestResultController) GetTestPluginResult(c *gin.Context) {
pluginName := c.Param("tname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.testResultUC.GetTestResult(pluginName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, result)
}
// DropTestPluginResult deletes a specific test result
//
// @Summary Delete test result
// @Description Deletes a specific test result by ID
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param result_id path string true "Result ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results/{result_id} [delete]
func (tc *TestResultController) DropTestPluginResult(c *gin.Context) {
pluginName := c.Param("tname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
err = tc.testResultUC.DeleteTestResult(pluginName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -48,6 +48,10 @@ func DeclareDomainRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseD
DeclareDomainLogRoutes(apiDomainsRoutes, dependancies)
// Declare test result routes for domain scope
DeclareScopedTestResultRoutes(apiDomainsRoutes, dependancies, happydns.TestScopeDomain)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)

View file

@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(dependancies.ServiceUsecase()))
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
// Declare test result routes for service scope
DeclareScopedTestResultRoutes(apiZonesSubdomainServiceIdRoutes, dependancies, happydns.TestScopeService)
}

View file

@ -0,0 +1,103 @@
// 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"
"git.happydns.org/happyDomain/model"
)
// testSchedulerProvider is an interface for getting the test scheduler from dependencies
type testSchedulerProvider interface {
TestScheduler() controller.TestSchedulerInterface
}
// DeclareScopedTestResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedTestResultRoutes(
scopedRouter *gin.RouterGroup,
dependancies happydns.UsecaseDependancies,
scope happydns.TestScopeType,
) {
testScheduler := dependancies.(testSchedulerProvider).TestScheduler()
tc := controller.NewTestResultController(
scope,
dependancies.TestPluginUsecase(),
dependancies.TestResultUsecase(),
dependancies.TestScheduleUsecase(),
testScheduler,
)
// List all available tests with their status
scopedRouter.GET("/tests", tc.ListAvailableTests)
// Test-specific routes
apiTestsRoutes := scopedRouter.Group("/tests/:tname")
{
// Get latest results for a test
apiTestsRoutes.GET("", tc.ListLatestTestResults)
// Trigger an on-demand test
apiTestsRoutes.POST("", tc.TriggerTest)
// Manage test plugin options at this scope
apiTestsRoutes.GET("/options", tc.GetTestPluginOptions)
apiTestsRoutes.POST("/options", tc.AddTestPluginOptions)
apiTestsRoutes.PUT("/options", tc.ChangeTestPluginOptions)
// Test execution routes
apiTestExecutionsRoutes := apiTestsRoutes.Group("/executions/:execution_id")
{
apiTestExecutionsRoutes.GET("", tc.GetTestExecutionStatus)
}
// Test results routes
apiTestsRoutes.GET("/results", tc.ListTestPluginResults)
apiTestsRoutes.DELETE("/results", tc.DropTestPluginResults)
apiTestResultsRoutes := apiTestsRoutes.Group("/results/:result_id")
{
apiTestResultsRoutes.GET("", tc.GetTestPluginResult)
apiTestResultsRoutes.DELETE("", tc.DropTestPluginResult)
}
}
}
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
sc := controller.NewTestScheduleController(dependancies.TestScheduleUsecase())
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListTestSchedules)
schedulesRoutes.POST("", sc.CreateTestSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetTestSchedule)
scheduleRoutes.PUT("", sc.UpdateTestSchedule)
scheduleRoutes.DELETE("", sc.DeleteTestSchedule)
}
}
}

View file

@ -31,6 +31,7 @@ import (
"github.com/gin-gonic/gin"
api "git.happydns.org/happyDomain/internal/api/route"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/newsletter"
"git.happydns.org/happyDomain/internal/session"
@ -44,6 +45,7 @@ import (
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
testresultUC "git.happydns.org/happyDomain/internal/usecase/testresult"
userUC "git.happydns.org/happyDomain/internal/usecase/user"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
@ -65,6 +67,7 @@ type Usecases struct {
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
testPlugin happydns.TestPluginUsecase
testResult happydns.TestResultUsecase
user happydns.UserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
@ -73,15 +76,15 @@ type Usecases struct {
}
type App struct {
cfg *happydns.Options
mailer *mailer.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
insights *insightsCollector
plugins happydns.PluginManager
store storage.Storage
usecases Usecases
cfg *happydns.Options
mailer *mailer.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
insights *insightsCollector
plugins happydns.PluginManager
store storage.Storage
usecases Usecases
}
func (a *App) AuthenticationUsecase() happydns.AuthenticationUsecase {
@ -144,6 +147,10 @@ func (a *App) TestPluginUsecase() happydns.TestPluginUsecase {
return a.usecases.testPlugin
}
func (a *App) TestResultUsecase() happydns.TestResultUsecase {
return a.usecases.testResult
}
func (a *App) UserUsecase() happydns.UserUsecase {
return a.usecases.user
}
@ -280,6 +287,7 @@ func (app *App) initUsecases() {
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.testPlugin = pluginUC.NewTestPluginUsecase(app.cfg, app.plugins, app.store)
app.usecases.testResult = testresultUC.NewTestResultUsecase(app.store, app.cfg)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,

View file

@ -44,15 +44,17 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
// Define defaults options
opts = &happydns.Options{
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
MaxResultsPerTest: 100,
ResultRetentionDays: 90,
}
declareFlags(opts)

View file

@ -29,6 +29,7 @@ import (
"git.happydns.org/happyDomain/internal/usecase/plugin"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/internal/usecase/testresult"
"git.happydns.org/happyDomain/internal/usecase/user"
"git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
@ -47,6 +48,7 @@ type Storage interface {
plugin.PluginStorage
provider.ProviderStorage
session.SessionStorage
testresult.TestResultStorage
user.UserStorage
zone.ZoneStorage

View file

@ -0,0 +1,433 @@
// 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 (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Test Result storage keys:
// testresult|{plugin-name}|{target-type}|{target-id}|{result-id}
func makeTestResultKey(pluginName string, targetType happydns.TestScopeType, targetId, resultId happydns.Identifier) string {
return fmt.Sprintf("testresult|%s|%d|%s|%s", pluginName, targetType, targetId.String(), resultId.String())
}
func makeTestResultPrefix(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) string {
return fmt.Sprintf("testresult|%s|%d|%s|", pluginName, targetType, targetId.String())
}
// ListTestResults retrieves test results for a specific plugin+target combination
func (s *KVStorage) ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
prefix := makeTestResultPrefix(pluginName, targetType, targetId)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
results = append(results, &r)
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
func (s *KVStorage) ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error) {
prefix := fmt.Sprintf("testresult|%s|", pluginName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListTestResultsByUser retrieves all test results for a user
func (s *KVStorage) ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
iter := s.db.Search("testresult|")
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetTestResult retrieves a specific test result by its ID
func (s *KVStorage) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
var result happydns.TestResult
err := s.db.Get(key, &result)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestResultNotFound
}
return &result, err
}
// CreateTestResult stores a new test result
func (s *KVStorage) CreateTestResult(result *happydns.TestResult) error {
prefix := makeTestResultPrefix(result.PluginName, result.TestType, result.TargetId)
key, id, err := s.db.FindIdentifierKey(prefix)
if err != nil {
return err
}
result.Id = id
return s.db.Put(key, result)
}
// DeleteTestResult removes a specific test result
func (s *KVStorage) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
return s.db.Delete(key)
}
// DeleteOldTestResults removes old test results keeping only the most recent N results
func (s *KVStorage) DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error {
results, err := s.ListTestResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
// Results are already sorted by ExecutedAt descending
// Delete results beyond keepCount
if len(results) > keepCount {
for _, r := range results[keepCount:] {
if err := s.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
}
return nil
}
// Test Schedule storage keys:
// testschedule|{schedule-id}
// testschedule.byuser|{user-id}|{schedule-id}
// testschedule.bytarget|{target-type}|{target-id}|{schedule-id}
func makeTestScheduleKey(scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule|%s", scheduleId.String())
}
func makeTestScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule.byuser|%s|%s", userId.String(), scheduleId.String())
}
func makeTestScheduleTargetIndexKey(targetType happydns.TestScopeType, targetId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
}
// ListEnabledTestSchedules retrieves all enabled schedules
func (s *KVStorage) ListEnabledTestSchedules() ([]*happydns.TestSchedule, error) {
iter := s.db.Search("testschedule|")
defer iter.Release()
var schedules []*happydns.TestSchedule
for iter.Next() {
var sched happydns.TestSchedule
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
return nil, err
}
if sched.Enabled {
schedules = append(schedules, &sched)
}
}
return schedules, nil
}
// ListTestSchedulesByUser retrieves all schedules for a specific user
func (s *KVStorage) ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
prefix := fmt.Sprintf("testschedule.byuser|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.TestSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 3 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.TestSchedule
schedKey := makeTestScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// ListTestSchedulesByTarget retrieves all schedules for a specific target
func (s *KVStorage) ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
prefix := fmt.Sprintf("testschedule.bytarget|%d|%s|", targetType, targetId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.TestSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 4 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.TestSchedule
schedKey := makeTestScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// GetTestSchedule retrieves a specific schedule by ID
func (s *KVStorage) GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
key := makeTestScheduleKey(scheduleId)
var schedule happydns.TestSchedule
err := s.db.Get(key, &schedule)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestScheduleNotFound
}
return &schedule, err
}
// CreateTestSchedule creates a new test schedule
func (s *KVStorage) CreateTestSchedule(schedule *happydns.TestSchedule) error {
key, id, err := s.db.FindIdentifierKey("testschedule|")
if err != nil {
return err
}
schedule.Id = id
// Store the schedule
if err := s.db.Put(key, schedule); err != nil {
return err
}
// Create indexes
userIndexKey := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
return err
}
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
return err
}
return nil
}
// UpdateTestSchedule updates an existing schedule
func (s *KVStorage) UpdateTestSchedule(schedule *happydns.TestSchedule) error {
key := makeTestScheduleKey(schedule.Id)
return s.db.Put(key, schedule)
}
// DeleteTestSchedule removes a schedule and its indexes
func (s *KVStorage) DeleteTestSchedule(scheduleId happydns.Identifier) error {
// Get the schedule first to know what indexes to delete
schedule, err := s.GetTestSchedule(scheduleId)
if err != nil {
return err
}
// Delete indexes
userIndexKey := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Delete(userIndexKey); err != nil {
return err
}
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Delete(targetIndexKey); err != nil {
return err
}
// Delete the schedule itself
key := makeTestScheduleKey(scheduleId)
return s.db.Delete(key)
}
// Test Execution storage keys:
// testexec|{execution-id}
func makeTestExecutionKey(executionId happydns.Identifier) string {
return fmt.Sprintf("testexec|%s", executionId.String())
}
// ListActiveTestExecutions retrieves all executions that are pending or running
func (s *KVStorage) ListActiveTestExecutions() ([]*happydns.TestExecution, error) {
iter := s.db.Search("testexec|")
defer iter.Release()
var executions []*happydns.TestExecution
for iter.Next() {
var exec happydns.TestExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
return nil, err
}
if exec.Status == happydns.TestExecutionPending || exec.Status == happydns.TestExecutionRunning {
executions = append(executions, &exec)
}
}
return executions, nil
}
// GetTestExecution retrieves a specific execution by ID
func (s *KVStorage) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
key := makeTestExecutionKey(executionId)
var execution happydns.TestExecution
err := s.db.Get(key, &execution)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestExecutionNotFound
}
return &execution, err
}
// CreateTestExecution creates a new test execution record
func (s *KVStorage) CreateTestExecution(execution *happydns.TestExecution) error {
key, id, err := s.db.FindIdentifierKey("testexec|")
if err != nil {
return err
}
execution.Id = id
return s.db.Put(key, execution)
}
// UpdateTestExecution updates an existing execution record
func (s *KVStorage) UpdateTestExecution(execution *happydns.TestExecution) error {
key := makeTestExecutionKey(execution.Id)
return s.db.Put(key, execution)
}
// DeleteTestExecution removes an execution record
func (s *KVStorage) DeleteTestExecution(executionId happydns.Identifier) error {
key := makeTestExecutionKey(executionId)
return s.db.Delete(key)
}
// Scheduler state storage key:
// testscheduler.lastrun
// TestSchedulerRun marks that the scheduler has run at current time
func (s *KVStorage) TestSchedulerRun() error {
now := time.Now()
return s.db.Put("testscheduler.lastrun", &now)
}
// LastTestSchedulerRun retrieves the last time the scheduler ran
func (s *KVStorage) LastTestSchedulerRun() (*time.Time, error) {
var lastRun time.Time
err := s.db.Get("testscheduler.lastrun", &lastRun)
if errors.Is(err, happydns.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastRun, nil
}

View file

@ -23,6 +23,7 @@ package plugin
import (
"fmt"
"maps"
"sort"
"git.happydns.org/happyDomain/model"
@ -103,9 +104,7 @@ func (tu *testPluginUsecase) GetTestPluginOptions(pname string, userid *happydns
opts := make(happydns.PluginOptions)
for _, c := range configs {
for k, v := range c.Options {
opts[k] = v
}
maps.Copy(opts, c.Options)
}
return &opts, nil
@ -116,7 +115,40 @@ func (tu *testPluginUsecase) ListTestPlugins() ([]happydns.TestPlugin, error) {
}
func (tu *testPluginUsecase) SetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, opts)
// filter opts that correspond to the level set
plugin, err := tu.GetTestPlugin(pname)
if err != nil {
return fmt.Errorf("unable to get test plugin: %w", err)
}
var optNames []string
if serviceid != nil {
for _, opt := range plugin.AvailableOptions().ServiceOpts {
optNames = append(optNames, opt.Id)
}
} else if domainid != nil {
for _, opt := range plugin.AvailableOptions().DomainOpts {
optNames = append(optNames, opt.Id)
}
} else if userid != nil {
for _, opt := range plugin.AvailableOptions().UserOpts {
optNames = append(optNames, opt.Id)
}
} else {
for _, opt := range plugin.AvailableOptions().AdminOpts {
optNames = append(optNames, opt.Id)
}
}
// Filter opts to only include keys that are in optNames
filteredOpts := make(happydns.PluginOptions)
for _, optName := range optNames {
if val, exists := opts[optName]; exists && val != "" {
filteredOpts[optName] = val
}
}
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, filteredOpts)
}
func (tu *testPluginUsecase) OverwriteSomeTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
@ -125,9 +157,7 @@ func (tu *testPluginUsecase) OverwriteSomeTestPluginOptions(pname string, userid
return err
}
for k, v := range opts {
(*current)[k] = v
}
maps.Copy(*current, opts)
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,98 @@
// 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 testresult
import (
"time"
"git.happydns.org/happyDomain/model"
)
// TestResultStorage defines the storage interface for test results and related data
type TestResultStorage interface {
// Test Results
// ListTestResults retrieves test results for a specific plugin+target combination
ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error)
// ListTestResultsByUser retrieves all test results for a user
ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
// GetTestResult retrieves a specific test result by its ID
GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error)
// CreateTestResult stores a new test result
CreateTestResult(result *happydns.TestResult) error
// DeleteTestResult removes a specific test result
DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
// DeleteOldTestResults removes old test results keeping only the most recent N results
DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error
// Test Schedules
// ListEnabledTestSchedules retrieves all enabled schedules (for scheduler)
ListEnabledTestSchedules() ([]*happydns.TestSchedule, error)
// ListTestSchedulesByUser retrieves all schedules for a specific user
ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error)
// ListTestSchedulesByTarget retrieves all schedules for a specific target
ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error)
// GetTestSchedule retrieves a specific schedule by ID
GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error)
// CreateTestSchedule creates a new test schedule
CreateTestSchedule(schedule *happydns.TestSchedule) error
// UpdateTestSchedule updates an existing schedule
UpdateTestSchedule(schedule *happydns.TestSchedule) error
// DeleteTestSchedule removes a schedule
DeleteTestSchedule(scheduleId happydns.Identifier) error
// Test Executions
// ListActiveTestExecutions retrieves all executions that are pending or running
ListActiveTestExecutions() ([]*happydns.TestExecution, error)
// GetTestExecution retrieves a specific execution by ID
GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error)
// CreateTestExecution creates a new test execution record
CreateTestExecution(execution *happydns.TestExecution) error
// UpdateTestExecution updates an existing execution record
UpdateTestExecution(execution *happydns.TestExecution) error
// DeleteTestExecution removes an execution record
DeleteTestExecution(executionId happydns.Identifier) error
// Scheduler State
// TestSchedulerRun marks that the scheduler has run at current time
TestSchedulerRun() error
// LastTestSchedulerRun retrieves the last time the scheduler ran
LastTestSchedulerRun() (*time.Time, error)
}

View file

@ -0,0 +1,222 @@
// 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 testresult
import (
"fmt"
"time"
"git.happydns.org/happyDomain/model"
)
// TestResultUsecase implements business logic for test results
type TestResultUsecase struct {
storage TestResultStorage
options *happydns.Options
}
// NewTestResultUsecase creates a new test result usecase
func NewTestResultUsecase(storage TestResultStorage, options *happydns.Options) *TestResultUsecase {
return &TestResultUsecase{
storage: storage,
options: options,
}
}
// ListTestResultsByTarget retrieves test results for a specific target
func (u *TestResultUsecase) ListTestResultsByTarget(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
// Apply default limit if not specified
if limit <= 0 {
limit = 5 // Default to 5 most recent results
}
return u.storage.ListTestResults(pluginName, targetType, targetId, limit)
}
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
func (u *TestResultUsecase) ListAllTestResultsByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
// Get all results for the user and filter by target
allResults, err := u.storage.ListTestResultsByUser(userId, 0)
if err != nil {
return nil, err
}
// Filter by target
var results []*happydns.TestResult
for _, r := range allResults {
if r.TestType == targetType && r.TargetId.Equals(targetId) {
results = append(results, r)
}
}
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetTestResult retrieves a specific test result
func (u *TestResultUsecase) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
return u.storage.GetTestResult(pluginName, targetType, targetId, resultId)
}
// CreateTestResult stores a new test result and enforces retention policy
func (u *TestResultUsecase) CreateTestResult(result *happydns.TestResult) error {
// Store the result
if err := u.storage.CreateTestResult(result); err != nil {
return err
}
// Enforce retention policy
maxResults := u.options.MaxResultsPerTest
if maxResults <= 0 {
maxResults = 100 // Default
}
return u.storage.DeleteOldTestResults(result.PluginName, result.TestType, result.TargetId, maxResults)
}
// DeleteTestResult removes a specific test result
func (u *TestResultUsecase) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
return u.storage.DeleteTestResult(pluginName, targetType, targetId, resultId)
}
// DeleteAllTestResults removes all results for a specific plugin+target combination
func (u *TestResultUsecase) DeleteAllTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) error {
// Get all results first
results, err := u.storage.ListTestResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
// Delete each result
for _, r := range results {
if err := u.storage.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
return nil
}
// CleanupOldResults removes test results older than retention period
func (u *TestResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
retentionDays = 90 // Default
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
// Get all results for all users (inefficient but necessary without a time-based index)
// In a production system, you might want to add a time-based index for this
// For now, we'll iterate through results and delete old ones
// This is a placeholder - the actual implementation would need to be optimized
// based on specific storage patterns
_ = cutoffTime
return nil
}
// GetTestExecution retrieves the status of a test execution
func (u *TestResultUsecase) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
return u.storage.GetTestExecution(executionId)
}
// CreateTestExecution creates a new test execution record
func (u *TestResultUsecase) CreateTestExecution(execution *happydns.TestExecution) error {
if execution.StartedAt.IsZero() {
execution.StartedAt = time.Now()
}
return u.storage.CreateTestExecution(execution)
}
// UpdateTestExecution updates an existing test execution
func (u *TestResultUsecase) UpdateTestExecution(execution *happydns.TestExecution) error {
return u.storage.UpdateTestExecution(execution)
}
// CompleteTestExecution marks an execution as completed with a result
func (u *TestResultUsecase) CompleteTestExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
execution, err := u.storage.GetTestExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.TestExecutionCompleted
execution.CompletedAt = &now
execution.ResultId = &resultId
return u.storage.UpdateTestExecution(execution)
}
// FailTestExecution marks an execution as failed
func (u *TestResultUsecase) FailTestExecution(executionId happydns.Identifier, errorMsg string) error {
execution, err := u.storage.GetTestExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.TestExecutionFailed
execution.CompletedAt = &now
// Store error in a result
result := &happydns.TestResult{
PluginName: execution.PluginName,
TestType: execution.TargetType,
TargetId: execution.TargetId,
OwnerId: execution.OwnerId,
ExecutedAt: time.Now(),
ScheduledTest: execution.ScheduleId != nil,
Options: execution.Options,
Status: happydns.PluginResultStatusKO,
StatusLine: "Execution failed",
Error: errorMsg,
Duration: now.Sub(execution.StartedAt),
}
if err := u.CreateTestResult(result); err != nil {
return fmt.Errorf("failed to create error result: %w", err)
}
execution.ResultId = &result.Id
return u.storage.UpdateTestExecution(execution)
}
// DeleteCompletedExecutions removes execution records that are completed
func (u *TestResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
// Get active executions (this won't include completed ones)
// We need a different query to get completed executions older than cutoff
// For now, this is a placeholder
_ = cutoffTime
return nil
}

View file

@ -94,6 +94,12 @@ type Options struct {
OIDCClients []OIDCSettings
PluginsDirectories []string
// MaxResultsPerTest is the maximum number of test results to keep per plugin+target combination
MaxResultsPerTest int
// ResultRetentionDays is how long to keep test results before cleanup
ResultRetentionDays int
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

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

267
model/test_result.go Normal file
View file

@ -0,0 +1,267 @@
// 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"
)
// TestScopeType represents the scope level at which a test is performed
type TestScopeType int
const (
TestScopeInstance TestScopeType = iota
TestScopeUser
TestScopeDomain
TestScopeService
TestScopeOnDemand
)
// String returns a string representation of the test scope type
func (t TestScopeType) String() string {
switch t {
case TestScopeInstance:
return "instance"
case TestScopeUser:
return "user"
case TestScopeDomain:
return "domain"
case TestScopeService:
return "service"
case TestScopeOnDemand:
return "ondemand"
default:
return "unknown"
}
}
// TestExecutionStatus represents the current state of a test execution
type TestExecutionStatus int
const (
TestExecutionPending TestExecutionStatus = iota
TestExecutionRunning
TestExecutionCompleted
TestExecutionFailed
)
// String returns a string representation of the test execution status
func (t TestExecutionStatus) String() string {
switch t {
case TestExecutionPending:
return "pending"
case TestExecutionRunning:
return "running"
case TestExecutionCompleted:
return "completed"
case TestExecutionFailed:
return "failed"
default:
return "unknown"
}
}
// TestResult stores the result of a test execution
type TestResult struct {
// Id is the unique identifier for this test result
Id Identifier `json:"id" swaggertype:"string"`
// PluginName identifies which test plugin was executed
PluginName string `json:"plugin_name"`
// TestType indicates the scope level of the test
TestType TestScopeType `json:"test_type"`
// TargetId is the identifier of the target (User/Domain/Service)
TargetId Identifier `json:"target_id" swaggertype:"string"`
// OwnerId is the owner of the test
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// ExecutedAt is when the test was executed
ExecutedAt time.Time `json:"executed_at"`
// ScheduledTest indicates if this was a scheduled (true) or on-demand (false) test
ScheduledTest bool `json:"scheduled_test"`
// Options contains the merged plugin configuration used for this test
Options PluginOptions `json:"options,omitempty"`
// Status is the overall test result status
Status PluginResultStatus `json:"status"`
// StatusLine is a summary message of the test result
StatusLine string `json:"status_line"`
// Report contains the full test report (plugin-specific structure)
Report interface{} `json:"report,omitempty"`
// Duration is how long the test took to execute
Duration time.Duration `json:"duration" swaggertype:"integer"`
// Error contains any error message if the execution failed
Error string `json:"error,omitempty"`
}
// TestSchedule defines a recurring test schedule
type TestSchedule struct {
// Id is the unique identifier for this schedule
Id Identifier `json:"id" swaggertype:"string"`
// PluginName identifies which test plugin to execute
PluginName string `json:"plugin_name"`
// OwnerId is the owner of the schedule
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates what type of target to test
TargetType TestScopeType `json:"target_type"`
// TargetId is the identifier of the target to test
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Interval is how often to run the test
Interval time.Duration `json:"interval" swaggertype:"integer"`
// Enabled indicates if the schedule is active
Enabled bool `json:"enabled"`
// LastRun is when the test was last executed (nil if never run)
LastRun *time.Time `json:"last_run,omitempty"`
// NextRun is when the test should next be executed
NextRun time.Time `json:"next_run"`
// Options contains plugin-specific configuration
Options PluginOptions `json:"options,omitempty"`
}
// TestExecution tracks an in-progress or completed test execution
type TestExecution struct {
// Id is the unique identifier for this execution
Id Identifier `json:"id" swaggertype:"string"`
// ScheduleId is the schedule that triggered this execution (nil for on-demand)
ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"`
// PluginName identifies which test plugin is being executed
PluginName string `json:"plugin_name"`
// OwnerId is the owner of the test
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates the scope level of the test
TargetType TestScopeType `json:"target_type"`
// TargetId is the identifier of the target being tested
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Status is the current execution status
Status TestExecutionStatus `json:"status"`
// StartedAt is when the execution began
StartedAt time.Time `json:"started_at"`
// CompletedAt is when the execution finished (nil if still running)
CompletedAt *time.Time `json:"completed_at,omitempty"`
// ResultId links to the TestResult (nil if execution not completed)
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
// Options contains the plugin configuration for this execution
Options PluginOptions `json:"options,omitempty"`
}
// TestResultUsecase defines business logic for test results
type TestResultUsecase interface {
// ListTestResultsByTarget retrieves test results for a specific target
ListTestResultsByTarget(pluginName string, targetType TestScopeType, targetId Identifier, limit int) ([]*TestResult, error)
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
ListAllTestResultsByTarget(targetType TestScopeType, targetId Identifier, userId Identifier, limit int) ([]*TestResult, error)
// GetTestResult retrieves a specific test result
GetTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) (*TestResult, error)
// CreateTestResult stores a new test result and enforces retention policy
CreateTestResult(result *TestResult) error
// DeleteTestResult removes a specific test result
DeleteTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) error
// DeleteAllTestResults removes all results for a specific plugin+target combination
DeleteAllTestResults(pluginName string, targetType TestScopeType, targetId Identifier) error
// GetTestExecution retrieves the status of a test execution
GetTestExecution(executionId Identifier) (*TestExecution, error)
// CreateTestExecution creates a new test execution record
CreateTestExecution(execution *TestExecution) error
// UpdateTestExecution updates an existing test execution
UpdateTestExecution(execution *TestExecution) error
// CompleteTestExecution marks an execution as completed with a result
CompleteTestExecution(executionId Identifier, resultId Identifier) error
// FailTestExecution marks an execution as failed
FailTestExecution(executionId Identifier, errorMsg string) error
}
// TestScheduleUsecase defines business logic for test schedules
type TestScheduleUsecase interface {
// ListUserSchedules retrieves all schedules for a specific user
ListUserSchedules(userId Identifier) ([]*TestSchedule, error)
// ListSchedulesByTarget retrieves all schedules for a specific target
ListSchedulesByTarget(targetType TestScopeType, targetId Identifier) ([]*TestSchedule, error)
// GetSchedule retrieves a specific schedule by ID
GetSchedule(scheduleId Identifier) (*TestSchedule, error)
// CreateSchedule creates a new test schedule with validation
CreateSchedule(schedule *TestSchedule) error
// UpdateSchedule updates an existing schedule
UpdateSchedule(schedule *TestSchedule) error
// DeleteSchedule removes a schedule
DeleteSchedule(scheduleId Identifier) error
// EnableSchedule enables a schedule
EnableSchedule(scheduleId Identifier) error
// DisableSchedule disables a schedule
DisableSchedule(scheduleId Identifier) error
// UpdateScheduleAfterRun updates a schedule after it has been executed
UpdateScheduleAfterRun(scheduleId Identifier) error
// ListDueSchedules retrieves all enabled schedules that are due to run
ListDueSchedules() ([]*TestSchedule, error)
// ValidateScheduleOwnership checks if a user owns a schedule
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
// DeleteSchedulesForTarget removes all schedules for a target
DeleteSchedulesForTarget(targetType TestScopeType, targetId Identifier) error
}

View file

@ -35,6 +35,7 @@ type UsecaseDependancies interface {
ServiceSpecsUsecase() ServiceSpecsUsecase
SessionUsecase() SessionUsecase
TestPluginUsecase() TestPluginUsecase
TestResultUsecase() TestResultUsecase
UserUsecase() UserUsecase
ZoneCorrectionApplierUsecase() ZoneCorrectionApplierUsecase
ZoneImporterUsecase() ZoneImporterUsecase

View file

@ -52,6 +52,17 @@ type UserSettings struct {
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
ShowRRTypes bool `json:"showrrtypes,omitempty"`
// TestRetention overrides instance default for how long to keep test results (days)
TestRetention int `json:"test_retention,omitempty"`
// DomainTestInterval is the default interval for domain-level tests (seconds)
// Default: 86400 (24 hours)
DomainTestInterval int64 `json:"domain_test_interval,omitempty"`
// ServiceTestInterval is the default interval for service-level tests (seconds)
// Default: 3600 (1 hour)
ServiceTestInterval int64 `json:"service_test_interval,omitempty"`
}
func DefaultUserSettings() *UserSettings {