Files
kandev/apps/backend/internal/integrations/alertsource/descriptor.go
T

243 lines
8.5 KiB
Go

package alertsource
import (
"fmt"
"reflect"
)
// Capabilities declares which of a Source's optional behaviors are active.
// A struct of three independent bools rather than a bitmask: the axes are
// orthogonal, the zero value ("nothing declared") is itself meaningful, and
// it needs no String() or mask arithmetic (D18).
type Capabilities struct {
Webhook bool
Poll bool
Enrich bool
}
// fieldCapabilities is the FieldError.Field value for MissingCapability and
// NilSource: neither describes a single capability axis, so one shared path
// is correct, not merely deduplicated. CapabilityNotImplemented and
// CapabilityNotDeclared instead use the per-axis fieldCapabilitiesPoll/
// fieldCapabilitiesEnrich below — two errors both scoped to "Capabilities"
// would tie on Field, Code AND Message when Poll and Enrich disagree the
// same way at once, leaving sortFieldErrors' documented tiebreak with
// nothing left to break the tie on and the result undiagnosable.
const fieldCapabilities = "Capabilities"
// fieldCapabilitiesPoll and fieldCapabilitiesEnrich are CheckCapabilities'
// per-axis Field values (see fieldCapabilities above for why they are not
// the shared constant).
const (
fieldCapabilitiesPoll = "Capabilities.Poll"
fieldCapabilitiesEnrich = "Capabilities.Enrich"
)
// Descriptor is a Source's static declaration: identity, the field spec
// governing its configuration, how it participates in dedup
// (FingerprintFields) and per-watch throttling (WatchMetadataKey), and
// which optional interfaces it implements (Capabilities). DisplayName and
// Category are deliberately unvalidated — UI text with no consumer that can
// break.
type Descriptor struct {
Type string
DisplayName string
Category string
WatchMetadataKey string
FingerprintFields []string
Config *FieldSpec
Capabilities Capabilities
}
// Validate checks the descriptor's own declaration for internal
// consistency. It reports MissingCapability itself — the one capability
// code it can reach without a Source, since the check compares two declared
// booleans (A1); the other three capability codes belong to
// CheckCapabilities, which alone can see an implementation. Errors from
// Config.Validate() are merged in under a "Config."-prefixed Field path so
// a caller has one flat sorted list to render. Same four rules as
// FieldSpec.Validate: aggregate (not fail-fast), sort by
// Field->Code->Message, never leak a value in Message, plain nil on
// success.
func (d Descriptor) Validate() error {
var errs []FieldError
if d.Type == "" {
errs = append(errs, FieldError{
Field: "Type",
Code: ErrCodeEmptyType,
Message: "type must not be empty",
})
}
errs = append(errs, validateWatchMetadataKey(d.WatchMetadataKey)...)
errs = append(errs, validateFingerprintFields(d.FingerprintFields)...)
errs = append(errs, validateDescriptorConfig(d.Config)...)
if !d.Capabilities.Webhook && !d.Capabilities.Poll {
errs = append(errs, FieldError{
Field: fieldCapabilities,
Code: ErrCodeMissingCapability,
Message: "at least one of Webhook or Poll must be declared",
})
}
return newSpecError(errs)
}
func validateWatchMetadataKey(key string) []FieldError {
if key == "" {
return []FieldError{{
Field: "WatchMetadataKey",
Code: ErrCodeMissingWatchMetadataKey,
Message: "watch metadata key must not be empty",
}}
}
if !isIdentifier(key) {
return []FieldError{{
Field: "WatchMetadataKey",
Code: ErrCodeInvalidWatchMetadataKey,
Message: "watch metadata key must be restricted to [A-Za-z0-9_]",
}}
}
return nil
}
// validateFingerprintFields checks FingerprintFields per D17: empty
// entirely -> EmptyFingerprintFields; an empty-string entry ->
// EmptyFingerprintField at its own index; a duplicate key ->
// DuplicateFingerprintField, reported once per repeated OCCURRENCE after
// the first, anchored at that occurrence's own index. ["a","a","a"] yields
// exactly two errors, at FingerprintFields[1] and FingerprintFields[2] —
// the first occurrence is never flagged, because it duplicates nothing
// before it.
func validateFingerprintFields(fields []string) []FieldError {
if len(fields) == 0 {
return []FieldError{{
Field: "FingerprintFields",
Code: ErrCodeEmptyFingerprintFields,
Message: "fingerprint fields must not be empty",
}}
}
var errs []FieldError
seen := make(map[string]bool, len(fields))
for i, f := range fields {
path := fmt.Sprintf("FingerprintFields[%d]", i)
switch {
case f == "":
errs = append(errs, FieldError{
Field: path,
Code: ErrCodeEmptyFingerprintField,
Message: "fingerprint field must not be empty",
})
case seen[f]:
errs = append(errs, FieldError{
Field: path,
Code: ErrCodeDuplicateFingerprintField,
Message: "fingerprint field declared more than once",
})
default:
seen[f] = true
}
}
return errs
}
// validateDescriptorConfig reports NilConfig without recursing when cfg is
// nil (a source with no declared configuration declares an empty FieldSpec
// instead), otherwise merges cfg.Validate()'s errors under a
// "Config."-prefixed Field path.
func validateDescriptorConfig(cfg *FieldSpec) []FieldError {
if cfg == nil {
return []FieldError{{
Field: "Config",
Code: ErrCodeNilConfig,
Message: "config must not be nil",
}}
}
err := cfg.Validate()
if err == nil {
return nil
}
specErr, ok := err.(*SpecError)
if !ok {
return nil
}
merged := make([]FieldError, len(specErr.Errors))
for i, fe := range specErr.Errors {
merged[i] = FieldError{
Field: "Config." + fe.Field,
Code: fe.Code,
Message: fe.Message,
}
}
return merged
}
// CheckCapabilities checks that d's declared Capabilities agree, in BOTH
// directions, with the interfaces s actually implements (D18). Poll: true
// without s implementing Poller is CapabilityNotImplemented (the watch's
// poll interval is configured and nothing ever polls); s implementing
// Poller with Poll: false is CapabilityNotDeclared (the frozen control flow
// skips undeclared sources, so an implemented method nobody calls is almost
// certainly a typo in the declaration). Both directions apply to Enrich the
// same way. Webhook has no interface to compare against — every Source
// implements Normalize — so it is declaration-only.
//
// A nil s (untyped nil, or a typed nil pointer boxed into a non-nil Source
// interface value, e.g. (*fakeSource)(nil)) is NilSource: the fourth
// capability code, distinct from the three agreement codes because those
// describe a disagreement between a declaration and an implementation, and
// none of them can describe the absence of the implementation itself.
func CheckCapabilities(d Descriptor, s Source) error {
if isNilInterfaceValue(s) {
return newSpecError([]FieldError{{
Field: fieldCapabilities,
Code: ErrCodeNilSource,
Message: "source must not be nil",
}})
}
var errs []FieldError
_, isPoller := s.(Poller)
errs = append(errs, capabilityAgreement(fieldCapabilitiesPoll, d.Capabilities.Poll, isPoller)...)
_, isEnricher := s.(Enricher)
errs = append(errs, capabilityAgreement(fieldCapabilitiesEnrich, d.Capabilities.Enrich, isEnricher)...)
return newSpecError(errs)
}
func capabilityAgreement(field string, declared, implemented bool) []FieldError {
switch {
case declared && !implemented:
return []FieldError{{
Field: field,
Code: ErrCodeCapabilityNotImplemented,
Message: "capability declared but not implemented",
}}
case !declared && implemented:
return []FieldError{{
Field: field,
Code: ErrCodeCapabilityNotDeclared,
Message: "capability implemented but not declared",
}}
default:
return nil
}
}
// isNilInterfaceValue reports whether v is nil, either as an untyped nil
// interface value or as a typed nil (pointer, map, slice, chan or func)
// boxed into a non-nil interface value. v == nil alone misses the typed
// case — (*fakeSource)(nil) is a non-nil Source under v == nil and panics
// on the first method call instead of reporting NilSource (D18). Takes any
// so the same check covers every interface-typed nil hazard in this
// package: a Source here, a SecretResolver in checkLoadCall (R5-09) — both
// are "an interface value that looks non-nil but panics on first use".
func isNilInterfaceValue(v any) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
return rv.IsNil()
default:
return false
}
}