Skip to main content

plecto_control/manifest/
mod.rs

1//! The declarative manifest (ADR 000007 / 000008): the single, static source of truth for
2//! which filters are loaded, pinned by OCI digest, with which trust roots, in what chain
3//! order. TOML (mirrors Cargo; ADR 000008 static config). Routes are deferred until the
4//! fast-path server exists; v0.1 has a single chain.
5//!
6//! Split by concern: this module holds only `Manifest` itself + `Manifest::from_toml`. Each
7//! `[section]`'s schema (every `struct`/`enum` + its serde defaults) lives in its own sibling
8//! module — `listen`, `observability`, `tls`, `resumption`, `upstream`, `route`, `state`,
9//! `trust`, `filter_entry`, `chain` — and is re-exported here so `crate::manifest::X` keeps resolving for
10//! every type. `validate` holds the build-time validation (`Upstream::validate_lb`,
11//! `FilterEntry::validate`, `State::validate`), `content_hash` holds the semantic content-hash,
12//! and `lowering` holds `FilterEntry::load_options` (manifest → host `LoadOptions`).
13
14mod chain;
15mod content_hash;
16mod filter_entry;
17mod listen;
18mod lowering;
19mod observability;
20mod resumption;
21mod route;
22mod state;
23mod tls;
24mod trust;
25mod upstream;
26mod validate;
27
28use serde::{Deserialize, Serialize};
29
30use crate::error::ControlError;
31
32pub use chain::Chain;
33pub use filter_entry::{
34    FilterEntry, IsolationKind, OutboundHttpConfig, OutboundTcpConfig, WasiKind,
35};
36// `AllowDest` / `TcpAllowDest` / `RateLimitConfig` are schema fields reached through
37// `OutboundHttpConfig` / `OutboundTcpConfig` / `FilterEntry` rather than by name elsewhere in this
38// crate today; `SchemeKind` is only named via `crate::manifest::X` from the `outbound-http`-gated
39// half of `lowering.rs`. Re-exported anyway so `crate::manifest::X` keeps resolving for every
40// schema type (module doc above).
41#[allow(unused_imports)]
42pub use filter_entry::{AllowDest, RateLimitConfig, SchemeKind, TcpAllowDest};
43pub use listen::{ClientAuth, Listen, ProxyProtocolTrust};
44// `ProxyProtocol` / `Drain` are schema fields reached through `Listen` rather than by name
45// elsewhere in this crate; re-exported for the same schema-type completeness reason as
46// `AllowDest` below.
47#[allow(unused_imports)]
48pub use listen::{Drain, ProxyProtocol};
49pub use observability::Observability;
50pub use resumption::Resumption;
51pub(crate) use route::MAX_BACKEND_WEIGHT;
52pub use route::{CompressionAlgorithm, RateLimitKeyKind, Route, RouteCompression, RouteRateLimit};
53// `Backend` / `RouteMatch` / `RouteUpgrade` are only named via `crate::manifest::X` from
54// `#[cfg(test)]` code elsewhere in the crate; re-exported for the same completeness reason.
55#[allow(unused_imports)]
56pub use route::{Backend, RouteMatch, RouteUpgrade};
57pub use state::{State, StateBackendKind};
58pub use tls::TlsCert;
59pub use trust::Trust;
60pub use upstream::{
61    CircuitBreaker, HashKeyKind, HealthConfig, LbAlgorithm, OutlierDetection, Upstream, UpstreamTls,
62};
63// `AddressSpec` / `HashConfig` / `WeightedAddress`: same as `Backend` above — only named via
64// `crate::manifest::X` from `#[cfg(test)]` code elsewhere in the crate.
65#[allow(unused_imports)]
66pub use upstream::{AddressSpec, HashConfig, WeightedAddress};
67pub(crate) use upstream::{MAX_HASH_TABLE_SIZE, MAX_INSTANCE_WEIGHT};
68
69/// A parsed manifest. Deserialised from TOML; no I/O happens here (key files and artifacts
70/// are resolved by `Control`). `Serialize` exists only to derive the semantic content hash
71/// (`content_hash`) — the canonical, representation-independent identity of the config.
72///
73/// Determinism invariant (f000004 #6): no map fields (`HashMap`). `content_hash` relies on
74/// `serde_json` emitting fields and `Vec` elements in a fixed order; a `HashMap` would
75/// serialise in nondeterministic order and silently break reload idempotency. If a manifest
76/// ever needs a map, use `BTreeMap` (ordered) and keep this invariant.
77#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct Manifest {
80    #[serde(default)]
81    pub trust: Trust,
82    /// `[state]`: the host state backend (ADR 000041). Rides the content hash like `[trust]`,
83    /// and like `[trust]` it is fixed at construction — a reload rejects a change.
84    #[serde(default)]
85    pub state: State,
86    /// `[[filter]]` entries.
87    #[serde(default, rename = "filter")]
88    pub filters: Vec<FilterEntry>,
89    /// The default `[chain]` — driven by the chain-only `Control::on_request` convenience and
90    /// used by a route that names no filters of its own. The fast-path server uses `[[route]]`.
91    #[serde(default)]
92    pub chain: Chain,
93    /// `[[upstream]]` entries: named backends the fast-path server forwards to (ADR 000013).
94    #[serde(default, rename = "upstream")]
95    pub upstreams: Vec<Upstream>,
96    /// `[[route]]` entries: host + path-prefix → an (inline) chain and an upstream (ADR 000013).
97    /// Empty until the fast-path server is configured; matching is the server's job, declared here.
98    #[serde(default, rename = "route")]
99    pub routes: Vec<Route>,
100    /// `[[tls]]` entries: server certificates for TLS termination (ADR 000014). Empty = plain
101    /// HTTP/1.1 (the fast path serves TLS only when at least one cert is declared).
102    #[serde(default, rename = "tls")]
103    pub tls: Vec<TlsCert>,
104    /// `[resumption]`: opt-in shared session-ticket keys across replicas (ADR 000062). `None`
105    /// (the default) keeps the per-node, process-lifetime ticket key (ADR 000052). Rides the
106    /// content hash like `[[tls]]` and is rebuilt on reload — the keys derive deterministically
107    /// from (file, cert set), so a rebuild with unchanged inputs keeps outstanding tickets valid.
108    #[serde(default)]
109    pub resumption: Option<Resumption>,
110    /// `[observability]`: operational metrics / access-log / admin-endpoint config (ADR 000009),
111    /// captured at construction. `skip_serializing` keeps it OUT of the semantic `content_hash`, so
112    /// toggling observability never counts as a config-version change (it is not part of the
113    /// filter/route identity, and the admin listener binds once at startup — like `[trust]`).
114    #[serde(default, skip_serializing)]
115    pub observability: Observability,
116    /// `[listen]`: the data-plane bind address + h3 advertisement (moka-1 field report §3.2/§3.4).
117    /// Mostly captured at construction like `[observability]` (the listener binds once at
118    /// startup, so a reload does not re-bind — restart to move the listener), and those fields
119    /// stay out of the semantic `content_hash` via field-level `skip_serializing` in `Listen`.
120    /// The exception is `listen.client_auth`, which `build_active` consumes on every reload and
121    /// therefore rides the hash (see `Listen`'s serialization note) — the section as a whole is
122    /// only skipped when it hashes to nothing anyway (no client_auth), preserving existing
123    /// config versions.
124    #[serde(default, skip_serializing_if = "Listen::content_hash_exempt")]
125    pub listen: Listen,
126}
127
128impl Manifest {
129    /// Parse a manifest from a TOML string.
130    pub fn from_toml(s: &str) -> Result<Self, ControlError> {
131        Ok(toml::from_str(s)?)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn parses_routes_and_upstreams() {
141        let m = Manifest::from_toml(
142            r#"
143[[filter]]
144id = "auth"
145source = "artifacts/auth"
146digest = "sha256:abc"
147
148[[upstream]]
149name = "api-svc"
150addresses = ["127.0.0.1:9000", "127.0.0.1:9001"]
151[upstream.health]
152path = "/healthz"
153
154[[route]]
155filters = ["auth"]
156upstream = "api-svc"
157strip_prefix = "/api"
158[route.match]
159host = "example.com"
160path_prefix = "/api"
161"#,
162        )
163        .unwrap();
164
165        assert_eq!(m.upstreams.len(), 1);
166        let addrs: Vec<&str> = m.upstreams[0]
167            .addresses
168            .iter()
169            .map(|a| a.address())
170            .collect();
171        assert_eq!(addrs, vec!["127.0.0.1:9000", "127.0.0.1:9001"]);
172        assert!(
173            m.upstreams[0].addresses.iter().all(|a| a.weight() == 1),
174            "bare addresses default to weight 1"
175        );
176        // health is required; the unspecified knobs take their defaults
177        assert_eq!(m.upstreams[0].health.path, "/healthz");
178        assert_eq!(m.upstreams[0].health.interval_ms, 2000);
179        assert_eq!(m.upstreams[0].health.timeout_ms, 1000);
180        assert_eq!(m.upstreams[0].health.healthy_threshold, 2);
181        assert_eq!(m.upstreams[0].health.unhealthy_threshold, 3);
182        assert_eq!(m.routes.len(), 1);
183        let r = &m.routes[0];
184        assert_eq!(r.matcher.host.as_deref(), Some("example.com"));
185        assert_eq!(r.matcher.path_prefix, "/api");
186        assert_eq!(r.filters, vec!["auth".to_string()]);
187        assert_eq!(r.upstream.as_deref(), Some("api-svc"));
188        assert!(r.backends.is_empty(), "single upstream uses the shorthand");
189        assert_eq!(r.strip_prefix.as_deref(), Some("/api"));
190        // a routing edit must flip the config version (routes ride the semantic hash)
191        let mut m2 = m.clone();
192        m2.routes[0].matcher.path_prefix = "/v2".to_string();
193        assert_ne!(
194            m.content_hash().unwrap(),
195            m2.content_hash().unwrap(),
196            "a route change must flip the content hash"
197        );
198    }
199
200    #[test]
201    fn rejects_unknown_fields() {
202        let parsed = Manifest::from_toml(
203            r#"
204[[filter]]
205id = "x"
206source = "s"
207digest = "sha256:abc"
208typo_field = true
209"#,
210        );
211        assert!(parsed.is_err(), "deny_unknown_fields should reject a typo");
212    }
213}