happyDomain/internal/api/controller/checker_results.go
Pierre-Olivier Mercier 50badc5811 checkers: store observations as json.RawMessage with cross-checker reuse
Refactor observation data pipeline to serialize once after collection and
keep json.RawMessage throughout storage and API responses. This eliminates
double-serialization and makes DB round-trips lossless.

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
2026-04-05 17:09:54 +07:00

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