happyDomain/model/checker.go
Pierre-Olivier Mercier 3297b5e2a3 checker: reject checker IDs containing the storage key separator
A checker ID is embedded verbatim in several "|"-delimited storage keys
(chckrcfg|, chckpln-chkr|, chckexec-chkr|, ...). An ID containing "|"
would misalign key parsing and let records collide. Validate at the
registration boundary and refuse such IDs.
2026-06-06 20:14:01 +09:00

322 lines
13 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 happydns
import (
"context"
"encoding/json"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// CheckerIDSeparator is the byte that delimits fields in every storage key
// that embeds a checker ID (chckrcfg|, chckpln-chkr|, chckexec-chkr|,
// chckeval-chkr|, dscent|, notifstate|, ...). A checker ID containing it would
// misalign key parsing and let one checker's records collide with another's,
// so registration rejects such IDs.
const CheckerIDSeparator = "|"
// ValidCheckerID reports whether id is safe to embed in happyDomain storage
// keys. It must not contain CheckerIDSeparator.
func ValidCheckerID(id string) bool {
return id != "" && !strings.Contains(id, CheckerIDSeparator)
}
// The types and helpers needed by external checker plugins live in the
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
// aliases so the rest of the happyDomain codebase keeps relying on this model.
//
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
// defined in this file because they describe orchestration state that is
// internal to the happyDomain server and never crosses the plugin boundary.
// --- Re-exports from checker-sdk-go ---
type CheckScopeType = sdk.CheckScopeType
const (
CheckScopeAdmin = sdk.CheckScopeAdmin
CheckScopeUser = sdk.CheckScopeUser
CheckScopeDomain = sdk.CheckScopeDomain
CheckScopeZone = sdk.CheckScopeZone
CheckScopeService = sdk.CheckScopeService
)
const (
AutoFillDomainName = sdk.AutoFillDomainName
AutoFillSubdomain = sdk.AutoFillSubdomain
AutoFillZone = sdk.AutoFillZone
AutoFillServiceType = sdk.AutoFillServiceType
AutoFillService = sdk.AutoFillService
AutoFillDiscoveryEntries = sdk.AutoFillDiscoveryEntries
)
type (
CheckTarget = sdk.CheckTarget
CheckerAvailability = sdk.CheckerAvailability
CheckerOptions = sdk.CheckerOptions
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
Status = sdk.Status
CheckState = sdk.CheckState
CheckMetric = sdk.CheckMetric
ObservationKey = sdk.ObservationKey
CheckIntervalSpec = sdk.CheckIntervalSpec
ObservationProvider = sdk.ObservationProvider
CheckRuleInfo = sdk.CheckRuleInfo
CheckRule = sdk.CheckRule
CheckRuleWithOptions = sdk.CheckRuleWithOptions
RulePrecheck = sdk.RulePrecheck
RulePrecheckRequest = sdk.RulePrecheckRequest
RulePrecheckResponse = sdk.RulePrecheckResponse
ObservationGetter = sdk.ObservationGetter
CheckAggregator = sdk.CheckAggregator
CheckerHTMLReporter = sdk.CheckerHTMLReporter
CheckerMetricsReporter = sdk.CheckerMetricsReporter
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
CheckerDefinition = sdk.CheckerDefinition
OptionsValidator = sdk.OptionsValidator
ExternalCollectRequest = sdk.ExternalCollectRequest
ExternalCollectResponse = sdk.ExternalCollectResponse
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
ExternalReportRequest = sdk.ExternalReportRequest
DiscoveryEntry = sdk.DiscoveryEntry
DiscoveryPublisher = sdk.DiscoveryPublisher
RelatedObservation = sdk.RelatedObservation
ReportContext = sdk.ReportContext
)
const (
StatusOK = sdk.StatusOK
StatusInfo = sdk.StatusInfo
StatusUnknown = sdk.StatusUnknown
StatusWarn = sdk.StatusWarn
StatusCrit = sdk.StatusCrit
StatusError = sdk.StatusError
)
// --- Helpers for converting between target identifier strings and *Identifier ---
// TargetIdentifier parses a target identifier string into an *Identifier.
// Returns nil if the string is empty or cannot be parsed.
func TargetIdentifier(s string) *Identifier {
if s == "" {
return nil
}
id, err := NewIdentifierFromString(s)
if err != nil {
return nil
}
return &id
}
// FormatIdentifier returns the string representation of id, or "" if nil.
func FormatIdentifier(id *Identifier) string {
if id == nil {
return ""
}
return id.String()
}
// --- Host-only types (orchestration state) ---
// CheckerRunRequest is the JSON body for manually triggering a checker.
type CheckerRunRequest struct {
Options CheckerOptions `json:"options,omitempty"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// CheckerOptionsPositional stores options with their positional key components.
type CheckerOptionsPositional struct {
CheckName string `json:"checkName"`
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
Options CheckerOptions `json:"options"`
}
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" validate:"required" readonly:"true"`
CheckerID string `json:"checkerId" validate:"required" readonly:"true"`
Target CheckTarget `json:"target" validate:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
// Disabled, when true, pauses the automatic scheduling of this plan
// regardless of per-rule Enabled state. Manual runs are unaffected.
Disabled bool `json:"disabled,omitempty"`
}
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
func (p *CheckPlan) IsFullyDisabled() bool {
if len(p.Enabled) == 0 {
return false
}
for _, v := range p.Enabled {
if v {
return false
}
}
return true
}
// IsRuleEnabled returns whether a specific rule is enabled.
// A nil or empty map means all rules are enabled. A missing key means enabled.
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
if len(p.Enabled) == 0 {
return true
}
v, ok := p.Enabled[ruleName]
if !ok {
return true
}
return v
}
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
//
// PrecheckFailures is the per-rule precheck outcome for the effective
// options resolved at the target's scope: keys are rule names, values
// are the reason the rule cannot run (typically "<X> API key is not
// configured"). Rules that pass their precheck — or do not implement
// one — are absent. Empty/omitted means "no rule is currently gated".
type CheckerStatus struct {
*CheckerDefinition
LatestExecution *Execution `json:"latestExecution,omitempty"`
Plan *CheckPlan `json:"plan,omitempty"`
Enabled bool `json:"enabled"`
EnabledRules map[string]bool `json:"enabledRules"`
PrecheckFailures map[string]string `json:"precheckFailures,omitempty"`
}
// CheckEvaluation is the result of running a checker on observed data.
type CheckEvaluation struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
CheckerID string `json:"checkerId" binding:"required"`
Target CheckTarget `json:"target" binding:"required"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
States []CheckState `json:"states" binding:"required" readonly:"true"`
}
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
//
// EnabledRulesHash fingerprints the rule-enable map that was in effect when
// the observation was collected. The engine recomputes the hash before
// reading the cache and bypasses the freshness window if it does not match,
// so a re-enabled rule does not consume stale data collected while it was
// disabled. Empty string means "no rule constraint was recorded" (legacy
// entries or full-default executions).
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
EnabledRulesHash string `json:"enabledRulesHash,omitempty"`
}
// StoredDiscoveryEntry is the host-side persistent form of a DiscoveryEntry:
// the opaque SDK-level (Type, Ref, Payload) triple augmented with the
// producing checker and target — the namespace under which the host dedupes
// and replaces entries across collection cycles.
type StoredDiscoveryEntry struct {
ProducerID string `json:"producerId"`
Target CheckTarget `json:"target"`
Type string `json:"type"`
Ref string `json:"ref"`
Payload json.RawMessage `json:"payload,omitempty" swaggertype:"object"`
}
// DiscoveryObservationRef links a consumer's observation to a specific
// DiscoveryEntry it covered. It lets the host resolve
// ObservationGetter.GetRelated and ReportContext.Related without re-parsing
// snapshots.
type DiscoveryObservationRef struct {
ProducerID string `json:"producerId"`
Target CheckTarget `json:"target"`
Ref string `json:"ref"`
ConsumerID string `json:"consumerId"`
Key ObservationKey `json:"key"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string"`
CollectedAt time.Time `json:"collectedAt" format:"date-time"`
}
// ExecutionStatus represents the lifecycle state of an execution.
type ExecutionStatus int
const (
ExecutionPending ExecutionStatus = iota
ExecutionRunning
ExecutionDone
ExecutionFailed
// ExecutionRateLimited indicates a planned execution that will be
// skipped because the user's MaxChecksPerDay quota is exhausted.
// Only used for synthetic planned executions returned by
// ListPlannedExecutions; never persisted.
ExecutionRateLimited
)
// TriggerType represents what initiated an execution.
type TriggerType int
const (
TriggerManual TriggerType = iota
TriggerSchedule
)
// TriggerInfo describes the trigger for an execution.
type TriggerInfo struct {
Type TriggerType `json:"type"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
}
// Execution represents a single run of a checker pipeline.
type Execution struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
Error string `json:"error,omitempty" readonly:"true"`
Result CheckState `json:"result" readonly:"true"`
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
}
// CheckerEngine orchestrates the full checker pipeline.
type CheckerEngine interface {
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
}