haproxy_spoa_hub_plugin_api/vtable.rs
1//! Hand-rolled `#[repr(C)]` plugin vtable.
2//!
3//! Design rationale: see ADR-001 / docs/abi-evolution.md (TBD). In short:
4//! `abi_stable`'s prefix-type model rejects loads where the host's vtable
5//! has more fields than the plugin's, blocking the host-grows-faster
6//! direction we need. The hand-rolled C-style vtable with a leading
7//! `api_version: u32` field, version-gated host access, and an
8//! append-only evolution discipline gives us bidirectional ABI compat
9//! without a third-party load-time check that fights us.
10//!
11//! # Layout invariants (load-bearing)
12//!
13//! 1. `api_version` MUST be the first field.
14//! 2. Existing fields MUST NOT be reordered or removed in any future
15//! crate version. New fields MUST be appended after the existing ones.
16//! 3. `PLUGIN_API_VERSION` increments by 1 each time a new field is
17//! appended and the corresponding accessor lands.
18//! 4. Hosts MUST read `api_version` before accessing any field beyond
19//! the v1 baseline. Reading newer fields on a plugin that reports
20//! a lower `api_version` is undefined behavior — the plugin's
21//! allocation does not include those bytes.
22//!
23//! See `crates/hub/tests/abi_matrix.rs` for the regression test that
24//! enforces invariants 1–3 by loading every published plugin version
25//! against the current hub binary.
26
27use std::os::raw::c_void;
28
29use abi_stable::std_types::{RBoxError, ROption, RResult, RStr, RString, RVec};
30
31use crate::metrics::RecordMetricFn;
32use crate::types::{Diagnostic, PluginContext, ProcessingResult, SpoeMessage};
33
34/// API version corresponding to the v1 baseline (initial release of the
35/// hand-rolled vtable). Every field declared in `PluginVTable` is part
36/// of v1 and is present on every plugin built against this crate.
37pub const PLUGIN_API_VERSION_V1: u32 = 1;
38
39/// API version v2: adds `set_metric_recorder` so plugins can emit
40/// Prometheus metrics through the hub's existing recorder. Plugins
41/// built against v2 keep loading on v1 hubs (the hub ignores the field
42/// because it doesn't know it exists); plugins built against v1 keep
43/// loading on v2 hubs (the hub gates access on `api_version >= 2` and
44/// skips the install for v1 plugins).
45pub const PLUGIN_API_VERSION_V2: u32 = 2;
46
47/// Latest API version this crate's `PluginVTable` exposes. Plugin
48/// authors set this as the `api_version` field. When future versions
49/// of this crate append a field, this constant bumps by 1.
50pub const PLUGIN_API_VERSION: u32 = PLUGIN_API_VERSION_V2;
51
52/// Symbol name the hub looks up via `dlsym` after `dlopen`.
53pub const GET_PLUGIN_VTABLE_SYMBOL: &[u8] = b"get_plugin_vtable\0";
54
55/// Type of the exported entry-point symbol.
56pub type GetPluginVTableFn = extern "C" fn() -> *const PluginVTable;
57
58/// The function table a plugin shared library exports.
59///
60/// `#[repr(C)]` is mandatory: it pins field offsets and padding rules
61/// so plugins built against an older version of this crate keep
62/// working when the hub is built against a newer one (and vice versa).
63///
64/// # Safety contract for hosts
65///
66/// Fields below the v1 baseline marker are version-gated. Hosts MUST
67/// NOT access them via `&PluginVTable` directly — the borrow would
68/// implicitly cover the whole struct, which on an older plugin is
69/// past the end of its allocation. Use the `read_*_field` accessors
70/// or read individual fields through the raw pointer (e.g.
71/// `unsafe { (*ptr).api_version }`) which read only the bytes for that
72/// field.
73#[repr(C)]
74pub struct PluginVTable {
75 // ============================================================
76 // v1 baseline — always present on every plugin built against
77 // this crate. All fields above the marker comment are mandatory
78 // and never change order.
79 // ============================================================
80 /// Plugin's reported API version. The hub uses this to gate
81 /// access to fields beyond the v1 baseline.
82 pub api_version: u32,
83
84 /// Factory: allocate and return a new plugin state pointer.
85 /// `RErr` aborts plugin loading.
86 pub create: extern "C" fn() -> RResult<*mut c_void, RBoxError>,
87
88 /// Destructor: free the plugin state pointer. Called once at hub
89 /// shutdown or when a plugin fails post-create. MUST tolerate
90 /// being called with a state pointer that `init()` has not yet
91 /// observed.
92 pub destroy: extern "C" fn(state: *mut c_void),
93
94 /// Initialize the plugin. Called once after `create` and any
95 /// schema/validate pre-checks. `RErr` aborts plugin loading.
96 pub init: extern "C" fn(state: *mut c_void, ctx: &PluginContext) -> RResult<(), RBoxError>,
97
98 /// Handle one SPOE message. Called per request. The implementation
99 /// is wrapped in `catch_unwind` by the `define_plugin!` macro so a
100 /// panic does not abort the hub process.
101 pub process: extern "C" fn(
102 state: *const c_void,
103 msg: &SpoeMessage,
104 ) -> RResult<ProcessingResult, RBoxError>,
105
106 /// Plugin's display name. Borrowed for the lifetime of the plugin
107 /// instance (until `destroy` is called). Plugins typically return
108 /// a `&'static str` literal.
109 pub name: extern "C" fn(state: *const c_void) -> RStr<'static>,
110
111 /// Plugin's `SemVer` string. Same lifetime contract as `name`.
112 pub plugin_version: extern "C" fn(state: *const c_void) -> RStr<'static>,
113
114 /// Shutdown hook. Called once before `destroy`.
115 pub shutdown: extern "C" fn(state: *const c_void),
116
117 /// Optional JSON Schema string for config validation. `RNone`
118 /// skips schema validation. The default `define_plugin!` macro
119 /// emits a thunk that returns `RNone` when the plugin author has
120 /// not declared one.
121 pub config_schema: extern "C" fn(state: *const c_void) -> ROption<RString>,
122
123 /// Deep config validation. Returns an empty `RVec` when the
124 /// plugin's config is valid. Errors block loading; warnings are
125 /// surfaced but do not block.
126 pub validate: extern "C" fn(state: *const c_void, ctx: &PluginContext) -> RVec<Diagnostic>,
127 // ============================================================
128 // End of v1 baseline. Future additions appear below this line.
129 // Each new field MUST be guarded on `api_version` in the host
130 // and bumps `PLUGIN_API_VERSION` by 1.
131 // ============================================================
132 //
133 // v2 additions — present iff api_version >= PLUGIN_API_VERSION_V2.
134 /// Install the hub's metric recorder on the plugin. Called by the
135 /// hub once between `create` and `init`, so the plugin can emit
136 /// metrics from `init` onward. The plugin stores the `record_fn`
137 /// and `ctx` (typically inside a [`MetricRecorder`] field of its
138 /// state) and invokes `record_fn(ctx, ...)` for every metric
139 /// event. The recorder remains valid until `destroy` returns; the
140 /// hub guarantees lifetime, the plugin guarantees not to call it
141 /// after `destroy`.
142 ///
143 /// Plugins that don't want metrics still need to provide a thunk
144 /// (the `define_plugin!` macro emits a no-op default) — the field
145 /// is unconditionally read on v2 hubs.
146 ///
147 /// **Host access:** gated on `api_version >= PLUGIN_API_VERSION_V2`.
148 /// Reading this field on a v1 plugin's vtable is UB — the v1
149 /// allocation does not include these bytes.
150 ///
151 /// [`MetricRecorder`]: crate::MetricRecorder
152 pub set_metric_recorder:
153 extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void),
154}
155
156impl PluginVTable {
157 /// Read just the `api_version` field of a plugin's vtable.
158 ///
159 /// # Safety
160 ///
161 /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
162 /// allocation that is at least 4 bytes long (every plugin has at
163 /// least the `api_version` field).
164 #[must_use]
165 pub unsafe fn read_api_version(vtable_ptr: *const PluginVTable) -> u32 {
166 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long.
167 // Reads only the first field; does not borrow the full struct.
168 unsafe { (*vtable_ptr).api_version }
169 }
170
171 /// Read the v2 `set_metric_recorder` field, returning `None` for
172 /// plugins built against v1 (whose allocation doesn't include it).
173 ///
174 /// # Safety
175 ///
176 /// `vtable_ptr` must be a non-null pointer to a `PluginVTable`
177 /// allocation produced by a published plugin (i.e. consistent with
178 /// the `api_version` field's claim about which fields are present).
179 /// On v1 plugins this function reads only `api_version` and returns
180 /// `None` without touching past the v1 baseline.
181 #[must_use]
182 pub unsafe fn read_set_metric_recorder(
183 vtable_ptr: *const PluginVTable,
184 ) -> Option<extern "C" fn(state: *mut c_void, record_fn: RecordMetricFn, ctx: *const c_void)>
185 {
186 // SAFETY: caller guarantees vtable_ptr is at least 4 bytes long
187 // and that api_version accurately describes the layout.
188 let api_version = unsafe { Self::read_api_version(vtable_ptr) };
189 if api_version < PLUGIN_API_VERSION_V2 {
190 return None;
191 }
192 // SAFETY: api_version >= V2 implies the allocation includes the
193 // set_metric_recorder field. Reading the field directly (not
194 // via `&PluginVTable`) avoids borrowing the whole struct.
195 Some(unsafe { (*vtable_ptr).set_metric_recorder })
196 }
197}