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}