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 HealthCheck(socketPath string) error
- func NarrowToUint32(n int) (uint32, error)
- type Client
- type Diagnostic
- type File
- type FileKind
- type Manager
- func (m *Manager) Close()
- func (m *Manager) Configured() bool
- func (m *Manager) Healthy() (ok bool, failures []string)
- func (m *Manager) Names() []string
- func (m *Manager) ValidateAll(ctx context.Context, files []File) *ValidationOutcome
- func (m *Manager) ValidateRenderedOutput(ctx context.Context, result *pipeline.PipelineResult) ([]string, error)
- type ManagerConfig
- type ManagerOption
- type Request
- type Response
- type Result
- type Severity
- type ValidationOutcome
Constants ¶
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 = 8 << 20 // 8 MiB
MaxFrameSize is the largest frame (length + payload) the client will encode or accept on the wire. Must match the validator's own limit — the hub's `DEFAULT_MAX_FRAME_BYTES`.
8 MiB rather than the original 1 MiB because a request now carries the data files a config references, and those are whole rule sets. Measured: the OWASP CRS the coraza plugin embeds (51 files, 713 KB on disk) JSON-encodes to 794 KB — 76% of 1 MiB before an operator adds a single custom rule, and the config file sharing the frame grows with the number of routes it describes. The old ceiling would have been reached in ordinary use, and every admission would fail identically until someone read the message.
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 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 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 ¶
type File struct {
Path string `json:"path"`
Content string `json:"content"`
// Kind tells the validator whether to validate this file or merely make
// it available to whatever validates the others. Empty means
// FileKindConfig and is omitted from the wire, so a request carrying no
// data files is byte-identical to one from before this field existed.
Kind FileKind `json:"kind,omitempty"`
}
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 FileKind ¶
type FileKind string
FileKind distinguishes a file to validate from one a validated file merely references.
const ( // FileKindConfig is a file the validator parses and checks. The default. FileKindConfig FileKind = "" // FileKindData is a file the validator must not parse, sent so that a // config referencing it can be checked. A WAF ruleset a hub config // `Include`s is the motivating case: the validator sidecar runs in the // controller pod and cannot read the HAProxy pod's filesystem, so // without the content travelling with the request there is nothing to // resolve the reference against. FileKindData FileKind = "data" )
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. Concurrent calls are safe; per-validator pools handle in-process parallelism.
Routing model: the pipeline hands the Manager every rendered file. For each (file, validator) pair where the validator's globs match the file's path, the Manager sends a single-file request frame over the socket. All resulting diagnostics are concatenated and returned as one slice. Errors fail every pipeline caller; admission also surfaces warnings through `AdmissionResponse.Warnings`.
Construction validates every invariant needed for safe dispatch. A failure aborts controller iteration construction.
func NewManager ¶
func NewManager(logger *slog.Logger, configs []ManagerConfig, opts ...ManagerOption) (*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).
func (*Manager) ValidateRenderedOutput ¶
func (m *Manager) ValidateRenderedOutput(ctx context.Context, result *pipeline.PipelineResult) ([]string, error)
ValidateRenderedOutput implements pipeline.RenderedOutputValidator.
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.
Files []string
// DataFiles is the list of glob patterns for files this validator needs
// in order to check the ones it validates, but must not validate on
// their own — a WAF ruleset a hub config `Include`s. Every matching
// file is attached to every request sent to this validator, marked
// FileKindData.
//
// A file matching both lists is data: it is the reference target, and
// parsing it as a config would produce a spurious error rather than a
// finding about the config that references it.
DataFiles []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. The API schema validates fields it can express; core config validation and this constructor also reject malformed globs.
type ManagerOption ¶
type ManagerOption func(*Manager)
ManagerOption configures a Manager beyond its per-validator entries.
func WithStagedRoot ¶
func WithStagedRoot(root string) ManagerOption
WithStagedRoot declares the directory rendered files will live in at runtime, so a validator can resolve a config's references to the data files sent alongside it. See Request.StagedRoot.
type Request ¶
type Request struct {
ProtocolVersion int `json:"protocol_version"`
Files []File `json:"files"`
// StagedRoot is the directory the data files' paths are relative to, as
// the process that will load them sees it at runtime.
//
// A validated config references its files by their runtime path
// (`/etc/haproxy/general/crs-*.conf`) while the request carries them under
// the controller's own identifiers (`general/crs-….conf`). The validator
// cannot bridge the two on its own, and inferring the link by matching a
// path suffix would resolve a mistyped directory just as readily as the
// right one — so it is stated rather than guessed.
//
// Omitted when empty, so a request without data files is byte-identical to
// one from before this field existed.
StagedRoot string `json:"staged_root,omitempty"`
}
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 locally by ProtocolError after transport or protocol-decode failures. Conforming validator responses (parsed via DecodeResponse) leave it false. Callers use the marker to distinguish a validator verdict from a transport failure.
`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. Real validator responses with a `path: ""` diagnostic (file-level errors, plugin panics surfaced by the sidecar) are not synthetic.
func (*Response) IsSynthetic ¶
IsSynthetic reports whether the response was produced by ProtocolError.
type ValidationOutcome ¶
type ValidationOutcome struct {
Warnings []Diagnostic
Errors []Diagnostic
Err error
}
ValidationOutcome bundles the warnings + errors collected across all (file, validator) round-trips for one ValidateAll call. Err is set when the complete dispatch could not finish, so an interrupted validation can never be mistaken for a valid verdict.
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. |