diff --git a/ank/src/cli.rs b/ank/src/cli.rs index 4dc94a16b..e1b7d30ae 100644 --- a/ank/src/cli.rs +++ b/ank/src/cli.rs @@ -137,8 +137,8 @@ pub enum SetCommands { #[arg(required = true)] object_field_mask: Vec, /// A file containing the new State Object Description in yaml format - #[arg(short = 'f', long = "file")] - state_object_file: Option, + #[arg(required = true)] + state_object_file: String, }, } diff --git a/ank/src/cli_commands/set_state.rs b/ank/src/cli_commands/set_state.rs index 7c548d603..bbb9f0b76 100644 --- a/ank/src/cli_commands/set_state.rs +++ b/ank/src/cli_commands/set_state.rs @@ -16,49 +16,110 @@ use common::{ objects::{CompleteState, StoredWorkloadSpec}, state_manipulation::{Object, Path}, }; +use std::io::{self, Read}; #[cfg(not(test))] fn read_file_to_string(file: String) -> std::io::Result { std::fs::read_to_string(file) } -use crate::{cli_error::CliError, output_and_error, output_debug}; +use crate::{cli_error::CliError, output_debug}; #[cfg(test)] use tests::read_to_string_mock as read_file_to_string; use super::CliCommands; -fn add_default_workload_spec_per_update_mask( - update_mask: &Vec, - complete_state: &mut CompleteState, -) { +fn create_state_with_default_workload_specs(update_mask: &[String]) -> CompleteState { + let mut complete_state = CompleteState::default(); + const WORKLOAD_ATTRIBUTE_LEVEL: usize = 4; + let workload_level_mask_parts = ["desiredState".to_string(), "workloads".to_string()]; + const WORKLOAD_NAME_POSITION: usize = 2; + for field_mask in update_mask { let path: Path = field_mask.into(); // if we want to set an attribute of a workload create a default object for the workload - if path.parts().len() >= 4 - && path.parts()[0] == "desiredState" - && path.parts()[1] == "workloads" + let mask_parts = path.parts(); + if mask_parts.len() >= WORKLOAD_ATTRIBUTE_LEVEL + && mask_parts.starts_with(&workload_level_mask_parts) { - let stored_workload = StoredWorkloadSpec { - agent: "".to_string(), - runtime: "".to_string(), - runtime_config: "".to_string(), - ..Default::default() - }; + complete_state.desired_state.workloads.insert( + mask_parts[WORKLOAD_NAME_POSITION].to_string(), + StoredWorkloadSpec::default(), + ); + } + } - complete_state - .desired_state - .workloads - .insert(path.parts()[2].to_string(), stored_workload); + complete_state +} + +// [impl->swdd~cli-supports-yaml-to-set-desired-state~1] +async fn process_inputs(reader: R, state_object_file: &str) -> Result { + match state_object_file { + "-" => { + let stdin = io::read_to_string(reader).map_err(|error| { + CliError::ExecutionError(format!( + "Could not read the state object from stdin.\nError: '{}'", + error + )) + })?; + let value: serde_yaml::Value = serde_yaml::from_str(&stdin).map_err(|error| { + CliError::YamlSerialization(format!( + "Could not convert stdin input to yaml.\nError: '{}'", + error + )) + })?; + Ok(Object::try_from(&value)?) + } + _ => { + let state_object_data = + read_file_to_string(state_object_file.to_string()).map_err(|error| { + CliError::ExecutionError(format!( + "Could not read the state object file '{}'.\nError: '{}'", + state_object_file, error + )) + })?; + let value: serde_yaml::Value = + serde_yaml::from_str(&state_object_data).map_err(|error| { + CliError::YamlSerialization(format!( + "Could not convert state object file to yaml.\nError: '{}'", + error + )) + })?; + Ok(Object::try_from(&value)?) } } } +fn overwrite_using_field_mask( + mut complete_state_object: Object, + object_field_mask: &Vec, + temp_obj: &Object, +) -> Result { + for field_mask in object_field_mask { + let path: Path = field_mask.into(); + + complete_state_object + .set( + &path, + temp_obj + .get(&path) + .ok_or(CliError::ExecutionError(format!( + "Specified update mask '{field_mask}' not found in the input config.", + )))? + .clone(), + ) + .map_err(|err| CliError::ExecutionError(err.to_string()))?; + } + + Ok(complete_state_object) +} + impl CliCommands { + // [impl->swdd~cli-provides-set-desired-state~1] pub async fn set_state( &mut self, object_field_mask: Vec, - state_object_file: Option, + state_object_file: String, ) -> Result<(), CliError> { output_debug!( "Got: object_field_mask={:?} state_object_file={:?}", @@ -66,44 +127,22 @@ impl CliCommands { state_object_file ); - let mut complete_state = CompleteState::default(); - if let Some(state_object_file) = state_object_file { - let state_object_data = - read_file_to_string(state_object_file).unwrap_or_else(|error| { - output_and_error!("Could not read the state object file.\nError: {}", error) - }); - let value: serde_yaml::Value = serde_yaml::from_str(&state_object_data)?; - let x = Object::try_from(&value)?; - - // This here is a workaround for the default workload specs - add_default_workload_spec_per_update_mask(&object_field_mask, &mut complete_state); - - // now overwrite with the values from the field mask - let mut complete_state_object: Object = complete_state.try_into()?; - for field_mask in &object_field_mask { - let path: Path = field_mask.into(); - - complete_state_object - .set( - &path, - x.get(&path) - .ok_or(CliError::ExecutionError(format!( - "Specified update mask '{field_mask}' not found in the input config.", - )))? - .clone(), - ) - .map_err(|err| CliError::ExecutionError(err.to_string()))?; - } - complete_state = complete_state_object.try_into()?; - } + let temp_obj = process_inputs(io::stdin(), &state_object_file).await?; + let default_complete_state = create_state_with_default_workload_specs(&object_field_mask); + + // now overwrite with the values from the field mask + let mut complete_state_object: Object = default_complete_state.try_into()?; + complete_state_object = + overwrite_using_field_mask(complete_state_object, &object_field_mask, &temp_obj)?; + let new_complete_state = complete_state_object.try_into()?; output_debug!( "Send UpdateState request with the CompleteState {:?}", - complete_state + new_complete_state ); // [impl->swdd~cli-blocks-until-ankaios-server-responds-set-desired-state~2] - self.update_state_and_wait_for_complete(complete_state, object_field_mask) + self.update_state_and_wait_for_complete(new_complete_state, object_field_mask) .await } } @@ -117,9 +156,211 @@ impl CliCommands { ////////////////////////////////////////////////////////////////////////////// #[cfg(test)] mod tests { - use std::io; + use super::{ + create_state_with_default_workload_specs, io, overwrite_using_field_mask, process_inputs, + CliCommands, StoredWorkloadSpec, + }; + use crate::cli_commands::server_connection::MockServerConnection; + use api::ank_base::UpdateStateSuccess; + use common::{ + objects::{CompleteState, RestartPolicy, State}, + state_manipulation::Object, + }; + use mockall::predicate::eq; + use serde_yaml::Value; + use std::{collections::HashMap, io::Cursor}; pub fn read_to_string_mock(_file: String) -> io::Result { - Ok("".into()) + Ok(_file) + } + + const RESPONSE_TIMEOUT_MS: u64 = 3000; + + const SAMPLE_CONFIG: &str = r#"desiredState: + workloads: + nginx: + agent: agent_A + tags: + - key: owner + value: Ankaios team + dependencies: {} + restartPolicy: ALWAYS + runtime: podman + runtimeConfig: | + image: docker.io/nginx:latest + commandOptions: ["-p", "8081:80"]"#; + + // [utest->swdd~cli-provides-set-desired-state~1] + #[test] + fn utest_create_state_with_default_workload_specs_empty_update_mask() { + let update_mask = vec![]; + + let complete_state = create_state_with_default_workload_specs(&update_mask); + + assert!(complete_state.desired_state.workloads.is_empty()); + } + + // [utest->swdd~cli-provides-set-desired-state~1] + #[test] + fn utest_create_state_with_default_workload_specs_with_update_mask() { + let update_mask = vec![ + "desiredState.workloads.nginx.restartPolicy".to_string(), + "desiredState.workloads.nginx2.restartPolicy".to_string(), + "desiredState.workloads.nginx3".to_string(), + ]; + + let complete_state = create_state_with_default_workload_specs(&update_mask); + + assert_eq!( + complete_state.desired_state.workloads.get("nginx"), + Some(&StoredWorkloadSpec::default()) + ); + + assert_eq!( + complete_state.desired_state.workloads.get("nginx2"), + Some(&StoredWorkloadSpec::default()) + ); + assert!(!complete_state + .desired_state + .workloads + .contains_key("nginx3")); + } + + // [utest->swdd~cli-provides-set-desired-state~1] + #[test] + fn utest_create_state_with_default_workload_specs_invalid_path() { + let update_mask = vec!["invalid.path".to_string()]; + + let complete_state = create_state_with_default_workload_specs(&update_mask); + + assert!(complete_state.desired_state.workloads.is_empty()); + } + + // [utest->swdd~cli-provides-set-desired-state~1] + #[test] + fn utest_overwrite_using_field_mask() { + let workload_spec = StoredWorkloadSpec::default(); + let mut complete_state = CompleteState { + desired_state: State { + workloads: HashMap::from([("nginx".to_string(), workload_spec)]), + ..Default::default() + }, + ..Default::default() + }; + let mut complete_state_object: Object = complete_state.try_into().unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(SAMPLE_CONFIG).unwrap(); + let temp_object = Object::try_from(&value).unwrap(); + let update_mask = vec!["desiredState.workloads.nginx".to_string()]; + + complete_state_object = + overwrite_using_field_mask(complete_state_object, &update_mask, &temp_object).unwrap(); + + complete_state = complete_state_object.try_into().unwrap(); + + assert!(complete_state.desired_state.workloads.contains_key("nginx")); + assert_eq!( + complete_state + .desired_state + .workloads + .get("nginx") + .unwrap() + .restart_policy, + RestartPolicy::Always + ) + } + + // [utest->swdd~cli-supports-yaml-to-set-desired-state~1] + #[tokio::test] + async fn utest_process_inputs_stdin() { + let input = SAMPLE_CONFIG; + let reader = Cursor::new(input); + let state_object_file = "-".to_string(); + + let temp_obj = process_inputs(reader, &state_object_file).await.unwrap(); + + let value: Value = serde_yaml::from_str(SAMPLE_CONFIG).unwrap(); + let expected_obj = Object::try_from(&value).unwrap(); + + assert_eq!(temp_obj, expected_obj); + } + + // [utest->swdd~cli-supports-yaml-to-set-desired-state~1] + #[tokio::test] + async fn utest_process_inputs_file() { + let state_object_file = SAMPLE_CONFIG.to_owned(); + + let temp_obj = process_inputs(io::empty(), &state_object_file) + .await + .unwrap(); + + let value: Value = serde_yaml::from_str(SAMPLE_CONFIG).unwrap(); + let expected_obj = Object::try_from(&value).unwrap(); + + assert_eq!(temp_obj, expected_obj); + } + + // [utest->swdd~cli-supports-yaml-to-set-desired-state~1] + #[tokio::test] + async fn utest_process_inputs_invalid_yaml() { + let input = "invalid yaml"; + let reader = Cursor::new(input); + let state_object_file = "-".to_string(); + + let temp_obj = process_inputs(reader, &state_object_file).await; + + assert!(temp_obj.is_ok()); + } + + // [utest->swdd~cli-provides-set-desired-state~1] + #[tokio::test] + async fn utest_set_state_ok() { + let update_mask = vec!["desiredState.workloads.nginx.restartPolicy".to_string()]; + let state_object_file = SAMPLE_CONFIG.to_owned(); + + let workload_spec = StoredWorkloadSpec { + restart_policy: RestartPolicy::Always, + ..Default::default() + }; + let updated_state = CompleteState { + desired_state: State { + workloads: HashMap::from([("nginx".to_string(), workload_spec)]), + ..Default::default() + }, + ..Default::default() + }; + let mut mock_server_connection = MockServerConnection::default(); + mock_server_connection + .expect_update_state() + .with(eq(updated_state), eq(update_mask.clone())) + .return_once(|_, _| Ok(UpdateStateSuccess::default())); + + let mut cmd = CliCommands { + _response_timeout_ms: RESPONSE_TIMEOUT_MS, + no_wait: true, + server_connection: mock_server_connection, + }; + + let set_state_result = cmd.set_state(update_mask, state_object_file).await; + assert!(set_state_result.is_ok()); + } + + // [utest->swdd~cli-provides-set-desired-state~1] + #[tokio::test] + async fn utest_set_state_failed() { + let wrong_update_mask = vec!["desiredState.workloads.notExistingWorkload".to_string()]; + let state_object_file = SAMPLE_CONFIG.to_owned(); + let mut mock_server_connection = MockServerConnection::default(); + mock_server_connection + .expect_update_state() + .return_once(|_, _| Ok(UpdateStateSuccess::default())); + + let mut cmd = CliCommands { + _response_timeout_ms: RESPONSE_TIMEOUT_MS, + no_wait: true, + server_connection: mock_server_connection, + }; + + let set_state_result = cmd.set_state(wrong_update_mask, state_object_file).await; + assert!(set_state_result.is_err()); } } diff --git a/ank/src/main.rs b/ank/src/main.rs index e1777a805..f5ddab5b2 100644 --- a/ank/src/main.rs +++ b/ank/src/main.rs @@ -124,8 +124,8 @@ async fn main() { object_field_mask, state_object_file ); - // [impl -> swdd~cli-provides-set-desired-state~1] - // [impl -> swdd~cli-blocks-until-ankaios-server-responds-set-desired-state~2] + + // [impl->swdd~cli-blocks-until-ankaios-server-responds-set-desired-state~2] if let Err(err) = cmd.set_state(object_field_mask, state_object_file).await { output_and_error!("Failed to set state: '{}'", err) } diff --git a/doc/docs/reference/complete-state.md b/doc/docs/reference/complete-state.md index 62a40d5d1..45b55ae47 100644 --- a/doc/docs/reference/complete-state.md +++ b/doc/docs/reference/complete-state.md @@ -125,7 +125,7 @@ The object field mask can be constructed using the field names of the [CompleteS commandOptions: ["-p", "8081:80"] ``` -3. Example `ank -k set state -f new-state.yaml desiredState.workloads.nginx.restartPolicy` changes the restart behavior of nginx workload to `NEVER`: +3. Example `ank -k set state desiredState.workloads.nginx.restartPolicy new-state.yaml` changes the restart behavior of nginx workload to `NEVER`: ```yaml title="new-state.yaml" desiredState: diff --git a/tests/stests/workloads/interworkload_dependencies.robot b/tests/stests/workloads/interworkload_dependencies.robot index cbd74e027..8ebd09ab0 100644 --- a/tests/stests/workloads/interworkload_dependencies.robot +++ b/tests/stests/workloads/interworkload_dependencies.robot @@ -87,7 +87,7 @@ Test Ankaios CLI update workload with pending delete # Actions When user triggers "ank -k get state > ${new_state_yaml_file}" And user updates the state "${new_state_yaml_file}" with "desiredState.workloads.backend.runtimeConfig.commandOptions=['-p', '8084:80']" - And user triggers "ank -k --no-wait set state -f ${new_state_yaml_file} desiredState.workloads.backend" + And user triggers "ank -k --no-wait set state desiredState.workloads.backend ${new_state_yaml_file}" And the workload "backend" shall have the execution state "Stopping(WaitingToStop)" on agent "agent_A" within "20" seconds And user triggers "ank -k delete workload frontend" # Asserts @@ -110,9 +110,9 @@ Test Ankaios CLI update workload with pending create And Ankaios agent is started with name "agent_A" And the workload "after_backend" shall have the execution state "Succeeded(Ok)" on agent "agent_A" within "20" seconds # Actions - When user triggers "ank -k --no-wait set state -f ${new_state_yaml_file} desiredState.workloads.after_backend" + When user triggers "ank -k --no-wait set state desiredState.workloads.after_backend ${new_state_yaml_file}" And the workload "after_backend" shall have the execution state "Pending(WaitingToStart)" on agent "agent_A" within "3" seconds - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.backend" + And user triggers "ank -k set state desiredState.workloads.backend ${new_state_yaml_file}" # Asserts Then the workload "backend" shall have the execution state "Succeeded(Ok)" on agent "agent_A" within "5" seconds And the workload "after_backend" shall have the execution state "Succeeded(Ok)" on agent "agent_A" within "5" seconds diff --git a/tests/stests/workloads/reject_state_with_cyclic_dependencies.robot b/tests/stests/workloads/reject_state_with_cyclic_dependencies.robot index bdab4d77a..1d7c64294 100644 --- a/tests/stests/workloads/reject_state_with_cyclic_dependencies.robot +++ b/tests/stests/workloads/reject_state_with_cyclic_dependencies.robot @@ -60,7 +60,7 @@ Test Ankaios CLI update state with cycle in interworkload dependencies is reject And Ankaios agent is started with name "agent_A" And all workloads of agent "agent_A" have an initial execution state # Actions - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.workload_C" + And user triggers "ank -k set state ${new_state_yaml_file} desiredState.workloads.workload_C" # Asserts Then the workload "workload_C" shall not exist And podman shall not have a container for workload "workload_C" on agent "agentA" within "5" seconds diff --git a/tests/stests/workloads/retry_creation_of_workload_podman.robot b/tests/stests/workloads/retry_creation_of_workload_podman.robot index 21443f6c9..a04eb4479 100644 --- a/tests/stests/workloads/retry_creation_of_workload_podman.robot +++ b/tests/stests/workloads/retry_creation_of_workload_podman.robot @@ -39,7 +39,7 @@ Test Ankaios Podman retry creation of a workload on creation failure And user triggers "ank -k delete workload hello1" And the workload "hello1" shall not exist on agent "agent_A" within "20" seconds And podman shall not have a container for workload "hello1" on agent "agent_A" within "20" seconds - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.hello1" + And user triggers "ank -k set state desiredState.workloads.hello1 ${new_state_yaml_file}" # Asserts Then the workload "hello1" shall have the execution state "Running(Ok)" from agent "agent_A" within "20" seconds [Teardown] Clean up Ankaios @@ -62,9 +62,9 @@ Test Ankaios Podman retry creation of a workload on creation failure intercepted And user triggers "ank -k delete workload hello1" And the workload "hello1" shall not exist on agent "agent_A" within "20" seconds And podman shall not have a container for workload "hello1" on agent "agent_A" within "20" seconds - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.hello1" + And user triggers "ank -k set state ${new_state_yaml_file} desiredState.workloads.hello1" And user updates the state "${new_state_yaml_file}" with "desiredState.workloads.hello1.runtimeConfig.commandArgs=['3']" - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.hello1" + And user triggers "ank -k set state desiredState.workloads.hello1 ${new_state_yaml_file}" # Asserts Then the workload "hello1" shall have the execution state "Succeeded(Ok)" from agent "agent_A" within "20" seconds [Teardown] Clean up Ankaios @@ -87,7 +87,7 @@ Test Ankaios Podman retry creation of a workload on creation failure intercepted And user triggers "ank -k delete workload hello1" And the workload "hello1" shall not exist on agent "agent_A" within "20" seconds And podman shall not have a container for workload "hello1" on agent "agent_A" within "20" seconds - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.hello1" + And user triggers "ank -k set state desiredState.workloads.hello1 ${new_state_yaml_file}" And the user waits "1" seconds And user triggers "ank -k delete workload hello1" # Asserts diff --git a/tests/stests/workloads/state_operations_with_field_mask.robot b/tests/stests/workloads/state_operations_with_field_mask.robot index a6e384431..14697c4c7 100644 --- a/tests/stests/workloads/state_operations_with_field_mask.robot +++ b/tests/stests/workloads/state_operations_with_field_mask.robot @@ -39,7 +39,7 @@ Test Ankaios CLI update workload And Ankaios agent is started with name "agent_A" And all workloads of agent "agent_A" have an initial execution state # Actions - And user triggers "ank -k --no-wait set state -f ${new_state_yaml_file} desiredState.workloads.simple.agent" + And user triggers "ank -k --no-wait set state desiredState.workloads.simple.agent ${new_state_yaml_file}" # Asserts Then the workload "simple" shall not exist on agent "agent_A" within "20" seconds And podman shall not have a container for workload "simple" on agent "agent_A" diff --git a/tests/stests/workloads/update_workload_podman.robot b/tests/stests/workloads/update_workload_podman.robot index 8d57bf957..f5d06f394 100644 --- a/tests/stests/workloads/update_workload_podman.robot +++ b/tests/stests/workloads/update_workload_podman.robot @@ -43,7 +43,7 @@ Test Ankaios CLI update workload # Actions When user triggers "ank -k get state > ${new_state_yaml_file}" And user updates the state "${new_state_yaml_file}" with "desiredState.workloads.nginx.runtimeConfig.commandOptions=['-p', '8082:80']" - And user triggers "ank -k set state -f ${new_state_yaml_file} desiredState.workloads.nginx" + And user triggers "ank -k set state desiredState.workloads.nginx ${new_state_yaml_file}" # Asserts Then the workload "nginx" shall have the execution state "Running(Ok)" on agent "agent_A" within "20" seconds And the command "curl localhost:8082" shall finish with exit code "0" within "10" seconds @@ -61,7 +61,7 @@ Test Ankaios Podman update workload from empty state # Actions When user triggers "ank -k get workloads" Then list of workloads shall be empty - When user triggers "ank -k set state --file ${CONFIGS_DIR}/update_state_create_one_workload.yaml desiredState.workloads" + When user triggers "ank -k set state desiredState.workloads ${CONFIGS_DIR}/update_state_create_one_workload.yaml" Then the workload "nginx" shall have the execution state "Running(Ok)" on agent "agent_A" within "20" seconds [Teardown] Clean up Ankaios @@ -77,7 +77,7 @@ Test Ankaios Podman Update workload with invalid api version # Actions When user triggers "ank -k get workloads" Then list of workloads shall be empty - When user triggers "ank -k set state --file ${CONFIGS_DIR}/update_state_invalid_version.yaml desiredState" + When user triggers "ank -k set state ${CONFIGS_DIR}/update_state_invalid_version.yaml desiredState" And user triggers "ank -k get workloads" Then list of workloads shall be empty @@ -95,7 +95,7 @@ Test Ankaios Podman Update workload with missing api version # Actions When user triggers "ank -k get workloads" Then list of workloads shall be empty - When user triggers "ank -k set state --file ${CONFIGS_DIR}/update_state_missing_version.yaml desiredState" + When user triggers "ank -k set state ${CONFIGS_DIR}/update_state_missing_version.yaml desiredState" And user triggers "ank -k get workloads" Then list of workloads shall be empty