Skip to main content

running_process/
daemon_registration_v2.rs

1//! Frozen v2 service-definition writer substrate.
2//!
3//! This direct surface owns only v2 `.servicedef.v2` construction, validation,
4//! location, and persistence. It deliberately excludes v2 manifests, loading,
5//! broker negotiation, endpoint transport, and runtime policy. The legacy
6//! client-only `broker::protocol_v2` path re-exports these exact items.
7
8use std::path::{Path, PathBuf};
9
10use prost::Message as _;
11
12use crate::daemon_registration_common::service_definition::{
13    ensure_service_definition_dir, service_definition_dir,
14};
15
16/// Shared validation error and service-name validation used by v1 and v2.
17pub use crate::daemon_registration_common::{
18    service_definition::ServiceDefinitionError,
19    validation::{validate_service_name, PipePathError},
20};
21/// Generated v2 service-definition types needed by the writer.
22pub use running_process_protocol::broker::v2::{BrokerIsolation, ServiceDefinition};
23
24/// v2 service-definition file extension. Distinct from v1's `servicedef`
25/// so the two records can coexist in one owner-private directory.
26pub const SERVICE_DEF_V2_EXTENSION: &str = "servicedef.v2";
27
28/// Return the v2 service-definition directory.
29///
30/// This is the exact v1 platform/env-selected root; only the file extension
31/// differs during the rollout.
32#[must_use]
33pub fn service_definition_dir_v2() -> PathBuf {
34    service_definition_dir()
35}
36
37/// Compute the v2 path for one service definition.
38///
39/// # Errors
40///
41/// Returns [`ServiceDefinitionError::InvalidName`] when the name fails the
42/// frozen `[a-z0-9-]{1,64}` validation shared with v1.
43pub fn service_definition_path_v2(
44    root: &Path,
45    service_name: &str,
46) -> Result<PathBuf, ServiceDefinitionError> {
47    validate_service_name(service_name)?;
48    Ok(root.join(format!("{service_name}.{SERVICE_DEF_V2_EXTENSION}")))
49}
50
51/// A decoded v2 definition together with its exact original wire bytes.
52///
53/// Keeping the original bytes preserves unknown protobuf fields when callers
54/// inspect or forward a record without editing it.
55#[derive(Clone, Debug)]
56pub struct LoadedServiceDefinitionV2 {
57    /// Validated record for the requested service.
58    pub definition: ServiceDefinition,
59    /// Exact bytes read from disk, without re-encoding.
60    pub bytes: Vec<u8>,
61}
62
63/// Read an existing v2 definition without creating or changing its directory.
64pub fn read_service_definition_v2(
65    root: &Path,
66    service_name: &str,
67) -> Result<LoadedServiceDefinitionV2, ServiceDefinitionError> {
68    let path = service_definition_path_v2(root, service_name)?;
69    if !crate::daemon_registration_common::secure_dir::private_dir_permissions_are_private(root)? {
70        return Err(ServiceDefinitionError::InsecureDirectory(
71            root.to_path_buf(),
72        ));
73    }
74    let bytes = std::fs::read(path)?;
75    let definition = ServiceDefinition::decode(bytes.as_slice())?;
76    validate_service_name(&definition.service_name)?;
77    if definition.service_name != service_name {
78        return Err(ServiceDefinitionError::ServiceNameMismatch {
79            requested: service_name.to_owned(),
80            actual: definition.service_name,
81        });
82    }
83    Ok(LoadedServiceDefinitionV2 { definition, bytes })
84}
85
86/// Validate the service name and write one `.servicedef.v2` file into `root`.
87///
88/// The established writer intentionally uses one direct `std::fs::write`.
89/// It is non-atomic; callers that require a different persistence policy own
90/// that policy above this frozen compatibility layer.
91///
92/// # Errors
93///
94/// Returns I/O, invalid-name, or insecure-directory errors from the shared
95/// service-definition path and owner-private directory policy.
96pub fn write_service_definition_v2(
97    root: &Path,
98    definition: &ServiceDefinition,
99) -> Result<PathBuf, ServiceDefinitionError> {
100    ensure_service_definition_dir(root)?;
101    let path = service_definition_path_v2(root, &definition.service_name)?;
102    std::fs::write(&path, definition.encode_to_vec())?;
103    Ok(path)
104}
105
106/// Builder for a generated v2 [`ServiceDefinition`].
107///
108/// The builder preserves the existing version-list order and inserts labels in
109/// the generated `HashMap`; it intentionally neither sorts nor canonicalizes
110/// labels. It does not set the optional v2 HTTP capability.
111#[derive(Debug, Clone)]
112pub struct ServiceDefinitionBuilder {
113    definition: ServiceDefinition,
114}
115
116impl ServiceDefinitionBuilder {
117    /// Start a definition for the per-user shared broker.
118    #[must_use]
119    pub fn shared_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
120        Self {
121            definition: ServiceDefinition {
122                service_name: service_name.into(),
123                binary_path: binary_path.into(),
124                isolation: BrokerIsolation::SharedBroker as i32,
125                ..Default::default()
126            },
127        }
128    }
129
130    /// Start a definition for a private per-service broker.
131    #[must_use]
132    pub fn private_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
133        Self {
134            definition: ServiceDefinition {
135                service_name: service_name.into(),
136                binary_path: binary_path.into(),
137                isolation: BrokerIsolation::PrivateBroker as i32,
138                ..Default::default()
139            },
140        }
141    }
142
143    /// Start a definition pinned to a named broker instance.
144    #[must_use]
145    pub fn explicit_instance(
146        service_name: impl Into<String>,
147        binary_path: impl Into<String>,
148        instance: impl Into<String>,
149    ) -> Self {
150        Self {
151            definition: ServiceDefinition {
152                service_name: service_name.into(),
153                binary_path: binary_path.into(),
154                isolation: BrokerIsolation::ExplicitInstance as i32,
155                explicit_instance: instance.into(),
156                ..Default::default()
157            },
158        }
159    }
160
161    /// Pin the canonicalized binary-directory allow-list root.
162    #[must_use]
163    pub fn per_version_binary_dir(mut self, dir: impl Into<String>) -> Self {
164        self.definition.per_version_binary_dir = dir.into();
165        self
166    }
167
168    /// Set the semver floor.
169    #[must_use]
170    pub fn min_version(mut self, version: impl Into<String>) -> Self {
171        self.definition.min_version = version.into();
172        self
173    }
174
175    /// Replace the version allow-list, retaining the caller's iteration order.
176    #[must_use]
177    pub fn version_allow_list<I, S>(mut self, versions: I) -> Self
178    where
179        I: IntoIterator<Item = S>,
180        S: Into<String>,
181    {
182        self.definition.version_allow_list = versions.into_iter().map(Into::into).collect();
183        self
184    }
185
186    /// Insert one label into the generated protobuf map.
187    #[must_use]
188    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
189        self.definition.labels.insert(key.into(), value.into());
190        self
191    }
192
193    /// Finalize the generated definition without adding validation policy.
194    #[must_use]
195    pub fn build(self) -> ServiceDefinition {
196        self.definition
197    }
198
199    /// Install into an explicit service-definition root.
200    pub fn install_in(self, root: &Path) -> Result<PathBuf, ServiceDefinitionError> {
201        write_service_definition_v2(root, &self.build())
202    }
203
204    /// Install into the established platform/env-selected root.
205    pub fn install(self) -> Result<PathBuf, ServiceDefinitionError> {
206        let root = service_definition_dir_v2();
207        crate::daemon_registration_common::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").expect("valid path");
226        assert_eq!(
227            path.to_string_lossy().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 definition =
243            ServiceDefinitionBuilder::shared_broker("zccache", "/usr/bin/zccache").build();
244        assert_eq!(definition.service_name, "zccache");
245        assert_eq!(definition.binary_path, "/usr/bin/zccache");
246        assert_eq!(definition.isolation, BrokerIsolation::SharedBroker as i32);
247        assert!(definition.explicit_instance.is_empty());
248        assert!(definition.http_server.is_none());
249    }
250
251    #[test]
252    fn private_broker_builder_sets_expected_fields() {
253        let definition = ServiceDefinitionBuilder::private_broker("svc", "/bin/x").build();
254        assert_eq!(definition.isolation, BrokerIsolation::PrivateBroker as i32);
255    }
256
257    #[test]
258    fn explicit_instance_builder_sets_expected_fields() {
259        let definition =
260            ServiceDefinitionBuilder::explicit_instance("svc", "/bin/x", "ci-trusted").build();
261        assert_eq!(
262            definition.isolation,
263            BrokerIsolation::ExplicitInstance as i32
264        );
265        assert_eq!(definition.explicit_instance, "ci-trusted");
266    }
267
268    #[test]
269    fn builder_chain_propagates_optional_fields() {
270        let definition = ServiceDefinitionBuilder::shared_broker("svc", "/bin/x")
271            .per_version_binary_dir("/usr/local/bin")
272            .min_version("1.2.3")
273            .version_allow_list(["1.2.3", "1.3.0"])
274            .label("env", "prod")
275            .label("region", "us-west")
276            .build();
277        assert_eq!(definition.per_version_binary_dir, "/usr/local/bin");
278        assert_eq!(definition.min_version, "1.2.3");
279        assert_eq!(definition.version_allow_list, vec!["1.2.3", "1.3.0"]);
280        assert_eq!(definition.labels.get("env"), Some(&"prod".to_owned()));
281        assert_eq!(definition.labels.get("region"), Some(&"us-west".to_owned()));
282        assert!(definition.http_server.is_none());
283    }
284
285    #[test]
286    fn install_in_writes_and_decodes_round_trip() {
287        let directory = tempdir().expect("tempdir");
288        let path = ServiceDefinitionBuilder::shared_broker("zccache", "/usr/bin/zccache")
289            .min_version("1.0.0")
290            .label("env", "prod")
291            .install_in(directory.path())
292            .expect("install");
293
294        assert_eq!(
295            path.file_name().and_then(|name| name.to_str()),
296            Some("zccache.servicedef.v2")
297        );
298        let decoded =
299            ServiceDefinition::decode(std::fs::read(&path).expect("read definition").as_slice())
300                .expect("decode definition");
301        assert_eq!(decoded.service_name, "zccache");
302        assert_eq!(decoded.binary_path, "/usr/bin/zccache");
303        assert_eq!(decoded.isolation, BrokerIsolation::SharedBroker as i32);
304        assert_eq!(decoded.min_version, "1.0.0");
305        assert_eq!(decoded.labels.get("env"), Some(&"prod".to_owned()));
306        assert!(decoded.http_server.is_none());
307    }
308
309    #[test]
310    fn write_service_definition_v2_rejects_invalid_name() {
311        let directory = tempdir().expect("tempdir");
312        let bad = ServiceDefinition {
313            service_name: "BAD-Caps".to_owned(),
314            ..Default::default()
315        };
316        assert!(write_service_definition_v2(directory.path(), &bad).is_err());
317    }
318
319    #[test]
320    fn write_service_definition_v2_creates_parent_dir() {
321        let directory = tempdir().expect("tempdir");
322        let nested = directory.path().join("nested");
323        let path = ServiceDefinitionBuilder::shared_broker("svc", "/bin/x")
324            .install_in(&nested)
325            .expect("install into nested");
326        assert!(path.exists());
327        assert!(nested.exists());
328    }
329
330    #[test]
331    fn builder_install_round_trip_preserves_every_field() {
332        let directory = tempdir().expect("tempdir");
333        let path = ServiceDefinitionBuilder::explicit_instance("svc", "/bin/x", "ci-trusted")
334            .per_version_binary_dir("/usr/local/bin")
335            .min_version("1.0.0")
336            .version_allow_list(["1.0.0", "1.1.0"])
337            .label("env", "prod")
338            .label("rollout", "blue")
339            .install_in(directory.path())
340            .expect("install");
341
342        let decoded =
343            ServiceDefinition::decode(std::fs::read(&path).expect("read definition").as_slice())
344                .expect("decode definition");
345        assert_eq!(decoded.service_name, "svc");
346        assert_eq!(decoded.binary_path, "/bin/x");
347        assert_eq!(decoded.isolation, BrokerIsolation::ExplicitInstance as i32);
348        assert_eq!(decoded.explicit_instance, "ci-trusted");
349        assert_eq!(decoded.per_version_binary_dir, "/usr/local/bin");
350        assert_eq!(decoded.min_version, "1.0.0");
351        assert_eq!(decoded.version_allow_list, vec!["1.0.0", "1.1.0"]);
352        assert_eq!(decoded.labels.get("env"), Some(&"prod".to_owned()));
353        assert_eq!(decoded.labels.get("rollout"), Some(&"blue".to_owned()));
354        assert!(decoded.http_server.is_none());
355    }
356}
357
358#[cfg(all(test, feature = "client"))]
359mod compatibility_tests {
360    use std::any::TypeId;
361
362    use prost::Message as _;
363    use tempfile::tempdir;
364
365    use super::*;
366
367    #[test]
368    fn legacy_client_v2_writer_paths_reexport_canonical_types_and_bytes() {
369        assert_eq!(
370            TypeId::of::<ServiceDefinition>(),
371            TypeId::of::<crate::broker::protocol_v2::ServiceDefinition>(),
372        );
373        assert_eq!(
374            TypeId::of::<BrokerIsolation>(),
375            TypeId::of::<crate::broker::protocol_v2::BrokerIsolation>(),
376        );
377        assert_eq!(
378            TypeId::of::<ServiceDefinitionBuilder>(),
379            TypeId::of::<crate::broker::protocol_v2::ServiceDefinitionBuilder>(),
380        );
381        assert_eq!(
382            TypeId::of::<ServiceDefinitionError>(),
383            TypeId::of::<crate::broker::server::service_def_loader::ServiceDefinitionError>(),
384        );
385
386        let definition = ServiceDefinitionBuilder::shared_broker("zccache", "/bin/zccache")
387            .per_version_binary_dir("/bin")
388            .min_version("1.2.3")
389            .version_allow_list(["1.2.3", "1.2.4"])
390            .label("package", "zccache")
391            .label("vendor", "zackees")
392            .build();
393        let canonical_root = tempdir().expect("canonical root");
394        let legacy_root = tempdir().expect("legacy root");
395        let canonical_path = write_service_definition_v2(canonical_root.path(), &definition)
396            .expect("canonical write");
397        let legacy_path = crate::broker::protocol_v2::write_service_definition_v2(
398            legacy_root.path(),
399            &definition,
400        )
401        .expect("legacy write");
402
403        let canonical_bytes = std::fs::read(canonical_path).expect("canonical bytes");
404        let legacy_bytes = std::fs::read(legacy_path).expect("legacy bytes");
405        assert_eq!(canonical_bytes, legacy_bytes);
406
407        let decoded = ServiceDefinition::decode(canonical_bytes.as_slice()).expect("decode bytes");
408        assert_eq!(decoded, definition);
409        assert!(decoded.http_server.is_none());
410    }
411}