Skip to main content

running_process/
daemon_registration_v2_compat.rs

1//! Frozen v2 service-definition semantics for application-owned rollout policy.
2//!
3//! The private substrate retains the established `.servicedef.v2` layout,
4//! validation, owner-private directory behavior, and deliberately non-atomic
5//! write. This facade deliberately owns no manifest, loader, negotiation,
6//! endpoint, or runtime policy.
7
8use std::error::Error;
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12use crate::daemon_registration_v2 as backend;
13
14/// Canonical v2 registration types and operations for consumers that require
15/// direct identity with the selected substrate. The existing module-level
16/// compatibility facade remains available while its narrower shared-broker
17/// semantics are migrated.
18pub mod canonical {
19    pub use crate::daemon_registration_v2::{
20        read_service_definition_v2, service_definition_dir_v2, service_definition_path_v2,
21        write_service_definition_v2, LoadedServiceDefinitionV2, ServiceDefinition,
22        ServiceDefinitionBuilder, ServiceDefinitionError,
23    };
24}
25
26/// Failure while validating or persisting a frozen v2 service definition.
27#[derive(Debug)]
28pub enum DaemonRegistrationV2Error {
29    /// A filesystem operation failed.
30    Io(std::io::Error),
31    /// A service name violates frozen registration validation.
32    InvalidName {
33        /// Stable diagnostic supplied by the underlying validator.
34        detail: String,
35    },
36    /// The service-definition directory was not private to its current user.
37    InsecureDirectory {
38        /// Directory rejected by the persistence layer.
39        path: PathBuf,
40    },
41    /// A definition failed a frozen semantic validation not otherwise exposed.
42    InvalidDefinition {
43        /// Stable diagnostic supplied by the underlying validator.
44        detail: String,
45    },
46}
47
48impl fmt::Display for DaemonRegistrationV2Error {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Io(error) => write!(formatter, "daemon registration v2 I/O failed: {error}"),
52            Self::InvalidName { detail } => {
53                write!(formatter, "invalid frozen v2 service name: {detail}")
54            }
55            Self::InsecureDirectory { path } => write!(
56                formatter,
57                "v2 service-definition directory is not owner-private: {}",
58                path.display()
59            ),
60            Self::InvalidDefinition { detail } => {
61                write!(formatter, "invalid frozen v2 service definition: {detail}")
62            }
63        }
64    }
65}
66
67impl Error for DaemonRegistrationV2Error {
68    fn source(&self) -> Option<&(dyn Error + 'static)> {
69        match self {
70            Self::Io(error) => Some(error),
71            _ => None,
72        }
73    }
74}
75
76fn service_error(error: backend::ServiceDefinitionError) -> DaemonRegistrationV2Error {
77    match error {
78        backend::ServiceDefinitionError::Io(error) => DaemonRegistrationV2Error::Io(error),
79        backend::ServiceDefinitionError::InvalidName(error) => {
80            DaemonRegistrationV2Error::InvalidName {
81                detail: error.to_string(),
82            }
83        }
84        backend::ServiceDefinitionError::InsecureDirectory(path) => {
85            DaemonRegistrationV2Error::InsecureDirectory { path }
86        }
87        other => DaemonRegistrationV2Error::InvalidDefinition {
88            detail: other.to_string(),
89        },
90    }
91}
92
93/// Return the platform- or environment-selected v2 service-definition directory.
94///
95/// The directory remains the established `running-process/services` root; v2
96/// uses a distinct file suffix so it can coexist with frozen v1 records.
97#[must_use]
98pub fn service_definition_directory() -> PathBuf {
99    backend::service_definition_dir_v2()
100}
101
102/// Compute the v2 path for one service definition beneath `root`.
103///
104/// # Errors
105///
106/// Returns [`DaemonRegistrationV2Error::InvalidName`] when `service_name`
107/// does not satisfy the frozen service-name policy.
108pub fn service_definition_path(
109    root: impl AsRef<Path>,
110    service_name: impl AsRef<str>,
111) -> Result<PathBuf, DaemonRegistrationV2Error> {
112    backend::service_definition_path_v2(root.as_ref(), service_name.as_ref()).map_err(service_error)
113}
114
115/// Write a v2 service definition into an explicit owner-private directory.
116///
117/// The frozen v2 writer uses one direct non-atomic write. Callers needing a
118/// different durability policy must own it above this compatibility surface.
119pub fn write_service_definition(
120    root: impl AsRef<Path>,
121    definition: &ServiceDefinition,
122) -> Result<PathBuf, DaemonRegistrationV2Error> {
123    backend::write_service_definition_v2(root.as_ref(), &definition.inner).map_err(service_error)
124}
125
126/// Builder for the shared-broker frozen v2 service definition used by current consumers.
127#[derive(Clone, Debug)]
128pub struct ServiceDefinitionBuilder {
129    service_name: String,
130    binary_path: String,
131    per_version_binary_dir: Option<String>,
132    min_version: Option<String>,
133    allowed_versions: Vec<String>,
134    labels: Vec<(String, String)>,
135}
136
137impl ServiceDefinitionBuilder {
138    /// Begin a shared-broker definition for an application-selected binary.
139    #[must_use]
140    pub fn shared_broker(service_name: impl Into<String>, binary_path: impl Into<String>) -> Self {
141        Self {
142            service_name: service_name.into(),
143            binary_path: binary_path.into(),
144            per_version_binary_dir: None,
145            min_version: None,
146            allowed_versions: Vec::new(),
147            labels: Vec::new(),
148        }
149    }
150
151    /// Set the per-version binary directory retained in the v2 definition.
152    #[must_use]
153    pub fn per_version_binary_dir(mut self, directory: impl Into<String>) -> Self {
154        self.per_version_binary_dir = Some(directory.into());
155        self
156    }
157
158    /// Set the minimum compatible service version.
159    #[must_use]
160    pub fn min_version(mut self, version: impl Into<String>) -> Self {
161        self.min_version = Some(version.into());
162        self
163    }
164
165    /// Append one allowed service version, retaining caller order.
166    #[must_use]
167    pub fn allow_version(mut self, version: impl Into<String>) -> Self {
168        self.allowed_versions.push(version.into());
169        self
170    }
171
172    /// Attach one application-selected label without canonicalizing map order.
173    #[must_use]
174    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
175        self.labels.push((key.into(), value.into()));
176        self
177    }
178
179    /// Finalize a compatibility v2 definition without persisting it.
180    #[must_use]
181    pub fn build(self) -> ServiceDefinition {
182        let mut inner =
183            backend::ServiceDefinitionBuilder::shared_broker(self.service_name, self.binary_path);
184        if let Some(directory) = self.per_version_binary_dir {
185            inner = inner.per_version_binary_dir(directory);
186        }
187        if let Some(version) = self.min_version {
188            inner = inner.min_version(version);
189        }
190        inner = inner.version_allow_list(self.allowed_versions);
191        for (key, value) in self.labels {
192            inner = inner.label(key, value);
193        }
194        ServiceDefinition {
195            inner: inner.build(),
196        }
197    }
198
199    /// Write the definition into the default owner-private v2 directory.
200    pub fn install(self) -> Result<PathBuf, DaemonRegistrationV2Error> {
201        let root = service_definition_directory();
202        self.install_in(root)
203    }
204
205    /// Write the definition into an explicit owner-private v2 directory.
206    pub fn install_in(self, root: impl AsRef<Path>) -> Result<PathBuf, DaemonRegistrationV2Error> {
207        write_service_definition(root, &self.build())
208    }
209}
210
211/// A frozen v2 service definition with its generated record kept private.
212#[derive(Clone, Debug, PartialEq)]
213pub struct ServiceDefinition {
214    inner: backend::ServiceDefinition,
215}
216
217impl ServiceDefinition {
218    /// Service name retained in the v2 definition.
219    #[must_use]
220    pub fn service_name(&self) -> &str {
221        &self.inner.service_name
222    }
223
224    /// Binary path string retained in the v2 definition.
225    #[must_use]
226    pub fn binary_path(&self) -> &str {
227        &self.inner.binary_path
228    }
229
230    /// Whether this definition selects the shared-broker isolation mode.
231    #[must_use]
232    pub fn is_shared_broker(&self) -> bool {
233        self.inner.isolation == backend::BrokerIsolation::SharedBroker as i32
234    }
235
236    /// Per-version binary directory retained in the v2 definition.
237    #[must_use]
238    pub fn per_version_binary_dir(&self) -> &str {
239        &self.inner.per_version_binary_dir
240    }
241
242    /// Minimum compatible version retained in the v2 definition.
243    #[must_use]
244    pub fn min_version(&self) -> &str {
245        &self.inner.min_version
246    }
247
248    /// Allowed versions in the caller-supplied order.
249    pub fn allowed_versions(&self) -> impl ExactSizeIterator<Item = &str> {
250        self.inner.version_allow_list.iter().map(String::as_str)
251    }
252
253    /// Return one application-selected label, if present.
254    #[must_use]
255    pub fn label(&self, key: &str) -> Option<&str> {
256        self.inner.labels.get(key).map(String::as_str)
257    }
258
259    /// Iterate the generated label map without imposing a canonical order.
260    pub fn labels(&self) -> impl ExactSizeIterator<Item = (&str, &str)> {
261        self.inner
262            .labels
263            .iter()
264            .map(|(key, value)| (key.as_str(), value.as_str()))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use tempfile::tempdir;
272
273    #[test]
274    fn compatibility_builder_retains_order_and_shared_isolation() {
275        let definition = ServiceDefinitionBuilder::shared_broker("service", "/bin/service")
276            .allow_version("2.0.0")
277            .allow_version("1.0.0")
278            .label("vendor", "first")
279            .label("vendor", "last")
280            .build();
281        assert!(definition.is_shared_broker());
282        assert_eq!(
283            definition.allowed_versions().collect::<Vec<_>>(),
284            ["2.0.0", "1.0.0"]
285        );
286        assert_eq!(definition.label("vendor"), Some("last"));
287    }
288
289    #[test]
290    fn compatibility_io_error_keeps_original_os_error_and_source() {
291        let error = service_error(backend::ServiceDefinitionError::Io(
292            std::io::Error::from_raw_os_error(13),
293        ));
294        assert!(error.source().is_some());
295        let DaemonRegistrationV2Error::Io(source) = error else {
296            panic!("I/O classification must remain intact");
297        };
298        assert_eq!(source.raw_os_error(), Some(13));
299    }
300
301    #[test]
302    fn facade_and_backend_keep_same_object_write_bytes_in_one_process() {
303        let definition = ServiceDefinitionBuilder::shared_broker("service", "/bin/service")
304            .per_version_binary_dir("/bin")
305            .min_version("1.2.3")
306            .allow_version("1.2.3")
307            .allow_version("1.2.4")
308            .label("vendor", "zackees")
309            .label("package", "fixture")
310            .build();
311        let facade_root = tempdir().expect("facade root");
312        let backend_root = tempdir().expect("backend root");
313
314        let facade_path =
315            write_service_definition(facade_root.path(), &definition).expect("facade write");
316        let backend_path =
317            backend::write_service_definition_v2(backend_root.path(), &definition.inner)
318                .expect("backend write");
319
320        assert_eq!(
321            std::fs::read(facade_path).expect("facade bytes"),
322            std::fs::read(backend_path).expect("backend bytes"),
323            "the comparison intentionally uses the same object in one process; labels remain a HashMap and are not a canonical byte promise"
324        );
325    }
326}