diff --git a/Cargo.lock b/Cargo.lock index fec8e0c6031..4f6c1879874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3882,6 +3882,7 @@ name = "tedge_api" version = "1.2.0" dependencies = [ "anyhow", + "assert-json-diff", "assert_matches", "camino", "clock", diff --git a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs index 279c7fd09cc..8c1da45be02 100644 --- a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs +++ b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs @@ -505,7 +505,7 @@ define_tedge_config! { firmware_update: bool, /// Enable device_profile feature - #[tedge_config(example = "true", default(value = false))] + #[tedge_config(example = "true", default(value = true))] device_profile: bool, }, diff --git a/crates/core/tedge_agent/src/agent.rs b/crates/core/tedge_agent/src/agent.rs index 3137a1306a0..8413cff1e34 100644 --- a/crates/core/tedge_agent/src/agent.rs +++ b/crates/core/tedge_agent/src/agent.rs @@ -1,3 +1,4 @@ +use crate::device_profile_manager::DeviceProfileManagerBuilder; use crate::file_transfer_server::actor::FileTransferServerBuilder; use crate::file_transfer_server::actor::FileTransferServerConfig; use crate::operation_file_cache::FileCacheActorBuilder; @@ -251,6 +252,8 @@ impl Agent { // Software update actor let mut software_update_builder = SoftwareManagerBuilder::new(self.config.sw_update_config); + DeviceProfileManagerBuilder::try_new(&self.config.operations_dir)?; + // Converter actor let mut converter_actor_builder = WorkflowActorBuilder::new( self.config.operation_config, diff --git a/crates/core/tedge_agent/src/device_profile_manager/mod.rs b/crates/core/tedge_agent/src/device_profile_manager/mod.rs new file mode 100644 index 00000000000..3967c22dbd6 --- /dev/null +++ b/crates/core/tedge_agent/src/device_profile_manager/mod.rs @@ -0,0 +1,17 @@ +use camino::Utf8PathBuf; +use tedge_utils::file::create_file_with_defaults; +use tedge_utils::file::FileError; + +pub struct DeviceProfileManagerBuilder {} + +impl DeviceProfileManagerBuilder { + pub fn try_new(ops_dir: &Utf8PathBuf) -> Result { + let workflow_file = ops_dir.join("device_profile.toml"); + if !workflow_file.exists() { + let workflow_definition = include_str!("../resources/device_profile.toml"); + + create_file_with_defaults(workflow_file, Some(workflow_definition))?; + } + Ok(Self {}) + } +} diff --git a/crates/core/tedge_agent/src/lib.rs b/crates/core/tedge_agent/src/lib.rs index 8f7f3d7fc7b..dc05030c3b4 100644 --- a/crates/core/tedge_agent/src/lib.rs +++ b/crates/core/tedge_agent/src/lib.rs @@ -21,6 +21,7 @@ use tedge_config::DEFAULT_TEDGE_CONFIG_PATH; use tracing::log::warn; mod agent; +mod device_profile_manager; mod file_transfer_server; mod operation_file_cache; mod operation_workflows; diff --git a/crates/core/tedge_agent/src/operation_workflows/actor.rs b/crates/core/tedge_agent/src/operation_workflows/actor.rs index 2ea746a4559..2f2b500143d 100644 --- a/crates/core/tedge_agent/src/operation_workflows/actor.rs +++ b/crates/core/tedge_agent/src/operation_workflows/actor.rs @@ -361,6 +361,25 @@ impl WorkflowActor { Ok(()) } + OperationAction::Iterate(target_json_path, handlers) => { + match OperationAction::process_iterate( + state.clone(), + &target_json_path, + handlers.clone(), + ) { + Ok(next_state) => { + self.publish_command_state(next_state, &mut log_file) + .await? + } + Err(err) => { + error!("Iteration failed due to: {err}"); + let new_state = state + .update(handlers.on_error.expect("on_error target can not be none")); + self.publish_command_state(new_state, &mut log_file).await?; + } + } + Ok(()) + } } } diff --git a/crates/core/tedge_agent/src/resources/device_profile.toml b/crates/core/tedge_agent/src/resources/device_profile.toml new file mode 100644 index 00000000000..4bf2a644f3f --- /dev/null +++ b/crates/core/tedge_agent/src/resources/device_profile.toml @@ -0,0 +1,40 @@ +operation = "device_profile" + +[init] +action = "proceed" +on_success = "scheduled" + +[scheduled] +action = "proceed" +on_success = "executing" + +[executing] +action = "proceed" +on_success = "next_operation" + +[next_operation] +iterate = "${.payload.operations}" +on_next = "apply_operation" +on_success = "successful" +on_error = "rollback" + +[apply_operation] +operation = "${.payload.@next.item.operation}" +input = "${.payload.@next.item.payload}" +on_exec = "awaiting_operation" + +[awaiting_operation] +action = "await-operation-completion" +on_success = "next_operation" +on_error = "rollback" + +[rollback] +action="proceed" +on_success = { status = "failed", reason = "Device profile application failed" } +on_error = { status = "failed", reason = "Rollback failed" } + +[successful] +action = "cleanup" + +[failed] +action = "cleanup" diff --git a/crates/core/tedge_api/Cargo.toml b/crates/core/tedge_api/Cargo.toml index 0848a5ade2e..3ca04686947 100644 --- a/crates/core/tedge_api/Cargo.toml +++ b/crates/core/tedge_api/Cargo.toml @@ -33,6 +33,7 @@ tokio = { workspace = true, features = ["fs", "process"] } [dev-dependencies] anyhow = { workspace = true } +assert-json-diff = { workspace = true } assert_matches = { workspace = true } clock = { workspace = true } maplit = { workspace = true } diff --git a/crates/core/tedge_api/src/workflow/error.rs b/crates/core/tedge_api/src/workflow/error.rs index 9a22208371e..a98868ac06a 100644 --- a/crates/core/tedge_api/src/workflow/error.rs +++ b/crates/core/tedge_api/src/workflow/error.rs @@ -15,6 +15,9 @@ pub enum WorkflowDefinitionError { #[error("Unknown action: {action}")] UnknownAction { action: String }, + + #[error("The provided target {0} is not a valid path expression")] + InvalidPathExpression(String), } /// Error related to a script definition diff --git a/crates/core/tedge_api/src/workflow/mod.rs b/crates/core/tedge_api/src/workflow/mod.rs index 39da9fc9543..da0b4dad2c9 100644 --- a/crates/core/tedge_api/src/workflow/mod.rs +++ b/crates/core/tedge_api/src/workflow/mod.rs @@ -9,11 +9,13 @@ mod toml_config; use crate::mqtt_topics::EntityTopicId; use crate::mqtt_topics::MqttSchema; use crate::mqtt_topics::OperationType; +use ::log::info; pub use error::*; use mqtt_channel::MqttMessage; use mqtt_channel::QoS; pub use script::*; use serde::Deserialize; +use serde_json::json; pub use state::*; use std::collections::HashMap; use std::fmt::Display; @@ -23,6 +25,7 @@ pub use supervisor::*; pub type OperationName = String; pub type StateName = String; pub type CommandId = String; +pub type JsonPath = String; /// An OperationWorkflow defines the state machine that rules an operation #[derive(Clone, Debug, Deserialize)] @@ -116,6 +119,21 @@ pub enum OperationAction { /// The command has been fully processed and needs to be cleared Clear, + + /// Extract the next item from the specified target array in the state payload. + /// The next item is captured into a `@next` fragment in the state payload output, + /// with an `index` field having an initial value of zero. + /// If the input already contains the `@next` fragment with an `index` value, + /// that index is incremented and the corresponding value from the array is + /// extracted as the next item into the `@next` fragment. + /// + /// ```toml + /// iterate = "${.payload.operations}" + /// on_next = "apply_operation" + /// on_success = "successful" + /// on_error = "failed" + /// ``` + Iterate(JsonPath, IterateHandlers), } impl Display for OperationAction { @@ -137,6 +155,9 @@ impl Display for OperationAction { "await sub-operation completion".to_string() } OperationAction::Clear => "wait for the requester to finalize the command".to_string(), + OperationAction::Iterate(json_path, _) => { + format!("iterate over {json_path}").to_string() + } }; f.write_str(&str) } @@ -218,7 +239,7 @@ impl OperationWorkflow { ) -> Option { match self.operation { // Custom operations (and restart) have a generic empty capability message - OperationType::Custom(_) | OperationType::Restart => { + OperationType::Custom(_) | OperationType::Restart | OperationType::DeviceProfile => { let meta_topic = schema.capability_topic_for(target, self.operation.clone()); let payload = "{}".to_string(); Some( @@ -263,6 +284,9 @@ impl OperationAction { state_excerpt, ) } + OperationAction::Iterate(target_json_path, handlers) => { + OperationAction::Iterate(target_json_path, handlers.with_default(default)) + } action => action, } } @@ -299,4 +323,409 @@ impl OperationAction { args: state.inject_values_into_parameters(&script.args), } } + + pub fn process_iterate( + state: GenericCommandState, + json_path: &str, + handlers: IterateHandlers, + ) -> Result { + // Extract the array + let Some(target) = state.extract_value(json_path) else { + return Err(IterationError::InvalidTarget(json_path.to_string())); + }; + + let Some(items) = target.as_array() else { + return Err(IterationError::TargetNotArray(json_path.to_string())); + }; + + if items.is_empty() { + info!("Nothing to iterate as operations array is empty"); + return Ok(state.update(handlers.on_success)); + } + + // Check for the presence of the next_operation key + let next_item = if let Some(next_item) = state.payload.get("@next") { + let mut next_item = next_item.clone(); + let index = next_item.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + + // Validate the index + if index >= items.len() { + return Err(IterationError::IndexOutOfBounds(index)); + } + + let next_index = index + 1; + if next_index >= items.len() { + info!("Iteration finished"); + return Ok(state.update(handlers.on_success)); + } + + next_item["index"] = json!(next_index); + next_item["item"] = items[next_index].clone(); + + next_item.clone() + } else { + // If next_operation does not exist, create it with index 0 + json!({ + "index": 0, + "item": items[0].clone() + }) + }; + + let next_operation_json = json!({ + "@next": next_item + }); + let new_state = state.update_with_json(next_operation_json); + + let new_state = new_state.update(handlers.on_next); + + Ok(new_state) + } +} + +#[derive(thiserror::Error, Debug, Eq, PartialEq)] +pub enum IterationError { + #[error("No object found at {0}")] + InvalidTarget(String), + + #[error("Object found at {0} is not an array")] + TargetNotArray(String), + + #[error("Index: {0} is out of bounds")] + IndexOutOfBounds(usize), +} + +#[cfg(test)] +mod tests { + use super::GenericCommandState; + use super::GenericStateUpdate; + use super::IterateHandlers; + use super::IterationError; + use super::OperationAction; + use assert_json_diff::assert_json_eq; + use assert_matches::assert_matches; + use serde_json::json; + + #[test] + fn test_iterate_first_iteration() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [ + { + "operation": "software_update", + "payload": { + "key": "value" + } + } + ] + }), + ); + + let new_state = + OperationAction::process_iterate(state, ".payload.operations", handlers).unwrap(); + + assert_eq!(new_state.status, "apply_operation"); + assert_json_eq!( + new_state.payload, + json!({ + "status": "apply_operation", + "operations": [ + { + "operation": "software_update", + "payload": { + "key": "value" + } + } + ], + "@next": { + "index": 0, + "item": { + "operation": "software_update", + "payload": { + "key": "value" + } + } + } + }) + ); + } + + #[test] + fn test_iterate_intermediate_iteration() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [ + { + "operation": "firmware_update", + "payload": { + "firmware_key": "firmware_value" + } + }, + { + "operation": "software_update", + "payload": { + "software_key": "software_value" + } + }, + { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + ], + "@next": { + "index": 1, + "item": { + "operation": "software_update", + "payload": { + "software_key": "software_value" + } + } + } + }), + ); + let new_state = + OperationAction::process_iterate(state, ".payload.operations", handlers).unwrap(); + + assert_eq!(new_state.status, "apply_operation"); + assert_json_eq!( + new_state.payload, + json!({ + "status": "apply_operation", + "operations": [ + { + "operation": "firmware_update", + "payload": { + "firmware_key": "firmware_value" + } + }, + { + "operation": "software_update", + "payload": { + "software_key": "software_value" + } + }, + { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + ], + "@next": { + "index": 2, + "item": { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + } + }) + ); + } + + #[test] + fn test_iterate_final_iteration() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [ + { + "operation": "firmware_update", + "payload": { + "firmware_key": "firmware_value" + } + }, + { + "operation": "software_update", + "payload": { + "software_key": "software_value" + } + }, + { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + ], + "@next": { + "index": 2, + "item": { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + } + }), + ); + + let new_state = + OperationAction::process_iterate(state, ".payload.operations", handlers).unwrap(); + + let expected_payload = json!({ + "status": "successful", + "operations": [ + { + "operation": "firmware_update", + "payload": { + "firmware_key": "firmware_value" + } + }, + { + "operation": "software_update", + "payload": { + "software_key": "software_value" + } + }, + { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + ], + "@next": { + "index": 2, + "item": { + "operation": "config_update", + "payload": { + "config_key": "config_value" + } + } + } + }); + + assert_eq!(new_state.status, "successful"); + assert_json_eq!(new_state.payload, expected_payload); + } + + #[test] + fn test_iterate_failed_iteration() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [ + { + "operation": "config_update", + "payload": {} + } + ], + "@next": { + "index": 1 + } + }), + ); + + let res = OperationAction::process_iterate(state, ".payload.operations", handlers); + assert_matches!(res, Err(IterationError::IndexOutOfBounds(1))) + } + + #[test] + fn test_iterate_empty_array() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [] + }), + ); + + let new_state = + OperationAction::process_iterate(state, ".payload.operations", handlers).unwrap(); + + assert_eq!(new_state.status, "successful"); + assert_json_eq!( + new_state.payload, + json!({ + "status": "successful", + "operations": [] + }) + ); + } + + #[test] + fn test_iterate_target_not_array() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": {} + }), + ); + + let res = OperationAction::process_iterate(state, ".payload.operations", handlers); + assert_matches!(res, Err(IterationError::TargetNotArray(_))) + } + + #[test] + fn test_iterate_invalid_target() { + let handlers = IterateHandlers::new( + "apply_operation".into(), + GenericStateUpdate::successful(), + Some(GenericStateUpdate::failed("bad input".to_string())), + ); + + let state = GenericCommandState::new( + "test/topic".try_into().unwrap(), + "next_operation".to_string(), + json!({ + "status": "next_operation", + "operations": [] + }), + ); + + let res = OperationAction::process_iterate(state, ".bad.json.path", handlers); + assert_matches!(res, Err(IterationError::InvalidTarget(_))) + } } diff --git a/crates/core/tedge_api/src/workflow/script.rs b/crates/core/tedge_api/src/workflow/script.rs index 01af580629b..4738522c44a 100644 --- a/crates/core/tedge_api/src/workflow/script.rs +++ b/crates/core/tedge_api/src/workflow/script.rs @@ -347,6 +347,39 @@ impl AwaitHandlers { } } +/// Define state transition on each iteration outcome +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct IterateHandlers { + pub on_next: GenericStateUpdate, + pub on_success: GenericStateUpdate, + pub on_error: Option, +} + +impl IterateHandlers { + pub fn new( + on_next: GenericStateUpdate, + on_success: GenericStateUpdate, + on_error: Option, + ) -> Self { + Self { + on_next, + on_success, + on_error, + } + } + + pub fn with_default(mut self, default: &DefaultHandlers) -> Self { + if self.on_error.is_none() { + self.on_error = default + .on_error + .clone() + .or_else(|| Some(GenericStateUpdate::failed("Iteration failed".to_string()))); + } + + self + } +} + /// Define default handlers for all state of an operation workflow #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DefaultHandlers { diff --git a/crates/core/tedge_api/src/workflow/state.rs b/crates/core/tedge_api/src/workflow/state.rs index 0655929fa0c..f87aff241e8 100644 --- a/crates/core/tedge_api/src/workflow/state.rs +++ b/crates/core/tedge_api/src/workflow/state.rs @@ -498,6 +498,12 @@ impl From for GenericStateUpdate { } } +impl From<&str> for GenericStateUpdate { + fn from(status: &str) -> Self { + status.to_string().into() + } +} + impl From for Value { fn from(update: GenericStateUpdate) -> Self { match update.reason { diff --git a/crates/core/tedge_api/src/workflow/toml_config.rs b/crates/core/tedge_api/src/workflow/toml_config.rs index 4ef7c86fddf..f2510a51059 100644 --- a/crates/core/tedge_api/src/workflow/toml_config.rs +++ b/crates/core/tedge_api/src/workflow/toml_config.rs @@ -3,7 +3,9 @@ use crate::workflow::AwaitHandlers; use crate::workflow::BgExitHandlers; use crate::workflow::DefaultHandlers; use crate::workflow::ExitHandlers; +use crate::workflow::GenericCommandState; use crate::workflow::GenericStateUpdate; +use crate::workflow::IterateHandlers; use crate::workflow::OperationAction; use crate::workflow::OperationWorkflow; use crate::workflow::ScriptDefinitionError; @@ -72,6 +74,7 @@ pub enum TomlOperationAction { BackgroundScript(ShellScript), Action(String), Operation(String), + Iterate(String), } impl Default for TomlOperationAction { @@ -129,6 +132,15 @@ impl TryFrom for OperationAction { handlers, )) } + TomlOperationAction::Iterate(target_json_path) => { + let handlers = TryInto::::try_into(input.handlers)?; + let Some(json_path) = GenericCommandState::extract_path(&target_json_path) else { + return Err(WorkflowDefinitionError::InvalidPathExpression( + target_json_path, + )); + }; + Ok(OperationAction::Iterate(json_path.to_string(), handlers)) + } TomlOperationAction::Action(command) => match command.as_str() { "builtin" => Ok(OperationAction::BuiltIn), "cleanup" => Ok(OperationAction::Clear), @@ -212,6 +224,9 @@ pub struct TomlExitHandlers { #[serde(skip_serializing_if = "Option::is_none")] on_exec: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + on_next: Option, } impl TryFrom for ExitHandlers { @@ -276,6 +291,25 @@ impl TryFrom for AwaitHandlers { } } +impl TryFrom for IterateHandlers { + type Error = WorkflowDefinitionError; + + fn try_from(value: TomlExitHandlers) -> Result { + let on_next = value.on_next.map(|u| u.into()).ok_or_else(|| { + WorkflowDefinitionError::MissingState { + state: "on_next".to_string(), + } + })?; + let on_success = value.on_success.map(|u| u.into()).ok_or_else(|| { + WorkflowDefinitionError::MissingState { + state: "on_success".to_string(), + } + })?; + let on_error = value.on_error.map(|u| u.into()); + Ok(IterateHandlers::new(on_next, on_success, on_error)) + } +} + impl TryFrom for DefaultHandlers { type Error = ScriptDefinitionError; @@ -356,6 +390,7 @@ impl FromStr for ExitCodes { mod tests { use super::*; use crate::workflow::GenericStateUpdate; + use assert_matches::assert_matches; use ExitCodes::*; #[test] @@ -399,6 +434,7 @@ on_kill = { status = "failed", reason = "killed"} # next status when kil on_timeout: None, on_stdout: Vec::new(), on_exec: None, + on_next: None, } ) } @@ -582,4 +618,96 @@ script = "/some/script/which/fails" } ) } + + #[test] + fn parse_iterate_toml() { + let file = r#" +operation = "custom_operation" + +[init] +action = "proceed" +on_success = "apply_operation" + +[apply_operation] +iterate = "${.payload.target}" +on_next = "next_operation" +on_success = "successful" +on_error = "failed" + +[next_operation] +action = "proceed" +on_success = "successful" + +[successful] +action = "cleanup" + +[failed] +action = "cleanup" +"#; + let input: TomlOperationWorkflow = toml::from_str(file).unwrap(); + let workflow = OperationWorkflow::try_from(input).unwrap(); + + match workflow.states.get("apply_operation").unwrap() { + OperationAction::Iterate( + target, + IterateHandlers { + on_next, + on_success, + on_error, + }, + ) => { + assert_eq!(target, ".payload.target"); + assert_eq!(on_next, &"next_operation".into()); + assert_eq!(on_success, &"successful".into()); + assert_eq!(on_error, &Some("failed".into())); + } + other => panic!("Expected iterate action, but got {other}"), + } + } + + #[test] + fn iterate_parse_fails_without_on_next() { + let file = r#" +operation = "custom_operation" + +[apply_operation] +iterate = "{.payload.target}" +on_success = "successful" +on_error = "failed" +"#; + let input: TomlOperationWorkflow = toml::from_str(file).unwrap(); + let res = OperationWorkflow::try_from(input); + assert_matches!(res, Err(WorkflowDefinitionError::MissingState { state }) if state == *"on_next"); + } + + #[test] + fn iterate_parse_fails_without_on_success() { + let file = r#" +operation = "custom_operation" + +[apply_operation] +iterate = "{.payload.target}" +on_next = "next_operation" +on_error = "failed" +"#; + let input: TomlOperationWorkflow = toml::from_str(file).unwrap(); + let res = OperationWorkflow::try_from(input); + assert_matches!(res, Err(WorkflowDefinitionError::MissingState { state }) if state == *"on_success"); + } + + #[test] + fn iterate_parse_fails_with_invalid_json_path() { + let file = r#" +operation = "custom_operation" + +[apply_operation] +iterate = "{invalid.json.path}" +on_next = "next_operation" +on_success = "successful" +on_error = "failed" +"#; + let input: TomlOperationWorkflow = toml::from_str(file).unwrap(); + let res = OperationWorkflow::try_from(input); + assert_matches!(res, Err(WorkflowDefinitionError::InvalidPathExpression(_))); + } } diff --git a/docs/src/references/agent/device-profile.md b/docs/src/references/agent/device-profile.md index 9eed3d5028a..83631b3ad83 100644 --- a/docs/src/references/agent/device-profile.md +++ b/docs/src/references/agent/device-profile.md @@ -183,21 +183,21 @@ on_success = "executing" on_error = { status = "failed", reason = "fail to sort the profile list"} [executing] -action = "builtin" +action = "proceed" on_success = "next_operation" [next_operation] -action = "iterator" +iterate = "${.payload.operations}" on_next = "apply_operation" on_success = "successful" on_error = { status = "failed", reason = "Failed to compute the next operation to be executed" } [apply_operation] -operation = "${.@next_operation.operation}" -input = "${.@next_operation.payload}" -on_exec = "awaiting_sub_operation" +operation = "${.payload.@next.operation.operation}" +input = "${.payload.@next.operation.payload}" +on_exec = "awaiting_operation" -[awaiting_sub_operation] +[awaiting_operation] action = "await-operation-completion" on_success = "next_operation" on_error = "rollback" @@ -254,12 +254,12 @@ action = "cleanup" the profile application is deemed complete and the workflow proceeds to the `on_success` target. If the operation computation fails for some reason, then the workflow moves to the `on_error` target. This builtin iteration logic can be overridden using a `script` action which can manipulate the order in any manner, dynamically. -* The `apply_sub_operation` state executes the sub-operation defined in the `@next_operation` field in the payload. +* The `apply_operation` state executes the sub-operation defined in the `@next_operation` field in the payload. The `input` to the sub-operation is also extracted from the `payload` field of the `@next_operation`. - As soon as the sub-operation is triggered, the workflow moves to the `awaiting_sub_operation` state defined as the `on_exec` target. -* In the `awaiting_sub_operation` state, workflow just waits monitoring the state of the sub-operation completion. + As soon as the sub-operation is triggered, the workflow moves to the `awaiting_operation` state defined as the `on_exec` target. +* In the `awaiting_operation` state, workflow just waits monitoring the state of the sub-operation completion. * Once the sub-operation is successful, the workflow must move back to the `next_operation` state, - so that the next sub-operation in the list can be applied. + so that the next operation in the list can be applied. * In case of a failure, the workflow moves to the `on_error` target state, keeping the `@next_operation` value in the payload intact, so that the item that caused the failure can be easily identified using the its `index` value. diff --git a/tests/RobotFramework/libraries/ThinEdgeIO/ThinEdgeIO.py b/tests/RobotFramework/libraries/ThinEdgeIO/ThinEdgeIO.py index 6dc4e730739..9e6a84f03b7 100644 --- a/tests/RobotFramework/libraries/ThinEdgeIO/ThinEdgeIO.py +++ b/tests/RobotFramework/libraries/ThinEdgeIO/ThinEdgeIO.py @@ -140,6 +140,69 @@ def end_test(self, _data: Any, result: Any): # self.remove_certificate_and_device(self.current) super().end_test(_data, result) + @keyword("Create And Apply Device Profile") + def create_and_apply_device_profile( + self, + profile_name: str, + profile_definition: Union[Dict[str, Any], str], + device_id: str, + **kwargs, + ) -> Dict[str, Any]: + """Create a new device profile in Cumulocity and apply it + + Args: + profile_name (str): Name of the device profile + profile_definition (Union[Dict[str, Any], str]): Fragments to be included in the profile body. + Defaults to {} + + Returns: + Dict[str, Any]: The created device profile operation object + """ + if profile_definition is None: + profile_definition = {} + + if isinstance(profile_definition, str): + profile_definition = json.loads(profile_definition) + + profile_body = { + "type": "c8y_Profile", + "name": profile_name, + "c8y_Filter": {} + } + profile_body.update(profile_definition) + + url = f"{c8y_lib.c8y.base_url}/inventory/managedObjects" + + response = c8y_lib.c8y.session.post( + url, + json=profile_body + ) + response.raise_for_status() + profile_id = response.json()['id'] + + profile_operation = { + "profileId": profile_id, + "profileName": profile_name, + } + profile_operation.update(profile_definition) + + c8y_lib.device_should_exist(device_id) + operation = c8y_lib.create_operation(fragments=profile_operation, description="Apply device profile " + profile_name) + + return operation + + @keyword("Delete Managed Object") + def delete_managed_object(self, internal_id: str, **kwargs) -> None: + """Delete managed object and related device user + + Args: + internal_id (str): Internal id of the managed object + """ + url = f"{c8y_lib.c8y.base_url}/inventory/managedObjects/{internal_id}" + + response = c8y_lib.c8y.session.delete(url) + response.raise_for_status() + @keyword("Get Debian Architecture") def get_debian_architecture(self): """Get the debian architecture""" diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.sh b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.sh deleted file mode 100755 index 9f5695df0d5..00000000000 --- a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/bin/sh -set -e - -EXIT_OK=0 - -STATUS="$1" -shift -PAYLOAD= - -example_payload() { - CONFIG_URL="${1:-http://127.0.0.1:8001/c8y/inventory/binaries/35861751}" - - # Convert url to a local url - case "$CONFIG_URL" in - */inventory/binaries/*) - echo "Converting config url to a c8y local proxy url" >&2 - MO_ID=$(echo "$CONFIG_URL" | rev | cut -d'/' -f1 | rev) - CONFIG_URL="http://127.0.0.1:8001/c8y/inventory/binaries/$MO_ID" - ;; - esac - - cat << EOT -{ - "status": "init", - "profile": [ - { - "operation": "firmware_update", - "skip": false, - "payload": { - "name": "core-image-tedge-rauc", - "remoteUrl": "https://127.0.0.1:8000/some/dummy/url", - "version": "20240430.1139" - } - }, - { - "operation": "software_update", - "skip": false, - "payload": { - "updateList": [ - { - "type": "apt", - "modules": [ - { - "name": "c8y-command-plugin", - "version": "latest", - "action": "install" - }, - { - "name": "jq", - "version": "latest", - "action": "install" - } - ] - } - ] - } - }, - { - "operation": "config_update", - "skip": false, - "payload": { - "type": "tedge-configuration-plugin", - "remoteUrl":"$CONFIG_URL" - } - } - ] -} -EOT -} - -if [ $# -eq 0 ]; then - # Test operation to help with initial creation and debugging - PAYLOAD="$(example_payload)" -else - PAYLOAD="$1" -fi - -log () { echo "$*" >&2; } -fail () { log "$@"; exit 1; } - -update_state() { - echo ':::begin-tedge:::' - jo -- "$@" - echo ':::end-tedge:::' -} - -create_test_operation() { - TOPIC="$1" - CONFIG_URL="$2" - PAYLOAD="$(example_payload "$CONFIG_URL")" - tedge mqtt pub -r "$TOPIC" "$PAYLOAD" -} - -scheduled() { - log "Filtering/sorting device profile artifacts" - - # TODO: Filter/sort the artifacts - ARTIFACTS=$(echo "$PAYLOAD" | jq '[ .profile[] | select(.skip != true) ]') - update_state currentIndex="-1" profile="$ARTIFACTS" -} - -next_artifact() { - log "Checking next artifact" - - if [ $# -gt 0 ]; then - PAYLOAD="$1" - shift - fi - - ARTIFACT_INDEX=$(echo "$PAYLOAD" | jq -r ".currentIndex // -1") - NEXT_ARTIFACT_INDEX=$((ARTIFACT_INDEX + 1)) - - CURRENT_ARTIFACT=$(echo "$PAYLOAD" | jq ".profile[$NEXT_ARTIFACT_INDEX]") - - if [ "$CURRENT_ARTIFACT" = "null" ]; then - log "No more artifacts to process" - # No more artifacts to process - update_state status=successful - exit "$EXIT_OK" - fi - - NEXT_STATUS=$(echo "$PAYLOAD" | jq -r ".profile[$NEXT_ARTIFACT_INDEX].operation") - - # Prepare command - log "Found next artifiact. status=$NEXT_STATUS" - update_state status="$NEXT_STATUS" currentIndex="$NEXT_ARTIFACT_INDEX" current="$CURRENT_ARTIFACT" -} - -case "$STATUS" in - scheduled) scheduled "$@";; - next_artifact) next_artifact "$@";; - create_test_operation) create_test_operation "$@";; - *) - fail "Unknown status. status=$STATUS" - ;; -esac - -exit "$EXIT_OK" diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.toml b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.toml deleted file mode 100644 index 202d7b83468..00000000000 --- a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile.toml +++ /dev/null @@ -1,69 +0,0 @@ -operation = "device_profile" - -[init] -action = "proceed" -on_success = "scheduled" - -[scheduled] -script = "/etc/tedge/operations/device_profile.sh ${.payload.status} ${.payload}" -on_success = "next_artifact" - -[next_artifact] -script = "/etc/tedge/operations/device_profile.sh ${.payload.status} ${.payload}" -on_stdout = ["firmware_update", "software_update", "config_update", "successful", "failed"] -on_error = { status = "failed", reason = "fail to run the script"} - -# -# Firmware -# -[firmware_update] -operation = "firmware_update" -input.name = "${.payload.current.payload.name}" -input.version = "${.payload.current.payload.version}" -input.remoteUrl = "${.payload.current.payload.remoteUrl}" -on_exec = "waiting_for_firmware_update" - -[waiting_for_firmware_update] -action = "await-operation-completion" -on_success = "next_artifact" -on_error = { status = "failed", reason = "fail to update the firmware"} - -# -# Software -# -[software_update] -operation = "software_update" -input.updateList = "${.payload.current.payload.updateList}" -on_exec = "waiting_for_software_update" - -[waiting_for_software_update] -action = "await-operation-completion" -on_success = "next_artifact" -on_error = { status = "failed", reason = "fail to install the software"} - -# -# Configuration -# -[config_update] -operation = "config_update" -input.type = "${.payload.current.payload.type}" -input.remoteUrl = "${.payload.current.payload.remoteUrl}" -on_exec = "waiting_for_config_update" - -[waiting_for_config_update] -action = "await-operation-completion" -on_success = "next_artifact" -on_error = { status = "failed", reason = "fail to update configuration"} - -# -# End states -# -[successful] -action = "cleanup" - -[failed] -action = "cleanup" - -# Points -# * How to do conditional actions, e.g. only try installing firmware if there is firmware to be installed -# * Allow variable expansion on the "operation" state property diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot index 83db656ffdc..1d50cec50c8 100644 --- a/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot +++ b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot @@ -3,6 +3,7 @@ Resource ../../../resources/common.resource Library ThinEdgeIO Library Cumulocity Library OperatingSystem +Library Collections Force Tags theme:tedge_agent Suite Setup Custom Setup @@ -12,34 +13,36 @@ Test Teardown Get Logs *** Test Cases *** Device profile is included in supported operations - Should Contain Supported Operations c8y_DeviceProfile ${CAPABILITY_MESSAGE}= Execute Command timeout 1 tedge mqtt sub 'te/device/main///cmd/device_profile' strip=${True} ignore_exit_code=${True} - Should Be Equal ${CAPABILITY_MESSAGE} {} + Should Be Equal ${CAPABILITY_MESSAGE} [te/device/main///cmd/device_profile] {} + Should Contain Supported Operations c8y_DeviceProfile Send device profile operation from Cumulocity IoT ${config_url}= Create Inventory Binary tedge-configuration-plugin tedge-configuration-plugin file=${CURDIR}/tedge-configuration-plugin.toml - ${PROFILE_ID}= Set Variable profile-abc - ${PROFILE_NAME}= Set Variable Custom Profile1 - - ${PAYLOAD}= Catenate SEPARATOR=\n { - ... "profileId":"${PROFILE_ID}", - ... "profileName":"${PROFILE_NAME}", + ${PROFILE_NAME}= Set Variable Test Profile + ${PROFILE_PAYLOAD}= Catenate SEPARATOR=\n { ... "c8y_DeviceProfile":{ - ... "firmware":[ - ... { - ... "name":"tedge-core", - ... "version":"1.0.0", - ... "url":"" - ... } - ... ], + # ... "firmware":[ + # ... { + # ... "name":"tedge-core", + # ... "version":"1.0.0", + # ... "url":"" + # ... } + # ... ], ... "software":[ ... { ... "name":"jq", ... "action":"install", ... "version":"latest", ... "url":"" + ... }, + ... { + ... "name":"tree", + ... "action":"install", + ... "version":"latest", + ... "url":"" ... } ... ], ... "configuration":[ @@ -50,16 +53,105 @@ Send device profile operation from Cumulocity IoT ... } ... ] ... }} - ${operation}= Cumulocity.Create Operation fragments=${PAYLOAD} description=Apply device profile: ${PROFILE_NAME} - Operation Should Be SUCCESSFUL ${operation} - Device Should Have Installed Software jq - Managed Object Should Have Fragment Values c8y_Profile.profileId=${PROFILE_ID} c8y_Profile.profileName=${PROFILE_NAME} c8y_Profile.profileExecuted=true -Trigger device profile operation locally - ${config_url}= Create Inventory Binary tedge-configuration-plugin tedge-configuration-plugin file=${CURDIR}/tedge-configuration-plugin.toml - Execute Command /etc/tedge/operations/device_profile.sh create_test_operation te/device/main///cmd/device_profile/robot-123 ${config_url} - ${cmd_messages} Should Have MQTT Messages te/device/main///cmd/device_profile/robot-123 message_pattern=.*successful.* maximum=1 timeout=30 - Execute Command tedge mqtt pub --retain te/device/main///cmd/device_profile/robot-123 '' + ${operation}= Create And Apply Device Profile ${PROFILE_NAME} ${PROFILE_PAYLOAD} ${DEVICE_SN} + ${operation}= Operation Should Be SUCCESSFUL ${operation} + ${profile_id}= Get From Dictionary ${operation} profileId + Managed Object Should Have Fragment Values c8y_Profile.profileName\=${PROFILE_NAME} c8y_Profile.profileExecuted\=true + Execute Command dpkg -l | grep jq + Execute Command dpkg -l | grep tree + [Teardown] Delete Managed Object ${profile_id} + +Send device profile operation locally + ${config_url}= Set Variable http://localhost:8000/tedge/file-transfer/main/config_update/robot-123 + + Execute Command curl -X PUT --data-binary "bad toml" "${config_url}" + + ${payload}= Catenate SEPARATOR=\n + ... { + ... "status": "init", + ... "name": "dev-profile", + ... "version": "v2", + ... "operations": [ + # ... { + # ... "operation": "firmware_update", + # ... "skip": true, + # ... "payload": { + # ... "name": "core-image-tedge-rauc", + # ... "remoteUrl": "https://abc.com/some/firmware/url", + # ... "version": "20240430.1139" + # ... } + # ... }, + ... { + ... "operation": "software_update", + ... "skip": false, + ... "payload": { + ... "updateList": [ + ... { + ... "type": "apt", + ... "modules": [ + ... { + ... "name": "yq", + ... "version": "latest", + ... "action": "install" + ... }, + ... { + ... "name": "jo", + ... "version": "latest", + ... "action": "install" + ... } + ... ] + ... } + ... ] + ... } + ... }, + ... { + ... "operation": "config_update", + ... "skip": false, + ... "payload": { + ... "type": "tedge-configuration-plugin", + ... "tedgeUrl": "${config_url}", + ... "remoteUrl": "" + ... } + ... }, + ... { + ... "operation": "restart", + ... "skip": false, + ... "payload": {} + ... }, + ... { + ... "operation": "software_update", + ... "skip": false, + ... "payload": { + ... "updateList": [ + ... { + ... "type": "apt", + ... "modules": [ + ... { + ... "name": "rolldice", + ... "version": "latest", + ... "action": "install" + ... } + ... ] + ... } + ... ] + ... } + ... } + ... ] + ... } + + Execute Command tedge mqtt pub --retain 'te/device/main///cmd/device_profile/robot-123' '${payload}' + ${cmd_messages} Should Have MQTT Messages te/device/main///cmd/device_profile/robot-123 message_pattern=.*successful.* maximum=1 timeout=60 + + # Validate installed packages + Execute Command dpkg -l | grep rolldice + Execute Command dpkg -l | grep yq + Execute Command dpkg -l | grep jo + + # Validate updated config file + Execute Command grep "bad toml" /etc/tedge/plugins/tedge-configuration-plugin.toml + + [Teardown] Execute Command tedge mqtt pub --retain te/device/main///cmd/device_profile/robot-123 '' *** Keywords *** @@ -70,15 +162,15 @@ Custom Setup ${DEVICE_SN}= Setup Set Suite Variable $DEVICE_SN Device Should Exist ${DEVICE_SN} + Copy Configuration Files - Execute Command apt-get update && apt-get install -y jq jo + Restart Service tedge-agent + # setup repos so that packages can be installed from them Execute Command curl -1sLf 'https://dl.cloudsmith.io/public/thinedge/tedge-main/setup.deb.sh' | sudo -E bash Execute Command curl -1sLf 'https://dl.cloudsmith.io/public/thinedge/community/setup.deb.sh' | sudo -E bash - Restart Service tedge-agent + Copy Configuration Files - ThinEdgeIO.Transfer To Device ${CURDIR}/device_profile.toml /etc/tedge/operations/ ThinEdgeIO.Transfer To Device ${CURDIR}/firmware_update.toml /etc/tedge/operations/ - ThinEdgeIO.Transfer To Device ${CURDIR}/device_profile.sh /etc/tedge/operations/ ThinEdgeIO.Transfer To Device ${CURDIR}/tedge_operator_helper.sh /etc/tedge/operations/