diff --git a/Cargo.lock b/Cargo.lock index 905843aacc3..6ee070af09f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3883,6 +3883,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 36ab4db1d5c..76f041114e3 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 f3e4239c68e..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 { @@ -588,12 +594,11 @@ impl TryFrom> for StateExcerpt { // A mapping that change nothing Ok(StateExcerpt::ExcerptMap(HashMap::new())) } - Some(value) if value.is_object() => Ok(value.into()), + Some(value) if value.is_object() || value.is_string() => Ok(value.into()), Some(value) => { let kind = match &value { Value::Bool(_) => "bool", Value::Number(_) => "number", - Value::String(_) => "string", Value::Array(_) => "array", _ => unreachable!(), }; 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 new file mode 100644 index 00000000000..83631b3ad83 --- /dev/null +++ b/docs/src/references/agent/device-profile.md @@ -0,0 +1,450 @@ +--- +title: Configuration Management +tags: [Reference, Device Profile, Firmware Management, Software Management, Configuration Management] +sidebar_position: 6 +description: Device profile API proposal +--- + +# Device profile + +A device profile defines any desired combination of firmware, software and associated configurations to be installed on a device. +Device profiles are used to get a fleet of devices into a consistent and homogeneous state by having the same set of firmware, +software and configurations installed on all of them. + +The `tedge-agent` handles `device_profile` operations as follows: + +* Declares `device_profile` support by sending the capability message to `//cmd/device_profile` topics +* Subscribes to `//cmd/device_profile/+` to receive `device_profile` commands. +* The `device_profile` command payload is an ordered list of firmware, software and configuration update operations. +* The agent processes each operation one by one, triggering sub-operations for the respective operation. +* On successful installation of all the modules, the applied profile information is published to the same capability topic. +* No rollback is performed on partial failures unless the subcommand for the failed module can rollback that single module. + +# Why device profile FAQ + +**Q1: Why do we need device profile when firmware, software and configuration management already exists** + +Performing and managing these operations individually for a long list of software and configuration items would be cumbersome, +especially when performed on a large fleet of devices as each operation will have to be monitored and managed separately. +Grouping them together into a single operation reduces that overhead considerably as you just have one operation to monitor +instead of multiple. + +**Q2: Why not model each device profile update as a firmware update that includes the desired software and configurations as well** + +Although this is a more robust approach, it is not feasible on all kinds of devices, +especially the ones that does not support delta firmware updates. +On such devices, pushing the entire firmware binary for each iterative change would considerably increase the binary size overhead. + +# Requirements + +* Ability to override the order of execution of the operations defined in the command input. +* Ability to dynamically control the next operation to be executed during the workflow execution. +* Provide an option to add any custom rollback step into the workflow, when feasible, + with sufficient info available to the rollback logic like which operation failed, which ones completed and which ones are pending. + +# Device profile capability + +A device that supports device profile operation must declare that capability +by publishing an empty JSON message to the `device_profile` command metadata topic as follows: + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///cmd/device_profile' '{}' +``` + +If a profile is applied on the factory image itself, this information can be published to the corresponding `twin` topic, +so that the existing profile information is propagated to the cloud as well. + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///twin/device_profile' '{ + "name": "prod-profile", + "version: "v1" +}' +``` + +If the current profile information is not known upfront, this step can be skipped. + +When a new profile is applied, this twin value is updated with the applied profile's `name` and `version`. + +# Device profile command + +Once the `device_profile` capability is declared, the device can receive `device_profile` commands +by subscribing to `//cmd/device_profile/+` MQTT topics. +For example, subscribe to the following topic for the `main` device: + +```sh te2mqtt formats=v1 +tedge mqtt sub 'te/device/main///cmd/device_profile/+' +``` + +A `device_profile` command with `id` "1234" is triggered as follows: + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///cmd/device_profile/1234' '{ + "status": "init", + "name": "prod-profile", + "version": "v2", + "operations": [ + { + "operation": "firmware_update", + "skip": false, + "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": "c8y-command-plugin", + "version": "latest", + "action": "install" + }, + { + "name": "collectd", + "version": "latest", + "type": "apt", + "action": "install" + } + ] + } + ] + } + }, + { + "operation": "config_update", + "skip": false, + "payload": { + "type": "collectd.conf", + "remoteUrl":"https://abc.com/some/collectd/conf", + "path": "/etc/collectd/collectd.conf" + } + }, + { + "operation": "software_update", + "skip": false, + "payload": { + "updateList": [ + { + "type": "apt", + "modules": [ + { + "name": "jq", + "version": "latest", + "action": "install" + } + ] + } + ] + } + } + ] +}' +``` + +The profile definition in the payload is an array of operations. +Each operation could be a `firmware_update`, `software_update` or `config_update`. +The operations are executed in the order in which they are defined in the profile definition, by default. +There is no restriction on the order of modules in a profile and hence can be defined in any preferred order. +For example, additional software can be installed or configurations updated before the firmware is updated. +This default execution order can also be overridden in the workflow definition, by updating the order in the `scheduled` state. + +The `"skip"` field is optional and the value is `false`, by default. +It can be used to skip any operations during the development/testing phase, without fully deleting the entry from the profile. + +## Tedge agent handling device profile commands + +The `tedge-agent` handles `device_profile` commands using the workflow definition at `/etc/tedge/operations/device_profile.toml`. +This workflow definition handles each module type using sub-operation workflows defined for that type. +For example, the firmware module is installed by triggering a `firmware_update` sub-command +which in turn uses the `firmware_update` workflow for that operation execution. +Similarly software modules are installed with `software_update` subcommands and +configuration updates are applied using `config_update` subcommands. +These subcommands are triggered for each module defined in the profile definition in that order. + +Here is a sample device profile workflow: + +```toml +operation = "device_profile" + +[init] +action = "proceed" +on_success = "scheduled" + +# Sort the inputs as desired +[scheduled] +script = "/etc/tedge/operations/device_profile.sh ${.payload.status} ${.payload}" +on_success = "executing" +on_error = { status = "failed", reason = "fail to sort the profile list"} + +[executing] +action = "proceed" +on_success = "next_operation" + +[next_operation] +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 = "${.payload.@next.operation.operation}" +input = "${.payload.@next.operation.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" } + +# +# End states +# +[successful] +action = "cleanup" + +[failed] +action = "cleanup" +``` + +* The workflow just proceeds to the `scheduled` state from the `init` state +* The order of operation execution must be finalized before the `executing` state + and the `scheduled` state is an ideal candidate for that. + If the `builtin` action is specified in this state, the default order as defined in the input is used. + This order can be overridden by the user using a `script` action, if desired. + The script is expected to return the updated `operations` list which replaces the original list + and then proceed to the `executing` state. +* The mandatory `executing` state simply passes the input to the `next_operation`. +* The `next_operation` state chooses the next operation to be executed from the list of `operations` in the input. + When the built-in `iterator` action is used, the next operation is picked up sequentially from the `operations` list. + The target operation is captured into a `@next_operation` object in the payload, + with an `index` field representing the index position of that operation in the `operations` list, + along with that operation's `operation` and `payload` values as follows: + + ```json + { + "@next_operation": { + "index": 0, + "operation": "firmware_update", + "payload": { + "name": "core-image-tedge-rauc", + "remoteUrl": "https://abc.com/some/firmware/url", + "version": "20240430.1139" + } + }, + ... // Other fields in the incoming payload + } + ``` + + If the `@next_operation` field is not present in the input payload. + one is added with an initial `index` value of `0` and the corresponding `operation` and `payload` values. + If the field already exists, the `index` value is incremented along with its `operation` and `payload` values. + Once the next operation is successfully computed, the workflow moves to the `on_next` target. + Once the `operations` list is exhausted (`index` value higher than its size), + 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_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_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 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. + Using the `index` value, all the operations that were previously applied can easily be identified + by looking up all the lower index values in the `operations` list. + This can come in handy for any rollback attempts if feasible. + For e.g: if a profile consisted of a 4 inter-connected configuration updates and if the profile application failed + during the 3rd configuration, + a profile level rollback can be implemented by identifying the previously applied config update operations + using the `index` value, and undoing them as well. +* The `rollback` state does nothing but just falls through to the `failed` state, + as there is no built-in support for a profile level rollbacks. + If such a rollback is feasible, this state must be overridden using a user provided `script` action. + +### On success + +Once all the operations in the profile are successfully completed, the successful status is published + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///cmd/device_profile/1234' '{ + "status": "successful", + ... // Other input fields +}' +``` + +...and the current applied device profile information is updated by publishing the same to the capability topic as follows: + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///twin/device_profile' '{ + "name": "prod-profile", + "version: "v2" +}' +``` + +### On failure + +On failure, the `device_profile` operation is aborted at the operation that caused the failure. +The remaining operations in the profile are executed and no attempt is made to rollback the already completed operations either. +If the sub-operations support rollbacks at the sub-operation level, it is performed for the failed operation. + +For example, if a profile includes firmware, 2 software packages and 1 configuration update in that sequence, +if the failure happens during the second software update, no rollback is performed at the overall `device_profile` operation level, +or even for that failed software update, unless the `software_update` workflow for that software type supports rollbacks. +In that case the firmware and 1st software would remain installed, with the failed software update and last config update skipped. +But, if the failure happens during the `firmware_update` itself, a rollback is most likely performed by that workflow, +as most `firmware_update` workflows support a robust rollback mechanism. + +The `device_profile` operation itself is marked failed as follows: + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///cmd/device_profile/1234' '{ + "status": "failed", + "reason": "Installation of software module: `jq` failed. Refer to operation logs for further details." + ... // Other input fields +}' +``` + +The same profile can be applied again after fixing the issues that caused the failure, +and it is left to the individual sub-operations to determine whether +the same operation that was successfully applied in the last attempt must be reapplied or not. +The user can also manually skip any operation using the `skip` field. + +# Cumulocity operation mapping + +Cumulocity device profiles represent a combination of firmware, one or more software packages and configuration files, +represented by the `c8y_DeviceProfile` operation type. +Here is a sample `c8y_DeviceProfile` operation payload on the `c8y/devicecontrol/notifications` topic: + +```json +{ + "delivery": { + "log": [], + "time": "2024-07-22T10:26:31.457Z", + "status": "PENDING" + }, + "agentId": "98523229", + "creationTime": "2024-07-22T10:26:31.441Z", + "deviceId": "98523229", + "id": "523244", + "status": "PENDING", + "profileName": "prod-profile-v2", + "description": "Assign device profile prod-profile-v2 to device TST_char_humane_exception", + "profileId": "50523216", + "c8y_DeviceProfile": { + "software": [ + { + "name": "c8y-command-plugin", + "action": "install", + "version": "latest", + "url": " " + }, + { + "name": "collectd", + "action": "install", + "version": "latest", + "url": " " + } + ], + "configuration": [ + { + "name": "collectd-v2", + "type": "collectd.conf", + "url": "https://t2373.basic.stage.c8y.io/inventory/binaries/88395" + } + ], + "firmware": { + "name": "core-image-tedge-rauc", + "version": "20240430.1139", + "url": "https://t2373.basic.stage.c8y.io/inventory/binaries/43226" + } + }, + "externalSource": { + "externalId": "TST_char_humane_exception", + "type": "c8y_Serial" + } +} +``` + +There can only be one firmware entry in the device profile along with multiple software and configuration items. +Artifacts of each type are always grouped together and hence do not allow interleaving of different artifact types. +The payload does not enforce any clear order between artifact types either. + +The mapping from this Cumulocity format to tedge JSON format is fairly straight-forward. +Each artifact type is mapped to the corresponding operation type in thin-edge +(e.g: `software` -> `software_update`, `configuration` -> `config_update` and `firmware` -> `firmware_update`). + +Since the thin-edge payload is an ordered list of operations offering flexibility in defining them in any order, +the C8y payload is mapped to the equivalent tedge JSON format by applying an implicit order between the artifact types, +starting with the `firmware_update` operation followed by `software_update` and then `config_update`. +Since both `software` and `configuration` values are arrays with a defined order, it is maintained during the mapping as well. + +The above payload, meant for the main device, is mapped to thin-edge JSON format as follows: + +```sh te2mqtt formats=v1 +tedge mqtt pub -r 'te/device/main///cmd/device_profile/523244' '{ + "status": "init", + "name": "prod-profile", + "operations": [ + { + "operation": "firmware_update", + "skip": false, + "payload": { + "name": "core-image-tedge-rauc", + "version": "20240430.1139", + "remoteUrl": "https://t2373.basic.stage.c8y.io/inventory/binaries/43226" + } + }, + { + "operation": "software_update", + "skip": false, + "payload": { + "updateList": [ + { + "type": "apt", + "modules": [ + { + "name": "c8y-command-plugin", + "version": "latest", + "action": "install" + }, + { + "name": "collectd", + "version": "latest", + "type": "apt", + "action": "install" + } + ] + } + ] + } + }, + { + "operation": "config_update", + "skip": false, + "payload": { + "type": "collectd.conf", + "remoteUrl":"https://t2373.basic.stage.c8y.io/inventory/binaries/88395" + } + } + ] +}' +``` + +Since Cumulocity device profiles do not contain any version information, it is omitted in the tedge JSON payload as well. + +If the users want to change this implicit order of operation execution, +then they may enforce a different order in the `device_profile` workflow definition, +by overriding any state (e.g: `scheduled` state) before the workflow moves to the `executing` state. 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_operation.robot b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot new file mode 100644 index 00000000000..1d50cec50c8 --- /dev/null +++ b/tests/RobotFramework/tests/tedge_agent/device_profile/device_profile_operation.robot @@ -0,0 +1,176 @@ +*** Settings *** +Resource ../../../resources/common.resource +Library ThinEdgeIO +Library Cumulocity +Library OperatingSystem +Library Collections + +Force Tags theme:tedge_agent +Suite Setup Custom Setup +Test Setup Custom Test Setup +Test Teardown Get Logs + +*** Test Cases *** + +Device profile is included in supported operations + ${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} [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_NAME}= Set Variable Test Profile + ${PROFILE_PAYLOAD}= Catenate SEPARATOR=\n { + ... "c8y_DeviceProfile":{ + # ... "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":[ + ... { + ... "name":"tedge-configuration-plugin", + ... "type":"tedge-configuration-plugin", + ... "url":"${config_url}" + ... } + ... ] + ... }} + + ${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 *** + +Custom Test Setup + Execute Command cmd=echo 'tedge ALL = (ALL) NOPASSWD: /usr/bin/tedge, /usr/bin/systemctl, /etc/tedge/sm-plugins/[a-zA-Z0-9]*, /bin/sync, /sbin/init, /sbin/shutdown, /usr/bin/on_shutdown.sh, /usr/bin/tedge-write /etc/*' > /etc/sudoers.d/tedge + +Custom Setup + ${DEVICE_SN}= Setup + Set Suite Variable $DEVICE_SN + Device Should Exist ${DEVICE_SN} + + Copy Configuration Files + 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 + + +Copy Configuration Files + ThinEdgeIO.Transfer To Device ${CURDIR}/firmware_update.toml /etc/tedge/operations/ + ThinEdgeIO.Transfer To Device ${CURDIR}/tedge_operator_helper.sh /etc/tedge/operations/ diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/firmware_update.toml b/tests/RobotFramework/tests/tedge_agent/device_profile/firmware_update.toml new file mode 100644 index 00000000000..01124b4136a --- /dev/null +++ b/tests/RobotFramework/tests/tedge_agent/device_profile/firmware_update.toml @@ -0,0 +1,63 @@ +operation = "firmware_update" + +[init] +action = "proceed" +on_success = "scheduled" + +[scheduled] +script = "/usr/bin/sleep 1" +on_success = "executing" + +[executing] +script = "/bin/sh -c 'echo \"Touching /tmp/custom_reboot_marker\" >&2; touch /tmp/custom_reboot_marker; /usr/bin/sleep 1'" +on_success = "restart" + +[restart] +operation = "restart" +on_exec = "waiting_for_restart" + +[waiting_for_restart] +action = "await-operation-completion" +on_success = "verify" +on_error = { status = "failed", reason = "fail to restart"} + +[verify] +script = "/usr/bin/sleep 1" +on_success = "agent_update" + +# Update the agent (as the image in the agent might be older) +[agent_update] +operation = "software_update" +input_script = "/etc/tedge/operations/tedge_operator_helper.sh update_agent" +on_exec = "waiting_for_agent_update" + +[waiting_for_agent_update] +action = "await-operation-completion" +on_success = "commit" +on_error = { status = "rollback" } + +[commit] +script = "/usr/bin/sleep 1" +on_success = "successful" + +[rollback] +script = "/usr/bin/sleep 1" +on_success = "restart_rollback" +on_error = "restart_rollback" + +[restart_rollback] +operation = "restart" +on_exec = "waiting_restart_after_rollback" +on_success = "waiting_restart_after_rollback" +on_error = { status = "failed", reason = "Failed to restart device" } + +[waiting_restart_after_rollback] +script = "/usr/bin/sleep 1" +on_success = "failed" +on_error = { status = "failed", reason = "Failed to restart device" } + +[successful] +action = "cleanup" + +[failed] +action = "cleanup" diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/tedge-configuration-plugin.toml b/tests/RobotFramework/tests/tedge_agent/device_profile/tedge-configuration-plugin.toml new file mode 100644 index 00000000000..11f47c0dff0 --- /dev/null +++ b/tests/RobotFramework/tests/tedge_agent/device_profile/tedge-configuration-plugin.toml @@ -0,0 +1,4 @@ +files = [ + { path = '/etc/tedge/tedge.toml', user = 'tedge', group = 'tedge', mode = 0o444 }, + { path = '/etc/tedge/system.toml', type = 'system.toml', user = 'tedge', group = 'tedge', mode = 0o444 }, +] \ No newline at end of file diff --git a/tests/RobotFramework/tests/tedge_agent/device_profile/tedge_operator_helper.sh b/tests/RobotFramework/tests/tedge_agent/device_profile/tedge_operator_helper.sh new file mode 100755 index 00000000000..a739169f22c --- /dev/null +++ b/tests/RobotFramework/tests/tedge_agent/device_profile/tedge_operator_helper.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e + +COMMAND="$1" +shift + +update_agent() { + cat << EOT +:::begin-tedge::: +{ + "updateList": [ + { + "type": "apt", + "modules": [ + { + "name": "tedge", + "version": "latest", + "action": "install" + }, + { + "name": "tedge-mapper", + "version": "latest", + "action": "install" + }, + { + "name": "tedge-agent", + "version": "latest", + "action": "install" + } + ] + } + ] +} +:::end-tedge::: +EOT +} + +case "$COMMAND" in + update_agent) update_agent "$@" ;; +esac +exit 0 diff --git a/tests/images/debian-systemd/debian-systemd.dockerfile b/tests/images/debian-systemd/debian-systemd.dockerfile index 9596a50eafe..7ce25a7d4cc 100644 --- a/tests/images/debian-systemd/debian-systemd.dockerfile +++ b/tests/images/debian-systemd/debian-systemd.dockerfile @@ -17,7 +17,10 @@ RUN apt-get -y update \ nginx \ netcat-openbsd \ iputils-ping \ - net-tools + net-tools \ + # json tools (for tests) + jq \ + jo # Install more recent version of mosquitto >= 2.0.18 from debian backports to avoid mosquitto following bugs: # The mosquitto repo can't be used as it does not included builds for arm64/aarch64 (only amd64 and armhf)