pluggablevalidator

package
v0.6.0-rc.1 Latest Latest
Warning

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

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

README

pkg/controller/pluggablevalidator

Dispatches rendered files to external validator sidecars (for example the SPOA hub in --validate-socket mode) before publication or deployment.

Overview

Manager implements the pipeline's rendered-output validator stage. It fans every successful render's files out to the configured validators over Unix domain sockets and aggregates the replies into a ValidationOutcome. Errors fail the pipeline before publication or deployment. Admission requests also return warnings through AdmissionResponse.Warnings; reconciliation records warning counts in its validation event and logs.

Every matching file is checked on every pipeline invocation. Protocol version 1 doesn't identify the validator process or implementation generation, so a content-keyed verdict could survive a sidecar restart and validate against the wrong implementation. A response whose result disagrees with its diagnostics is a protocol failure and blocks the pipeline.

Future result reuse requires a new protocol version that exposes an authenticated hermetic-environment root at a linearizable validation point. That root must cover the validator runtime, executable, configuration, and dependencies. A socket inode, process ID, connection-liveness probe, or self-declared implementation version can't close the replacement race.

Configured() reports whether any validators are registered. ValidateAll is a no-op when none are, so callers don't have to pre-check; /healthz uses Configured() to omit the check when the feature is not configured.

The wire protocol itself is documented in docs/development/validator-protocol.md.

Quick Start

mgr, err := pluggablevalidator.NewManager(logger, configs)
if err != nil {
    return err
}

outcome := mgr.ValidateAll(ctx, files)
if outcome.Err != nil {
    return outcome.Err
}
if len(outcome.Errors) > 0 {
    return errors.New("rendered output rejected")
}

Cancellation makes the outcome an error even when every completed validator returned valid. Active socket I/O is interrupted and the connection is discarded. A partial fan-out is not a validation verdict.

See Also

License

Apache-2.0 — see root LICENSE.

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 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 = 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.

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 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 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"`
	// 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

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).

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

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. Real validator responses with a `path: ""` diagnostic (file-level errors, plugin panics surfaced by the sidecar) are not synthetic.

func (*Response) IsSynthetic

func (r *Response) IsSynthetic() bool

IsSynthetic reports whether the response was produced by ProtocolError.

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 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
	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.

Jump to

Keyboard shortcuts

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