Skip to main content

running_process/broker/protocol_v2/
io.rs

1//! v2 service-definition I/O helpers (slice 22b of zccache#782).
2//!
3//! Convenience layer for writing and locating `.servicedef.v2` files —
4//! the v2 broker reads these instead of v1's `.servicedef`. Coexists
5//! with the v1 helpers in [`super::super::server::service_def_loader`]
6//! during the v1→v2 rollout; both extensions can live in the same
7//! per-OS service-definition directory.
8//!
9//! Mirrors the v1 surface (`ServiceDefinitionBuilder`,
10//! `service_definition_dir`, `write_service_definition`) so v1→v2
11//! migration on the consumer side is mechanical — every v1 call has
12//! a v2 counterpart with the same argument shape.
13//!
14//! Reads (loader) intentionally live elsewhere: the v2 broker owns the
15//! load path; this module is the *write* + *layout* side for
16//! consumer-side installers (e.g. zccache's `service_definition.rs`).
17
18use std::path::{Path, PathBuf};
19
20use prost::Message as _;
21
22use crate::broker::lifecycle::names::validate_service_name;
23use crate::broker::secure_dir;
24use crate::broker::server::service_def_loader::{
25    ensure_service_definition_dir, service_definition_dir, ServiceDefinitionError,
26};
27
28use super::{BrokerIsolation, ServiceDefinition};
29
30/// v2 service-definition file extension. Distinct from v1's `servicedef`
31/// so a v1 broker never accidentally tries to decode a v2 file (and
32/// vice versa).
33pub const SERVICE_DEF_V2_EXTENSION: &str = "servicedef.v2";
34
35/// Return the v2 service-definition directory.
36///
37/// Same per-OS path as v1's [`service_definition_dir`] — both
38/// extensions cohabit the directory during rollout. The broker
39/// chooses which to load based on its v1/v2 mode.
40#[must_use]
41pub fn service_definition_dir_v2() -> PathBuf {
42    service_definition_dir()
43}
44
45/// Compute the v2 file path for one service definition.
46///
47/// # Errors
48///
49/// Returns [`ServiceDefinitionError::InvalidName`] when the service name
50/// fails [`validate_service_name`] (same rules as v1).
51pub fn service_definition_path_v2(
52    root: &Path,
53    service_name: &str,
54) -> Result<PathBuf, ServiceDefinitionError> {
55    validate_service_name(service_name)?;
56    Ok(root.join(format!("{service_name}.{SERVICE_DEF_V2_EXTENSION}")))
57}
58
59/// Validate and write one `.servicedef.v2` file into `root`.
60///
61/// Consumer installers (e.g. zccache's daemon startup) should use this
62/// helper instead of re-implementing the proto encode + path layout.
63///
64/// # Errors
65///
66/// - [`ServiceDefinitionError::Io`] for filesystem failures (mkdir, write).
67/// - [`ServiceDefinitionError::InvalidName`] when `definition.service_name`
68///   fails validation.
69/// - [`ServiceDefinitionError::InsecureDirectory`] when `root` exists
70///   but has world/group write bits set.
71pub fn write_service_definition_v2(
72    root: &Path,
73    definition: &ServiceDefinition,
74) -> Result<PathBuf, ServiceDefinitionError> {
75    ensure_service_definition_dir(root)?;
76    let path = service_definition_path_v2(root, &definition.service_name)?;
77    std::fs::write(&path, definition.encode_to_vec())?;
78    Ok(path)
79}
80
81/// Builder for [`ServiceDefinition`].
82///
83/// Mirrors the v1 [`super::super::builders::ServiceDefinitionBuilder`]
84/// API verbatim so consumers can swap `use` paths and keep the same
85/// call sites. v2 ships the same launcher + isolation field set as v1
86/// (slice 22 of zccache#782) plus the v2-only HTTP capability slot.
87#[derive(Debug, Clone)]
88pub struct ServiceDefinitionBuilder {
89    definition: ServiceDefinition,
90}
91
92impl ServiceDefinitionBuilder {
93    /// Start a builder for a service that opts in to the per-user
94    /// shared broker (the common case for first-party tools).
95    ///
96    /// `service_name` must satisfy [`validate_service_name`] (already
97    /// enforced by [`write_service_definition_v2`] at install time;
98    /// the builder itself is permissive so a caller can finish
99    /// constructing then validate once).
100    #[must_use]
101    pub fn shared_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
102        Self {
103            definition: ServiceDefinition {
104                service_name: service_name.into(),
105                binary_path: binary_path.into(),
106                isolation: BrokerIsolation::SharedBroker as i32,
107                ..Default::default()
108            },
109        }
110    }
111
112    /// Start a builder for a service that uses a private per-service
113    /// broker (the default — safest for third-party consumers).
114    #[must_use]
115    pub fn private_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
116        Self {
117            definition: ServiceDefinition {
118                service_name: service_name.into(),
119                binary_path: binary_path.into(),
120                isolation: BrokerIsolation::PrivateBroker as i32,
121                ..Default::default()
122            },
123        }
124    }
125
126    /// Start a builder pinned to a named broker instance (e.g.
127    /// `"ci-trusted"` / `"ci-untrusted"`).
128    #[must_use]
129    pub fn explicit_instance(
130        service_name: impl Into<String>,
131        binary_path: impl Into<String>,
132        instance: impl Into<String>,
133    ) -> Self {
134        Self {
135            definition: ServiceDefinition {
136                service_name: service_name.into(),
137                binary_path: binary_path.into(),
138                isolation: BrokerIsolation::ExplicitInstance as i32,
139                explicit_instance: instance.into(),
140                ..Default::default()
141            },
142        }
143    }
144
145    /// Pin the canonicalized binary-directory allow-list root.
146    #[must_use]
147    pub fn per_version_binary_dir(mut self, dir: impl Into<String>) -> Self {
148        self.definition.per_version_binary_dir = dir.into();
149        self
150    }
151
152    /// Set the semver floor; broker refuses Hello below this.
153    #[must_use]
154    pub fn min_version(mut self, version: impl Into<String>) -> Self {
155        self.definition.min_version = version.into();
156        self
157    }
158
159    /// Pin to a strict allow-list of versions.
160    #[must_use]
161    pub fn version_allow_list<I, S>(mut self, versions: I) -> Self
162    where
163        I: IntoIterator<Item = S>,
164        S: Into<String>,
165    {
166        self.definition.version_allow_list = versions.into_iter().map(Into::into).collect();
167        self
168    }
169
170    /// Add a key/value label.
171    #[must_use]
172    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
173        self.definition.labels.insert(key.into(), value.into());
174        self
175    }
176
177    /// Finalize into a [`ServiceDefinition`]. Does not validate; call
178    /// [`write_service_definition_v2`] to validate + install.
179    #[must_use]
180    pub fn build(self) -> ServiceDefinition {
181        self.definition
182    }
183
184    /// Install into a specific service-definition directory. Equivalent
185    /// to `write_service_definition_v2(root, &self.build())`.
186    ///
187    /// # Errors
188    ///
189    /// See [`write_service_definition_v2`].
190    pub fn install_in(self, root: &Path) -> Result<PathBuf, ServiceDefinitionError> {
191        write_service_definition_v2(root, &self.build())
192    }
193
194    /// Install into the platform's default v2 service-definition
195    /// directory ([`service_definition_dir_v2`]).
196    ///
197    /// # Errors
198    ///
199    /// See [`write_service_definition_v2`].
200    pub fn install(self) -> Result<PathBuf, ServiceDefinitionError> {
201        let root = service_definition_dir_v2();
202        // Mirror the v1 install path: ensure the dir exists *and* is
203        // privately-permissioned before writing. `ensure_service_definition_dir`
204        // is called transitively by `write_service_definition_v2`, but
205        // calling `ensure_private_dir` here too matches v1's belt-and-
206        // suspenders pattern for the default-root path.
207        secure_dir::ensure_private_dir(&root)?;
208        self.install_in(&root)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use tempfile::tempdir;
216
217    #[test]
218    fn extension_is_servicedef_v2() {
219        assert_eq!(SERVICE_DEF_V2_EXTENSION, "servicedef.v2");
220    }
221
222    #[test]
223    fn service_definition_path_v2_uses_v2_extension() {
224        let root = Path::new("/svc");
225        let path = service_definition_path_v2(root, "zccache").unwrap();
226        assert_eq!(
227            path.to_str().unwrap().replace('\\', "/"),
228            "/svc/zccache.servicedef.v2"
229        );
230    }
231
232    #[test]
233    fn service_definition_path_v2_rejects_invalid_name() {
234        let root = Path::new("/svc");
235        assert!(service_definition_path_v2(root, "ZCCACHE").is_err());
236        assert!(service_definition_path_v2(root, "").is_err());
237        assert!(service_definition_path_v2(root, "a/b").is_err());
238    }
239
240    #[test]
241    fn shared_broker_builder_sets_expected_fields() {
242        let def = ServiceDefinitionBuilder::shared_broker("zccache", "/usr/bin/zccache").build();
243        assert_eq!(def.service_name, "zccache");
244        assert_eq!(def.binary_path, "/usr/bin/zccache");
245        assert_eq!(def.isolation, BrokerIsolation::SharedBroker as i32);
246        assert!(def.explicit_instance.is_empty());
247    }
248
249    #[test]
250    fn private_broker_builder_sets_expected_fields() {
251        let def = ServiceDefinitionBuilder::private_broker("svc", "/bin/x").build();
252        assert_eq!(def.isolation, BrokerIsolation::PrivateBroker as i32);
253    }
254
255    #[test]
256    fn explicit_instance_builder_sets_expected_fields() {
257        let def =
258            ServiceDefinitionBuilder::explicit_instance("svc", "/bin/x", "ci-trusted").build();
259        assert_eq!(def.isolation, BrokerIsolation::ExplicitInstance as i32);
260        assert_eq!(def.explicit_instance, "ci-trusted");
261    }
262
263    #[test]
264    fn builder_chain_propagates_optional_fields() {
265        let def = ServiceDefinitionBuilder::shared_broker("svc", "/bin/x")
266            .per_version_binary_dir("/usr/local/bin")
267            .min_version("1.2.3")
268            .version_allow_list(["1.2.3", "1.3.0"])
269            .label("env", "prod")
270            .label("region", "us-west")
271            .build();
272        assert_eq!(def.per_version_binary_dir, "/usr/local/bin");
273        assert_eq!(def.min_version, "1.2.3");
274        assert_eq!(def.version_allow_list, vec!["1.2.3", "1.3.0"]);
275        assert_eq!(def.labels.get("env"), Some(&"prod".to_owned()));
276        assert_eq!(def.labels.get("region"), Some(&"us-west".to_owned()));
277    }
278
279    #[test]
280    fn install_in_writes_and_decodes_round_trip() {
281        let dir = tempdir().expect("tempdir");
282        let path = ServiceDefinitionBuilder::shared_broker("zccache", "/usr/bin/zccache")
283            .min_version("1.0.0")
284            .label("env", "prod")
285            .install_in(dir.path())
286            .expect("install_in");
287
288        assert_eq!(
289            path.file_name().and_then(|s| s.to_str()),
290            Some("zccache.servicedef.v2")
291        );
292
293        let bytes = std::fs::read(&path).expect("read file");
294        let decoded = ServiceDefinition::decode(bytes.as_slice()).expect("decode");
295        assert_eq!(decoded.service_name, "zccache");
296        assert_eq!(decoded.binary_path, "/usr/bin/zccache");
297        assert_eq!(decoded.isolation, BrokerIsolation::SharedBroker as i32);
298        assert_eq!(decoded.min_version, "1.0.0");
299        assert_eq!(decoded.labels.get("env"), Some(&"prod".to_owned()));
300    }
301
302    #[test]
303    fn write_service_definition_v2_rejects_invalid_name() {
304        let dir = tempdir().expect("tempdir");
305        let bad = ServiceDefinition {
306            service_name: "BAD-Caps".to_owned(),
307            ..Default::default()
308        };
309        let err = write_service_definition_v2(dir.path(), &bad).expect_err("must reject");
310        let _ = err;
311    }
312
313    #[test]
314    fn write_service_definition_v2_creates_parent_dir() {
315        let dir = tempdir().expect("tempdir");
316        let nested = dir.path().join("nested");
317        let path = ServiceDefinitionBuilder::shared_broker("svc", "/bin/x")
318            .install_in(&nested)
319            .expect("install_in into nested");
320        assert!(path.exists());
321        assert!(nested.exists());
322    }
323
324    /// Round-trip: write via the builder, read the bytes back as a
325    /// raw [`ServiceDefinition`], assert every builder field survived.
326    /// Pins the contract from a different angle than the standalone
327    /// proto round-trip tests in `mod.rs`.
328    #[test]
329    fn builder_install_round_trip_preserves_every_field() {
330        let dir = tempdir().expect("tempdir");
331        let path = ServiceDefinitionBuilder::explicit_instance("svc", "/bin/x", "ci-trusted")
332            .per_version_binary_dir("/usr/local/bin")
333            .min_version("1.0.0")
334            .version_allow_list(["1.0.0", "1.1.0"])
335            .label("env", "prod")
336            .label("rollout", "blue")
337            .install_in(dir.path())
338            .expect("install_in");
339
340        let bytes = std::fs::read(&path).expect("read");
341        let decoded = ServiceDefinition::decode(bytes.as_slice()).expect("decode");
342        assert_eq!(decoded.service_name, "svc");
343        assert_eq!(decoded.binary_path, "/bin/x");
344        assert_eq!(decoded.isolation, BrokerIsolation::ExplicitInstance as i32);
345        assert_eq!(decoded.explicit_instance, "ci-trusted");
346        assert_eq!(decoded.per_version_binary_dir, "/usr/local/bin");
347        assert_eq!(decoded.min_version, "1.0.0");
348        assert_eq!(decoded.version_allow_list, vec!["1.0.0", "1.1.0"]);
349        assert_eq!(decoded.labels.len(), 2);
350        assert_eq!(decoded.labels.get("env"), Some(&"prod".to_owned()));
351        assert_eq!(decoded.labels.get("rollout"), Some(&"blue".to_owned()));
352    }
353}