Refactor observation data pipeline to serialize once after collection and keep json.RawMessage throughout storage and API responses. This eliminates double-serialization and makes DB round-trips lossless. Key changes: - ObservationGetter.Get() adopts json.Unmarshal semantics (dest any) - ObservationSnapshot.Data uses map[ObservationKey]json.RawMessage - Add freshness-based observation cache (ObservationCacheStorage) that stores lightweight snapshot pointers, enabling cross-checker reuse of recent observations without re-collecting
393 lines
14 KiB
Go
393 lines
14 KiB
Go
// This file is part of the happyDomain (R) project.
|
|
// Copyright (c) 2020-2026 happyDomain
|
|
// Authors: Pierre-Olivier Mercier, et al.
|
|
//
|
|
// This program is offered under a commercial and under the AGPL license.
|
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
//
|
|
// For AGPL licensing:
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
|
"git.happydns.org/happyDomain/internal/api/middleware"
|
|
"git.happydns.org/happyDomain/model"
|
|
)
|
|
|
|
// ListExecutions returns executions for a checker on a target.
|
|
//
|
|
// @Summary List executions for a checker
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param limit query int false "Maximum number of results"
|
|
// @Param include_planned query bool false "Include upcoming planned executions from the scheduler"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {array} happydns.Execution
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [get]
|
|
func (cc *CheckerController) ListExecutions(c *gin.Context) {
|
|
cname := c.Param("checkerId")
|
|
target := targetFromContext(c)
|
|
|
|
limit := 0
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
execs, err := cc.statusUC.ListExecutionsByChecker(cname, target, limit)
|
|
if err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if execs == nil {
|
|
execs = []*happydns.Execution{}
|
|
}
|
|
|
|
if c.Query("include_planned") == "true" || c.Query("include_planned") == "1" {
|
|
planned := cc.statusUC.ListPlannedExecutions(cname, target)
|
|
execs = append(planned, execs...)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, execs)
|
|
}
|
|
|
|
// DeleteExecution deletes an execution record.
|
|
//
|
|
// @Summary Delete an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 204
|
|
// @Failure 400 {object} happydns.ErrorResponse
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId} [delete]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId} [delete]
|
|
func (cc *CheckerController) DeleteExecution(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if err := cc.statusUC.DeleteExecution(execID); err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// DeleteCheckerExecutions deletes all executions for a checker on a target.
|
|
//
|
|
// @Summary Delete all executions for a checker
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 204
|
|
// @Failure 400 {object} happydns.ErrorResponse
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions [delete]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions [delete]
|
|
func (cc *CheckerController) DeleteCheckerExecutions(c *gin.Context) {
|
|
cname := c.Param("checkerId")
|
|
target := targetFromContext(c)
|
|
|
|
if err := cc.statusUC.DeleteExecutionsByChecker(cname, target); err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// GetExecutionObservations returns the observation snapshot for an execution.
|
|
//
|
|
// @Summary Get observations for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} happydns.ObservationSnapshot
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations [get]
|
|
func (cc *CheckerController) GetExecutionObservations(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, snap)
|
|
}
|
|
|
|
// GetExecutionObservation returns a specific observation key from an execution's snapshot.
|
|
//
|
|
// @Summary Get a specific observation for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param obsKey path string true "Observation key"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} any
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey} [get]
|
|
func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
obsKey := c.Param("obsKey")
|
|
val, ok := snap.Data[obsKey]
|
|
if !ok {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"})
|
|
return
|
|
}
|
|
|
|
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
|
|
}
|
|
|
|
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
|
|
//
|
|
// @Summary Get results for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} happydns.CheckEvaluation
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results [get]
|
|
func (cc *CheckerController) GetExecutionResults(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
eval, err := cc.statusUC.GetResultsByExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, eval)
|
|
}
|
|
|
|
// GetExecutionResult returns a specific rule's result from an execution.
|
|
//
|
|
// @Summary Get a specific rule result for an execution
|
|
// @Tags checkers
|
|
// @Produce json
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param ruleName path string true "Rule name"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {object} happydns.CheckState
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/results/{ruleName} [get]
|
|
func (cc *CheckerController) GetExecutionResult(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
eval, err := cc.statusUC.GetResultsByExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Results not available"})
|
|
return
|
|
}
|
|
|
|
ruleName := c.Param("ruleName")
|
|
for _, state := range eval.States {
|
|
if state.Code == ruleName {
|
|
c.JSON(http.StatusOK, state)
|
|
return
|
|
}
|
|
}
|
|
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Rule result not found"})
|
|
}
|
|
|
|
// GetExecutionHTMLReport returns the HTML report for a specific observation of an execution.
|
|
//
|
|
// @Summary Get execution observation HTML report
|
|
// @Description Returns the full HTML document generated from an observation's data. Only available for observation providers that implement HTML reporting.
|
|
// @Tags checkers
|
|
// @Produce html
|
|
// @Param checkerId path string true "Checker ID"
|
|
// @Param executionId path string true "Execution ID"
|
|
// @Param obsKey path string true "Observation key"
|
|
// @Param domain path string true "Domain identifier"
|
|
// @Param zoneid path string true "Zone identifier"
|
|
// @Param subdomain path string true "Subdomain"
|
|
// @Param serviceid path string true "Service identifier"
|
|
// @Success 200 {string} string "HTML document"
|
|
// @Failure 404 {object} happydns.ErrorResponse
|
|
// @Failure 500 {object} happydns.ErrorResponse
|
|
// @Router /domains/{domain}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checkers/{checkerId}/executions/{executionId}/observations/{obsKey}/report [get]
|
|
func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
|
|
execID, err := happydns.NewIdentifierFromString(c.Param("executionId"))
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid execution ID"})
|
|
return
|
|
}
|
|
|
|
exec, err := cc.statusUC.GetExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
if !targetMatchesContext(targetFromContext(c), exec.Target) {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Execution not found"})
|
|
return
|
|
}
|
|
|
|
snap, err := cc.statusUC.GetObservationsByExecution(execID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observations not available"})
|
|
return
|
|
}
|
|
|
|
obsKey := c.Param("obsKey")
|
|
val, ok := snap.Data[obsKey]
|
|
if !ok {
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Observation key not found"})
|
|
return
|
|
}
|
|
|
|
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
|
|
if err != nil {
|
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if !supported {
|
|
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("observation %q does not support HTML reports", obsKey))
|
|
return
|
|
}
|
|
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
|
}
|