pluggablevalidator

package
v0.2.0-alpha.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 5, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package pluggablevalidator implements the controller-side client for the pluggable-validator-sidecar wire protocol. The authoritative wire-protocol specification lives at docs/development/validator-protocol.md.

Index

Constants

View Source
const DefaultCacheCapacity = 256

DefaultCacheCapacity is the default LRU cache size. Sized for a healthy reconciliation churn — one entry per (validator, file-path, distinct-content) tuple.

View Source
const DefaultMaxConnections = 4

DefaultMaxConnections is the per-validator pool ceiling when `spec.validators[i].maxConnections` is omitted. Sized to match typical reconciliation-burst concurrency (a handful of in-flight validations) without holding many idle file descriptors per validator.

View Source
const DefaultMaxParallelDispatch = 16

DefaultMaxParallelDispatch caps the number of concurrent (validator, file) round-trips ValidateAll runs in parallel. Per-validator connection-pool ceilings still apply, so this is a top-level safety net rather than a primary throttle — a render with 200 files spread across 3 validators won't spawn 600 goroutines all racing for socket time. Sized for typical reconciliation bursts.

View Source
const DefaultTimeout = 5 * time.Second

DefaultTimeout matches the hub-side default per-request timeout so operators see consistent latency budgets across both ends of the wire.

View Source
const MaxFrameSize = 1 << 20 // 1 MiB

MaxFrameSize is the largest frame (length + payload) the client will encode or accept on the wire. Mirrors the hub-side default (haproxy-spoa-hub --validate-max-frame-bytes).

View Source
const ProtocolVersion = 1

ProtocolVersion is the current wire-protocol major version. Bumped only on breaking changes per docs/development/validator-protocol.md "Versioning".

Variables

This section is empty.

Functions

func EncodeRequest

func EncodeRequest(w io.Writer, req *Request) (int, error)

EncodeRequest serialises a Request into a length-prefixed JSON frame and writes it to w. Returns the number of bytes written (including the 4-byte length prefix) or an error.

The caller-supplied request MUST already have a non-empty files array and the expected ProtocolVersion. EncodeRequest validates these invariants before writing — a partial write of a malformed request would leave the socket in an unusable state.

func HashContent

func HashContent(content []byte) string

HashContent returns the hex-encoded sha256 of the given content. Used to build CacheKeys without keeping the full payload around. Stable across goroutines.

func HealthCheck

func HealthCheck(socketPath string) error

HealthCheck verifies a validator socket is reachable and accepting connections. Returns nil on success or a wrapped error describing the failure.

The check is intentionally lightweight (sub-ms in the happy path) so it can run on every Kubernetes liveness/readiness probe interval (default 10s). It does NOT exercise the protocol — a malformed validator that accepts connections but produces garbage will pass HealthCheck. Catching that needs a deeper round-trip probe, which is out of scope for /healthz.

On Linux, `os.OpenFile` against a stream unix socket fails with ENXIO regardless of the socket's state, so we use a short non-blocking dial as the readiness check instead. Stat + mode check still rules out the regular-file case before paying the dial cost.

func NarrowToUint32

func NarrowToUint32(n int) (uint32, error)

NarrowToUint32 converts a non-negative int to uint32, returning an error if the value would overflow. The explicit bounds check lets static analyzers (gosec G115) prove the cast is safe — relying on caller-side invariants would silence the warning at the cost of correctness.

Exported for test helpers in subpackages.

Types

type CacheKey

type CacheKey struct {
	ValidatorName string
	Path          string
	ContentSHA256 string // hex-encoded sha256 of the file content
}

