diff --git a/internal/api-admin/controller/plugin_test_controller.go b/internal/api-admin/controller/plugin_test_controller.go new file mode 100644 index 00000000..aa4e3170 --- /dev/null +++ b/internal/api-admin/controller/plugin_test_controller.go @@ -0,0 +1,181 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + apicontroller "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// TestPluginController handles admin-level plugin operations. +// All methods in this controller work with admin-scoped options (nil user/domain/service IDs). +type TestPluginController struct { + *apicontroller.BaseTestPluginController +} + +func NewTestPluginController(testPluginService happydns.TestPluginUsecase) *TestPluginController { + return &TestPluginController{ + BaseTestPluginController: apicontroller.NewBaseTestPluginController(testPluginService), + } +} + +// TestPluginHandler is a middleware that retrieves a test plugin by name and sets it in the context. +func (uc *TestPluginController) TestPluginHandler(c *gin.Context) { + pname := c.Param("pname") + + plugin, err := uc.BaseTestPluginController.GetTestPluginService().GetTestPlugin(pname) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Plugin not found"}) + return + } + + c.Set("plugin", plugin) + + c.Next() +} + +// TestPluginOptionHandler is a middleware that retrieves a specific admin-level plugin option and sets it in the context. +func (uc *TestPluginController) TestPluginOptionHandler(c *gin.Context) { + pname := c.Param("pname") + optname := c.Param("optname") + + // Get admin-level options (nil user/domain/service IDs) + opts, err := uc.BaseTestPluginController.GetTestPluginService().GetTestPluginOptions(pname, nil, nil, nil) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + + c.Set("option", (*opts)[optname]) + + c.Next() +} + +// GetTestPluginOptions retrieves all admin-level options for a test plugin. +// +// @Summary Get test plugin options (admin) +// @Schemes +// @Description Retrieves all admin-level configuration options for a specific test plugin. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pname path string true "Plugin name" +// @Success 200 {object} happydns.PluginOptions "Plugin options as key-value pairs" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pname}/options [get] +func (uc *TestPluginController) GetTestPluginOptions(c *gin.Context) { + pname := c.Param("pname") + + // Get admin-level options (nil user/domain/service IDs) + uc.GetTestPluginOptionsWithScope(c, pname, nil, nil, nil) +} + +// AddTestPluginOptions adds or overwrites specific admin-level options for a test plugin. +// +// @Summary Add test plugin options (admin) +// @Schemes +// @Description Adds or overwrites specific admin-level configuration options for a test plugin without affecting other options. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pname path string true "Plugin name" +// @Param body body happydns.SetPluginOptionsRequest true "Options to add or overwrite" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pname}/options [post] +func (uc *TestPluginController) AddTestPluginOptions(c *gin.Context) { + pname := c.Param("pname") + + // Add admin-level options (nil user/domain/service IDs) + uc.AddTestPluginOptionsWithScope(c, pname, nil, nil, nil) +} + +// ChangeTestPluginOptions replaces all admin-level options for a test plugin. +// +// @Summary Replace test plugin options (admin) +// @Schemes +// @Description Replaces all admin-level configuration options for a test plugin with the provided options. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pname path string true "Plugin name" +// @Param body body happydns.SetPluginOptionsRequest true "New complete set of options" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pname}/options [put] +func (uc *TestPluginController) ChangeTestPluginOptions(c *gin.Context) { + pname := c.Param("pname") + + // Replace admin-level options (nil user/domain/service IDs) + uc.ChangeTestPluginOptionsWithScope(c, pname, nil, nil, nil) +} + +// GetTestPluginOption retrieves a specific admin-level option value for a test plugin. +// +// @Summary Get test plugin option (admin) +// @Schemes +// @Description Retrieves the value of a specific admin-level configuration option for a test plugin. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pname path string true "Plugin name" +// @Param optname path string true "Option name" +// @Success 200 {object} object "Option value (type varies)" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pname}/options/{optname} [get] +func (uc *TestPluginController) GetTestPluginOption(c *gin.Context) { + uc.GetTestPluginOptionValue(c) +} + +// SetTestPluginOption sets or updates a specific admin-level option value for a test plugin. +// +// @Summary Set test plugin option (admin) +// @Schemes +// @Description Sets or updates the value of a specific admin-level configuration option for a test plugin. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pname path string true "Plugin name" +// @Param optname path string true "Option name" +// @Param body body object true "Option value (type varies by option)" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pname}/options/{optname} [put] +func (uc *TestPluginController) SetTestPluginOption(c *gin.Context) { + pname := c.Param("pname") + optname := c.Param("optname") + + // Set admin-level option (nil user/domain/service IDs) + uc.SetTestPluginOptionWithScope(c, pname, optname, nil, nil, nil) +} diff --git a/internal/api-admin/controller/scheduler_controller.go b/internal/api-admin/controller/scheduler_controller.go new file mode 100644 index 00000000..e173ebd3 --- /dev/null +++ b/internal/api-admin/controller/scheduler_controller.go @@ -0,0 +1,102 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/model" +) + +// AdminSchedulerController handles admin operations on the test scheduler +type AdminSchedulerController struct { + scheduler happydns.AdminSchedulerUsecase +} + +func NewAdminSchedulerController(scheduler happydns.AdminSchedulerUsecase) *AdminSchedulerController { + return &AdminSchedulerController{scheduler: scheduler} +} + +// GetSchedulerStatus returns the current scheduler state +// +// @Summary Get scheduler status +// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules +// @Tags scheduler +// @Produce json +// @Success 200 {object} happydns.SchedulerStatus +// @Router /scheduler [get] +func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) { + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// EnableScheduler enables the test scheduler at runtime +// +// @Summary Enable scheduler +// @Description Enables the test scheduler at runtime without restarting the server +// @Tags scheduler +// @Success 200 {object} happydns.SchedulerStatus +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/enable [post] +func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) { + if err := ctrl.scheduler.SetEnabled(true); err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// DisableScheduler disables the test scheduler at runtime +// +// @Summary Disable scheduler +// @Description Disables the test scheduler at runtime without restarting the server +// @Tags scheduler +// @Success 200 {object} happydns.SchedulerStatus +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/disable [post] +func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) { + if err := ctrl.scheduler.SetEnabled(false); err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus()) +} + +// RescheduleUpcoming randomizes the next run time of all enabled schedules +// within their respective intervals to spread load evenly. +// +// @Summary Reschedule upcoming tests +// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load +// @Tags scheduler +// @Produce json +// @Success 200 {object} map[string]int +// @Failure 500 {object} happydns.ErrorResponse +// @Router /scheduler/reschedule-upcoming [post] +func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) { + n, err := ctrl.scheduler.RescheduleUpcomingTests() + if err != nil { + c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"rescheduled": n}) +} diff --git a/internal/api-admin/route/plugin.go b/internal/api-admin/route/plugin.go new file mode 100644 index 00000000..9eb136a7 --- /dev/null +++ b/internal/api-admin/route/plugin.go @@ -0,0 +1,58 @@ +// 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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" + "git.happydns.org/happyDomain/internal/storage" + "git.happydns.org/happyDomain/model" +) + +func declarePluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies, store storage.Storage) { + apiPluginsRoutes := router.Group("/plugins") + declareTestPluginsRoutes(apiPluginsRoutes, dependancies) +} + +func declareTestPluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) { + tpc := controller.NewTestPluginController(dependancies.TestPluginUsecase()) + + apiTestPluginsRoutes := router.Group("/tests") + + apiTestPluginsRoutes.GET("", tpc.ListTestPlugins) + + apiTestPluginRoutes := apiTestPluginsRoutes.Group("/:pname") + apiTestPluginRoutes.Use(tpc.TestPluginHandler) + + apiTestPluginRoutes.GET("", tpc.GetTestPluginStatus) + //apiTestPluginRoutes.POST("", tpc.ChangeTestPluginStatus) + + apiTestPluginRoutes.GET("/options", tpc.GetTestPluginOptions) + apiTestPluginRoutes.POST("/options", tpc.AddTestPluginOptions) + apiTestPluginRoutes.PUT("/options", tpc.ChangeTestPluginOptions) + + apiTestPluginOptionsRoutes := apiTestPluginRoutes.Group("/options/:optname") + apiTestPluginOptionsRoutes.Use(tpc.TestPluginOptionHandler) + apiTestPluginOptionsRoutes.GET("", tpc.GetTestPluginOption) + apiTestPluginOptionsRoutes.PUT("", tpc.SetTestPluginOption) +} diff --git a/internal/api-admin/route/route.go b/internal/api-admin/route/route.go index fe387b93..a3ae338e 100644 --- a/internal/api-admin/route/route.go +++ b/internal/api-admin/route/route.go @@ -34,7 +34,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage, declareBackupRoutes(cfg, apiRoutes, s) declareDomainRoutes(apiRoutes, dependancies, s) + declarePluginsRoutes(apiRoutes, dependancies, s) declareProviderRoutes(apiRoutes, dependancies, s) + declareSchedulerRoutes(apiRoutes, dependancies) declareSessionsRoutes(cfg, apiRoutes, s) declareUserAuthsRoutes(apiRoutes, dependancies, s) declareUsersRoutes(apiRoutes, dependancies, s) diff --git a/internal/api-admin/route/scheduler.go b/internal/api-admin/route/scheduler.go new file mode 100644 index 00000000..84258903 --- /dev/null +++ b/internal/api-admin/route/scheduler.go @@ -0,0 +1,45 @@ +// 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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api-admin/controller" + apicontroller "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// schedulerDepsProvider is satisfied by App which exposes TestScheduler() +type schedulerDepsProvider interface { + TestScheduler() apicontroller.TestSchedulerInterface +} + +func declareSchedulerRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) { + ctrl := controller.NewAdminSchedulerController(dependancies.TestScheduler()) + + schedulerRoute := router.Group("/scheduler") + schedulerRoute.GET("", ctrl.GetSchedulerStatus) + schedulerRoute.POST("/enable", ctrl.EnableScheduler) + schedulerRoute.POST("/disable", ctrl.DisableScheduler) + schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming) +} diff --git a/internal/api/controller/plugin_test_base_controller.go b/internal/api/controller/plugin_test_base_controller.go new file mode 100644 index 00000000..58a95262 --- /dev/null +++ b/internal/api/controller/plugin_test_base_controller.go @@ -0,0 +1,131 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// BaseTestPluginController contains shared functionality for test plugin controllers. +// It provides common methods that can be used by both admin and user-scoped controllers. +type BaseTestPluginController struct { + testPluginService happydns.TestPluginUsecase +} + +func NewBaseTestPluginController(testPluginService happydns.TestPluginUsecase) *BaseTestPluginController { + return &BaseTestPluginController{ + testPluginService, + } +} + +// GetTestPluginService returns the test plugin service for use by derived controllers. +func (bc *BaseTestPluginController) GetTestPluginService() happydns.TestPluginUsecase { + return bc.testPluginService +} + +// ListTestPlugins retrieves all available test plugins. +func (bc *BaseTestPluginController) ListTestPlugins(c *gin.Context) { + plugins, err := bc.testPluginService.ListTestPlugins() + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + ret := map[string]happydns.PluginVersionInfo{} + + for _, p := range plugins { + pnames := p.PluginEnvName() + ret[pnames[0]] = p.Version() + } + + happydns.ApiResponse(c, ret, nil) +} + +// GetTestPluginStatus retrieves the status and available options for a test plugin. +func (bc *BaseTestPluginController) GetTestPluginStatus(c *gin.Context) { + plugin := c.MustGet("plugin").(happydns.TestPlugin) + + c.JSON(http.StatusOK, happydns.PluginStatus{ + PluginVersionInfo: plugin.Version(), + Opts: plugin.AvailableOptions(), + }) +} + +// GetTestPluginOptionsWithScope retrieves all options for a test plugin with the given scope. +func (bc *BaseTestPluginController) GetTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + opts, err := bc.testPluginService.GetTestPluginOptions(pname, userId, domainId, serviceId) + happydns.ApiResponse(c, opts, err) +} + +// AddTestPluginOptionsWithScope adds or overwrites specific options for a test plugin with the given scope. +func (bc *BaseTestPluginController) AddTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req happydns.SetPluginOptionsRequest + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + err = bc.testPluginService.OverwriteSomeTestPluginOptions(pname, userId, domainId, serviceId, req.Options) + happydns.ApiResponse(c, true, err) +} + +// ChangeTestPluginOptionsWithScope replaces all options for a test plugin with the given scope. +func (bc *BaseTestPluginController) ChangeTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req happydns.SetPluginOptionsRequest + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + err = bc.testPluginService.SetTestPluginOptions(pname, userId, domainId, serviceId, req.Options) + happydns.ApiResponse(c, true, err) +} + +// GetTestPluginOptionValue retrieves a specific option value from the context. +func (bc *BaseTestPluginController) GetTestPluginOptionValue(c *gin.Context) { + opt := c.MustGet("option") + + happydns.ApiResponse(c, opt, nil) +} + +// SetTestPluginOptionWithScope sets or updates a specific option value for a test plugin with the given scope. +func (bc *BaseTestPluginController) SetTestPluginOptionWithScope(c *gin.Context, pname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) { + var req interface{} + err := c.ShouldBindJSON(&req) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + po := happydns.PluginOptions{} + po[optname] = req + + err = bc.testPluginService.OverwriteSomeTestPluginOptions(pname, userId, domainId, serviceId, po) + happydns.ApiResponse(c, true, err) +} diff --git a/internal/api/controller/plugin_test_controller.go b/internal/api/controller/plugin_test_controller.go new file mode 100644 index 00000000..7e8e5a90 --- /dev/null +++ b/internal/api/controller/plugin_test_controller.go @@ -0,0 +1,211 @@ +// 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 . +// +// 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 . + +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/model" +) + +// TestPluginController handles user-scoped plugin operations for the main API. +// All methods work with options scoped to the authenticated user. +type TestPluginController struct { + *BaseTestPluginController +} + +func NewTestPluginController(testPluginService happydns.TestPluginUsecase) *TestPluginController { + return &TestPluginController{ + BaseTestPluginController: NewBaseTestPluginController(testPluginService), + } +} + +// ListTestPlugins retrieves all available test plugins. +// +// @Summary List all test plugins +// @Schemes +// @Description Returns a list of all available test plugins with their version information. +// @Tags plugins +// @Accept json +// @Produce json +// @Success 200 {object} map[string]happydns.PluginVersionInfo "Map of plugin names to version info" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests [get] +func (uc *TestPluginController) ListTestPlugins(c *gin.Context) { + uc.BaseTestPluginController.ListTestPlugins(c) +} + +// GetTestPluginStatus retrieves the status and available options for a test plugin. +// +// @Summary Get test plugin status +// @Schemes +// @Description Retrieves the status information and available options for a specific test plugin. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Success 200 {object} happydns.PluginStatus "Plugin status with version info and available options" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Router /plugins/tests/{pid} [get] +func (uc *TestPluginController) GetTestPluginStatus(c *gin.Context) { + uc.BaseTestPluginController.GetTestPluginStatus(c) +} + +// TestPluginHandler is a middleware that retrieves a test plugin by name and sets it in the context. +func (uc *TestPluginController) TestPluginHandler(c *gin.Context) { + pname := c.Param("pid") + + plugin, err := uc.testPluginService.GetTestPlugin(pname) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Plugin not found"}) + return + } + + c.Set("plugin", plugin) + + c.Next() +} + +// TestPluginOptionHandler is a middleware that retrieves a specific plugin option for the authenticated user and sets it in the context. +func (uc *TestPluginController) TestPluginOptionHandler(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + pname := c.Param("pid") + optname := c.Param("optname") + + opts, err := uc.testPluginService.GetTestPluginOptions(pname, &user.Id, nil, nil) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()}) + return + } + + c.Set("option", (*opts)[optname]) + + c.Next() +} + +// GetTestPluginOptions retrieves all options for a test plugin for the authenticated user. +// +// @Summary Get test plugin options +// @Schemes +// @Description Retrieves all configuration options for a specific test plugin for the authenticated user. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Success 200 {object} happydns.PluginOptions "Plugin options as key-value pairs" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pid}/options [get] +func (uc *TestPluginController) GetTestPluginOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + pname := c.Param("pid") + + uc.GetTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil) +} + +// AddTestPluginOptions adds or overwrites specific options for a test plugin for the authenticated user. +// +// @Summary Add test plugin options +// @Schemes +// @Description Adds or overwrites specific configuration options for a test plugin for the authenticated user without affecting other options. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Param body body happydns.SetPluginOptionsRequest true "Options to add or overwrite" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pid}/options [post] +func (uc *TestPluginController) AddTestPluginOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + pname := c.Param("pid") + + uc.AddTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil) +} + +// ChangeTestPluginOptions replaces all options for a test plugin for the authenticated user. +// +// @Summary Replace test plugin options +// @Schemes +// @Description Replaces all configuration options for a test plugin for the authenticated user with the provided options. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Param body body happydns.SetPluginOptionsRequest true "New complete set of options" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pid}/options [put] +func (uc *TestPluginController) ChangeTestPluginOptions(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + pname := c.Param("pid") + + uc.ChangeTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil) +} + +// GetTestPluginOption retrieves a specific option value for a test plugin for the authenticated user. +// +// @Summary Get test plugin option +// @Schemes +// @Description Retrieves the value of a specific configuration option for a test plugin for the authenticated user. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Param optname path string true "Option name" +// @Success 200 {object} object "Option value (type varies)" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pid}/options/{optname} [get] +func (uc *TestPluginController) GetTestPluginOption(c *gin.Context) { + uc.GetTestPluginOptionValue(c) +} + +// SetTestPluginOption sets or updates a specific option value for a test plugin for the authenticated user. +// +// @Summary Set test plugin option +// @Schemes +// @Description Sets or updates the value of a specific configuration option for a test plugin for the authenticated user. +// @Tags plugins +// @Accept json +// @Produce json +// @Param pid path string true "Plugin name" +// @Param optname path string true "Option name" +// @Param body body object true "Option value (type varies by option)" +// @Success 200 {object} bool "Success status" +// @Failure 400 {object} happydns.ErrorResponse "Invalid request body" +// @Failure 404 {object} happydns.ErrorResponse "Plugin not found" +// @Failure 500 {object} happydns.ErrorResponse "Internal server error" +// @Router /plugins/tests/{pid}/options/{optname} [put] +func (uc *TestPluginController) SetTestPluginOption(c *gin.Context) { + user := c.MustGet("LoggedUser").(*happydns.User) + pname := c.Param("pid") + optname := c.Param("optname") + + uc.SetTestPluginOptionWithScope(c, pname, optname, &user.Id, nil, nil) +} diff --git a/internal/api/controller/service.go b/internal/api/controller/service.go index 820f19eb..93c1f900 100644 --- a/internal/api/controller/service.go +++ b/internal/api/controller/service.go @@ -93,7 +93,7 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) { c.JSON(http.StatusOK, zone) } -// GetServiceService retrieves the designated Service. +// GetZoneService retrieves the designated Service. // // @Summary Get the Service. // @Schemes diff --git a/internal/api/controller/testresult_controller.go b/internal/api/controller/testresult_controller.go new file mode 100644 index 00000000..7aad3bd5 --- /dev/null +++ b/internal/api/controller/testresult_controller.go @@ -0,0 +1,583 @@ +// 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 . +// +// 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 . + +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) + GetSchedulerStatus() happydns.SchedulerStatus + SetEnabled(enabled bool) error + RescheduleUpcomingTests() (int, 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: true, // enabled by default unless explicitly disabled via a schedule + } + + // 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) +} diff --git a/internal/api/controller/testschedule_controller.go b/internal/api/controller/testschedule_controller.go new file mode 100644 index 00000000..611286a4 --- /dev/null +++ b/internal/api/controller/testschedule_controller.go @@ -0,0 +1,231 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2024 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 . +// +// 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 . + +package controller + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/middleware" + "git.happydns.org/happyDomain/model" +) + +// TestScheduleController handles test schedule operations +type TestScheduleController struct { + testScheduleUC happydns.TestScheduleUsecase +} + +func NewTestScheduleController(testScheduleUC happydns.TestScheduleUsecase) *TestScheduleController { + return &TestScheduleController{ + testScheduleUC: testScheduleUC, + } +} + +// ListTestSchedules retrieves schedules for the authenticated user +// +// @Summary List test schedules +// @Description Retrieves test schedules for the authenticated user with optional pagination +// @Tags test-schedules +// @Produce json +// @Param limit query int false "Maximum number of schedules to return (0 = all)" +// @Param offset query int false "Number of schedules to skip (default: 0)" +// @Success 200 {array} happydns.TestSchedule +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules [get] +func (tc *TestScheduleController) ListTestSchedules(c *gin.Context) { + user := middleware.MyUser(c) + + schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id) + if err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + // Apply pagination + limit := 0 + offset := 0 + fmt.Sscanf(c.Query("limit"), "%d", &limit) + fmt.Sscanf(c.Query("offset"), "%d", &offset) + + if offset > len(schedules) { + offset = len(schedules) + } + schedules = schedules[offset:] + if limit > 0 && len(schedules) > limit { + schedules = schedules[:limit] + } + + c.JSON(http.StatusOK, schedules) +} + +// CreateTestSchedule creates a new test schedule +// +// @Summary Create test schedule +// @Description Creates a new test schedule for the authenticated user +// @Tags test-schedules +// @Accept json +// @Produce json +// @Param body body happydns.TestSchedule true "Test schedule to create" +// @Success 201 {object} happydns.TestSchedule +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules [post] +func (tc *TestScheduleController) CreateTestSchedule(c *gin.Context) { + user := middleware.MyUser(c) + + var schedule happydns.TestSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + // Set user ID + schedule.OwnerId = user.Id + + if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusCreated, schedule) +} + +// GetTestSchedule retrieves a specific schedule +// +// @Summary Get test schedule +// @Description Retrieves a specific test schedule by ID +// @Tags test-schedules +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Success 200 {object} happydns.TestSchedule +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [get] +func (tc *TestScheduleController) GetTestSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + schedule, err := tc.testScheduleUC.GetSchedule(scheduleId) + if err != nil { + middleware.ErrorResponse(c, http.StatusNotFound, err) + return + } + + c.JSON(http.StatusOK, schedule) +} + +// UpdateTestSchedule updates an existing schedule +// +// @Summary Update test schedule +// @Description Updates an existing test schedule +// @Tags test-schedules +// @Accept json +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Param body body happydns.TestSchedule true "Updated schedule" +// @Success 200 {object} happydns.TestSchedule +// @Failure 400 {object} happydns.ErrorResponse +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [put] +func (tc *TestScheduleController) UpdateTestSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + var schedule happydns.TestSchedule + if err := c.ShouldBindJSON(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, err) + return + } + + // Ensure ID matches + schedule.Id = scheduleId + schedule.OwnerId = user.Id + + if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, schedule) +} + +// DeleteTestSchedule deletes a schedule +// +// @Summary Delete test schedule +// @Description Deletes a test schedule +// @Tags test-schedules +// @Produce json +// @Param schedule_id path string true "Schedule ID" +// @Success 204 "No Content" +// @Failure 404 {object} happydns.ErrorResponse +// @Failure 500 {object} happydns.ErrorResponse +// @Router /plugins/tests/schedules/{schedule_id} [delete] +func (tc *TestScheduleController) DeleteTestSchedule(c *gin.Context) { + user := middleware.MyUser(c) + scheduleIdStr := c.Param("schedule_id") + + scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr) + if err != nil { + middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID")) + return + } + + // Verify ownership + if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil { + middleware.ErrorResponse(c, http.StatusForbidden, err) + return + } + + if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil { + middleware.ErrorResponse(c, http.StatusInternalServerError, err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index c58fd768..c0830664 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -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) diff --git a/internal/api/route/plugin.go b/internal/api/route/plugin.go new file mode 100644 index 00000000..6fde192e --- /dev/null +++ b/internal/api/route/plugin.go @@ -0,0 +1,49 @@ +// 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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + happydns "git.happydns.org/happyDomain/model" +) + +func DeclarePluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) { + tpc := controller.NewTestPluginController(dependancies.TestPluginUsecase()) + + router.GET("/plugins/tests", tpc.ListTestPlugins) + + apiTestPluginRoutes := router.Group("/plugins/tests/:pid") + apiTestPluginRoutes.Use(tpc.TestPluginHandler) + + apiTestPluginRoutes.GET("", tpc.GetTestPluginStatus) + + apiTestPluginRoutes.GET("/options", tpc.GetTestPluginOptions) + apiTestPluginRoutes.POST("/options", tpc.AddTestPluginOptions) + apiTestPluginRoutes.PUT("/options", tpc.ChangeTestPluginOptions) + + apiTestPluginOptionsRoutes := apiTestPluginRoutes.Group("/options/:optname") + apiTestPluginOptionsRoutes.Use(tpc.TestPluginOptionHandler) + apiTestPluginOptionsRoutes.GET("", tpc.GetTestPluginOption) + apiTestPluginOptionsRoutes.PUT("", tpc.SetTestPluginOption) +} diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 208c4886..2276eb84 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -77,9 +77,11 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dependancies happy DeclareAuthenticationCheckRoutes(apiAuthRoutes, dependancies, lc) DeclareDomainRoutes(apiAuthRoutes, dependancies) + DeclarePluginsRoutes(apiAuthRoutes, dependancies) DeclareProviderRoutes(apiAuthRoutes, dependancies) DeclareProviderSettingsRoutes(apiAuthRoutes, dependancies) DeclareRecordRoutes(apiAuthRoutes, dependancies) + DeclareTestScheduleRoutes(apiAuthRoutes, dependancies) DeclareUsersRoutes(apiAuthRoutes, dependancies, lc) DeclareSessionRoutes(apiAuthRoutes, dependancies) } diff --git a/internal/api/route/service.go b/internal/api/route/service.go index 6927660f..76380dc0 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -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) } diff --git a/internal/api/route/testresults.go b/internal/api/route/testresults.go new file mode 100644 index 00000000..87d8d1be --- /dev/null +++ b/internal/api/route/testresults.go @@ -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 . +// +// 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 . + +package route + +import ( + "github.com/gin-gonic/gin" + + "git.happydns.org/happyDomain/internal/api/controller" + "git.happydns.org/happyDomain/model" +) + +// 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.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) + } + } +} diff --git a/internal/app/app.go b/internal/app/app.go index b5b45b3d..315caaa7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -40,9 +40,11 @@ import ( domainUC "git.happydns.org/happyDomain/internal/usecase/domain" domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/orchestrator" + pluginUC "git.happydns.org/happyDomain/internal/usecase/plugin" 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" @@ -63,6 +65,9 @@ type Usecases struct { session happydns.SessionUsecase service happydns.ServiceUsecase serviceSpecs happydns.ServiceSpecsUsecase + testPlugin happydns.TestPluginUsecase + testResult happydns.TestResultUsecase + testSchedule happydns.TestScheduleUsecase user happydns.UserUsecase zone happydns.ZoneUsecase zoneService happydns.ZoneServiceUsecase @@ -71,14 +76,16 @@ type Usecases struct { } type App struct { - cfg *happydns.Options - mailer *mailer.Mailer - newsletter happydns.NewsletterSubscriptor - router *gin.Engine - srv *http.Server - insights *insightsCollector - store storage.Storage - usecases Usecases + cfg *happydns.Options + mailer *mailer.Mailer + newsletter happydns.NewsletterSubscriptor + router *gin.Engine + srv *http.Server + insights *insightsCollector + testScheduler happydns.AdminSchedulerUsecase + plugins happydns.PluginManager + store storage.Storage + usecases Usecases } func (a *App) AuthenticationUsecase() happydns.AuthenticationUsecase { @@ -137,6 +144,22 @@ func (a *App) SessionUsecase() happydns.SessionUsecase { return a.usecases.session } +func (a *App) TestPluginUsecase() happydns.TestPluginUsecase { + return a.usecases.testPlugin +} + +func (a *App) TestResultUsecase() happydns.TestResultUsecase { + return a.usecases.testResult +} + +func (a *App) TestScheduleUsecase() happydns.TestScheduleUsecase { + return a.usecases.testSchedule +} + +func (a *App) TestScheduler() happydns.AdminSchedulerUsecase { + return a.testScheduler +} + func (a *App) UserUsecase() happydns.UserUsecase { return a.usecases.user } @@ -166,7 +189,9 @@ func NewApp(cfg *happydns.Options) *App { app.initStorageEngine() app.initNewsletter() app.initInsights() + app.initPlugins() app.initUsecases() + app.initTestScheduler() app.setupRouter() return app @@ -180,7 +205,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App { app.initMailer() app.initNewsletter() + app.initPlugins() app.initUsecases() + app.initTestScheduler() app.setupRouter() return app @@ -243,6 +270,19 @@ func (app *App) initInsights() { } } +func (app *App) initTestScheduler() { + if !app.cfg.DisableScheduler { + app.testScheduler = newTestScheduler( + app.cfg, + app.store, + app.usecases.testPlugin, + ) + } else { + // Use a disabled scheduler that returns clear errors + app.testScheduler = &disabledScheduler{} + } +} + func (app *App) initUsecases() { sessionService := sessionUC.NewService(app.store) authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService) @@ -270,6 +310,9 @@ func (app *App) initUsecases() { app.usecases.authUser = authUserService 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.testSchedule = testresultUC.NewTestScheduleUsecase(app.store, app.cfg) app.usecases.orchestrator = orchestrator.NewOrchestrator( domainLogService, @@ -310,6 +353,11 @@ func (app *App) Start() { go app.insights.Run() } + // Start the test scheduler if it's the real implementation (not disabled) + if scheduler, ok := app.testScheduler.(*testScheduler); ok && scheduler != nil { + go scheduler.Run() + } + 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) @@ -331,4 +379,9 @@ func (app *App) Stop() { if app.insights != nil { app.insights.Close() } + + // Close the test scheduler if it's the real implementation (not disabled) + if scheduler, ok := app.testScheduler.(*testScheduler); ok && scheduler != nil { + scheduler.Close() + } } diff --git a/internal/app/plugins.go b/internal/app/plugins.go new file mode 100644 index 00000000..f3c0340a --- /dev/null +++ b/internal/app/plugins.go @@ -0,0 +1,111 @@ +// 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 . +// +// 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 . + +package app + +import ( + "errors" + "fmt" + "log" + "os" + "path" + "plugin" + + "git.happydns.org/happyDomain/model" +) + +func (a *App) initPlugins() error { + manager := PluginManger{ + testsIdx: map[string]happydns.TestPlugin{}, + } + a.plugins = &manager + + var ret error + + for _, directory := range a.cfg.PluginsDirectories { + files, err := os.ReadDir(directory) + if err != nil { + ret = errors.Join(ret, err) + continue + } + + for _, file := range files { + if file.IsDir() { + continue + } + + fname := path.Join(directory, file.Name()) + + err = manager.loadPlugin(fname) + if err != nil { + ret = errors.Join(ret, fmt.Errorf("unable to load plugin %q: %w", fname, err)) + } + } + } + + return ret +} + +type PluginManger struct { + tests []happydns.TestPlugin + testsIdx map[string]happydns.TestPlugin +} + +func (m *PluginManger) loadPlugin(fname string) error { + p, err := plugin.Open(fname) + if err != nil { + return err + } + + newplugin, err := p.Lookup("NewTestPlugin") + if err != nil { + return err + } + + myplugin, err := newplugin.(func() (happydns.TestPlugin, error))() + if err != nil { + return err + } + + m.tests = append(m.tests, myplugin) + + // Index the plugin by its names + pluginNames := myplugin.PluginEnvName() + for _, name := range pluginNames { + if p, exists := m.testsIdx[name]; exists { + log.Printf("Plugin name conflict: the plugin at %q tries to register the name %q but it's already registered by %q", fname, name, p.Version().Name) + continue + } + + m.testsIdx[name] = myplugin + } + + log.Printf("Plugin %s loaded (version %s)", myplugin.Version().Name, myplugin.Version().Version) + return nil +} + +func (m *PluginManger) GetTestPlugins() []happydns.TestPlugin { + return m.tests +} + +func (m *PluginManger) GetTestPluginsIndex() map[string]happydns.TestPlugin { + return m.testsIdx +} diff --git a/internal/app/testscheduler.go b/internal/app/testscheduler.go new file mode 100644 index 00000000..ddfca0f3 --- /dev/null +++ b/internal/app/testscheduler.go @@ -0,0 +1,727 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +package app + +import ( + "context" + "fmt" + "log" + "runtime" + "sync" + "time" + + "git.happydns.org/happyDomain/internal/storage" + "git.happydns.org/happyDomain/internal/usecase/testresult" + "git.happydns.org/happyDomain/model" +) + +const ( + SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests + SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions + SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets + TestExecutionTimeout = 5 * time.Minute // Max time for a single test + MaxRetries = 3 // Max retry attempts for failed tests +) + +// Priority levels for test execution queue +const ( + PriorityOnDemand = iota // On-demand tests (highest priority) + PriorityOverdue // Overdue scheduled tests + PriorityScheduled // Regular scheduled tests +) + +// testScheduler manages background test execution +type testScheduler struct { + cfg *happydns.Options + store storage.Storage + pluginUsecase happydns.TestPluginUsecase + resultUsecase *testresult.TestResultUsecase + scheduleUsecase *testresult.TestScheduleUsecase + stop chan bool + runNowChan chan *happydns.TestSchedule + queue *priorityQueue + activeExecutions map[string]*activeExecution + workers []*worker + mu sync.RWMutex + wg sync.WaitGroup + runtimeEnabled bool + running bool +} + +// activeExecution tracks a running test execution +type activeExecution struct { + execution *happydns.TestExecution + cancel context.CancelFunc + startTime time.Time +} + +// queueItem represents a test execution request in the queue +type queueItem struct { + schedule *happydns.TestSchedule + execution *happydns.TestExecution + priority int + queuedAt time.Time + retries int +} + +// priorityQueue manages test execution queue with priority levels +type priorityQueue struct { + items []*queueItem + mu sync.Mutex +} + +// newPriorityQueue creates a new priority queue +func newPriorityQueue() *priorityQueue { + return &priorityQueue{ + items: make([]*queueItem, 0), + } +} + +// Push adds an item to the queue +func (q *priorityQueue) Push(item *queueItem) { + q.mu.Lock() + defer q.mu.Unlock() + + q.items = append(q.items, item) + + // Sort by priority (lower number = higher priority) + // Within same priority, FIFO order + for i := len(q.items) - 1; i > 0; i-- { + if q.items[i].priority < q.items[i-1].priority { + q.items[i], q.items[i-1] = q.items[i-1], q.items[i] + } else { + break + } + } +} + +// Pop removes and returns the highest priority item +func (q *priorityQueue) Pop() *queueItem { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.items) == 0 { + return nil + } + + item := q.items[0] + q.items = q.items[1:] + return item +} + +// Len returns the queue length +func (q *priorityQueue) Len() int { + q.mu.Lock() + defer q.mu.Unlock() + return len(q.items) +} + +// worker processes tests from the queue +type worker struct { + id int + scheduler *testScheduler + stop chan bool +} + +// disabledScheduler is a no-op implementation used when scheduler is disabled +type disabledScheduler struct{} + +// TriggerOnDemandTest returns an error indicating the scheduler is disabled +func (d *disabledScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error) { + return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration") +} + +// GetSchedulerStatus returns a status indicating the scheduler is disabled +func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus { + return happydns.SchedulerStatus{ + ConfigEnabled: false, + RuntimeEnabled: false, + Running: false, + } +} + +// SetEnabled returns an error since the scheduler is disabled in configuration +func (d *disabledScheduler) SetEnabled(enabled bool) error { + return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime") +} + +// RescheduleUpcomingTests returns an error since the scheduler is disabled +func (d *disabledScheduler) RescheduleUpcomingTests() (int, error) { + return 0, fmt.Errorf("test scheduler is disabled in configuration") +} + +// newTestScheduler creates a new test scheduler +func newTestScheduler( + cfg *happydns.Options, + store storage.Storage, + pluginUsecase happydns.TestPluginUsecase, +) *testScheduler { + numWorkers := cfg.TestWorkers + if numWorkers <= 0 { + numWorkers = runtime.NumCPU() + } + + scheduler := &testScheduler{ + cfg: cfg, + store: store, + pluginUsecase: pluginUsecase, + resultUsecase: testresult.NewTestResultUsecase(store, cfg), + scheduleUsecase: testresult.NewTestScheduleUsecase(store, cfg), + stop: make(chan bool), + runNowChan: make(chan *happydns.TestSchedule, 100), + queue: newPriorityQueue(), + activeExecutions: make(map[string]*activeExecution), + workers: make([]*worker, numWorkers), + runtimeEnabled: true, + } + + // Create workers + for i := 0; i < numWorkers; i++ { + scheduler.workers[i] = &worker{ + id: i, + scheduler: scheduler, + stop: make(chan bool), + } + } + + return scheduler +} + +// Close stops the scheduler +func (s *testScheduler) Close() { + log.Println("Stopping test scheduler...") + + // Stop the main loop + s.stop <- true + + // Stop all workers + for _, w := range s.workers { + w.stop <- true + } + + // Cancel all active executions + s.mu.Lock() + for _, exec := range s.activeExecutions { + exec.cancel() + } + s.mu.Unlock() + + // Wait for all workers to finish + s.wg.Wait() + + log.Println("Test scheduler stopped") +} + +// Run starts the scheduler main loop +func (s *testScheduler) Run() { + if s.cfg.DisableScheduler { + log.Println("Test scheduler disabled by configuration") + return + } + + s.mu.Lock() + s.running = true + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.running = false + s.mu.Unlock() + }() + + log.Printf("Starting test scheduler with %d workers...\n", len(s.workers)) + + // Reschedule overdue tests before starting workers so that tests missed + // during a server suspend or shutdown are spread into the near future + // instead of all firing at once. + if n, err := s.scheduleUsecase.RescheduleOverdueTests(); err != nil { + log.Printf("Warning: failed to reschedule overdue tests: %v\n", err) + } else if n > 0 { + log.Printf("Rescheduled %d overdue test(s) into the near future\n", n) + } + + // Start workers + for _, w := range s.workers { + s.wg.Add(1) + go w.run(&s.wg) + } + + // Main scheduling loop + checkTicker := time.NewTicker(SchedulerCheckInterval) + cleanupTicker := time.NewTicker(SchedulerCleanupInterval) + discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval) + defer checkTicker.Stop() + defer cleanupTicker.Stop() + defer discoveryTicker.Stop() + + // Initial discovery: create default schedules for all existing targets + s.discoverAndEnsureSchedules() + // Initial check + s.checkSchedules() + + for { + select { + case <-checkTicker.C: + s.checkSchedules() + + case <-cleanupTicker.C: + s.cleanup() + + case <-discoveryTicker.C: + s.discoverAndEnsureSchedules() + + case schedule := <-s.runNowChan: + s.queueOnDemandTest(schedule) + + case <-s.stop: + return + } + } +} + +// checkSchedules checks for due tests and queues them +func (s *testScheduler) checkSchedules() { + s.mu.RLock() + enabled := s.runtimeEnabled + s.mu.RUnlock() + if !enabled { + return + } + + dueSchedules, err := s.scheduleUsecase.ListDueSchedules() + if err != nil { + log.Printf("Error listing due schedules: %v\n", err) + return + } + + now := time.Now() + for _, schedule := range dueSchedules { + // Determine priority based on how overdue the test is + priority := PriorityScheduled + if schedule.NextRun.Add(schedule.Interval).Before(now) { + priority = PriorityOverdue + } + + // Create execution record + execution := &happydns.TestExecution{ + ScheduleId: &schedule.Id, + PluginName: schedule.PluginName, + OwnerId: schedule.OwnerId, + TargetType: schedule.TargetType, + TargetId: schedule.TargetId, + Status: happydns.TestExecutionPending, + StartedAt: time.Now(), + Options: schedule.Options, + } + + if err := s.resultUsecase.CreateTestExecution(execution); err != nil { + log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id, err) + continue + } + + // Queue the test + s.queue.Push(&queueItem{ + schedule: schedule, + execution: execution, + priority: priority, + queuedAt: now, + retries: 0, + }) + } + + // Mark scheduler run + if err := s.store.TestSchedulerRun(); err != nil { + log.Printf("Error marking scheduler run: %v\n", err) + } +} + +// discoverAndEnsureSchedules creates default (enabled) schedules for all +// (plugin, target) pairs that don't yet have an explicit schedule record. +// This implements the opt-out model: tests run automatically unless a schedule +// with Enabled=false has been explicitly saved. +func (s *testScheduler) discoverAndEnsureSchedules() { + s.mu.RLock() + enabled := s.runtimeEnabled + s.mu.RUnlock() + if !enabled { + return + } + + plugins, err := s.pluginUsecase.ListTestPlugins() + if err != nil { + log.Printf("Error listing test plugins for discovery: %v\n", err) + return + } + + // Filter domain-level plugins + var domainPlugins []happydns.TestPlugin + for _, p := range plugins { + if p.Version().AvailableOn.ApplyToDomain { + domainPlugins = append(domainPlugins, p) + } + } + + if len(domainPlugins) > 0 { + iter, err := s.store.ListAllDomains() + if err != nil { + log.Printf("Error listing domains for schedule discovery: %v\n", err) + } else { + defer iter.Close() + for iter.Next() { + domain := iter.Item() + if domain == nil { + continue + } + for _, plugin := range domainPlugins { + pluginName := plugin.Version().Name + schedules, err := s.scheduleUsecase.ListSchedulesByTarget(happydns.TestScopeDomain, domain.Id) + if err != nil { + continue + } + + hasSchedule := false + for _, sched := range schedules { + if sched.PluginName == pluginName { + hasSchedule = true + break + } + } + + if !hasSchedule { + if err := s.scheduleUsecase.CreateSchedule(&happydns.TestSchedule{ + PluginName: pluginName, + OwnerId: domain.Owner, + TargetType: happydns.TestScopeDomain, + TargetId: domain.Id, + Enabled: true, + }); err != nil { + log.Printf("Error auto-creating schedule for domain %s / plugin %s: %v\n", + domain.Id, pluginName, err) + } + } + } + } + } + } + + // Service-level plugin discovery is deferred: services live inside zones + // and enumeration would require iterating all zones across all domains. + // Services get auto-scheduled on their first explicit interaction instead. +} + +// queueOnDemandTest queues an on-demand test execution +func (s *testScheduler) queueOnDemandTest(schedule *happydns.TestSchedule) { + execution := &happydns.TestExecution{ + ScheduleId: nil, // On-demand has no schedule + PluginName: schedule.PluginName, + OwnerId: schedule.OwnerId, + TargetType: schedule.TargetType, + TargetId: schedule.TargetId, + Status: happydns.TestExecutionPending, + StartedAt: time.Now(), + Options: schedule.Options, + } + + if err := s.resultUsecase.CreateTestExecution(execution); err != nil { + log.Printf("Error creating on-demand execution: %v\n", err) + return + } + + s.queue.Push(&queueItem{ + schedule: schedule, + execution: execution, + priority: PriorityOnDemand, + queuedAt: time.Now(), + retries: 0, + }) +} + +// TriggerOnDemandTest triggers an immediate test execution +func (s *testScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error) { + // Create a temporary schedule for on-demand execution + schedule := &happydns.TestSchedule{ + PluginName: pluginName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Interval: 0, // On-demand, no interval + Enabled: true, + Options: options, + } + + // Create execution record + execution := &happydns.TestExecution{ + ScheduleId: nil, + PluginName: pluginName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Status: happydns.TestExecutionPending, + StartedAt: time.Now(), + Options: options, + } + + if err := s.resultUsecase.CreateTestExecution(execution); err != nil { + return happydns.Identifier{}, err + } + + // Queue with highest priority + s.queue.Push(&queueItem{ + schedule: schedule, + execution: execution, + priority: PriorityOnDemand, + queuedAt: time.Now(), + retries: 0, + }) + + return execution.Id, nil +} + +// GetSchedulerStatus returns a snapshot of the current scheduler state +func (s *testScheduler) GetSchedulerStatus() happydns.SchedulerStatus { + s.mu.RLock() + activeCount := len(s.activeExecutions) + running := s.running + runtimeEnabled := s.runtimeEnabled + s.mu.RUnlock() + + nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20) + + return happydns.SchedulerStatus{ + ConfigEnabled: !s.cfg.DisableScheduler, + RuntimeEnabled: runtimeEnabled, + Running: running, + WorkerCount: len(s.workers), + QueueSize: s.queue.Len(), + ActiveCount: activeCount, + NextSchedules: nextSchedules, + } +} + +// SetEnabled enables or disables the scheduler at runtime +func (s *testScheduler) SetEnabled(enabled bool) error { + s.mu.Lock() + s.runtimeEnabled = enabled + s.mu.Unlock() + return nil +} + +// RescheduleUpcomingTests randomizes the next run time of all enabled schedules +// within their respective intervals, delegating to the schedule usecase. +func (s *testScheduler) RescheduleUpcomingTests() (int, error) { + return s.scheduleUsecase.RescheduleUpcomingTests() +} + +// cleanup removes old execution records and expired test results +func (s *testScheduler) cleanup() { + log.Println("Running scheduler cleanup...") + + // Delete completed/failed execution records older than 7 days + if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil { + log.Printf("Error cleaning up old executions: %v\n", err) + } + + // Delete test results older than the configured retention period + if err := s.resultUsecase.CleanupOldResults(); err != nil { + log.Printf("Error cleaning up old test results: %v\n", err) + } + + log.Println("Scheduler cleanup complete") +} + +// worker.run processes tests from the queue +func (w *worker) run(wg *sync.WaitGroup) { + defer wg.Done() + + log.Printf("Worker %d started\n", w.id) + + for { + select { + case <-w.stop: + log.Printf("Worker %d stopped\n", w.id) + return + default: + // Try to get work from queue + item := w.scheduler.queue.Pop() + if item == nil { + // No work, sleep briefly + time.Sleep(1 * time.Second) + continue + } + + // Execute the test + w.executeTest(item) + } + } +} + +// executeTest runs a test plugin and stores the result +func (w *worker) executeTest(item *queueItem) { + ctx, cancel := context.WithTimeout(context.Background(), TestExecutionTimeout) + defer cancel() + + execution := item.execution + schedule := item.schedule + + // Always update schedule NextRun after execution, whether it succeeds or fails. + // This prevents the schedule from being re-queued on the next tick if the test fails. + if item.execution.ScheduleId != nil { + defer func() { + if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil { + log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err) + } + }() + } + + // Mark execution as running + execution.Status = happydns.TestExecutionRunning + if err := w.scheduler.resultUsecase.UpdateTestExecution(execution); err != nil { + log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err) + _ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, err.Error()) + return + } + + // Track active execution + w.scheduler.mu.Lock() + w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{ + execution: execution, + cancel: cancel, + startTime: time.Now(), + } + w.scheduler.mu.Unlock() + + defer func() { + w.scheduler.mu.Lock() + delete(w.scheduler.activeExecutions, execution.Id.String()) + w.scheduler.mu.Unlock() + }() + + // Get the plugin + plugin, err := w.scheduler.pluginUsecase.GetTestPlugin(schedule.PluginName) + if err != nil { + errMsg := fmt.Sprintf("plugin not found: %s - %v", schedule.PluginName, err) + log.Printf("Worker %d: %s\n", w.id, errMsg) + _ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, errMsg) + return + } + + // Merge options: global defaults < user opts < domain/service opts < schedule opts + var domainId, serviceId *happydns.Identifier + switch schedule.TargetType { + case happydns.TestScopeDomain: + domainId = &schedule.TargetId + case happydns.TestScopeService: + serviceId = &schedule.TargetId + } + baseOptions, err := w.scheduler.pluginUsecase.GetTestPluginOptions(schedule.PluginName, &schedule.OwnerId, domainId, serviceId) + if err != nil { + log.Printf("Worker %d: warning, could not fetch plugin options for %s: %v\n", w.id, schedule.PluginName, err) + } + var mergedOptions happydns.PluginOptions + if baseOptions != nil { + mergedOptions = w.scheduler.scheduleUsecase.MergePluginOptions(nil, nil, *baseOptions, schedule.Options) + } else { + mergedOptions = schedule.Options + } + + // Prepare metadata + meta := make(map[string]string) + meta["target_type"] = schedule.TargetType.String() + meta["target_id"] = schedule.TargetId.String() + + // Run the test + startTime := time.Now() + resultChan := make(chan *happydns.PluginResult, 1) + errorChan := make(chan error, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + errorChan <- fmt.Errorf("plugin panicked: %v", r) + } + }() + result, err := plugin.RunTest(mergedOptions, meta) + if err != nil { + errorChan <- err + } else { + resultChan <- result + } + }() + + // Wait for result or timeout + var pluginResult *happydns.PluginResult + var testErr error + + select { + case pluginResult = <-resultChan: + // Test completed successfully + case testErr = <-errorChan: + // Test returned an error + case <-ctx.Done(): + // Timeout + testErr = fmt.Errorf("test execution timeout after %v", TestExecutionTimeout) + } + + duration := time.Since(startTime) + + // Store the result + result := &happydns.TestResult{ + PluginName: schedule.PluginName, + TestType: schedule.TargetType, + TargetId: schedule.TargetId, + OwnerId: schedule.OwnerId, + ExecutedAt: time.Now(), + ScheduledTest: item.execution.ScheduleId != nil, + Options: schedule.Options, + Duration: duration, + } + + if testErr != nil { + result.Status = happydns.PluginResultStatusKO + result.StatusLine = "Test execution failed" + result.Error = testErr.Error() + } else if pluginResult != nil { + result.Status = pluginResult.Status + result.StatusLine = pluginResult.StatusLine + result.Report = pluginResult.Report + } else { + result.Status = happydns.PluginResultStatusKO + result.StatusLine = "Unknown error" + result.Error = "No result or error returned from plugin" + } + + // Save the result + if err := w.scheduler.resultUsecase.CreateTestResult(result); err != nil { + log.Printf("Worker %d: Error saving test result: %v\n", w.id, err) + _ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, err.Error()) + return + } + + // Complete the execution + if err := w.scheduler.resultUsecase.CompleteTestExecution(execution.Id, result.Id); err != nil { + log.Printf("Worker %d: Error completing execution: %v\n", w.id, err) + return + } + + log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n", + w.id, schedule.PluginName, schedule.TargetId, result.Status, duration) +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 9d5f2515..00abf21c 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -57,6 +57,8 @@ func declareFlags(o *happydns.Options) { flag.StringVar(&o.MailSMTPPassword, "mail-smtp-password", o.MailSMTPPassword, "Password associated with the given username for SMTP authentication") flag.BoolVar(&o.MailSMTPTLSSNoVerify, "mail-smtp-tls-no-verify", o.MailSMTPTLSSNoVerify, "Do not verify certificate validity on SMTP connection") + flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)") + // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations } diff --git a/internal/config/config.go b/internal/config/config.go index 6cefb7e9..d1715821 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,15 +44,18 @@ 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, + TestWorkers: 2, } declareFlags(opts) diff --git a/internal/storage/inmemory/database.go b/internal/storage/inmemory/database.go index 824d0cc9..387ff8d7 100644 --- a/internal/storage/inmemory/database.go +++ b/internal/storage/inmemory/database.go @@ -44,6 +44,7 @@ type InMemoryStorage struct { domainLogs map[string]*happydns.DomainLogWithDomainId domainLogsByDomains map[string][]*happydns.Identifier providers map[string]*happydns.ProviderMessage + pluginsCfg map[string]*happydns.PluginOptions sessions map[string]*happydns.Session users map[string]*happydns.User usersByEmail map[string]*happydns.User diff --git a/internal/storage/interface.go b/internal/storage/interface.go index 9b654e62..a120cacd 100644 --- a/internal/storage/interface.go +++ b/internal/storage/interface.go @@ -26,8 +26,10 @@ import ( "git.happydns.org/happyDomain/internal/usecase/domain" "git.happydns.org/happyDomain/internal/usecase/domain_log" "git.happydns.org/happyDomain/internal/usecase/insight" + "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" @@ -43,8 +45,10 @@ type Storage interface { domain.DomainStorage domainlog.DomainLogStorage insight.InsightStorage + plugin.PluginStorage provider.ProviderStorage session.SessionStorage + testresult.TestResultStorage user.UserStorage zone.ZoneStorage diff --git a/internal/storage/kvtpl/plugin.go b/internal/storage/kvtpl/plugin.go new file mode 100644 index 00000000..aa802de2 --- /dev/null +++ b/internal/storage/kvtpl/plugin.go @@ -0,0 +1,185 @@ +// 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 . +// +// 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 . + +package database + +import ( + "errors" + "fmt" + "strings" + + "git.happydns.org/happyDomain/model" +) + +func (s *KVStorage) ListAllPluginConfigurations() (happydns.Iterator[happydns.PluginOptions], error) { + iter := s.db.Search("plugincfg-") + return NewKVIterator[happydns.PluginOptions](s.db, iter), nil +} + +func buildPluginKey(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string { + u := "" + if user != nil { + u = user.String() + } + + d := "" + if domain != nil { + d = domain.String() + } + + s := "" + if service != nil { + s = service.String() + } + + return strings.Join([]string{pname, u, d, s}, "/") +} + +func keyToPositional(key string, opts *happydns.PluginOptions) (*happydns.PluginOptionsPositional, error) { + tmp := strings.Split(key, "/") + + if len(tmp) < 4 { + return nil, fmt.Errorf("malformed plugin configuration key, got %q", key) + } + + pname := tmp[0] + + var userid *happydns.Identifier + if len(tmp[1]) > 0 { + u, err := happydns.NewIdentifierFromString(tmp[1]) + if err != nil { + return nil, err + } + userid = &u + } + + var domainid *happydns.Identifier + if len(tmp[2]) > 0 { + d, err := happydns.NewIdentifierFromString(tmp[2]) + if err != nil { + return nil, err + } + domainid = &d + } + + var serviceid *happydns.Identifier + if len(tmp[3]) > 0 { + s, err := happydns.NewIdentifierFromString(tmp[3]) + if err != nil { + return nil, err + } + serviceid = &s + } + + return &happydns.PluginOptionsPositional{ + PluginName: pname, + UserId: userid, + DomainId: domainid, + ServiceId: serviceid, + Options: *opts, + }, nil +} + +func (s *KVStorage) ListPluginConfiguration(pname string) (configs []*happydns.PluginOptionsPositional, err error) { + iter := s.db.Search("plugincfg-" + pname + "/") + defer iter.Release() + + for iter.Next() { + var p happydns.PluginOptions + + e := s.db.DecodeData(iter.Value(), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "plugincfg-"), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + configs = append(configs, opts) + } + + return +} + +func (s *KVStorage) GetPluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.PluginOptionsPositional, err error) { + iter := s.db.Search("plugincfg-" + pname + "/") + defer iter.Release() + + for iter.Next() { + var p happydns.PluginOptions + + e := s.db.DecodeData(iter.Value(), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "plugincfg-"), &p) + if e != nil { + err = errors.Join(err, e) + continue + } + + // Match logic: + // - When parameter is nil: match ONLY configs with nil ID (requesting specific scope) + // - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID + matchUser := (user == nil && opts.UserId == nil) || + (user != nil && (opts.UserId == nil || opts.UserId.Equals(*user))) + + matchDomain := (domain == nil && opts.DomainId == nil) || + (domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain))) + + matchService := (service == nil && opts.ServiceId == nil) || + (service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service))) + + if matchUser && matchDomain && matchService { + configs = append(configs, opts) + } + } + + return +} + +func (s *KVStorage) UpdatePluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.PluginOptions) error { + return s.db.Put(fmt.Sprintf("plugincfg-%s", buildPluginKey(pname, user, domain, service)), opts) +} + +func (s *KVStorage) DeletePluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error { + return s.db.Delete(fmt.Sprintf("plugincfg-%s", buildPluginKey(pname, user, domain, service))) +} + +func (s *KVStorage) ClearPluginConfigurations() error { + iter := s.db.Search("plugincfg-") + defer iter.Release() + + for iter.Next() { + err := s.db.Delete(iter.Key()) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/storage/kvtpl/testresult.go b/internal/storage/kvtpl/testresult.go new file mode 100644 index 00000000..652444eb --- /dev/null +++ b/internal/storage/kvtpl/testresult.go @@ -0,0 +1,486 @@ +// 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 . +// +// 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 . + +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 +} + +// DeleteTestResultsBefore removes all test results with ExecutedAt older than cutoff +func (s *KVStorage) DeleteTestResultsBefore(cutoff time.Time) error { + iter := s.db.Search("testresult|") + defer iter.Release() + + var toDelete []string + for iter.Next() { + var r happydns.TestResult + if err := s.db.DecodeData(iter.Value(), &r); err != nil { + continue + } + if r.ExecutedAt.Before(cutoff) { + toDelete = append(toDelete, string(iter.Key())) + } + } + + for _, key := range toDelete { + if err := s.db.Delete(key); 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) +} + +// DeleteCompletedExecutionsBefore removes completed or failed execution records older than cutoff +func (s *KVStorage) DeleteCompletedExecutionsBefore(cutoff time.Time) error { + iter := s.db.Search("testexec|") + defer iter.Release() + + var toDelete []string + for iter.Next() { + var exec happydns.TestExecution + if err := s.db.DecodeData(iter.Value(), &exec); err != nil { + continue + } + if exec.Status != happydns.TestExecutionCompleted && exec.Status != happydns.TestExecutionFailed { + continue + } + if exec.CompletedAt != nil && exec.CompletedAt.Before(cutoff) { + toDelete = append(toDelete, string(iter.Key())) + } + } + + for _, key := range toDelete { + if err := s.db.Delete(key); err != nil { + return err + } + } + + return nil +} + +// 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 +} diff --git a/internal/usecase/plugin/plugin_storage.go b/internal/usecase/plugin/plugin_storage.go new file mode 100644 index 00000000..60b5a6ec --- /dev/null +++ b/internal/usecase/plugin/plugin_storage.go @@ -0,0 +1,46 @@ +// 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 . +// +// 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 . + +package plugin + +import ( + "git.happydns.org/happyDomain/model" +) + +type PluginStorage interface { + // ListAllPluginConfigurations retrieves the list of known Providers. + ListAllPluginConfigurations() (happydns.Iterator[happydns.PluginOptions], error) + + // ListPluginConfiguration retrieves all providers own by the given User. + ListPluginConfiguration(string) ([]*happydns.PluginOptionsPositional, error) + + // GetPluginConfiguration retrieves the full Provider with the given identifier and owner. + GetPluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.PluginOptionsPositional, error) + + // UpdatePluginConfiguration updates the fields of the given Provider. + UpdatePluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.PluginOptions) error + + // DeletePluginConfiguration removes the given Provider from the database. + DeletePluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error + + // ClearPluginConfigurations deletes all Providers present in the database. + ClearPluginConfigurations() error +} diff --git a/internal/usecase/plugin/plugin_test_usecase.go b/internal/usecase/plugin/plugin_test_usecase.go new file mode 100644 index 00000000..087cf2b6 --- /dev/null +++ b/internal/usecase/plugin/plugin_test_usecase.go @@ -0,0 +1,163 @@ +// 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 . +// +// 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 . + +package plugin + +import ( + "fmt" + "maps" + "sort" + + "git.happydns.org/happyDomain/model" +) + +type testPluginUsecase struct { + config *happydns.Options + manager happydns.PluginManager + store PluginStorage +} + +func NewTestPluginUsecase(cfg *happydns.Options, manager happydns.PluginManager, store PluginStorage) happydns.TestPluginUsecase { + return &testPluginUsecase{ + config: cfg, + manager: manager, + store: store, + } +} + +func (tu *testPluginUsecase) GetTestPlugin(pname string) (happydns.TestPlugin, error) { + if plugin, ok := tu.manager.GetTestPluginsIndex()[pname]; !ok { + return nil, fmt.Errorf("unable to find plugin named %q", pname) + } else { + return plugin, nil + } +} + +type ByOptionPosition []*happydns.PluginOptionsPositional + +func (a ByOptionPosition) Len() int { return len(a) } +func (a ByOptionPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByOptionPosition) Less(i, j int) bool { + if a[i].PluginName != a[j].PluginName { + return a[i].PluginName < a[j].PluginName + } + + if res := compareIdentifiers(a[i].UserId, a[j].UserId); res != 0 { + return res < 0 + } + + if res := compareIdentifiers(a[i].DomainId, a[j].DomainId); res != 0 { + return res < 0 + } + + if res := compareIdentifiers(a[i].ServiceId, a[j].ServiceId); res != 0 { + return res < 0 + } + + return false +} + +func compareIdentifiers(a, b *happydns.Identifier) int { + if a == nil && b == nil { + return 0 + } + if a == nil { + return -1 + } + if b == nil { + return 1 + } + + if a.Equals(*b) { + return 0 + } + + return a.Compare(*b) +} + +func (tu *testPluginUsecase) GetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.PluginOptions, error) { + configs, err := tu.store.GetPluginConfiguration(pname, userid, domainid, serviceid) + if err != nil { + return nil, err + } + + sort.Sort(ByOptionPosition(configs)) + + opts := make(happydns.PluginOptions) + + for _, c := range configs { + maps.Copy(opts, c.Options) + } + + return &opts, nil +} + +func (tu *testPluginUsecase) ListTestPlugins() ([]happydns.TestPlugin, error) { + return tu.manager.GetTestPlugins(), nil +} + +func (tu *testPluginUsecase) SetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error { + // 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 { + current, err := tu.GetTestPluginOptions(pname, userid, domainid, serviceid) + if err != nil { + return err + } + + maps.Copy(*current, opts) + + return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, *current) +} diff --git a/internal/usecase/plugin/plugin_test_usecase_test.go b/internal/usecase/plugin/plugin_test_usecase_test.go new file mode 100644 index 00000000..a812c2de --- /dev/null +++ b/internal/usecase/plugin/plugin_test_usecase_test.go @@ -0,0 +1,85 @@ +package plugin_test + +import ( + "sort" + "testing" + + uc "git.happydns.org/happyDomain/internal/usecase/plugin" + "git.happydns.org/happyDomain/model" +) + +func TestSortByPluginName(t *testing.T) { + slice := []*happydns.PluginOptionsPositional{ + {PluginName: "zeta"}, + {PluginName: "alpha"}, + {PluginName: "beta"}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + got := []string{slice[0].PluginName, slice[1].PluginName, slice[2].PluginName} + want := []string{"alpha", "beta", "zeta"} + + for i := range want { + if got[i] != want[i] { + t.Errorf("expected %v, got %v", want, got) + break + } + } +} + +func TestNilBeforeNonNil(t *testing.T) { + uid, _ := happydns.NewRandomIdentifier() + slice := []*happydns.PluginOptionsPositional{ + {PluginName: "alpha", UserId: &uid}, + {PluginName: "alpha", UserId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].UserId != nil { + t.Errorf("expected nil UserId first, got %+v", slice[0].UserId) + } +} + +func TestDomainIdOrder(t *testing.T) { + did, _ := happydns.NewRandomIdentifier() + slice := []*happydns.PluginOptionsPositional{ + {PluginName: "alpha", UserId: nil, DomainId: &did}, + {PluginName: "alpha", UserId: nil, DomainId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].DomainId != nil { + t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId) + } +} + +func TestServiceIdOrder(t *testing.T) { + sid, _ := happydns.NewRandomIdentifier() + slice := []*happydns.PluginOptionsPositional{ + {PluginName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid}, + {PluginName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + + if slice[0].ServiceId != nil { + t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId) + } +} + +func TestStableGrouping(t *testing.T) { + uid, _ := happydns.NewRandomIdentifier() + + slice := []*happydns.PluginOptionsPositional{ + {PluginName: "alpha", UserId: &uid}, + {PluginName: "alpha", UserId: &uid}, + } + + sort.Sort(uc.ByOptionPosition(slice)) + if slice[0].PluginName != slice[1].PluginName { + t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1]) + } +} diff --git a/internal/usecase/testresult/storage.go b/internal/usecase/testresult/storage.go new file mode 100644 index 00000000..4ff9ba9f --- /dev/null +++ b/internal/usecase/testresult/storage.go @@ -0,0 +1,104 @@ +// 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 . +// +// 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 . + +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 + + // DeleteTestResultsBefore removes all test results older than the given time + DeleteTestResultsBefore(cutoff time.Time) 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 + + // DeleteCompletedExecutionsBefore removes completed or failed execution records older than the given time + DeleteCompletedExecutionsBefore(cutoff time.Time) 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) +} diff --git a/internal/usecase/testresult/testresult_usecase.go b/internal/usecase/testresult/testresult_usecase.go new file mode 100644 index 00000000..1d51f990 --- /dev/null +++ b/internal/usecase/testresult/testresult_usecase.go @@ -0,0 +1,206 @@ +// 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 . +// +// 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 . + +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 the configured retention period +func (u *TestResultUsecase) CleanupOldResults() error { + retentionDays := u.options.ResultRetentionDays + if retentionDays <= 0 { + retentionDays = 90 // Default + } + + cutoffTime := time.Now().AddDate(0, 0, -retentionDays) + return u.storage.DeleteTestResultsBefore(cutoffTime) +} + +// 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 completed or failed execution records older than olderThan +func (u *TestResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error { + cutoffTime := time.Now().Add(-olderThan) + return u.storage.DeleteCompletedExecutionsBefore(cutoffTime) +} diff --git a/internal/usecase/testresult/testschedule_usecase.go b/internal/usecase/testresult/testschedule_usecase.go new file mode 100644 index 00000000..bb3aec1c --- /dev/null +++ b/internal/usecase/testresult/testschedule_usecase.go @@ -0,0 +1,367 @@ +// 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 . +// +// 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 . + +package testresult + +import ( + "fmt" + "math/rand" + "sort" + "time" + + "git.happydns.org/happyDomain/model" +) + +const ( + // Default test intervals + DefaultUserTestInterval = 4 * time.Hour // 4 hours for domain tests + DefaultDomainTestInterval = 24 * time.Hour // 24 hours for domain tests + DefaultServiceTestInterval = 1 * time.Hour // 1 hour for service tests + MinimumTestInterval = 5 * time.Minute // Minimum interval allowed +) + +// TestScheduleUsecase implements business logic for test schedules +type TestScheduleUsecase struct { + storage TestResultStorage + options *happydns.Options +} + +// NewTestScheduleUsecase creates a new test schedule usecase +func NewTestScheduleUsecase(storage TestResultStorage, options *happydns.Options) *TestScheduleUsecase { + return &TestScheduleUsecase{ + storage: storage, + options: options, + } +} + +// ListUserSchedules retrieves all schedules for a specific user +func (u *TestScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.TestSchedule, error) { + return u.storage.ListTestSchedulesByUser(userId) +} + +// ListSchedulesByTarget retrieves all schedules for a specific target +func (u *TestScheduleUsecase) ListSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) { + return u.storage.ListTestSchedulesByTarget(targetType, targetId) +} + +// GetSchedule retrieves a specific schedule by ID +func (u *TestScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) { + return u.storage.GetTestSchedule(scheduleId) +} + +// CreateSchedule creates a new test schedule with validation +func (u *TestScheduleUsecase) CreateSchedule(schedule *happydns.TestSchedule) error { + // Set default interval if not specified + if schedule.Interval == 0 { + schedule.Interval = u.getDefaultInterval(schedule.TargetType) + } + + // Validate interval + if schedule.Interval < MinimumTestInterval { + return fmt.Errorf("test interval must be at least %v", MinimumTestInterval) + } + + // Calculate next run time: pick a random offset within the interval + // to spread load evenly across all schedules + // TODO: Use a smarter load balance function in the future + if schedule.NextRun.IsZero() { + offset := time.Duration(rand.Int63n(int64(schedule.Interval))) + schedule.NextRun = time.Now().Add(offset) + } + + return u.storage.CreateTestSchedule(schedule) +} + +// UpdateSchedule updates an existing schedule +func (u *TestScheduleUsecase) UpdateSchedule(schedule *happydns.TestSchedule) error { + // Validate interval + if schedule.Interval < MinimumTestInterval { + return fmt.Errorf("test interval must be at least %v", MinimumTestInterval) + } + + // Get existing schedule to preserve certain fields + existing, err := u.storage.GetTestSchedule(schedule.Id) + if err != nil { + return err + } + + // Preserve LastRun if not explicitly changed + if schedule.LastRun == nil { + schedule.LastRun = existing.LastRun + } + + // Recalculate next run time if interval changed + if schedule.Interval != existing.Interval { + if schedule.LastRun != nil { + schedule.NextRun = schedule.LastRun.Add(schedule.Interval) + } else { + schedule.NextRun = time.Now().Add(schedule.Interval) + } + } + + return u.storage.UpdateTestSchedule(schedule) +} + +// DeleteSchedule removes a schedule +func (u *TestScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error { + return u.storage.DeleteTestSchedule(scheduleId) +} + +// EnableSchedule enables a schedule +func (u *TestScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetTestSchedule(scheduleId) + if err != nil { + return err + } + + schedule.Enabled = true + + // Reset next run time if it's in the past + if schedule.NextRun.Before(time.Now()) { + schedule.NextRun = time.Now().Add(schedule.Interval) + } + + return u.storage.UpdateTestSchedule(schedule) +} + +// DisableSchedule disables a schedule +func (u *TestScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetTestSchedule(scheduleId) + if err != nil { + return err + } + + schedule.Enabled = false + return u.storage.UpdateTestSchedule(schedule) +} + +// UpdateScheduleAfterRun updates a schedule after it has been executed +func (u *TestScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error { + schedule, err := u.storage.GetTestSchedule(scheduleId) + if err != nil { + return err + } + + now := time.Now() + schedule.LastRun = &now + schedule.NextRun = now.Add(schedule.Interval) + + return u.storage.UpdateTestSchedule(schedule) +} + +// ListDueSchedules retrieves all enabled schedules that are due to run +func (u *TestScheduleUsecase) ListDueSchedules() ([]*happydns.TestSchedule, error) { + schedules, err := u.storage.ListEnabledTestSchedules() + if err != nil { + return nil, err + } + + now := time.Now() + var dueSchedules []*happydns.TestSchedule + + for _, schedule := range schedules { + if schedule.NextRun.Before(now) { + dueSchedules = append(dueSchedules, schedule) + } + } + + return dueSchedules, nil +} + +// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending +func (u *TestScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.TestSchedule, error) { + schedules, err := u.storage.ListEnabledTestSchedules() + if err != nil { + return nil, err + } + + sort.Slice(schedules, func(i, j int) bool { + return schedules[i].NextRun.Before(schedules[j].NextRun) + }) + + if limit > 0 && len(schedules) > limit { + schedules = schedules[:limit] + } + + return schedules, nil +} + +// getDefaultInterval returns the default test interval based on target type +func (u *TestScheduleUsecase) getDefaultInterval(targetType happydns.TestScopeType) time.Duration { + switch targetType { + case happydns.TestScopeUser: + return DefaultUserTestInterval + case happydns.TestScopeDomain: + return DefaultDomainTestInterval + case happydns.TestScopeService: + return DefaultServiceTestInterval + default: + return DefaultDomainTestInterval + } +} + +// MergePluginOptions merges plugin options from different scopes +// Priority: schedule options > domain options > user options > global options +func (u *TestScheduleUsecase) MergePluginOptions( + globalOpts happydns.PluginOptions, + userOpts happydns.PluginOptions, + domainOpts happydns.PluginOptions, + scheduleOpts happydns.PluginOptions, +) happydns.PluginOptions { + merged := make(happydns.PluginOptions) + + // Start with global options + for k, v := range globalOpts { + merged[k] = v + } + + // Override with user options + for k, v := range userOpts { + merged[k] = v + } + + // Override with domain options + for k, v := range domainOpts { + merged[k] = v + } + + // Override with schedule options (highest priority) + for k, v := range scheduleOpts { + merged[k] = v + } + + return merged +} + +// ValidateScheduleOwnership checks if a user owns a schedule +func (u *TestScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error { + schedule, err := u.storage.GetTestSchedule(scheduleId) + if err != nil { + return err + } + + if !schedule.OwnerId.Equals(userId) { + return fmt.Errorf("user does not own this schedule") + } + + return nil +} + +// CreateDefaultSchedulesForTarget creates default schedules for a new target +func (u *TestScheduleUsecase) CreateDefaultSchedulesForTarget( + pluginName string, + targetType happydns.TestScopeType, + targetId happydns.Identifier, + ownerId happydns.Identifier, + enabled bool, +) error { + schedule := &happydns.TestSchedule{ + PluginName: pluginName, + OwnerId: ownerId, + TargetType: targetType, + TargetId: targetId, + Interval: u.getDefaultInterval(targetType), + Enabled: enabled, + NextRun: time.Now().Add(u.getDefaultInterval(targetType)), + Options: make(happydns.PluginOptions), + } + + return u.CreateSchedule(schedule) +} + +// rescheduleTests reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)]. +func (u *TestScheduleUsecase) rescheduleTests(schedules []*happydns.TestSchedule, maxOffsetFn func(*happydns.TestSchedule) time.Duration) (int, error) { + count := 0 + now := time.Now() + for _, schedule := range schedules { + maxOffset := maxOffsetFn(schedule) + if maxOffset <= 0 { + maxOffset = time.Second + } + schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset)))) + if err := u.storage.UpdateTestSchedule(schedule); err != nil { + return count, err + } + count++ + } + return count, nil +} + +// RescheduleUpcomingTests randomizes the next run time of all enabled schedules +// within their respective intervals to spread load evenly. Useful after a restart. +func (u *TestScheduleUsecase) RescheduleUpcomingTests() (int, error) { + schedules, err := u.storage.ListEnabledTestSchedules() + if err != nil { + return 0, err + } + return u.rescheduleTests(schedules, func(s *happydns.TestSchedule) time.Duration { + return s.Interval + }) +} + +// RescheduleOverdueTests reschedules tests whose NextRun is in the past, +// spreading them over a short window to avoid scheduler famine (e.g. after +// a long machine suspend or server downtime). +func (u *TestScheduleUsecase) RescheduleOverdueTests() (int, error) { + schedules, err := u.storage.ListEnabledTestSchedules() + if err != nil { + return 0, err + } + + now := time.Now() + var overdue []*happydns.TestSchedule + for _, s := range schedules { + if s.NextRun.Before(now) { + overdue = append(overdue, s) + } + } + + if len(overdue) == 0 { + return 0, nil + } + + // Spread overdue tests over a small window proportional to their count, + // capped at MinimumTestInterval, to prevent all of them from running at once. + spreadWindow := time.Duration(len(overdue)) * 5 * time.Second + if spreadWindow > MinimumTestInterval { + spreadWindow = MinimumTestInterval + } + + return u.rescheduleTests(overdue, func(s *happydns.TestSchedule) time.Duration { + return spreadWindow + }) +} + +// DeleteSchedulesForTarget removes all schedules for a target +func (u *TestScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) error { + schedules, err := u.storage.ListTestSchedulesByTarget(targetType, targetId) + if err != nil { + return err + } + + for _, schedule := range schedules { + if err := u.storage.DeleteTestSchedule(schedule.Id); err != nil { + return err + } + } + + return nil +} diff --git a/model/config.go b/model/config.go index 75ddcfc3..6fde1942 100644 --- a/model/config.go +++ b/model/config.go @@ -92,6 +92,20 @@ type Options struct { MailSMTPTLSSNoVerify bool 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 + + // TestWorkers is the number of concurrent test executions allowed + TestWorkers int + + // DisableScheduler disables the background test scheduler + DisableScheduler bool } // GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL. diff --git a/model/errors.go b/model/errors.go index d3bb697b..fdc964ac 100644 --- a/model/errors.go +++ b/model/errors.go @@ -27,15 +27,18 @@ 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") + ErrTestScheduleNotFound = errors.New("test schedule 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." diff --git a/model/identifier.go b/model/identifier.go index 32031d94..f8fdb094 100644 --- a/model/identifier.go +++ b/model/identifier.go @@ -27,6 +27,7 @@ import ( "encoding/base64" "encoding/gob" "errors" + "slices" ) const IDENTIFIER_LEN = 16 @@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool { return bytes.Equal(i, other) } +func (i Identifier) Compare(other Identifier) int { + return slices.Compare(i, other) +} + func (i *Identifier) String() string { return base64.RawURLEncoding.EncodeToString(*i) } diff --git a/model/plugin.go b/model/plugin.go new file mode 100644 index 00000000..a8213eb5 --- /dev/null +++ b/model/plugin.go @@ -0,0 +1,103 @@ +// 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 . +// +// 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 . + +package happydns + +const ( + PluginResultStatusKO PluginResultStatus = iota + PluginResultStatusWarn + PluginResultStatusInfo + PluginResultStatusOK +) + +type PluginResultStatus int + +type PluginOptions map[string]interface{} + +type SetPluginOptionsRequest struct { + Options PluginOptions `json:"options"` +} + +type PluginOptionsPositional struct { + PluginName string + UserId *Identifier + DomainId *Identifier + ServiceId *Identifier + + Options PluginOptions +} + +type TestPlugin interface { + PluginEnvName() []string + Version() PluginVersionInfo + AvailableOptions() PluginOptionsDocumentation + + RunTest(options PluginOptions, meta map[string]string) (*PluginResult, error) +} + +type PluginVersionInfo struct { + Name string `json:"name"` + Version string `json:"version"` + AvailableOn PluginAvailability `json:"availableOn"` +} + +type PluginAvailability struct { + ApplyToDomain bool `json:"applyToDomain,omitempty"` + ApplyToService bool `json:"applyToService,omitempty"` + LimitToProviders []string `json:"limitToProviders,omitempty"` + LimitToServices []string `json:"limitToServices,omitempty"` +} + +type PluginOptionsDocumentation struct { + RunOpts []PluginOptionDocumentation `json:"runOpts,omitempty"` + ServiceOpts []PluginOptionDocumentation `json:"serviceOpts,omitempty"` + DomainOpts []PluginOptionDocumentation `json:"domainOpts,omitempty"` + UserOpts []PluginOptionDocumentation `json:"userOpts,omitempty"` + AdminOpts []PluginOptionDocumentation `json:"adminOpts,omitempty"` + Variables []PluginVariableDocumentation `json:"vars,omitempty"` +} + +type PluginOptionDocumentation Field +type PluginVariableDocumentation Field + +type PluginStatus struct { + PluginVersionInfo + Opts PluginOptionsDocumentation `json:"options"` +} + +type PluginResult struct { + Status PluginResultStatus `json:"status"` + StatusLine string `json:"statusLine,omitempty"` + Report interface{} `json:"report"` +} + +type PluginManager interface { + GetTestPlugins() []TestPlugin + GetTestPluginsIndex() map[string]TestPlugin +} + +type TestPluginUsecase interface { + GetTestPlugin(string) (TestPlugin, error) + GetTestPluginOptions(string, *Identifier, *Identifier, *Identifier) (*PluginOptions, error) + ListTestPlugins() ([]TestPlugin, error) + OverwriteSomeTestPluginOptions(string, *Identifier, *Identifier, *Identifier, PluginOptions) error + SetTestPluginOptions(string, *Identifier, *Identifier, *Identifier, PluginOptions) error +} diff --git a/model/test_result.go b/model/test_result.go new file mode 100644 index 00000000..9f046cae --- /dev/null +++ b/model/test_result.go @@ -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 . +// +// 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 . + +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"` +} + +// SchedulerStatus holds a snapshot of the scheduler state for monitoring +type SchedulerStatus struct { + // ConfigEnabled indicates if the scheduler is enabled in the configuration file + ConfigEnabled bool `json:"config_enabled"` + + // RuntimeEnabled indicates if the scheduler is currently enabled at runtime + RuntimeEnabled bool `json:"runtime_enabled"` + + // Running indicates if the scheduler goroutine is currently running + Running bool `json:"running"` + + // WorkerCount is the number of worker goroutines + WorkerCount int `json:"worker_count"` + + // QueueSize is the number of items currently waiting in the execution queue + QueueSize int `json:"queue_size"` + + // ActiveCount is the number of tests currently being executed + ActiveCount int `json:"active_count"` + + // NextSchedules contains the upcoming scheduled tests sorted by next run time + NextSchedules []*TestSchedule `json:"next_schedules"` +} + +// 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 + + // RescheduleUpcomingTests randomizes next run times for all enabled schedules + // within their respective intervals to spread load evenly. + RescheduleUpcomingTests() (int, error) + + // RescheduleOverdueTests reschedules overdue tests to run soon, spread over a + // short window to avoid scheduler famine after a suspend or server restart. + RescheduleOverdueTests() (int, error) +} + +// AdminSchedulerUsecase is satisfied by both testScheduler and disabledScheduler +type AdminSchedulerUsecase interface { + TriggerOnDemandTest(pluginName string, targetType TestScopeType, targetID Identifier, userID Identifier, options PluginOptions) (Identifier, error) + GetSchedulerStatus() SchedulerStatus + SetEnabled(enabled bool) error + RescheduleUpcomingTests() (int, error) +} diff --git a/model/usecase.go b/model/usecase.go index 958843d3..326c5a75 100644 --- a/model/usecase.go +++ b/model/usecase.go @@ -34,6 +34,10 @@ type UsecaseDependancies interface { ServiceUsecase() ServiceUsecase ServiceSpecsUsecase() ServiceSpecsUsecase SessionUsecase() SessionUsecase + TestPluginUsecase() TestPluginUsecase + TestResultUsecase() TestResultUsecase + TestScheduleUsecase() TestScheduleUsecase + TestScheduler() AdminSchedulerUsecase UserUsecase() UserUsecase ZoneCorrectionApplierUsecase() ZoneCorrectionApplierUsecase ZoneImporterUsecase() ZoneImporterUsecase diff --git a/model/usersettings.go b/model/usersettings.go index b50aa5c8..97b7a705 100644 --- a/model/usersettings.go +++ b/model/usersettings.go @@ -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 { diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 00000000..f1fe8d1e --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1 @@ +*.so \ No newline at end of file diff --git a/plugins/matrix/Makefile b/plugins/matrix/Makefile new file mode 100644 index 00000000..64c83f48 --- /dev/null +++ b/plugins/matrix/Makefile @@ -0,0 +1,7 @@ +PLUGIN_NAME=matrix +TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so + +all: $(TARGET) + +$(TARGET): *.go + go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME) diff --git a/plugins/matrix/main.go b/plugins/matrix/main.go new file mode 100644 index 00000000..789ae424 --- /dev/null +++ b/plugins/matrix/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "git.happydns.org/happyDomain/model" +) + +func NewTestPlugin() (happydns.TestPlugin, error) { + return &MatrixTester{ + TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s", + }, nil +} diff --git a/plugins/matrix/test.go b/plugins/matrix/test.go new file mode 100644 index 00000000..d218924e --- /dev/null +++ b/plugins/matrix/test.go @@ -0,0 +1,148 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "git.happydns.org/happyDomain/model" +) + +type MatrixTester struct { + TesterURI string +} + +func (p *MatrixTester) PluginEnvName() []string { + return []string{ + "matrixim", + } +} + +func (p *MatrixTester) Version() happydns.PluginVersionInfo { + return happydns.PluginVersionInfo{ + Name: "Matrix Federation Tester", + Version: "0.1", + AvailableOn: happydns.PluginAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.MatrixIM"}, + }, + } +} + +func (p *MatrixTester) AvailableOptions() happydns.PluginOptionsDocumentation { + return happydns.PluginOptionsDocumentation{ + RunOpts: []happydns.PluginOptionDocumentation{ + { + Id: "serviceDomain", + Type: "string", + Label: "Matrix domain", + Placeholder: "matrix.org", + Default: "matrix.org", + Required: true, + }, + }, + AdminOpts: []happydns.PluginOptionDocumentation{ + { + Id: "federationTesterServer", + Type: "string", + Label: "Federation Tester Server", + Placeholder: "https://federationtester.matrix.org/", + Default: "https://federationtester.matrix.org/", + Required: true, + }, + }, + } +} + +type FederationTesterResponse struct { + WellKnownResult struct { + Server string `json:"m.server"` + Result string `json:"result"` + } + DNSResult struct { + SRVError *struct { + Message string + } + } + ConnectionReports map[string]struct { + Errors []string + } + ConnectionErrors map[string]struct { + Message string + } + Version struct { + Name string `json:"name"` + Version string `json:"version"` + } + FederationOK bool `json:"FederationOK"` +} + +func (p *MatrixTester) RunTest(options happydns.PluginOptions, meta map[string]string) (*happydns.PluginResult, error) { + var domain string + + if dn, ok := options["domain"]; ok { + domain, _ = dn.(string) + } else if origin, ok := options["origin"]; ok { + domain, _ = origin.(string) + } + + if domain == "" { + return nil, fmt.Errorf("domain not defined") + } + + domain = strings.TrimSuffix(domain, ".") + + resp, err := http.Get(fmt.Sprintf(p.TesterURI, domain)) + if err != nil { + return nil, fmt.Errorf("unable to perform the test: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("Sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + + var status happydns.PluginResultStatus + var statusLine string + var federationTest FederationTesterResponse + + err = json.NewDecoder(resp.Body).Decode(&federationTest) + if err != nil { + log.Printf("Error in check_matrix_federation, when decoding json: %s", err.Error()) + return nil, fmt.Errorf("sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + + if federationTest.FederationOK { + status = happydns.PluginResultStatusOK + statusLine = "Running " + federationTest.Version.Name + " " + federationTest.Version.Version + } else { + status = happydns.PluginResultStatusKO + + if federationTest.DNSResult.SRVError != nil && federationTest.WellKnownResult.Result != "" { + statusLine = fmt.Sprintf("%s OR %s", federationTest.DNSResult.SRVError.Message, federationTest.WellKnownResult.Result) + } else if len(federationTest.ConnectionErrors) > 0 { + var msg strings.Builder + for srv, cerr := range federationTest.ConnectionErrors { + if msg.Len() > 0 { + msg.WriteString("; ") + } + msg.WriteString(srv) + msg.WriteString(": ") + msg.WriteString(cerr.Message) + } + statusLine = fmt.Sprintf("Connection errors: %s", msg.String()) + } else if federationTest.WellKnownResult.Server != strings.TrimSuffix(domain, ".") { + statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s.", federationTest.WellKnownResult.Server, strings.TrimSuffix(domain, ".")) + } else { + statusLine = fmt.Sprintf("An unimplemented error occurs. Please report this to happydomain team. But know that federation seems to be broken. Check https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, ".")) + } + } + + return &happydns.PluginResult{ + Status: status, + StatusLine: statusLine, + Report: federationTest, + }, nil +} diff --git a/web-admin/routes.go b/web-admin/routes.go index f3ab3f32..27ad1894 100644 --- a/web-admin/routes.go +++ b/web-admin/routes.go @@ -111,6 +111,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) { // Routes to virtual content router.GET("/auth_users/*_", serveOrReverse("/", cfg)) router.GET("/domains/*_", serveOrReverse("/", cfg)) + router.GET("/plugins/*_", serveOrReverse("/", cfg)) router.GET("/providers/*_", serveOrReverse("/", cfg)) router.GET("/sessions/*_", serveOrReverse("/", cfg)) router.GET("/users/*_", serveOrReverse("/", cfg)) diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index f28ed70e..3fbd65bf 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -101,6 +101,12 @@ Sessions + + Plugins + + + Scheduler + diff --git a/web-admin/src/routes/plugins/+page.svelte b/web-admin/src/routes/plugins/+page.svelte new file mode 100644 index 00000000..d1ac5a25 --- /dev/null +++ b/web-admin/src/routes/plugins/+page.svelte @@ -0,0 +1,143 @@ + + + + + + + +

