1use 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
16pub use crate::daemon_registration_common::{
18 service_definition::ServiceDefinitionError,
19 validation::{validate_service_name, PipePathError},
20};
21pub use running_process_protocol::broker::v2::{BrokerIsolation, ServiceDefinition};
23
24pub const SERVICE_DEF_V2_EXTENSION: &str = "servicedef.v2";
27
28#[must_use]
33pub fn service_definition_dir_v2() -> PathBuf {
34 service_definition_dir()
35}
36
37pub 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#[derive(Clone, Debug)]
56pub struct LoadedServiceDefinitionV2 {
57 pub definition: ServiceDefinition,
59 pub bytes: Vec<u8>,
61}
62
63pub 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
86pub 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#[derive(Debug, Clone)]
112pub struct ServiceDefinitionBuilder {
113 definition: ServiceDefinition,
114}
115
116impl ServiceDefinitionBuilder {
117 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
195 pub fn build(self) -> ServiceDefinition {
196 self.definition
197 }
198
199 pub fn install_in(self, root: &Path) -> Result<PathBuf, ServiceDefinitionError> {
201 write_service_definition_v2(root, &self.build())
202 }
203
204 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}