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
- func EncodeRequest(w io.Writer, req *Request) (int, error)
- func HashContent(content []byte) string
- func HealthCheck(socketPath string) error
- func NarrowToUint32(n int) (uint32, error)
- type CacheKey
- type Client
- type Diagnostic
- type File
- type Manager
- type ManagerConfig
- type Request
- type Response
- type Result
- type ResultCache
- type Severity
- type ValidationOutcome
Constants ¶
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.
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.
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.
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.
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).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Configured reports whether any validators are registered. Callers (webhook, /healthz) skip the dispatch when no validators are configured.
func (*Manager) Healthy ¶
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 ¶
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 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 ¶
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 ¶
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 ¶
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 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 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. |