a0774f4f1a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
419 lines
15 KiB
Go
419 lines
15 KiB
Go
package alertsource
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
)
|
|
|
|
// ConfigMode selects which requiredness rule ValidateConfig applies to a
|
|
// Secret field. Its zero value is ConfigCreate — the stricter mode — so a
|
|
// forgotten or zero-valued argument fails closed rather than silently
|
|
// accepting a config a real create would have rejected.
|
|
type ConfigMode int
|
|
|
|
const (
|
|
// ConfigCreate requires every declared field, Secret or not.
|
|
ConfigCreate ConfigMode = iota
|
|
// ConfigUpdate additionally treats an absent Secret key as legal
|
|
// (D6/D15): a writeOnly form field cannot round-trip a secret it was
|
|
// never given, so its absence means "leave the stored value alone",
|
|
// never "clear it". An absent non-secret required key is still
|
|
// MissingRequired under this mode too.
|
|
ConfigUpdate
|
|
)
|
|
|
|
const secretRedactionMarker = "SENTINEL-SECRET-VALUE"
|
|
|
|
// msgFieldRequired is ErrCodeMissingRequired's shared message, used at every
|
|
// site that reports an absent required field.
|
|
const msgFieldRequired = "field is required"
|
|
|
|
// SecretResolver reads a previously-stored secret by its store id. Declared
|
|
// locally so *secretadapter.Adapter satisfies it structurally without this
|
|
// package importing secretadapter.
|
|
type SecretResolver interface {
|
|
Reveal(ctx context.Context, id string) (string, error)
|
|
}
|
|
|
|
// SecretWriter is T06's write-side seam (D15): Set upserts, Delete removes,
|
|
// and ListIDs enumerates for orphan cleanup after a source delete. Declared
|
|
// here, alongside SecretKey, so T06 does not invent a second spelling of the
|
|
// same seam; nothing in T04 calls it.
|
|
type SecretWriter interface {
|
|
Set(ctx context.Context, id, name, value string) error
|
|
Delete(ctx context.Context, id string) error
|
|
ListIDs(ctx context.Context) ([]string, error)
|
|
}
|
|
|
|
// SecretKey is the one place the "alertsource:<source_id>:<field>" spelling
|
|
// exists, so LoadConfig's reads and T06's several write sites cannot drift
|
|
// apart.
|
|
func SecretKey(sourceID, field string) string {
|
|
return fmt.Sprintf("alertsource:%s:%s", sourceID, field)
|
|
}
|
|
|
|
// ValidatedConfig is ValidateConfig's result: the non-secret keys destined
|
|
// for alert_sources.config_json, and the Secret-declared keys and their
|
|
// plaintext values destined for the secret store. Both maps are always
|
|
// non-nil (constructed with make, even when empty) because a nil map
|
|
// marshals to JSON null, which config_json's NOT NULL DEFAULT '{}' column
|
|
// cannot hold.
|
|
type ValidatedConfig struct {
|
|
Public map[string]any
|
|
Secrets map[string]string
|
|
}
|
|
|
|
// String returns a redacted rendering: Public as-is, Secrets replaced by a
|
|
// fixed marker. fmt reaches every exported field by reflection, so %+v/%#v
|
|
// would otherwise print Secrets' plaintext values in full without this.
|
|
func (c ValidatedConfig) String() string { return c.redacted() }
|
|
|
|
// GoString mirrors String: %#v dispatches to GoStringer, and Go's default
|
|
// %#v rendering of an exported map would otherwise print every value.
|
|
func (c ValidatedConfig) GoString() string { return c.redacted() }
|
|
|
|
func (c ValidatedConfig) redacted() string {
|
|
redactedSecrets := make(map[string]string, len(c.Secrets))
|
|
for k := range c.Secrets {
|
|
redactedSecrets[k] = secretRedactionMarker
|
|
}
|
|
return fmt.Sprintf("{Public:%v Secrets:%v}", c.Public, redactedSecrets)
|
|
}
|
|
|
|
// ValidateConfig type-checks raw against spec, applies requiredness (relaxed
|
|
// for Secret fields under ConfigUpdate per D6), and resolves declared
|
|
// defaults. It is pure: no I/O, used at source create/update. raw is the API
|
|
// request body and still carries any plaintext secrets.
|
|
func ValidateConfig(spec *FieldSpec, raw map[string]any, mode ConfigMode) (*ValidatedConfig, error) {
|
|
if err := checkConfigCall(spec, mode); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := spec.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &ValidatedConfig{Public: map[string]any{}, Secrets: map[string]string{}}
|
|
var errs []FieldError
|
|
errs = append(errs, checkUnknownFields(spec, raw)...)
|
|
for _, e := range spec.fields {
|
|
errs = append(errs, applyField(e, raw, mode, result)...)
|
|
}
|
|
if err := newValidationError(errs); err != nil {
|
|
return nil, err
|
|
}
|
|
// applyField stores each field's CANONICAL Go value (shared with
|
|
// LoadConfig's phase 1, which needs that same canonical form for
|
|
// Config's accessors). Public alone is destined for config_json, so
|
|
// only here is it rendered through D4's per-kind render — a raw
|
|
// time.Duration would otherwise marshal as a JSON number and corrupt
|
|
// the "Duration is a JSON string" contract on the next read.
|
|
renderPublicForJSON(spec, result.Public)
|
|
return result, nil
|
|
}
|
|
|
|
// renderPublicForJSON rewrites public's values in place through render, so
|
|
// every value is in the form D9 promises config_json holds. render is the
|
|
// identity for String/Int/Bool, so this only changes Duration entries.
|
|
func renderPublicForJSON(spec *FieldSpec, public map[string]any) {
|
|
for _, e := range spec.fields {
|
|
if e.secret {
|
|
continue
|
|
}
|
|
if v, ok := public[e.name]; ok {
|
|
public[e.name] = render(e.kind, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkConfigCall validates ValidateConfig's own call-level arguments
|
|
// (A2/D10: argument checks before spec validation, which runs before field
|
|
// processing).
|
|
func checkConfigCall(spec *FieldSpec, mode ConfigMode) error {
|
|
if spec == nil {
|
|
return newSpecError([]FieldError{{Field: "", Code: ErrCodeNilSpec, Message: "field spec must not be nil"}})
|
|
}
|
|
if mode != ConfigCreate && mode != ConfigUpdate {
|
|
return newSpecError([]FieldError{{Field: "mode", Code: ErrCodeInvalidConfigMode, Message: "config mode must be ConfigCreate or ConfigUpdate"}})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkUnknownFields reports any raw key the spec does not declare, matching
|
|
// the additionalProperties:false contract Schema emits (D9) — a key the
|
|
// schema would reject must be rejected here too, not silently dropped.
|
|
func checkUnknownFields(spec *FieldSpec, raw map[string]any) []FieldError {
|
|
declared := make(map[string]bool, len(spec.fields))
|
|
for _, e := range spec.fields {
|
|
declared[e.name] = true
|
|
}
|
|
var errs []FieldError
|
|
for k := range raw {
|
|
if !declared[k] {
|
|
errs = append(errs, FieldError{Field: k, Code: ErrCodeUnknownField, Message: "field is not declared by this source's config spec"})
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// applyField type-checks, defaults and routes a single declared field from
|
|
// raw into result.Public or result.Secrets, per D6/D10/D15. It always
|
|
// stores each field's CANONICAL Go value (string/int/bool/time.Duration) —
|
|
// never the JSON-rendered form — because this routine is shared with
|
|
// LoadConfig's phase 1 (loadNonSecretFields), which needs that canonical
|
|
// value directly for Config's accessors. ValidateConfig alone renders
|
|
// Duration entries back to a JSON-safe string, afterward, via
|
|
// renderPublicForJSON.
|
|
func applyField(e fieldEntry, raw map[string]any, mode ConfigMode, result *ValidatedConfig) []FieldError {
|
|
v, present := raw[e.name]
|
|
|
|
if e.secret {
|
|
return applySecretField(e, v, present, mode, result)
|
|
}
|
|
|
|
if !present {
|
|
if e.hasDefault {
|
|
result.Public[e.name] = e.defaultVal
|
|
return nil
|
|
}
|
|
if !e.optional {
|
|
return []FieldError{{Field: e.name, Code: ErrCodeMissingRequired, Message: msgFieldRequired}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
coerced, ok := coerceValue(e.kind, v)
|
|
if !ok {
|
|
// D10: a Duration value of the correct Go shape (a string) but
|
|
// unparseable content is InvalidValue, not WrongType — coerceValue
|
|
// folds both failure modes into ok=false, so the distinction is
|
|
// made here from the ORIGINAL v, not from anything coerceValue
|
|
// returns.
|
|
if e.kind == FieldKindDuration {
|
|
if _, isString := v.(string); isString {
|
|
return []FieldError{{Field: e.name, Code: ErrCodeInvalidValue, Message: "not a valid Go duration"}}
|
|
}
|
|
}
|
|
return []FieldError{{Field: e.name, Code: ErrCodeWrongType, Message: fmt.Sprintf("expected %s", e.kind.tag())}}
|
|
}
|
|
result.Public[e.name] = coerced
|
|
return nil
|
|
}
|
|
|
|
// applySecretField handles D6/D15's Secret-specific requiredness relaxation
|
|
// and the "empty string is always rejected" rule.
|
|
func applySecretField(e fieldEntry, v any, present bool, mode ConfigMode, result *ValidatedConfig) []FieldError {
|
|
if !present {
|
|
if !e.optional && mode == ConfigCreate {
|
|
return []FieldError{{Field: e.name, Code: ErrCodeMissingRequired, Message: msgFieldRequired}}
|
|
}
|
|
return nil
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return []FieldError{{Field: e.name, Code: ErrCodeWrongType, Message: "expected string"}}
|
|
}
|
|
if s == "" {
|
|
if !e.optional {
|
|
return []FieldError{{Field: e.name, Code: ErrCodeMissingRequired, Message: msgFieldRequired}}
|
|
}
|
|
return []FieldError{{Field: e.name, Code: ErrCodeInvalidValue, Message: "an empty secret value is not accepted; omit the field to leave it unchanged"}}
|
|
}
|
|
result.Secrets[e.name] = s
|
|
return nil
|
|
}
|
|
|
|
// Config is what Enrich and Poll receive: a wrapper whose only exported
|
|
// surface is four typed accessors, mirroring Benthos's ParsedConfig. Only
|
|
// LoadConfig constructs one, so an unvalidated map can never reach a
|
|
// source.
|
|
type Config struct {
|
|
values map[string]any
|
|
}
|
|
|
|
// GetString returns name's value and whether it is present.
|
|
func (c Config) GetString(name string) (string, bool) {
|
|
v, ok := c.values[name]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
s, ok := v.(string)
|
|
return s, ok
|
|
}
|
|
|
|
// GetInt returns name's value and whether it is present.
|
|
func (c Config) GetInt(name string) (int, bool) {
|
|
v, ok := c.values[name]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
n, ok := v.(int)
|
|
return n, ok
|
|
}
|
|
|
|
// GetBool returns name's value and whether it is present.
|
|
func (c Config) GetBool(name string) (bool, bool) {
|
|
v, ok := c.values[name]
|
|
if !ok {
|
|
return false, false
|
|
}
|
|
b, ok := v.(bool)
|
|
return b, ok
|
|
}
|
|
|
|
// GetDuration returns name's value and whether it is present.
|
|
func (c Config) GetDuration(name string) (time.Duration, bool) {
|
|
v, ok := c.values[name]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
d, ok := v.(time.Duration)
|
|
return d, ok
|
|
}
|
|
|
|
// String returns a redacted rendering: declared field names with every
|
|
// value replaced by a fixed marker. fmt reaches unexported struct fields by
|
|
// reflection, so an exported accessor-only surface does not by itself make
|
|
// %+v safe — String/GoString are what make it safe.
|
|
func (c Config) String() string { return c.redacted() }
|
|
|
|
// GoString mirrors String: %#v dispatches to GoStringer, and Go's default
|
|
// %#v rendering of an unexported map would otherwise print every value.
|
|
func (c Config) GoString() string { return c.redacted() }
|
|
|
|
func (c Config) redacted() string {
|
|
redacted := make(map[string]string, len(c.values))
|
|
for k := range c.values {
|
|
redacted[k] = secretRedactionMarker
|
|
}
|
|
return fmt.Sprintf("%v", redacted)
|
|
}
|
|
|
|
// LoadConfig resolves a stored (secret-stripped) config plus the live secret
|
|
// store into a Config, for use at ingest/poll time. sourceID is required
|
|
// because SecretKey needs it and stored does not carry one.
|
|
func LoadConfig(ctx context.Context, spec *FieldSpec, sourceID string, stored map[string]any, r SecretResolver) (Config, error) {
|
|
if err := checkLoadCall(spec, sourceID, r); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := spec.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
values, errs := loadNonSecretFields(spec, stored)
|
|
if err := newValidationError(errs); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
secretValues, err := loadSecretFields(ctx, spec, sourceID, r)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := newValidationError(secretValues.errs); err != nil {
|
|
return Config{}, err
|
|
}
|
|
for k, v := range secretValues.values {
|
|
values[k] = v
|
|
}
|
|
return Config{values: values}, nil
|
|
}
|
|
|
|
// checkLoadCall validates LoadConfig's own call-level arguments, in the
|
|
// order A2 pins: NilSpec before EmptySourceID before NilResolver, none of
|
|
// which requires spec.Validate() to have run.
|
|
func checkLoadCall(spec *FieldSpec, sourceID string, r SecretResolver) error {
|
|
if spec == nil {
|
|
return newSpecError([]FieldError{{Field: "", Code: ErrCodeNilSpec, Message: "field spec must not be nil"}})
|
|
}
|
|
if sourceID == "" {
|
|
return newSpecError([]FieldError{{Field: "sourceID", Code: ErrCodeEmptySourceID, Message: "source id must not be empty"}})
|
|
}
|
|
if isNilInterfaceValue(r) && specDeclaresSecret(spec) {
|
|
return newSpecError([]FieldError{{Field: "", Code: ErrCodeNilResolver, Message: "a secret resolver is required because the spec declares a Secret field"}})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// specDeclaresSecret reports whether spec declares at least one Secret
|
|
// field. isNil entries are skipped: Validate() has already run by every
|
|
// call site that needs this answered, so a nil entry never reaches here in
|
|
// practice, but this helper does not assume that ordering itself.
|
|
func specDeclaresSecret(spec *FieldSpec) bool {
|
|
for _, e := range spec.fields {
|
|
if !e.isNil && e.secret {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// loadNonSecretFields is LoadConfig's pure phase 1: type-check and default
|
|
// every non-secret field, reusing applyField's per-field routine via a
|
|
// throwaway ValidatedConfig so the rule exists in exactly one place. Errors
|
|
// here short-circuit phase 2 (the resolver is never called for a config
|
|
// already known to be invalid).
|
|
func loadNonSecretFields(spec *FieldSpec, stored map[string]any) (map[string]any, []FieldError) {
|
|
values := map[string]any{}
|
|
var errs []FieldError
|
|
scratch := &ValidatedConfig{Public: map[string]any{}, Secrets: map[string]string{}}
|
|
for _, e := range spec.fields {
|
|
if e.secret {
|
|
continue
|
|
}
|
|
fieldErrs := applyField(e, stored, ConfigCreate, scratch)
|
|
errs = append(errs, fieldErrs...)
|
|
}
|
|
for k, v := range scratch.Public {
|
|
values[k] = v
|
|
}
|
|
return values, errs
|
|
}
|
|
|
|
// secretLoadResult separates a resolved secret value from a validation
|
|
// error so loadSecretFields can distinguish "no value" from "outage" for
|
|
// its caller without a third return value.
|
|
type secretLoadResult struct {
|
|
values map[string]any
|
|
errs []FieldError
|
|
}
|
|
|
|
// loadSecretFields is LoadConfig's phase 2: resolve every declared Secret
|
|
// field, in declaration order, ALWAYS calling Reveal exactly once per field
|
|
// regardless of what earlier fields resolved to — an outage on field 1 must
|
|
// not suppress the resolver call for field 2. Only after every field has
|
|
// been resolved does an outage's classification win: if any non-ErrNotFound
|
|
// error occurred, that error (the first one encountered, by construction of
|
|
// the loop) is returned wrapped and every collected absence is discarded —
|
|
// a failing store's not-found answers are not trustworthy.
|
|
func loadSecretFields(ctx context.Context, spec *FieldSpec, sourceID string, r SecretResolver) (secretLoadResult, error) {
|
|
result := secretLoadResult{values: map[string]any{}}
|
|
var outageErr error
|
|
for _, e := range spec.fields {
|
|
if !e.secret {
|
|
continue
|
|
}
|
|
value, err := r.Reveal(ctx, SecretKey(sourceID, e.name))
|
|
if err != nil && !errors.Is(err, secrets.ErrNotFound) {
|
|
if outageErr == nil {
|
|
outageErr = fmt.Errorf("resolving secret %q: %w", e.name, err)
|
|
}
|
|
continue
|
|
}
|
|
absent := errors.Is(err, secrets.ErrNotFound) || value == ""
|
|
if absent {
|
|
if !e.optional {
|
|
result.errs = append(result.errs, FieldError{Field: e.name, Code: ErrCodeMissingRequired, Message: msgFieldRequired})
|
|
}
|
|
continue
|
|
}
|
|
result.values[e.name] = value
|
|
}
|
|
if outageErr != nil {
|
|
return secretLoadResult{}, outageErr
|
|
}
|
|
return result, nil
|
|
}
|