Preview of the proposed up-rust native-frame-model branch (up-rust b6b99c6d, up-spec f0e9b17) — not released documentation. branch · write-up

up_rust/
symphony.rs

1/********************************************************************************
2 * Copyright (c) 2025 Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 ********************************************************************************/
13
14/*!
15Types and helpers that allow implementing an Eclipse Symphony™ Target Provider as a uService
16exposed via the Communication Layer API's `RpcServer`.
17*/
18
19use std::{collections::HashMap, sync::Arc};
20
21use async_trait::async_trait;
22use serde_json::Value;
23use symphony::models::{ComponentResultSpec, ComponentSpec, DeploymentSpec};
24use tracing::{debug, error, trace, warn, Level};
25
26use crate::{
27    communication::{RequestHandler, RpcServer, ServiceInvocationError, UPayload},
28    UAttributes, UPayloadFormat,
29};
30
31/// Resource id of the symphony `get` method.
32pub const METHOD_GET_RESOURCE_ID: u16 = 0x0001;
33/// Resource id of the symphony `update` method.
34pub const METHOD_UPDATE_RESOURCE_ID: u16 = 0x0002;
35/// Resource id of the symphony `delete` method.
36pub const METHOD_DELETE_RESOURCE_ID: u16 = 0x0003;
37
38/// Registers RPC endpoints for managing a deployment target via Eclipse Symphony's uProtocol
39/// Target Provider.
40///
41/// This function registers three RPC endpoints that delegate to the provided [`DeploymentTarget`] implementation:
42/// - `Get` (resource ID `0x0001`) - Retrieves current component status
43/// - `Update` (resource ID `0x0002`) - Updates deployment components  
44/// - `Delete` (resource ID `0x0003`) - Removes deployment components
45///
46/// # Arguments
47/// * `rpc_server` - The RPC server to register the endpoints on
48/// * `deployment_target` - The deployment target implementation to delegate requests to
49///
50/// # Errors
51/// Returns an error if any of the endpoints cannot be registered on the RPC server.
52pub async fn register_target_provider_endpoints<R: RpcServer, T: DeploymentTarget + 'static>(
53    rpc_server: &R,
54    deployment_target: Arc<T>,
55) -> Result<(), Box<dyn std::error::Error>> {
56    let get_op = Arc::new(GetOperation {
57        target: deployment_target.clone(),
58    });
59    let apply_op = Arc::new(ApplyOperation {
60        target: deployment_target,
61    });
62    rpc_server
63        .register_endpoint(None, METHOD_GET_RESOURCE_ID, get_op)
64        .await
65        .inspect_err(|e| error!("failed to register Get operation on RPC Server: {e}"))?;
66    rpc_server
67        .register_endpoint(None, METHOD_UPDATE_RESOURCE_ID, apply_op.clone())
68        .await
69        .inspect_err(|e| error!("failed to register Update operation on RPC Server: {e}"))?;
70    rpc_server
71        .register_endpoint(None, METHOD_DELETE_RESOURCE_ID, apply_op)
72        .await
73        .inspect_err(|e| error!("failed to register Delete operation on RPC Server: {e}"))?;
74    Ok(())
75}
76
77#[cfg_attr(any(test, feature = "test-util"), mockall::automock)]
78#[async_trait]
79/// Deployment-side hooks a symphony target implements to apply resource changes.
80pub trait DeploymentTarget: Send + Sync {
81    /// Retrieves the current status of components within a deployment.
82    ///
83    /// # Arguments
84    /// * `components` - The components whose current status should be retrieved
85    /// * `deployment_spec` - The deployment context containing these components
86    ///
87    /// # Returns
88    /// A vector of [`ComponentSpec`] representing the currently deployed state of the requested components.
89    ///
90    /// # Errors
91    /// Returns an error if the current deployment status cannot be determined.
92    async fn get(
93        &self,
94        components: Vec<ComponentSpec>,
95        deployment_spec: DeploymentSpec,
96    ) -> Result<Vec<ComponentSpec>, Box<dyn std::error::Error>>;
97
98    /// Updates the specified components within a deployment.
99    ///
100    /// # Arguments
101    /// * `components_to_update` - The components to be updated
102    /// * `deployment_spec` - The deployment context for these components
103    ///
104    /// # Returns
105    /// A map where keys are component identifiers and values are [`ComponentResultSpec`]
106    /// indicating the outcome of each component's update operation.
107    ///
108    /// # Errors
109    /// Returns an error if the update operation cannot be performed. Individual component
110    /// failures should be reported in the returned map rather than as an overall error.
111    async fn update(
112        &self,
113        components_to_update: Vec<ComponentSpec>,
114        deployment_spec: DeploymentSpec,
115    ) -> Result<HashMap<String, ComponentResultSpec>, Box<dyn std::error::Error>>;
116
117    /// Removes the specified components from a deployment.
118    ///
119    /// # Arguments
120    /// * `components_to_delete` - The components to be removed
121    /// * `deployment_spec` - The deployment context for these components
122    ///
123    /// # Returns
124    /// A map where keys are component identifiers and values are [`ComponentResultSpec`]
125    /// indicating the outcome of each component's deletion operation.
126    ///
127    /// # Errors
128    /// Returns an error if the delete operation cannot be performed. Individual component
129    /// failures should be reported in the returned map rather than as an overall error.
130    async fn delete(
131        &self,
132        components_to_delete: Vec<ComponentSpec>,
133        deployment_spec: DeploymentSpec,
134    ) -> Result<HashMap<String, ComponentResultSpec>, Box<dyn std::error::Error>>;
135}
136
137fn extract_request_data(
138    request_payload: Option<UPayload>,
139) -> Result<Value, ServiceInvocationError> {
140    let Some(req_payload) =
141        request_payload.filter(|req_payload| req_payload.payload_format() == UPayloadFormat::Json)
142    else {
143        return Err(ServiceInvocationError::InvalidArgument(
144            "request has no JSON payload".to_string(),
145        ));
146    };
147
148    serde_json::from_slice(req_payload.payload().to_vec().as_slice()).map_err(|err| {
149        debug!("failed to deserialize request payload: {:?}", err);
150        ServiceInvocationError::InvalidArgument(
151            "request payload is not a valid UTF-8 string".to_string(),
152        )
153    })
154}
155
156struct GetOperation<T: DeploymentTarget> {
157    target: Arc<T>,
158}
159
160#[async_trait::async_trait]
161impl<T: DeploymentTarget> RequestHandler for GetOperation<T> {
162    // expects a DeploymentSpec in the request and returns an array of ComponentSpecs
163    async fn handle_request(
164        &self,
165        _resource_id: u16,
166        message_attributes: &UAttributes,
167        request_payload: Option<UPayload>,
168    ) -> Result<Option<UPayload>, ServiceInvocationError> {
169        let source_uri = message_attributes.source().to_uri(true);
170        if tracing::enabled!(Level::DEBUG) {
171            debug!(source = source_uri, "processing GET request");
172        }
173        let request_data = extract_request_data(request_payload)?;
174        if tracing::enabled!(Level::TRACE) {
175            trace!(
176                source = source_uri,
177                "payload: {}",
178                serde_json::to_string_pretty(&request_data).expect("failed to serialize Value")
179            );
180        }
181        let deployment_spec: DeploymentSpec = request_data
182            .get("deployment")
183            .ok_or_else(|| {
184                debug!(
185                    source = source_uri,
186                    "request does not contain DeploymentSpec"
187                );
188                ServiceInvocationError::InvalidArgument(
189                    "request does not contain DeploymentSpec".to_string(),
190                )
191            })
192            .and_then(|deployment| {
193                serde_json::from_value(deployment.clone()).map_err(|err| {
194                    debug!(
195                        source = source_uri,
196                        "request contains invalid DeploymentSpec: {err}"
197                    );
198                    ServiceInvocationError::InvalidArgument(
199                        "request contains invalid DeploymentSpec".to_string(),
200                    )
201                })
202            })?;
203        let component_specs: Vec<ComponentSpec> = request_data
204            .get("components")
205            .ok_or_else(|| {
206                debug!(
207                    source = source_uri,
208                    "request does not contain ComponentSpec array"
209                );
210                ServiceInvocationError::InvalidArgument(
211                    "request does not contain ComponentSpec array".to_string(),
212                )
213            })
214            .and_then(|components| {
215                serde_json::from_value(components.clone()).map_err(|err| {
216                    debug!(
217                        source = source_uri,
218                        "request contains invalid ComponentSpec array: {err}"
219                    );
220                    ServiceInvocationError::InvalidArgument(
221                        "request contains invalid ComponentSpec array".to_string(),
222                    )
223                })
224            })?;
225
226        let result = self
227            .target
228            .get(component_specs, deployment_spec)
229            .await
230            .map_err(|err| {
231                warn!(source = source_uri, "error getting component status: {err}");
232                ServiceInvocationError::Internal("failed to get component status".to_string())
233            })?;
234        let serialized_response_data = serde_json::to_vec(&result).map_err(|err| {
235            warn!(
236                source = source_uri,
237                "error serializing ComponentSpec: {err}"
238            );
239            ServiceInvocationError::Internal("failed to create response payload".to_string())
240        })?;
241        if tracing::enabled!(Level::TRACE) {
242            trace!(
243                source = source_uri,
244                "returning response: {}",
245                serde_json::to_string_pretty(&result).expect("failed to serialize Value")
246            );
247        }
248        let response_payload = UPayload::new(serialized_response_data, UPayloadFormat::Json);
249        Ok(Some(response_payload))
250    }
251}
252
253struct ApplyOperation<T: DeploymentTarget> {
254    target: Arc<T>,
255}
256
257#[async_trait::async_trait]
258impl<T: DeploymentTarget> RequestHandler for ApplyOperation<T> {
259    async fn handle_request(
260        &self,
261        resource_id: u16,
262        message_attributes: &UAttributes,
263        request_payload: Option<UPayload>,
264    ) -> Result<Option<UPayload>, ServiceInvocationError> {
265        let source_uri = message_attributes.source().to_uri(true);
266        let sink_uri = message_attributes.sink_unchecked().to_uri(true);
267        if tracing::enabled!(Level::DEBUG) {
268            debug!(source = source_uri, method = sink_uri, "processing request",);
269        }
270        let request_data = extract_request_data(request_payload)?;
271        if tracing::enabled!(Level::TRACE) {
272            let json =
273                serde_json::to_string_pretty(&request_data).expect("failed to serialize Value");
274            trace!("payload: {}", json);
275        }
276
277        let deployment_spec: DeploymentSpec = request_data
278            .get("deployment")
279            .ok_or_else(|| {
280                debug!(
281                    source = source_uri,
282                    method = sink_uri,
283                    "request does not contain DeploymentSpec"
284                );
285                ServiceInvocationError::InvalidArgument(
286                    "request does not contain DeploymentSpec".to_string(),
287                )
288            })
289            .and_then(|deployment| {
290                serde_json::from_value(deployment.clone()).map_err(|err| {
291                    debug!(
292                        source = source_uri,
293                        method = sink_uri,
294                        "request contains invalid DeploymentSpec: {err}"
295                    );
296                    ServiceInvocationError::InvalidArgument(
297                        "request contains invalid DeploymentSpec".to_string(),
298                    )
299                })
300            })?;
301
302        let affected_components: Vec<ComponentSpec> = request_data
303            .get("components")
304            .ok_or_else(|| {
305                debug!(
306                    source = source_uri,
307                    method = sink_uri,
308                    "request does not contain ComponentSpec array"
309                );
310                ServiceInvocationError::InvalidArgument(
311                    "request does not contain ComponentSpec array".to_string(),
312                )
313            })
314            .and_then(|components| {
315                serde_json::from_value(components.clone()).map_err(|err| {
316                    debug!(
317                        source = source_uri,
318                        method = sink_uri,
319                        "request contains invalid ComponentSpec array: {err}"
320                    );
321                    ServiceInvocationError::InvalidArgument(
322                        "request contains invalid ComponentSpec array".to_string(),
323                    )
324                })
325            })?;
326
327        let result = match resource_id {
328            METHOD_UPDATE_RESOURCE_ID => self
329                .target
330                .update(affected_components, deployment_spec)
331                .await
332                .map_err(|err| {
333                    warn!(
334                        source = source_uri,
335                        method = sink_uri,
336                        "error updating components: {err}"
337                    );
338                    ServiceInvocationError::Internal("failed to update components".to_string())
339                }),
340            METHOD_DELETE_RESOURCE_ID => self
341                .target
342                .delete(affected_components, deployment_spec)
343                .await
344                .map_err(|err| {
345                    warn!(
346                        source = source_uri,
347                        method = sink_uri,
348                        "error deleting components: {err}"
349                    );
350                    ServiceInvocationError::Internal("failed to delete components".to_string())
351                }),
352            _ => {
353                return Err(ServiceInvocationError::Unimplemented(
354                    "no such operation".to_string(),
355                ));
356            }
357        }?;
358
359        let serialized_response_data = serde_json::to_vec(&result).map_err(|err| {
360            warn!(
361                source = source_uri,
362                method = sink_uri,
363                "error serializing HashMap: {err}"
364            );
365            ServiceInvocationError::Internal("failed to create response payload".to_string())
366        })?;
367
368        let response_payload = UPayload::new(serialized_response_data, UPayloadFormat::Json);
369        Ok(Some(response_payload))
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use serde_json::json;
376
377    use crate::{communication::MockRpcServerImpl, UUri, UUID};
378
379    use super::*;
380
381    #[tokio::test]
382    async fn test_register_target_provider_endpoints_fails() {
383        let mut rpc_server = MockRpcServerImpl::new();
384        rpc_server
385            .expect_do_register_endpoint()
386            .returning(|_, _, _| {
387                Err(crate::communication::RegistrationError::MaxListenersExceeded)
388            });
389        let deployment_target = MockDeploymentTarget::new();
390
391        assert!(
392            register_target_provider_endpoints(&rpc_server, Arc::new(deployment_target))
393                .await
394                .is_err_and(|e| matches!(
395                    e.downcast_ref(),
396                    Some(crate::communication::RegistrationError::MaxListenersExceeded)
397                ))
398        );
399    }
400
401    #[tokio::test]
402    async fn test_register_target_provider_endpoints_succeeds() {
403        let mut rpc_server = MockRpcServerImpl::new();
404        rpc_server
405            .expect_do_register_endpoint()
406            .returning(|_, _, _| Ok(()));
407        let deployment_target = MockDeploymentTarget::new();
408
409        assert!(
410            register_target_provider_endpoints(&rpc_server, Arc::new(deployment_target))
411                .await
412                .is_ok()
413        );
414    }
415
416    fn create_method_uri(resource_id: u16) -> UUri {
417        UUri::try_from_parts("authority", 0x10AA2, 0x01, resource_id)
418            .expect("failed to create method URI")
419    }
420
421    fn create_request_attributes(resource_id: u16) -> UAttributes {
422        UAttributes {
423            commstatus: None,
424            id: UUID::build(),
425            source: UUri::try_from_parts("authority", 0x10AA1, 0x01, 0x0000)
426                .expect("failed to create source URI"),
427            sink: Some(create_method_uri(resource_id)),
428            payload_format: Some(UPayloadFormat::Json),
429            type_: crate::UMessageType::Request,
430            priority: Some(crate::UPriority::CS4),
431            permission_level: None,
432            ttl: Some(5000),
433            traceparent: None,
434            token: None,
435            reqid: None,
436            payload_encoding_registry_id: None,
437            payload_encoding: None,
438            payload_content_type: None,
439        }
440    }
441
442    #[tokio::test]
443    async fn test_endpoints_delegate_to_deployment_target() {
444        let mut mock_target = MockDeploymentTarget::default();
445        mock_target
446            .expect_get()
447            .once()
448            .returning(move |_, _| Ok(vec![]));
449        mock_target
450            .expect_update()
451            .once()
452            .returning(move |_, _| Ok(HashMap::new()));
453        mock_target
454            .expect_delete()
455            .once()
456            .returning(move |_, _| Ok(HashMap::new()));
457        let target = Arc::new(mock_target);
458        let get_op = Arc::new(GetOperation {
459            target: target.clone(),
460        });
461        let apply_op = Arc::new(ApplyOperation {
462            target: target.clone(),
463        });
464
465        let request_data = json!({
466            "deployment": DeploymentSpec::empty(),
467            "components": []
468        });
469        let payload = UPayload::new(
470            serde_json::to_vec(&request_data).expect("failed to create request payload"),
471            UPayloadFormat::Json,
472        );
473        assert!(get_op
474            .handle_request(
475                METHOD_GET_RESOURCE_ID,
476                &create_request_attributes(METHOD_GET_RESOURCE_ID),
477                Some(payload.clone()),
478            )
479            .await
480            .is_ok());
481        assert!(apply_op
482            .handle_request(
483                METHOD_UPDATE_RESOURCE_ID,
484                &create_request_attributes(METHOD_UPDATE_RESOURCE_ID),
485                Some(payload.clone()),
486            )
487            .await
488            .is_ok());
489        assert!(apply_op
490            .handle_request(
491                METHOD_DELETE_RESOURCE_ID,
492                &create_request_attributes(METHOD_DELETE_RESOURCE_ID),
493                Some(payload.clone()),
494            )
495            .await
496            .is_ok());
497    }
498}