nixfleet_proto/
lib.rs

1#![allow(clippy::doc_lazy_continuation)]
2//! Boundary-contract types. Optional fields serialize `null` (not omitted)
3//! to match the Nix evaluator's shape so JCS bytes round-trip identically.
4
5pub mod agent_event;
6pub mod agent_wire;
7pub mod bootstrap_nonces;
8pub mod clock;
9pub mod enroll_wire;
10pub mod evidence;
11pub mod evidence_signing;
12pub mod fleet_resolved;
13pub mod fleet_view;
14pub mod host_key;
15pub mod host_rollout_state;
16pub mod revocations;
17pub mod rollout_manifest;
18pub mod trust;
19
20#[cfg(any(test, feature = "testing"))]
21pub mod testing;
22
23/// A signed channel ref (typically a closure hash or a tagged ref name).
24/// Shared between reconciler (`PlanAction::OpenRollout.target_ref`) and
25/// state-machine (`RolloutEvent::RolloutOpened.target_ref`); lives in proto
26/// so both pure crates can depend on it without cross-edges (RFC-0008 §7).
27pub type ChannelRef = String;
28
29/// Content-addressed rollout identifier (RFC-0008 §6.3).
30///
31/// Identity contract: a `RolloutId` is the canonical
32/// `"{channel}@{channel_ref}"` composite. Constructed only via
33/// [`RolloutId::new`]; the private inner field prevents ad-hoc
34/// construction the same way `Verified<T>` prevents ad-hoc construction
35/// of a "verified" payload (RFC-0006 §3). Test code that needs to forge
36/// a pre-formed identifier goes through `from_raw_for_tests` under the
37/// `test-helpers` feature gate.
38///
39/// Rationale (RFC-0008 §6.3): two channels can share a `channel_ref`
40/// (the architectural point of multi-channel cascading from a single
41/// git push). `channel_ref` alone collides in that topology;
42/// `channel` alone violates the content-addressed property of the rest
43/// of the cycle. The composite preserves both: unique per
44/// `(channel, channel_ref)` AND deterministic across replays.
45///
46/// SQL bindings: call `.as_str()` on the field at the `params![...]`
47/// site. Production code MUST NOT round-trip raw strings into
48/// `from_raw_for_tests` — the type-level enforcement is the contract.
49#[derive(
50    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
51)]
52#[serde(transparent)]
53pub struct RolloutId(String);
54
55impl RolloutId {
56    /// Construct the canonical `"{channel}@{channel_ref}"` identifier.
57    /// The only non-test path to a `RolloutId`.
58    ///
59    /// `channel` MUST NOT contain `@`; the parser splits on the FIRST
60    /// `@` boundary (per `channel()` / `channel_ref()`), so an `@` in
61    /// `channel` would break round-trip identity. The wire-level route
62    /// validator (CP `looks_like_rollout_id`) locks `channel` to
63    /// `[a-z0-9_-]+` already; the `debug_assert!` pins the same
64    /// invariant at the type boundary so a wire-bypass round-trip
65    /// fails fast in development.
66    pub fn new(channel: &str, channel_ref: &str) -> Self {
67        debug_assert!(
68            !channel.contains('@'),
69            "channel must not contain '@' (per RFC-0008 §6.3 canonical format)",
70        );
71        Self(format!("{channel}@{channel_ref}"))
72    }
73
74    /// String view for SQL bindings + logging. Cheap (no allocation).
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78
79    /// Channel half of the canonical composite (per RFC-0008 §6.3).
80    /// Splits on the first `@`; the constructor's invariant guarantees
81    /// the channel never carries `@` itself, so this returns the
82    /// operator-declared channel name unmodified.
83    pub fn channel(&self) -> &str {
84        self.0.split_once('@').map(|(c, _)| c).unwrap_or(&self.0)
85    }
86
87    /// Channel-ref half of the canonical composite (per RFC-0008 §6.3).
88    /// Splits on the first `@`; if no `@` is present (only reachable via
89    /// `from_raw_for_tests` with a malformed input), returns the empty
90    /// string rather than panic.
91    pub fn channel_ref(&self) -> &str {
92        self.0.split_once('@').map(|(_, r)| r).unwrap_or("")
93    }
94
95    /// **TEST ONLY.** Bypasses the canonical-format constructor. Available
96    /// only when the crate is compiled with `#[cfg(test)]` (proto's own
97    /// lib tests) or with the `test-helpers` feature flag (downstream
98    /// crates' tests, enabled via `[dev-dependencies]` so production
99    /// builds cannot reach it). Same convention as
100    /// `nixfleet_reconciler::Verified::unverified_for_tests`.
101    #[cfg(any(test, feature = "test-helpers"))]
102    pub fn from_raw_for_tests(raw: String) -> Self {
103        Self(raw)
104    }
105}
106
107/// **TEST ONLY.** Ergonomic `"rid".into()` construction for test
108/// fixtures. Same `test-helpers` gate as `from_raw_for_tests`;
109/// production code constructs via `RolloutId::new(channel, channel_ref)`.
110#[cfg(any(test, feature = "test-helpers"))]
111impl From<&str> for RolloutId {
112    fn from(s: &str) -> Self {
113        Self(s.to_string())
114    }
115}
116
117#[cfg(any(test, feature = "test-helpers"))]
118impl From<String> for RolloutId {
119    fn from(s: String) -> Self {
120        Self(s)
121    }
122}
123
124impl std::fmt::Display for RolloutId {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130impl AsRef<str> for RolloutId {
131    fn as_ref(&self) -> &str {
132        &self.0
133    }
134}
135
136impl std::borrow::Borrow<str> for RolloutId {
137    fn borrow(&self) -> &str {
138        &self.0
139    }
140}
141
142#[cfg(feature = "rusqlite")]
143impl rusqlite::types::FromSql for RolloutId {
144    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
145        // Reads round-trip through the canonical-format constraint at
146        // the storage layer (RFC-0008 §6.3): rollout_id is written via
147        // `RolloutId::new`'s output and read back unchanged. The DB
148        // boundary is the one legitimate non-`new` construction path;
149        // every other path goes through `new` or `from_raw_for_tests`.
150        <String as rusqlite::types::FromSql>::column_result(value).map(Self)
151    }
152}
153
154#[cfg(feature = "rusqlite")]
155impl rusqlite::types::ToSql for RolloutId {
156    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
157        self.0.to_sql()
158    }
159}
160
161pub use agent_event::{
162    AgentEvent, AgentEventEnvelope, OnHealthFailureWire, ProbeModeWire, ProbeStatusWire,
163    ProbeSubResultWire, ProbeTopologyEntryWire,
164};
165pub use bootstrap_nonces::{BootstrapNonceEntry, BootstrapNonces};
166pub use clock::{Clock, ClockHandle, FakeClock, SystemClock};
167pub use fleet_resolved::{
168    Channel, ChannelEdge, DisruptionBudget, Edge, FleetResolved, HealthGate, Host, Meta,
169    OnHealthFailure, Pin, PolicyWave, RolloutPolicy, STRATEGY_ALL_AT_ONCE, Selector,
170    SystemdFailedUnits, Wave, normalize_rollout_policies,
171};
172pub use fleet_view::{
173    HostStatusEntry, HostsResponse, RolloutEventEntry, RolloutEvents, RolloutHostEntry,
174    RolloutHosts,
175};
176pub use host_rollout_state::HostRolloutState;
177pub use revocations::{RevocationEntry, Revocations};
178pub use rollout_manifest::{HostWave, RolloutBudget, RolloutManifest};
179pub use trust::{KeySlot, TrustConfig, TrustedPubkey};
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn channel_accessor_returns_left_of_at() {
187        let id = RolloutId::new("stable", "abc1234");
188        assert_eq!(id.channel(), "stable");
189    }
190
191    #[test]
192    fn channel_ref_accessor_returns_right_of_at() {
193        let id = RolloutId::new("stable", "abc1234");
194        assert_eq!(id.channel_ref(), "abc1234");
195    }
196
197    #[test]
198    fn accessors_split_on_first_at_only() {
199        // Constructor's debug_assert prevents `@` in `channel`, but a
200        // `channel_ref` containing `@` (reachable only via
201        // `from_raw_for_tests` since the route validator restricts
202        // channel_ref to lowercase hex) parses correctly because
203        // split_once splits on the FIRST `@` only. Documents the parser's
204        // semantics so a future change to split_once's behaviour fails
205        // this test loudly.
206        let weird = RolloutId::from_raw_for_tests("a@b@c".to_string());
207        assert_eq!(weird.channel(), "a");
208        assert_eq!(weird.channel_ref(), "b@c");
209    }
210}