CacheKey identifies a cache entry. Keyed per-(validator, file-path, content-hash) so:

  • the same file routed to two validators caches independently (each validator may decide differently);
  • the same validator handling multiple files caches each file independently (changing one file doesn't invalidate the others);
  • identical content at different paths is NOT collapsed (the wire-protocol response carries the path, so the cached response is path-specific).

The wire-protocol contract requires validators to be pure functions of their input — under that contract, this key is sufficient and safe.

func NewCacheKey

func NewCacheKey(validatorName, path string, content []byte) CacheKey

NewCacheKey builds a CacheKey for a (validator, file-path, content) tuple.

type Client

type Client struct {
	// Name is the operator-facing validator name from
	// spec.validators[i].name. Surfaced in synthetic protocol-level
	// diagnostics so users can identify which validator failed.
	Name string

	// SocketPath is the absolute filesystem path to the validator's
	// unix domain socket.
	SocketPath string

	// Timeout is the per-call deadline for one request-response
	// cycle (acquire + write + read). Zero falls back to
	// DefaultTimeout.
	Timeout time.Duration

	// MaxConnections caps the pool size. Zero falls back to
	// DefaultMaxConnections; values < 1 are clamped up to 1.
	MaxConnections int
	// contains filtered or unexported fields
}

Client speaks the HAPTIC validator wire protocol over a unix socket. One Client maps to one configured validator: one socket path, one timeout budget, one connection pool. Clients are safe for concurrent use; the pool serialises within-connection traffic and parallelises across connections.

Pool semantics (adaptive):

  • Start small (zero connections; lazy open on first use).
  • Grow on contention up to MaxConnections (acquires that find no free connection and have headroom open a fresh one).
  • Shrink on idleness (connections checked back into the pool past `idleClose` are closed instead of returned, so the pool deflates when traffic dies down).
  • Connections that error on read/write are discarded (poisoned) and replaced lazily on next acquire.

func NewClient

func NewClient(name, socketPath string, timeout time.Duration, maxConnections int) *Client

NewClient builds a Client for the given validator socket. `timeout <= 0` falls back to DefaultTimeout; `maxConnections <= 0` falls back to DefaultMaxConnections.

func (*Client) Close

func (c *Client) Close()

Close drains the pool and shuts every idle connection. Safe to call repeatedly; the pool is unusable after the first close. Used during iteration teardown.

func (*Client) Validate

func (c *Client) Validate(ctx context.Context, req *Request) (*Response, error)

Validate sends one request frame and reads one response frame on a pooled connection. Returns the decoded Response or, on transport / protocol failure, a synthetic ProtocolError Response.

Validate never returns a non-nil error alongside a non-nil Response: the caller treats every transport failure as a protocol-level error diagnostic. The error return is reserved for caller-supplied invariant violations (nil request, etc.) where the failure is "you misused this API" rather than "the validator is unreachable".

type Diagnostic

type Diagnostic struct {
	Path    string `json:"path"`
	Line    uint32 `json:"line"`
	Column  uint32 `json:"column"`
	Message string `json:"message"`
}

Diagnostic is one finding in a Response.

Line and Column are 1-based; 0 means "unknown / file-level". For protocol-level diagnostics (frame errors, version mismatch, missing fields), Path is the empty string.

type File

type File struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

File is one entry in a Request's files array.

Path is an opaque identifier echoed back in diagnostics — the validator MUST NOT open it from disk. Content is the file's UTF-8 bytes (TOML text for hub configs).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager is the controller-side entry point for the validator- sidecar feature. It owns one Client per configured validator plus a shared content-hash result cache. Concurrent calls are safe; per-validator pools handle in-process parallelism.

Routing model: the webhook hands the Manager every rendered file produced by the dry-run. For each (file, validator) pair where the validator's globs match the file's path, the Manager either returns the cached Response or sends a single-file request frame over the socket. All resulting diagnostics are concatenated and returned as one slice. The webhook surfaces the warnings via `AdmissionResponse.Warnings` (kubectl prints them as soft warnings) and the errors as the admission denial reason.

Construction validates that validator names are unique. Configs with duplicate names cause New to fail; the CRD's OpenAPI schema enforces uniqueness too, so this is a defensive check rather than a primary validation surface.

func NewManager

func NewManager(logger *slog.Logger, configs []ManagerConfig) (*Manager, error)

NewManager builds a Manager from the parsed `spec.validators` slice. A zero-length slice produces a no-op Manager whose Configured() returns false.

func (*Manager) Close

func (m *Manager) Close()

Close shuts every per-validator pool. Used during iteration teardown.

func (*Manager) Configured

func (m *Manager) Configured() bool

Configured reports whether any validators are registered. Callers (webhook, /healthz) skip the dispatch when no validators are configured.

func (*Manager) Healthy

func (m *Manager) Healthy() (ok bool, failures []string)

Healthy reports whether every configured validator socket passes HealthCheck. Returns (true, nil) when all are healthy; otherwise returns (false, failures) where each failure entry is "<validator-name>: <reason>". Iteration order matches Names().

Designed for /healthz injection — sub-millisecond happy path so it can run on every Kubernetes probe interval.

func (*Manager) Names

func (m *Manager) Names() []string

Names returns the validator names in the order they were registered. For deterministic iteration in callers that need a stable order (e.g., /healthz output formatting).

func (*Manager) ValidateAll

func (m *Manager) ValidateAll(ctx context.Context, files []File) *ValidationOutcome

ValidateAll fans the rendered files out to every configured validator whose globs match. Tasks run in parallel, bounded by DefaultMaxParallelDispatch — independent (validator, file) pairs are independent work units (different sockets, different content, no shared state) so there's no benefit to running them serially. Per-validator connection pools throttle within- validator concurrency.

Returns aggregated diagnostics. The returned outcome is always non-nil; an empty outcome means nothing matched any glob (or no validators are configured). Diagnostics are sorted by (path, line, column, validator-name) so output is deterministic across runs even though tasks complete out of order.

On transport failure to a single validator, that validator's errors are surfaced and the others continue. The caller decides whether to treat partial failures as admission denials (typical fail-closed behaviour: yes).

type ManagerConfig

type ManagerConfig struct {
	// Name is the operator-facing validator identifier.
	Name string
	// SocketPath is the absolute filesystem path to the validator's
	// unix domain socket.
	SocketPath string
	// Files is the list of glob patterns matched against rendered
	// file paths to decide which files to send to this validator.
	// Patterns follow Go's `path/filepath.Match` rules; absolute
	// paths only.
	Files []string
	// Timeout is the per-call deadline for one (file, validator)
	// request-response cycle. Zero falls back to DefaultTimeout.
	Timeout time.Duration
	// MaxConnections caps the controller-side pool to this
	// validator's socket. Zero falls back to DefaultMaxConnections.
	MaxConnections int
}

ManagerConfig captures one entry from `spec.validators` on HAProxyTemplateConfig. Validation of these values (RFC 1123 name, absolute socket path, valid globs, positive timeout) is performed by the CRD's OpenAPI schema before this struct is built; the Manager treats the fields as already-clean.

type Request

type Request struct {
	ProtocolVersion int    `json:"protocol_version"`
	Files           []File `json:"files"`
}

Request is the JSON payload the controller writes onto a validator socket.

type Response

type Response struct {
	ProtocolVersion int          `json:"protocol_version"`
	Result          Result       `json:"result"`
	Warnings        []Diagnostic `json:"warnings"`
	Errors          []Diagnostic `json:"errors"`
	// contains filtered or unexported fields
}

Response is the JSON payload a validator returns. Result is computed from the warning/error counts per the wire-protocol contract.

The unexported `synthetic` field marks responses produced by ProtocolError as transport-level failures. Real validator responses (parsed via DecodeResponse) leave it false. The cache uses this marker to decide whether to memoise the response — synthetic ones represent transient sidecar outages and must NOT be cached, while real validator responses (including those with `path: ""` diagnostics, e.g. plugin panics or file-level errors) are deterministic functions of the input and SHOULD be cached.

`synthetic` is unexported so JSON encoders skip it — the wire format is unchanged.

func DecodeResponse

func DecodeResponse(r io.Reader) (*Response, error)

DecodeResponse reads one length-prefixed JSON response frame from r and returns the parsed Response. The frame size MUST NOT exceed MaxFrameSize; oversized frames return an error without reading the body so the client can drop the connection.

func ProtocolError

func ProtocolError(message string) *Response

ProtocolError builds a synthetic error-severity Response carrying a single protocol-level Diagnostic with the given message. Used when the client needs to surface a frame-level failure (oversized frame, malformed JSON, connection refused) as a Response so callers don't have to switch on a separate error type.

The returned Response is marked synthetic so the cache layer can skip it. Real validator responses with a `path: ""` diagnostic (file-level errors, plugin panics surfaced by the sidecar) are NOT synthetic and will be cached normally.

func (*Response) IsSynthetic

func (r *Response) IsSynthetic() bool

IsSynthetic reports whether the response was produced by ProtocolError (transport-level failure surfaced as a Response). The cache layer uses this to avoid memoising transient outages.

type Result

type Result string

Result is the aggregate outcome string returned in a Response.

const (
	// ResultValid is "no errors and no warnings".
	ResultValid Result = "valid"
	// ResultWarning is "warnings but no errors".
	ResultWarning Result = "warning"
	// ResultError is "at least one error".
	ResultError Result = "error"
)

type ResultCache

type ResultCache struct {
	// contains filtered or unexported fields
}

ResultCache is a process-local LRU cache mapping CacheKey to *Response. Concurrent access is serialised via an internal mutex; the cache is safe for use by multiple goroutines.

func NewResultCache

func NewResultCache(capacity int) *ResultCache

NewResultCache returns a cache with the given capacity. A capacity <= 0 falls back to DefaultCacheCapacity.

func (*ResultCache) Get

func (c *ResultCache) Get(key CacheKey) (*Response, bool)

Get returns the cached Response for a key, or (nil, false) on miss. A hit promotes the entry to most-recently-used.

func (*ResultCache) Len

func (c *ResultCache) Len() int

Len returns the number of cached entries. For tests and metrics.

func (*ResultCache) Put

func (c *ResultCache) Put(key CacheKey, response *Response)

Put records a Response under the key. If the cache is at capacity, the least-recently-used entry is evicted first.

The Response is stored by reference; callers MUST NOT mutate it after caching.

type Severity

type Severity string

Severity is the diagnostic severity in the wire format.

const (
	// SeverityWarning is a non-blocking finding.
	SeverityWarning Severity = "warning"
	// SeverityError blocks admission.
	SeverityError Severity = "error"
)

type ValidationOutcome

type ValidationOutcome struct {
	Warnings []Diagnostic
	Errors   []Diagnostic
}

ValidationOutcome bundles the warnings + errors collected across all (file, validator) round-trips for one ValidateAll call. The caller maps these to the admission webhook's response shape: warnings → `AdmissionResponse.Warnings`, errors → admission denial reason. A non-nil ValidationOutcome with zero entries in both lists is the equivalent of `result: "valid"`.

func (*ValidationOutcome) Result

func (o *ValidationOutcome) Result() Result

Result computes the aggregate result string from the populated lists. Mirrors the wire-protocol's per-response `result` field computation but at the cross-validator aggregate level.

Directories

Path Synopsis
Package testutil provides a fixture unix-socket server for exercising the pluggablevalidator client without spinning up a real haproxy-spoa-hub sidecar.
Package testutil provides a fixture unix-socket server for exercising the pluggablevalidator client without spinning up a real haproxy-spoa-hub sidecar.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL