running_process/broker/server/combined_service_def_loader.rs
1//! v2-first service-definition lookup for the shared (v2) broker serve path.
2//!
3//! soldr#2364. `serve_launching_backends` / [`super::HelloRouter`] historically
4//! read only v1 `.servicedef` files, but soldr — and every consumer that
5//! installs via [`crate::broker::protocol_v2::write_service_definition_v2`] —
6//! writes `.servicedef.v2` files. So a real Hello against the shared broker was
7//! `Refused { "service definition was not found" }`: the file was on disk under
8//! the v2 extension the v1 loader never looks for.
9//!
10//! This module closes that gap without disturbing the downstream routing chain.
11//! [`CombinedServiceDefinitionLoader`] tries the v2 file first, falls back to
12//! the v1 file, and returns the **v1** [`ServiceDefinition`] type that
13//! `check_version_allowed`, [`super::BrokerInstanceKey::from_service_definition`],
14//! `BackendLaunchRequest`, and the rest of the launch path already consume. The
15//! v2 schema is a strict superset of v1 (identical field numbers 1–8, plus the
16//! v2-only optional `http_server` capability at field 10), so the
17//! down-conversion is lossless for everything the broker launch path reads.
18//!
19//! [`super::HelloRouter`] takes its loader as a `&dyn `[`ServiceDefinitionSource`] so
20//! both the bare v1 [`ServiceDefinitionLoader`] (used by the crate's existing
21//! unit/integration tests, which install v1 files) and this combined loader
22//! satisfy it: a `&ServiceDefinitionLoader` unsize-coerces to the trait object
23//! at the call site, so those call sites need no change.
24
25use crate::broker::protocol::ServiceDefinition;
26use crate::broker::protocol_v2;
27use crate::broker::server::service_def_loader::{ServiceDefinitionError, ServiceDefinitionLoader};
28use std::path::{Path, PathBuf};
29
30/// A source of v1-typed service definitions for [`super::HelloRouter`].
31///
32/// Implemented by the bare v1 [`ServiceDefinitionLoader`] and by
33/// [`CombinedServiceDefinitionLoader`]. The router only ever needs to look a
34/// service up by name, so this is the whole surface.
35pub trait ServiceDefinitionSource: Sync {
36 /// Look up (always re-reading from disk) the service definition for
37 /// `service_name`, returning the v1 [`ServiceDefinition`] type.
38 fn lookup_or_reload(
39 &self,
40 service_name: &str,
41 ) -> Result<ServiceDefinition, ServiceDefinitionError>;
42}
43
44impl ServiceDefinitionSource for ServiceDefinitionLoader {
45 fn lookup_or_reload(
46 &self,
47 service_name: &str,
48 ) -> Result<ServiceDefinition, ServiceDefinitionError> {
49 ServiceDefinitionLoader::lookup_or_reload(self, service_name)
50 }
51}
52
53/// Reads `.servicedef.v2` first, then `.servicedef`, returning the v1
54/// [`ServiceDefinition`] the broker routing chain consumes.
55#[derive(Clone)]
56pub struct CombinedServiceDefinitionLoader {
57 root: PathBuf,
58}
59
60impl CombinedServiceDefinitionLoader {
61 /// Build a loader rooted at `root` (the service-definition directory).
62 pub fn new(root: impl Into<PathBuf>) -> Self {
63 Self { root: root.into() }
64 }
65
66 /// The service-definition directory this loader reads from.
67 pub fn root(&self) -> &Path {
68 &self.root
69 }
70
71 /// Load and validate one service definition, preferring the v2 file.
72 ///
73 /// A missing v2 file (only) falls back to the v1 file; any other v2 error
74 /// (insecure directory, decode failure, name mismatch) is returned as-is so
75 /// a genuinely broken v2 file is never silently masked by a v1 fallback.
76 pub fn load(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
77 match protocol_v2::ServiceDefinitionLoader::new(&self.root).load(service_name) {
78 Ok(v2) => Ok(service_definition_v2_to_v1(v2)),
79 Err(err) if is_missing_file(&err) => {
80 ServiceDefinitionLoader::new(&self.root).load(service_name)
81 }
82 Err(err) => Err(err),
83 }
84 }
85
86 /// Re-read one service definition from disk (alias for [`Self::load`]).
87 pub fn reload(&self, service_name: &str) -> Result<ServiceDefinition, ServiceDefinitionError> {
88 self.load(service_name)
89 }
90
91 /// Lookup that always re-reads — mirrors the v1 loader's contract.
92 pub fn lookup_or_reload(
93 &self,
94 service_name: &str,
95 ) -> Result<ServiceDefinition, ServiceDefinitionError> {
96 self.load(service_name)
97 }
98}
99
100impl ServiceDefinitionSource for CombinedServiceDefinitionLoader {
101 fn lookup_or_reload(
102 &self,
103 service_name: &str,
104 ) -> Result<ServiceDefinition, ServiceDefinitionError> {
105 CombinedServiceDefinitionLoader::lookup_or_reload(self, service_name)
106 }
107}
108
109/// True when `err` is a plain "file not found" — the only v2 error that should
110/// trigger a v1 fallback rather than surface.
111fn is_missing_file(err: &ServiceDefinitionError) -> bool {
112 matches!(err, ServiceDefinitionError::Io(io) if io.kind() == std::io::ErrorKind::NotFound)
113}
114
115/// Down-convert a v2 [`protocol_v2::ServiceDefinition`] to the v1
116/// [`ServiceDefinition`] the broker launch chain consumes.
117///
118/// Fields 1–8 are identical across the two schemas (same field numbers, same
119/// wire types; `isolation` is a plain `i32` in both, with matching
120/// `BrokerIsolation` discriminants). The v2-only `http_server` capability
121/// (field 10) has no v1 equivalent and is dropped — the broker launch path
122/// never reads it (it is an aggregator-facing HTTP hint, not launch policy).
123pub fn service_definition_v2_to_v1(v2: protocol_v2::ServiceDefinition) -> ServiceDefinition {
124 ServiceDefinition {
125 service_name: v2.service_name,
126 binary_path: v2.binary_path,
127 isolation: v2.isolation,
128 explicit_instance: v2.explicit_instance,
129 per_version_binary_dir: v2.per_version_binary_dir,
130 min_version: v2.min_version,
131 version_allow_list: v2.version_allow_list,
132 labels: v2.labels,
133 }
134}
135
136#[cfg(test)]
137mod tests;