+ + Plugins Management +

+

+ + Manage all test plugins + + {#await pluginsQ then pluginsR} + Total: {Object.keys(pluginsR.data ?? {}).length} plugins + {/await} +

+ +
+ + + + + + + + + + + + + {#await pluginsQ} + Please wait... + {:then pluginsR} + {@const plugins = pluginsR.data} +
+ + + + + + + + + + + {#if !plugins || Object.keys(plugins).length == 0} + + + + {:else} + {#each Object.entries(plugins ?? {}).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]} + + + + + + + {/each} + {/if} + +
Plugin NameVersionAvailabilityActions
+ No plugins available +
{pluginInfo.name || pluginName}{pluginInfo.version} + {#if pluginInfo.availableOn} + {#if pluginInfo.availableOn.applyToDomain} + Domain + {/if} + {#if pluginInfo.availableOn.limitToProviders && pluginInfo.availableOn.limitToProviders.length > 0} + + Provider-specific + + {/if} + {#if pluginInfo.availableOn.limitToServices && pluginInfo.availableOn.limitToServices.length > 0} + + Service-specific + + {/if} + {:else} + General + {/if} + + + + Manage + +
+
+ {:catch error} + +

+ + Error loading plugins: {error.message} +

+
+ {/await} +
diff --git a/web-admin/src/routes/plugins/[pname]/+page.svelte b/web-admin/src/routes/plugins/[pname]/+page.svelte new file mode 100644 index 00000000..fff0c288 --- /dev/null +++ b/web-admin/src/routes/plugins/[pname]/+page.svelte @@ -0,0 +1,320 @@ + + + + + + + + +

+ + {pname} +

+ +
+ + {#await pluginStatusQ} + +

+ + {$t("plugins.tests.loading-info")} +

+
+ {:then status} + {#if status} + + + + + {$t("plugins.tests.detail.test-information")} + + +
+
{$t("plugins.tests.detail.name")}
+
{status.name}
+ +
{$t("plugins.tests.detail.version")}
+
{status.version}
+ +
{$t("plugins.tests.detail.availability")}
+
+ {#if status.availableOn} +
+ {#if status.availableOn.applyToDomain} + {$t("plugins.tests.availability.domain-level")} + {/if} + {#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0} + + {$t("plugins.tests.availability.providers", { + providers: status.availableOn.limitToProviders.join(', '), + })} + + {/if} + {#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0} + + {$t("plugins.tests.availability.services", { + services: status.availableOn.limitToServices.join(', '), + })} + + {/if} + {#if !status.availableOn.applyToDomain && + (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && + (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)} + {$t("plugins.tests.availability.general")} + {/if} +
+ {:else} + {$t("plugins.tests.availability.general")} + {/if} +
+
+
+
+ + + + {#await pluginOptionsQ} + + +

+ + {$t("plugins.tests.detail.loading-options")} +

+
+
+ {:then _optionsR} + {@const adminOpts = status.options?.adminOpts || []} + {@const readOnlyOptGroups = [ + { label: $t("plugins.tests.option-groups.global-settings"), opts: status.options?.userOpts || [] }, + { label: $t("plugins.tests.option-groups.domain-settings"), opts: status.options?.domainOpts || [] }, + { label: $t("plugins.tests.option-groups.service-settings"), opts: status.options?.serviceOpts || [] }, + { label: $t("plugins.tests.option-groups.test-parameters"), opts: status.options?.runOpts || [] }, + ]} + {@const hasAnyOpts = adminOpts.length > 0 || readOnlyOptGroups.some(g => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(adminOpts)} + + {#if orphanedOpts.length > 0} + +
+
+ + {$t("plugins.tests.detail.orphaned-options", { + options: orphanedOpts.join(', '), + })} +
+ +
+
+ {/if} + + {#if adminOpts.length > 0} + + + {$t("plugins.tests.detail.configuration")} + + +
+ {#each adminOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} +
+ +
+
+
+
+ {/if} + + + + {#if !hasAnyOpts} + + + + + {$t("plugins.tests.detail.no-configurable-options")} + + + + {/if} + {:catch error} + + + + + {$t("plugins.tests.detail.error-loading-options", { + error: error.message, + })} + + + + {/await} + +
+ {:else} + + + {$t("plugins.tests.test-info-not-found")} + + {/if} + {:catch error} + + + {$t("plugins.tests.error-loading-test", { error: error.message })} + + {/await} +
diff --git a/web-admin/src/routes/scheduler/+page.svelte b/web-admin/src/routes/scheduler/+page.svelte new file mode 100644 index 00000000..72049086 --- /dev/null +++ b/web-admin/src/routes/scheduler/+page.svelte @@ -0,0 +1,334 @@ + + + + + + + +

+ + Test Scheduler +

+

Monitor and control the background test scheduler

+ +
+ + {#if loading} +
+ + Loading scheduler status... +
+ {:else if error} + + + Error loading scheduler status: {error} + + + {:else if status} + + + +
+ Scheduler Status + +
+
+ + + +
Config Enabled
+ {#if status.config_enabled} + Yes + {:else} + No + {/if} + + +
Runtime Enabled
+ {#if status.runtime_enabled} + Yes + {:else} + Disabled + {/if} + + +
Running
+ {#if status.running} + Running + {:else} + Stopped + {/if} + + +
Workers
+ {status.worker_count} + + +
Queue Size
+ {status.queue_size} + + +
Active Executions
+ {status.active_count} + +
+ + {#if status.config_enabled} +
+ {#if status.runtime_enabled} + + {:else} + + {/if} + +
+ {:else} +

+ + The scheduler is disabled in the server configuration and cannot be enabled at + runtime. +

+ {/if} +
+
+ + + + + + Upcoming Scheduled Tests + {#if status.next_schedules} + {status.next_schedules.length} + {/if} + + +
+ + + + + + + + + + + + + {#if !status.next_schedules || status.next_schedules.length === 0} + + + + {:else} + {#each status.next_schedules as schedule} + + + + + + + + + {/each} + {/if} + +
PluginTarget TypeTarget IDIntervalLast RunNext Run
+ No scheduled tests +
{schedule.plugin_name}{targetTypeName(schedule.target_type)}{schedule.target_id}{formatDuration(schedule.interval)} + {#if schedule.last_run} + {new Date(schedule.last_run).toLocaleString()} + {:else} + Never + {/if} + + {#if new Date(schedule.next_run) < new Date()} + + + {new Date(schedule.next_run).toLocaleString()} + + {:else} + {new Date(schedule.next_run).toLocaleString()} + {/if} +
+
+
+
+ {/if} +
diff --git a/web/src/lib/api/plugins.ts b/web/src/lib/api/plugins.ts new file mode 100644 index 00000000..9b752b2a --- /dev/null +++ b/web/src/lib/api/plugins.ts @@ -0,0 +1,91 @@ +// 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 . +// +// 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 . + +import { + getPluginsTests, + getPluginsTestsByPid, + getPluginsTestsByPidOptions, + postPluginsTestsByPidOptions, + putPluginsTestsByPidOptions, + getPluginsTestsByPidOptionsByOptname, + putPluginsTestsByPidOptionsByOptname, +} from "$lib/api-base/sdk.gen"; +import { unwrapSdkResponse } from "./errors"; +import type { + PluginList, + PluginStatus, + PluginOptions, +} from "$lib/model/plugin"; + +export async function listPlugins(): Promise { + return unwrapSdkResponse(await getPluginsTests()) as PluginList; +} + +export async function getPluginStatus(pluginId: string): Promise { + return unwrapSdkResponse( + await getPluginsTestsByPid({ + path: { pid: pluginId }, + }), + ) as PluginStatus; +} + +export async function getPluginOptions(pluginId: string): Promise { + return unwrapSdkResponse( + await getPluginsTestsByPidOptions({ + path: { pid: pluginId }, + }), + ) as PluginOptions; +} + +export async function addPluginOptions(pluginId: string, options: PluginOptions): Promise { + return unwrapSdkResponse( + await postPluginsTestsByPidOptions({ + path: { pid: pluginId }, + body: { options } as any, + }), + ) as boolean; +} + +export async function updatePluginOptions(pluginId: string, options: PluginOptions): Promise { + return unwrapSdkResponse( + await putPluginsTestsByPidOptions({ + path: { pid: pluginId }, + body: { options } as any, + }), + ) as boolean; +} + +export async function getPluginOption(pluginId: string, optionName: string): Promise { + return unwrapSdkResponse( + await getPluginsTestsByPidOptionsByOptname({ + path: { pid: pluginId, optname: optionName }, + }), + ); +} + +export async function setPluginOption(pluginId: string, optionName: string, value: any): Promise { + return unwrapSdkResponse( + await putPluginsTestsByPidOptionsByOptname({ + path: { pid: pluginId, optname: optionName }, + body: value as any, + }), + ) as boolean; +} diff --git a/web/src/lib/api/tests.ts b/web/src/lib/api/tests.ts new file mode 100644 index 00000000..f6f19a8c --- /dev/null +++ b/web/src/lib/api/tests.ts @@ -0,0 +1,190 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2022-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// 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 . + +import type { PostDomainsByDomainTestsByTnameResponse } from "$lib/api-base/types.gen"; +import { + getDomainsByDomainTests, + getDomainsByDomainTestsByTname, + postDomainsByDomainTestsByTname, + getDomainsByDomainTestsByTnameExecutionsByExecutionId, + getDomainsByDomainTestsByTnameOptions, + putDomainsByDomainTestsByTnameOptions, + getDomainsByDomainTestsByTnameResults, + getDomainsByDomainTestsByTnameResultsByResultId, + deleteDomainsByDomainTestsByTnameResultsByResultId, + deleteDomainsByDomainTestsByTnameResults, + getPluginsTestsSchedules, + getPluginsTestsSchedulesByScheduleId, + postPluginsTestsSchedules, + putPluginsTestsSchedulesByScheduleId, + deletePluginsTestsSchedulesByScheduleId, +} from "$lib/api-base/sdk.gen"; +import type { + TestResult, + TestExecution, + TestSchedule, + AvailableTest, + CreateScheduleRequest, +} from "$lib/model/test"; +import type { PluginOptions } from "$lib/model/plugin"; +import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors"; + +// Domain test operations +export async function listAvailableTests(domainId: string): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTests({ path: { domain: domainId } }), + ) as unknown as AvailableTest[]; +} + +export async function listTestResults( + domainId: string, + testName: string, + limit?: number, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTestsByTnameResults({ + path: { domain: domainId, tname: testName }, + query: limit !== undefined ? { limit } : undefined, + }), + ) as TestResult[]; +} + +export async function getLatestTestResults( + domainId: string, + testName: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTestsByTname({ path: { domain: domainId, tname: testName } }), + ) as TestResult[]; +} + +export async function triggerTest( + domainId: string, + testName: string, + options?: PluginOptions, +): Promise { + return unwrapSdkResponse( + await postDomainsByDomainTestsByTname({ + path: { domain: domainId, tname: testName }, + body: { options } as any, + }), + ) as PostDomainsByDomainTestsByTnameResponse; +} + +export async function getTestExecution( + domainId: string, + testName: string, + executionId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTestsByTnameExecutionsByExecutionId({ + path: { domain: domainId, tname: testName, execution_id: executionId }, + }), + ) as TestExecution; +} + +export async function getTestResult( + domainId: string, + testName: string, + resultId: string, +): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTestsByTnameResultsByResultId({ + path: { domain: domainId, tname: testName, result_id: resultId }, + }), + ) as TestResult; +} + +export async function deleteTestResult( + domainId: string, + testName: string, + resultId: string, +): Promise { + unwrapEmptyResponse( + await deleteDomainsByDomainTestsByTnameResultsByResultId({ + path: { domain: domainId, tname: testName, result_id: resultId }, + }), + ); +} + +export async function deleteAllTestResults(domainId: string, testName: string): Promise { + unwrapEmptyResponse( + await deleteDomainsByDomainTestsByTnameResults({ + path: { domain: domainId, tname: testName }, + }), + ); +} + +export async function getTestOptions(domainId: string, testName: string): Promise { + return unwrapSdkResponse( + await getDomainsByDomainTestsByTnameOptions({ + path: { domain: domainId, tname: testName }, + }), + ) as PluginOptions; +} + +export async function updateTestOptions( + domainId: string, + testName: string, + options: PluginOptions, +): Promise { + unwrapEmptyResponse( + await putDomainsByDomainTestsByTnameOptions({ + path: { domain: domainId, tname: testName }, + body: { options } as any, + }), + ); +} + +// Schedule operations +export async function listUserSchedules(): Promise { + return unwrapSdkResponse(await getPluginsTestsSchedules()) as TestSchedule[]; +} + +export async function getTestSchedule(scheduleId: string): Promise { + return unwrapSdkResponse( + await getPluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }), + ) as TestSchedule; +} + +export async function createTestSchedule(schedule: CreateScheduleRequest): Promise { + return unwrapSdkResponse( + await postPluginsTestsSchedules({ body: schedule as any }), + ) as TestSchedule; +} + +export async function updateTestSchedule( + scheduleId: string, + schedule: Partial, +): Promise { + unwrapEmptyResponse( + await putPluginsTestsSchedulesByScheduleId({ + path: { schedule_id: scheduleId }, + body: schedule as any, + }), + ); +} + +export async function deleteTestSchedule(scheduleId: string): Promise { + unwrapEmptyResponse( + await deletePluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }), + ); +} diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index ec88eb85..9f1892aa 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -24,7 +24,7 @@ + + + + {$t("tests.run-test.title")}: {pluginDisplayName} + + + {#if pluginStatusPromise && domainOptionsPromise} + {#await Promise.all([pluginStatusPromise, domainOptionsPromise])} +
+ +

{$t("tests.run-test.loading-options")}

+
+ {:then [status, _domainOpts]} + {@const runOpts = status.options?.runOpts || []} + {#if runOpts.length > 0} +

+ {$t("tests.run-test.configure-info")} +

+
{ + e.preventDefault(); + handleRunTest(); + }} + > + {#each runOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} +
+ {:else} + + + {$t("tests.run-test.no-options")} + + {/if} + {:catch error} + + + {$t("tests.run-test.error-loading-options", { error: error.message })} + + {/await} + {/if} +
+ + + + +
diff --git a/web/src/lib/components/plugins/PluginOptionsGroups.svelte b/web/src/lib/components/plugins/PluginOptionsGroups.svelte new file mode 100644 index 00000000..00ed0f42 --- /dev/null +++ b/web/src/lib/components/plugins/PluginOptionsGroups.svelte @@ -0,0 +1,89 @@ + + + + +{#each groups as optGroup} + {#if optGroup.opts.length > 0} + + + {optGroup.label} + {t("plugins.tests.detail.read-only")} + + +
+ {#each optGroup.opts as optDoc} + {@const optName = optDoc.id!} +
+ {optDoc.label || optDoc.id}: +
+
+ {#if optDoc.default} + {optDoc.default} + {:else if optDoc.placeholder} + {optDoc.placeholder} + {/if} + {#if optDoc.description} + {optDoc.description} + {/if} + {t("plugins.tests.option-groups.type", { + type: optDoc.type || "string", + })} + {#if optDoc.required} + {t("plugins.tests.option-groups.required")} + {/if} +
+ {/each} +
+
+
+ {/if} +{/each} diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index 9819fbec..e991a706 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -82,6 +82,7 @@ "share": "Share the zone…", "upload": "Import a zone file", "view": "View my zone", + "view-tests": "View tests", "others": "More actions on {{domain}}" }, "alert": { @@ -235,6 +236,7 @@ "my-domains": "My domains", "my-providers": "My domain providers", "dns-resolver": "DNS resolver", + "plugins": "Domain Testers", "my-account": "My account", "logout": "Sign out", "provider-features": "Supported providers", @@ -472,7 +474,7 @@ "showrrtypes-title": "Show DNS record types", "preferences": { "title": "Preferences", - "description": "Customize your preferences" + "description": "Customize your preferences" }, "security": { "title": "Security & Access", @@ -535,10 +537,193 @@ "ttl": "Remaining time in cache", "showDNSSEC": "Show DNSSEC records in answer (if any)" }, + "tests": { + "run-test": { + "title": "Run Test", + "loading-options": "Loading test options...", + "configure-info": "Configure test options below. Pre-filled values are from domain-level settings.", + "no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.", + "error-loading-options": "Error loading test options: {{error}}", + "run-button": "Run Test", + "triggered-success": "Test triggered successfully! Execution ID: {{id}}", + "trigger-failed": "Failed to trigger test: {{error}}" + }, + "never": "Never", + "na": "N/A", + "relative": { + "in-less-than-a-minute": "in less than a minute", + "just-now": "just now", + "in": "in {{label}}", + "ago": "{{label}} ago" + }, + "status": { + "ok": "OK", + "info": "Info", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown", + "not-run": "Not run" + }, + "list": { + "title": "Tests for ", + "loading": "Loading tests...", + "loading-plugins": "Loading plugin information...", + "no-tests": "No tests available for this domain.", + "run-test": "Run Test", + "view-results": "View Results", + "error-loading": "Error loading tests: {{error}}", + "unknown-version": "Unknown", + "table": { + "plugin": "Test Plugin", + "status": "Status", + "last-run": "Last Run", + "schedule": "Schedule", + "actions": "Actions" + }, + "schedule": { + "enabled": "Enabled", + "disabled": "Disabled" + } + }, + "schedule": { + "title": "Schedule", + "card-title": "Automatic scheduling", + "auto-enabled": "Run automatically", + "auto-disabled": "Disabled (run manually only)", + "interval-label": "Check interval", + "hours": "hours", + "interval-hint": "Minimum 1 hour. The test will run once per interval.", + "next-run": "Next scheduled run", + "last-run": "Last run", + "no-schedule-yet": "No schedule created yet. Save to create one.", + "save": "Save", + "save-failed": "Failed to save schedule", + "saved": "Schedule saved successfully." + }, + "results": { + "loading": "Loading test results...", + "no-results": "No test results yet. Click \"Run Test Now\" to execute the test.", + "title": "Test Results ({{count}})", + "run-test-now": "Run Test Now", + "back-to-tests": "Back to Tests", + "delete-all": "Delete All", + "delete-confirm": "Are you sure you want to delete this test result?", + "delete-all-confirm": "Are you sure you want to delete ALL test results for this test? This cannot be undone.", + "delete-failed": "Failed to delete result", + "delete-all-failed": "Failed to delete results", + "configure": "Configure", + "domain-level": "Domain-level", + "error-loading": "Error loading test results: {{error}}", + "table": { + "executed-at": "Executed At", + "status": "Status", + "message": "Message", + "duration": "Duration", + "type": "Type", + "actions": "Actions" + }, + "type": { + "scheduled": "Scheduled", + "manual": "Manual" + }, + "view": "View" + }, + "result": { + "title": "Test Result Details", + "loading": "Loading test result...", + "relaunch": "Relaunch Test", + "delete": "Delete Result", + "back-to-results": "Back to Results", + "relaunch-failed": "Failed to relaunch test", + "delete-confirm": "Are you sure you want to delete this test result?", + "delete-failed": "Failed to delete result", + "error-loading": "Error loading test result: {{error}}", + "milliseconds": "milliseconds", + "seconds": "seconds", + "type": { + "scheduled": "Scheduled Test", + "manual": "Manual Test" + }, + "test-options": "Test Options", + "full-report": "Full Report", + "field": { + "domain": "Domain:", + "executed-at": "Executed At:", + "duration": "Duration:", + "status": "Status:", + "status-message": "Status Message:", + "error": "Error:", + "plugin-version": "Plugin Version:" + } + } + }, + "plugins": { + "tests": { + "title": "Domain Tests", + "description": "Configure automated tests for your domains", + "available-count": "Available: {{count}} tests", + "search-placeholder": "Search tests...", + "loading": "Loading tests...", + "loading-info": "Loading test information...", + "no-tests": "No tests available", + "error-loading": "Error loading tests: {{error}}", + "error-loading-test": "Error loading test: {{error}}", + "test-info-not-found": "Error: Test information not found", + "table": { + "name": "Test Name", + "version": "Version", + "availability": "Availability", + "actions": "Actions" + }, + "availability": { + "domain": "Domain", + "provider-specific": "Provider-specific", + "service-specific": "Service-specific", + "general": "General", + "domain-level": "Domain-level", + "providers": "Providers: {{providers}}", + "services": "Services: {{services}}" + }, + "actions": { + "configure": "Configure" + }, + "detail": { + "test-information": "Test Information", + "name": "Name:", + "version": "Version:", + "availability": "Availability:", + "loading-options": "Loading options...", + "configuration": "Configuration", + "save-changes": "Save Changes", + "no-configurable-options": "This test has no configurable options.", + "error-loading-options": "Error loading options: {{error}}", + "orphaned-options": "Orphaned options detected: {{options}}", + "clean-up": "Clean Up", + "read-only": "(Read-only)" + }, + "option-groups": { + "global-settings": "Global Settings", + "domain-settings": "Domain-specific Settings", + "service-settings": "Service-specific Settings", + "test-parameters": "Test Parameters", + "type": "Type: {{type}}", + "required": "Required" + }, + "messages": { + "options-updated": "Plugin options updated successfully", + "options-cleaned": "Orphaned options removed successfully", + "update-failed": "Failed to update options: {{error}}", + "clean-failed": "Failed to clean options: {{error}}" + }, + "back-button": "Back to Plugins" + } + }, "zones": { "upload": "Import a zone", "import-text": "Import from text", "import-file": "Import from file", - "return-to": "Go to the zone" + "return-to": "Go to the zone", + "return-to-results": "Back to Results", + "return-to-tests": "Back to Tests" } } diff --git a/web/src/lib/locales/fr.json b/web/src/lib/locales/fr.json index 8eeaadd0..bf021a43 100644 --- a/web/src/lib/locales/fr.json +++ b/web/src/lib/locales/fr.json @@ -473,6 +473,86 @@ "no-group": "Divers", "title": "Vos groupes" }, + "tests": { + "run-test": { + "title": "Lancer le test", + "loading-options": "Chargement des options du test...", + "configure-info": "Configurez les options du test ci-dessous. Les valeurs préremplies proviennent des paramètres au niveau du domaine.", + "no-options": "Ce test n'a pas d'options configurables. Cliquez sur \"Lancer le test\" pour l'exécuter avec les paramètres par défaut.", + "error-loading-options": "Erreur lors du chargement des options du test : {{error}}", + "run-button": "Lancer le test" + }, + "never": "Jamais", + "na": "N/A", + "relative": { + "in-less-than-a-minute": "dans moins d'une minute", + "just-now": "à l'instant", + "in": "dans {{label}}", + "ago": "il y a {{label}}" + } + }, + "plugins": { + "tests": { + "title": "Tests de domaines", + "description": "Configurez les tests automatisés pour vos domaines", + "available-count": "Disponibles : {{count}} tests", + "search-placeholder": "Rechercher des tests...", + "loading": "Chargement des tests...", + "loading-info": "Chargement des informations du test...", + "no-tests": "Aucun test disponible", + "error-loading": "Erreur lors du chargement des tests : {{error}}", + "error-loading-test": "Erreur lors du chargement du test : {{error}}", + "test-info-not-found": "Erreur : Informations du test introuvables", + "back-to-tests": "Retour aux tests", + "table": { + "name": "Nom du test", + "version": "Version", + "availability": "Disponibilité", + "actions": "Actions" + }, + "availability": { + "domain": "Domaine", + "provider-specific": "Spécifique au fournisseur", + "service-specific": "Spécifique au service", + "general": "Général", + "domain-level": "Niveau domaine", + "providers": "Fournisseurs : {{providers}}", + "services": "Services : {{services}}" + }, + "actions": { + "configure": "Configurer" + }, + "detail": { + "test-information": "Informations du test", + "name": "Nom :", + "version": "Version :", + "availability": "Disponibilité :", + "loading-options": "Chargement des options...", + "configuration": "Configuration", + "save-changes": "Enregistrer les modifications", + "no-configurable-options": "Ce test n'a pas d'options configurables.", + "error-loading-options": "Erreur lors du chargement des options : {{error}}", + "orphaned-options": "Options orphelines détectées : {{options}}", + "clean-up": "Nettoyer", + "read-only": "(Lecture seule)" + }, + "option-groups": { + "global-settings": "Paramètres globaux", + "domain-settings": "Paramètres spécifiques au domaine", + "service-settings": "Paramètres spécifiques au service", + "test-parameters": "Paramètres du test", + "type": "Type : {{type}}", + "required": "Requis" + }, + "messages": { + "options-updated": "Options du plugin mises à jour avec succès", + "options-cleaned": "Options orphelines supprimées avec succès", + "update-failed": "Échec de la mise à jour des options : {{error}}", + "clean-failed": "Échec du nettoyage des options : {{error}}" + }, + "back-button": "Retour aux plugins" + } + }, "zones": { "upload": "Importer une zone", "import-text": "Depuis du texte", diff --git a/web/src/lib/model/plugin.ts b/web/src/lib/model/plugin.ts new file mode 100644 index 00000000..81b83068 --- /dev/null +++ b/web/src/lib/model/plugin.ts @@ -0,0 +1,54 @@ +// 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 . +// +// 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 . + +import type { + HappydnsPluginAvailability, + HappydnsPluginOptionDocumentation, + HappydnsPluginOptionsDocumentation, + HappydnsPluginOptions, +} from "$lib/api-base/types.gen"; + +// Re-export auto-generated types with better names +export type PluginAvailability = HappydnsPluginAvailability; +export type PluginOptions = HappydnsPluginOptions; +export type PluginOptionsDocumentation = HappydnsPluginOptionsDocumentation; + +// Make 'id' required for PluginOptionDocumentation +export interface PluginOptionDocumentation extends Omit { + id: string; +} + +// Make 'name' and 'version' required for PluginVersionInfo +export interface PluginVersionInfo { + name: string; + version: string; + availableOn?: PluginAvailability; +} + +// Make 'name' and 'version' required for PluginStatus +export interface PluginStatus { + name: string; + version: string; + availableOn?: PluginAvailability; + options?: PluginOptionsDocumentation; +} + +export type PluginList = Record; diff --git a/web/src/lib/model/test.ts b/web/src/lib/model/test.ts new file mode 100644 index 00000000..734cad22 --- /dev/null +++ b/web/src/lib/model/test.ts @@ -0,0 +1,106 @@ +// 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 . +// +// 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 . + +import type { PluginOptions } from './plugin'; + +export enum TestScopeType { + TestScopeInstance = 0, + TestScopeUser = 1, + TestScopeDomain = 2, + TestScopeZone = 3, + TestScopeService = 4, + TestScopeOnDemand = 5, +} + +export enum TestExecutionStatus { + TestExecutionPending = 0, + TestExecutionRunning = 1, + TestExecutionCompleted = 2, + TestExecutionFailed = 3, +} + +export enum PluginResultStatus { + KO = 0, + Warn = 1, + Info = 2, + OK = 3, +} + +export interface TestResult { + id: string; + plugin_name: string; + test_type: TestScopeType; + target_id: string; + user_id: string; + executed_at: string; + scheduled_test: boolean; + options?: PluginOptions; + status: PluginResultStatus; + status_line: string; + report?: any; + duration?: number; + error?: string; +} + +export interface TestSchedule { + id: string; + plugin_name: string; + user_id: string; + target_type: TestScopeType; + target_id: string; + interval: number; + enabled: boolean; + last_run?: string; + next_run: string; + options?: PluginOptions; +} + +export interface TestExecution { + id: string; + schedule_id?: string; + plugin_name: string; + user_id: string; + target_id: string; + status: TestExecutionStatus; + started_at: string; + completed_at?: string; + result_id?: string; +} + +export interface AvailableTest { + plugin_name: string; + enabled: boolean; + schedule?: TestSchedule; + last_result?: TestResult; +} + +export interface TriggerTestRequest { + options?: PluginOptions; +} + +export interface CreateScheduleRequest { + plugin_name: string; + target_type: TestScopeType; + target_id: string; + interval: number; + enabled: boolean; + options?: PluginOptions; +} diff --git a/web/src/lib/stores/plugins.ts b/web/src/lib/stores/plugins.ts new file mode 100644 index 00000000..3ec91bc4 --- /dev/null +++ b/web/src/lib/stores/plugins.ts @@ -0,0 +1,32 @@ +// 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 . +// +// 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 . + +import { listPlugins } from "$lib/api/plugins"; +import type { PluginList } from "$lib/model/plugin"; +import { writable, type Writable } from "svelte/store"; + +export const plugins: Writable = writable(undefined); + +export async function refreshPlugins() { + const data = await listPlugins(); + plugins.set(data); + return data; +} diff --git a/web/src/lib/translations.ts b/web/src/lib/translations.ts index fb35aba1..e40d349a 100644 --- a/web/src/lib/translations.ts +++ b/web/src/lib/translations.ts @@ -39,6 +39,10 @@ interface Params { min?: number; max?: number; suggestion?: string; + error?: string; + providers?: string; + services?: string; + options?: string; // add more parameters that are used here } diff --git a/web/src/lib/utils/datetime.ts b/web/src/lib/utils/datetime.ts index ea80159d..fc4233b1 100644 --- a/web/src/lib/utils/datetime.ts +++ b/web/src/lib/utils/datetime.ts @@ -31,3 +31,59 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null { return null; } } + +/** + * Format a date string for display in test UI + * @param dateString ISO date string or undefined + * @param style Display style: "short", "medium", or "long" + * @param t i18n translation function + * @returns Formatted date string, or $t("tests.never") if undefined/invalid + */ +export function formatTestDate( + dateString: string | undefined, + style: "short" | "medium" | "long", + t: (k: string) => string, +): string { + if (!dateString) return t("tests.never"); + const d = new Date(dateString); + if (isNaN(d.getTime())) return t("tests.never"); + return new Intl.DateTimeFormat(undefined, { + dateStyle: style, + timeStyle: "short", + }).format(d); +} + +/** + * Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago") + * @param dateString ISO date string or undefined + * @param t i18n translation function + * @returns Relative time string, or empty string if undefined/invalid + */ +export function formatRelative(dateString: string | undefined, t: (k: string) => string): string { + if (!dateString) return ""; + const d = new Date(dateString); + if (isNaN(d.getTime())) return ""; + const now = new Date(); + const diffMs = d.getTime() - now.getTime(); + const absDiffMs = Math.abs(diffMs); + + if (absDiffMs < 60_000) + return diffMs > 0 ? t("tests.relative.in-less-than-a-minute") : t("tests.relative.just-now"); + + const minutes = Math.floor(absDiffMs / 60_000); + const hours = Math.floor(absDiffMs / 3_600_000); + const days = Math.floor(absDiffMs / 86_400_000); + + let label: string; + if (days > 0) { + label = `${days}d ${hours % 24}h`; + } else if (hours > 0) { + label = `${hours}h ${minutes % 60}m`; + } else { + label = `${minutes}m`; + } + + return diffMs > 0 + ? t("tests.relative.in").replace("{{label}}", label) + : t("tests.relative.ago").replace("{{label}}", label); +} diff --git a/web/src/lib/utils/index.ts b/web/src/lib/utils/index.ts index 8f586662..73e24eab 100644 --- a/web/src/lib/utils/index.ts +++ b/web/src/lib/utils/index.ts @@ -2,4 +2,5 @@ * Centralized utility exports */ -export { toDatetimeLocal, fromDatetimeLocal } from './datetime'; +export { toDatetimeLocal, fromDatetimeLocal, formatTestDate, formatRelative } from './datetime'; +export { getStatusColor, getStatusKey, formatDuration } from './test'; diff --git a/web/src/lib/utils/test.ts b/web/src/lib/utils/test.ts new file mode 100644 index 00000000..adf53ffb --- /dev/null +++ b/web/src/lib/utils/test.ts @@ -0,0 +1,39 @@ +import { PluginResultStatus } from "$lib/model/test"; + +export function getStatusColor(status: PluginResultStatus): string { + switch (status) { + case PluginResultStatus.OK: + return "success"; + case PluginResultStatus.Info: + return "info"; + case PluginResultStatus.Warn: + return "warning"; + case PluginResultStatus.KO: + return "danger"; + default: + return "secondary"; + } +} + +export function getStatusKey(status: PluginResultStatus): string { + switch (status) { + case PluginResultStatus.OK: + return "tests.status.ok"; + case PluginResultStatus.Info: + return "tests.status.info"; + case PluginResultStatus.Warn: + return "tests.status.warning"; + case PluginResultStatus.KO: + return "tests.status.error"; + default: + return "tests.status.unknown"; + } +} + +export function formatDuration(duration: number | undefined, t: (k: string) => string): string { + if (!duration) return t("tests.na"); + const seconds = duration / 1000000000; + if (seconds < 1) + return `${(seconds * 1000).toFixed(0)} ${t("tests.result.milliseconds")}`; + return `${seconds.toFixed(2)} ${t("tests.result.seconds")}`; +} diff --git a/web/src/routes/domains/[dn]/+layout.svelte b/web/src/routes/domains/[dn]/+layout.svelte index 1f4b9365..6dbbbc24 100644 --- a/web/src/routes/domains/[dn]/+layout.svelte +++ b/web/src/routes/domains/[dn]/+layout.svelte @@ -76,7 +76,17 @@ goto( "/domains/" + encodeURIComponent(domainLink(dn)) + - (page.data.isAuditPage ? "/logs" : page.data.isHistoryPage ? "/history" : ""), + (page.route.id + ? page.route.id.startsWith("/domains/[dn]/logs") + ? "/logs" + : page.route.id.startsWith("/domains/[dn]/history") + ? "/history" + : page.route.id.startsWith("/domains/[dn]/tests/[tname]") + ? `/tests/${page.params.tname!}` + : page.route.id.startsWith("/domains/[dn]/tests") + ? "/tests" + : "" + : ""), ); } if (selectedDomain != dn) { @@ -166,7 +176,34 @@ - {#if page.data.isHistoryPage || page.data.isAuditPage} + {#if page.route.id && page.route.id.startsWith("/domains/[dn]/tests/[tname]")} + {#if page.route.id.startsWith("/domains/[dn]/tests/[tname]/results/")} + + {:else} + + {/if} + {:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/tests"))} + + + + + {#if loading} +
+ +

{$t("tests.list.loading")}

+
+ {:else if loadError} + +

+ + {$t("tests.list.error-loading", { error: loadError })} +

+
+ {:else if !test} + +

+ + {$t("tests.list.no-tests")} +

+
+ {:else} + + +

+ + {$t("tests.schedule.card-title")} +

+
+ +
+
+ + +
+
+ + {#if formEnabled} +
+ +
+ + + {$t("tests.schedule.hours")} + +
+
+ {$t("tests.schedule.interval-hint")} +
+
+ {/if} + + {#if test.schedule} +
+
+ {#if test.schedule.last_run} +
+ + {$t("tests.schedule.last-run")}: + + + {formatTestDate(test.schedule.last_run, "medium", $t)} + + ({formatRelative(test.schedule.last_run, $t)}) + + +
+ {/if} + {#if test.enabled && test.schedule.next_run} +
+ + {$t("tests.schedule.next-run")}: + + + {formatTestDate(test.schedule.next_run, "medium", $t)} + + ({formatRelative(test.schedule.next_run, $t)}) + + +
+ {/if} +
+
+ {:else} +

+ + {$t("tests.schedule.no-schedule-yet")} +

+ {/if} + + +
+
+ {/if} + diff --git a/web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte b/web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte new file mode 100644 index 00000000..9dede5d4 --- /dev/null +++ b/web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte @@ -0,0 +1,237 @@ + + + + + + {testName} Results - {data.domain.domain} - happyDomain + + +
+
+

+ {data.domain.domain} + – + {#await pluginPromise then plugin} + {plugin.name || testName} + {:catch} + {testName} + {/await} +

+
+ + {#await pluginPromise then plugin} + + {/await} +
+
+ + {#if errorMessage} + {#key errorMessage} + + + {errorMessage} + + {/key} + {/if} + + {#await resultsPromise} +
+ +

{$t("tests.results.loading")}

+
+ {:then results} + {#if !results || results.length === 0} + +

+ + {$t("tests.results.no-results")} +

+
+ {:else} +
+

{$t("tests.results.title", { count: results.length })}

+ +
+ + + + + + + + + + + + + + {#each results as result} + + + + + + + + + {/each} + +
{$t("tests.results.table.executed-at")}{$t("tests.results.table.status")}{$t("tests.results.table.message")}{$t("tests.results.table.duration")}{$t("tests.results.table.type")}{$t("tests.results.table.actions")}
+ {formatTestDate(result.executed_at, "short", $t)} + + + {$t(getStatusKey(result.status))} + + + {result.status_line} + {#if result.error} +
+ {result.error} + {/if} +
+ {formatDuration(result.duration, $t)} + + {#if result.scheduled_test} + + + {$t("tests.results.type.scheduled")} + + {:else} + + + {$t("tests.results.type.manual")} + + {/if} + + + + + +
+ {/if} + {:catch error} + +

+ + {$t("tests.results.error-loading", { error: error.message })} +

+
+ {/await} +
+ + diff --git a/web/src/routes/domains/[dn]/tests/[tname]/results/[rid]/+page.svelte b/web/src/routes/domains/[dn]/tests/[tname]/results/[rid]/+page.svelte new file mode 100644 index 00000000..c90e77fb --- /dev/null +++ b/web/src/routes/domains/[dn]/tests/[tname]/results/[rid]/+page.svelte @@ -0,0 +1,309 @@ + + + + + + + Test Result - {testName} - {data.domain.domain} - happyDomain + + + +
+
+

+ {data.domain.domain} + – + {$t("tests.result.title")} +

+
+ + +
+
+ + {#if errorMessage} + {#key errorMessage} + + + {errorMessage} + + {/key} + {/if} + + {#await Promise.all([resultPromise, pluginPromise])} +
+ +

{$t("tests.result.loading")}

+
+ {:then [result, plugin]} + + + + +
+
+

+ {plugin.name || testName} +

+ {#if plugin.version} + + {plugin.version} + + {/if} +
+ {#if result.scheduled_test} + + + {$t("tests.result.type.scheduled")} + + {:else} + + + {$t("tests.result.type.manual")} + + {/if} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + {#if result.error} + + + + + {/if} + +
{$t("tests.result.field.domain")}{data.domain.domain}
{$t("tests.result.field.executed-at")}{formatTestDate(result.executed_at, "long", $t)}
{$t("tests.result.field.duration")}{formatDuration(result.duration, $t)}
{$t("tests.result.field.status")} + + {$t(getStatusKey(result.status))} + +
{$t("tests.result.field.status-message")}{result.status_line}
{$t("tests.result.field.error")}{result.error}
+
+
+ + {#if result.options && Object.keys(result.options).length > 0} + + + +
+ + {$t("tests.result.test-options")} +
+
+ + + + {#each Object.entries(plugin.options ?? {}) as [optKey, optVals]} + {#each optVals as option} + {@const value = + (option.id + ? result.options[option.id] + : undefined) || + option.default || + option.placeholder || + ""} + + + + + {/each} + {/each} + +
+ {option.label}: + + {#if typeof value === "object"} +
{JSON.stringify(
+                                                                    value,
+                                                                    null,
+                                                                    2,
+                                                                )}
+ {:else} + {value} + {/if} +
+
+
+ + {/if} +
+ + {#if result.report} + + +
+ + {$t("tests.result.full-report")} +
+
+ + {#if typeof result.report === "string"} +
{result.report}
+ {:else} +
{JSON.stringify(result.report, null, 2)}
+ {/if} +
+
+ {/if} + {:catch error} + +

+ + {$t("tests.result.error-loading", { error: error.message })} +

+
+ {/await} +
+ + diff --git a/web/src/routes/plugins/+page.svelte b/web/src/routes/plugins/+page.svelte new file mode 100644 index 00000000..518f6320 --- /dev/null +++ b/web/src/routes/plugins/+page.svelte @@ -0,0 +1,150 @@ + + + + + + {$t('plugins.tests.title')} - happyDomain + + + + + +

+ + {$t('plugins.tests.title')} +

+

+ + {$t('plugins.tests.description')} + + {#if $plugins} + {$t('plugins.tests.available-count', { count: Object.keys($plugins).length })} + {/if} +

+ +
+ + + + + + + + + + + + + {#if !$plugins} + +

+ + {$t('plugins.tests.loading')} +

+
+ {:else} +
+ + + + + + + + + + + {#if Object.keys($plugins).length == 0} + + + + {:else} + {#each Object.entries($plugins).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]} + + + + + + + {/each} + {/if} + +
{$t('plugins.tests.table.name')}{$t('plugins.tests.table.version')}{$t('plugins.tests.table.availability')}{$t('plugins.tests.table.actions')}
+ {$t('plugins.tests.no-tests')} +
{pluginInfo.name || pluginName}{pluginInfo.version} + {#if pluginInfo.availableOn} + {#if pluginInfo.availableOn.applyToDomain} + {$t('plugins.tests.availability.domain')} + {/if} + {#if pluginInfo.availableOn.limitToProviders && pluginInfo.availableOn.limitToProviders.length > 0} + + {$t('plugins.tests.availability.provider-specific')} + + {/if} + {#if pluginInfo.availableOn.limitToServices && pluginInfo.availableOn.limitToServices.length > 0} + + {$t('plugins.tests.availability.service-specific')} + + {/if} + {:else} + {$t('plugins.tests.availability.general')} + {/if} + + + + {$t('plugins.tests.actions.configure')} + +
+
+ {/if} +
diff --git a/web/src/routes/plugins/[pid]/+page.svelte b/web/src/routes/plugins/[pid]/+page.svelte new file mode 100644 index 00000000..b341583a --- /dev/null +++ b/web/src/routes/plugins/[pid]/+page.svelte @@ -0,0 +1,353 @@ + + + + + + {pid} - {$t("plugins.tests.title")} - happyDomain + + + + + + +

+ + {pid} +

+ +
+ + {#await pluginStatusPromise} + +

+ + {$t("plugins.tests.loading-info")} +

+
+ {:then status} + {#if status} + + + + + {$t("plugins.tests.detail.test-information")} + + +
+
{$t("plugins.tests.detail.name")}
+
{status.name}
+ +
{$t("plugins.tests.detail.version")}
+
{status.version}
+ +
{$t("plugins.tests.detail.availability")}
+
+ {#if status.availableOn} +
+ {#if status.availableOn.applyToDomain} + {$t( + "plugins.tests.availability.domain-level", + )} + {/if} + {#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0} + + {$t("plugins.tests.availability.providers", { + providers: + status.availableOn.limitToProviders.join( + ", ", + ), + })} + + {/if} + {#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0} + + {$t("plugins.tests.availability.services", { + services: + status.availableOn.limitToServices.join( + ", ", + ), + })} + + {/if} + {#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)} + {$t( + "plugins.tests.availability.general", + )} + {/if} +
+ {:else} + {$t("plugins.tests.availability.general")} + {/if} +
+
+
+
+ + + + {#await pluginOptionsPromise} + + +

+ + {$t("plugins.tests.detail.loading-options")} +

+
+
+ {:then options} + {@const userOpts = status.options?.userOpts || []} + {@const readOnlyOptGroups = [ + { + key: "adminOpts", + label: $t("plugins.tests.option-groups.global-settings"), + opts: status.options?.adminOpts || [], + }, + { + key: "domainOpts", + label: $t("plugins.tests.option-groups.domain-settings"), + opts: status.options?.domainOpts || [], + }, + { + key: "serviceOpts", + label: $t("plugins.tests.option-groups.service-settings"), + opts: status.options?.serviceOpts || [], + }, + { + key: "runOpts", + label: $t("plugins.tests.option-groups.test-parameters"), + opts: status.options?.runOpts || [], + }, + ]} + {@const hasAnyOpts = + userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)} + {@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)} + + {#if orphanedOpts.length > 0} + +
+
+ + {$t("plugins.tests.detail.orphaned-options", { + options: orphanedOpts.join(", "), + })} +
+ +
+
+ {/if} + + {#if userOpts.length > 0} + + + {$t("plugins.tests.detail.configuration")} + + +
{ + e.preventDefault(); + saveOptions(); + }} + > + {#each userOpts as optDoc} + {#if optDoc.id} + {@const optName = optDoc.id} + + + + {/if} + {/each} +
+ +
+
+
+
+ {/if} + + + + {#if !hasAnyOpts} + + + + + {$t("plugins.tests.detail.no-configurable-options")} + + + + {/if} + {:catch error} + + + + + {$t("plugins.tests.detail.error-loading-options", { + error: error.message, + })} + + + + {/await} + +
+ {:else} + + + {$t("plugins.tests.test-info-not-found")} + + {/if} + {:catch error} + + + {$t("plugins.tests.error-loading-test", { error: error.message })} + + {/await} +