diff --git a/hawkbit/examples/polling.rs b/hawkbit/examples/polling.rs index c57ec9b..630f7b1 100644 --- a/hawkbit/examples/polling.rs +++ b/hawkbit/examples/polling.rs @@ -74,6 +74,20 @@ async fn main() -> Result<()> { .await?; } + if let Some(cancel_action) = reply.cancel_action() { + println!("Action to cancel: {}", cancel_action.id().await?); + + cancel_action + .send_feedback(Execution::Proceeding, Finished::None, vec!["Cancelling"]) + .await?; + + cancel_action + .send_feedback(Execution::Closed, Finished::Success, vec![]) + .await?; + + println!("Action cancelled"); + } + let t = reply.polling_sleep()?; sleep(t).await; } diff --git a/hawkbit/src/ddi.rs b/hawkbit/src/ddi.rs index 0c9028c..436c9a6 100644 --- a/hawkbit/src/ddi.rs +++ b/hawkbit/src/ddi.rs @@ -13,6 +13,7 @@ // FIXME: set link to hawbit/examples/polling.rs once we have the final public repo +mod cancel_action; mod client; mod common; mod config_data; @@ -20,6 +21,7 @@ mod deployment_base; mod feedback; mod poll; +pub use cancel_action::CancelAction; pub use client::{Client, Error}; pub use common::{Execution, Finished}; pub use config_data::{ConfigRequest, Mode}; diff --git a/hawkbit/src/ddi/cancel_action.rs b/hawkbit/src/ddi/cancel_action.rs new file mode 100644 index 0000000..0cf7217 --- /dev/null +++ b/hawkbit/src/ddi/cancel_action.rs @@ -0,0 +1,77 @@ +// Copyright 2021, Collabora Ltd. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// Cancelled operation + +use reqwest::Client; +use serde::Deserialize; + +use crate::ddi::client::Error; +use crate::ddi::common::{send_feedback_internal, Execution, Finished}; + +/// A request from the server to cancel an update. +/// +/// Call [`CancelAction::id()`] to retrieve the ID of the action to cancel. +/// +/// Cancel actions need to be closed by sending feedback to the server using +/// [`CancelAction::send_feedback`] with either +/// [`Finished::Success`] or [`Finished::Failure`]. +#[derive(Debug)] +pub struct CancelAction { + client: Client, + url: String, +} + +impl CancelAction { + pub(crate) fn new(client: Client, url: String) -> Self { + Self { client, url } + } + + /// Retrieve the id of the action to cancel. + pub async fn id(&self) -> Result { + let reply = self.client.get(&self.url).send().await?; + reply.error_for_status_ref()?; + + let reply = reply.json::().await?; + Ok(reply.cancel_action.stop_id) + } + + /// Send feedback to server about this cancel action. + /// + /// # Arguments + /// * `execution`: status of the action execution. + /// * `finished`: defined status of the result. The action will be kept open on the server until the controller on the device reports either [`Finished::Success`] or [`Finished::Failure`]. + /// * `details`: list of details message information. + pub async fn send_feedback( + &self, + execution: Execution, + finished: Finished, + details: Vec<&str>, + ) -> Result<(), Error> { + let id = self.id().await?; + + send_feedback_internal::( + &self.client, + &self.url, + &id, + execution, + finished, + None, + details, + ) + .await + } +} + +#[derive(Debug, Deserialize)] +struct CancelReply { + id: String, + #[serde(rename = "cancelAction")] + cancel_action: CancelActionReply, +} + +#[derive(Debug, Deserialize)] +struct CancelActionReply { + #[serde(rename = "stopId")] + stop_id: String, +} diff --git a/hawkbit/src/ddi/common.rs b/hawkbit/src/ddi/common.rs index 8814b70..ff22ad1 100644 --- a/hawkbit/src/ddi/common.rs +++ b/hawkbit/src/ddi/common.rs @@ -3,7 +3,12 @@ use std::fmt; +use reqwest::Client; use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::ddi::client::Error; +use crate::ddi::feedback::Feedback; #[derive(Debug, Deserialize)] pub struct Link { @@ -46,3 +51,30 @@ pub enum Finished { /// Operation is still in-progress None, } + +pub(crate) async fn send_feedback_internal( + client: &Client, + url: &str, + id: &str, + execution: Execution, + finished: Finished, + progress: Option, + details: Vec<&str>, +) -> Result<(), Error> { + let mut url: Url = url.parse()?; + { + let mut paths = url + .path_segments_mut() + .map_err(|_| url::ParseError::SetHostOnCannotBeABaseUrl)?; + paths.push("feedback"); + } + url.set_query(None); + + let details = details.iter().map(|m| m.to_string()).collect(); + let feedback = Feedback::new(id, execution, finished, progress, details); + + let reply = client.post(&url.to_string()).json(&feedback).send().await?; + reply.error_for_status()?; + + Ok(()) +} diff --git a/hawkbit/src/ddi/deployment_base.rs b/hawkbit/src/ddi/deployment_base.rs index 5fc2f35..f8922db 100644 --- a/hawkbit/src/ddi/deployment_base.rs +++ b/hawkbit/src/ddi/deployment_base.rs @@ -15,11 +15,9 @@ use tokio::{ fs::{DirBuilder, File}, io::AsyncWriteExt, }; -use url::Url; use crate::ddi::client::Error; -use crate::ddi::common::{Execution, Finished, Link}; -use crate::ddi::feedback::Feedback; +use crate::ddi::common::{send_feedback_internal, Execution, Finished, Link}; #[derive(Debug)] /// A pending update whose details have not been retrieved yet. @@ -262,42 +260,6 @@ impl Update { Ok(result) } - async fn send_feedback_internal( - &self, - execution: Execution, - finished: Finished, - progress: Option, - details: Vec<&str>, - ) -> Result<(), Error> { - let mut url: Url = self.url.parse()?; - { - match url.path_segments_mut() { - Err(_) => { - return Err(Error::ParseUrlError( - url::ParseError::SetHostOnCannotBeABaseUrl, - )) - } - Ok(mut paths) => { - paths.push("feedback"); - } - } - } - url.set_query(None); - - let details = details.iter().map(|m| m.to_string()).collect(); - let feedback = Feedback::new(&self.info.id, execution, finished, progress, details); - - let reply = self - .client - .post(&url.to_string()) - .json(&feedback) - .send() - .await?; - reply.error_for_status()?; - - Ok(()) - } - /// Send feedback to server about this update, with custom progress information. /// /// # Arguments @@ -312,8 +274,16 @@ impl Update { progress: T, details: Vec<&str>, ) -> Result<(), Error> { - self.send_feedback_internal(execution, finished, Some(progress), details) - .await + send_feedback_internal( + &self.client, + &self.url, + &self.info.id, + execution, + finished, + Some(progress), + details, + ) + .await } /// Send feedback to server about this update. @@ -325,8 +295,16 @@ impl Update { finished: Finished, details: Vec<&str>, ) -> Result<(), Error> { - self.send_feedback_internal::(execution, finished, None, details) - .await + send_feedback_internal::( + &self.client, + &self.url, + &self.info.id, + execution, + finished, + None, + details, + ) + .await } } diff --git a/hawkbit/src/ddi/feedback.rs b/hawkbit/src/ddi/feedback.rs index 060dbc0..1d02e12 100644 --- a/hawkbit/src/ddi/feedback.rs +++ b/hawkbit/src/ddi/feedback.rs @@ -22,6 +22,7 @@ struct Status { #[derive(Debug, Serialize)] pub struct ResultT { finished: Finished, + #[serde(skip_serializing_if = "Option::is_none")] progress: Option, } diff --git a/hawkbit/src/ddi/poll.rs b/hawkbit/src/ddi/poll.rs index 6254bfb..cb265d5 100644 --- a/hawkbit/src/ddi/poll.rs +++ b/hawkbit/src/ddi/poll.rs @@ -8,6 +8,7 @@ use std::time::Duration; use reqwest::Client; use serde::Deserialize; +use crate::ddi::cancel_action::CancelAction; use crate::ddi::client::Error; use crate::ddi::common::Link; use crate::ddi::config_data::ConfigRequest; @@ -75,6 +76,17 @@ impl Reply { None => None, } } + + /// Returns pending cancel action, if any. + pub fn cancel_action(&self) -> Option { + match &self.reply.links { + Some(links) => links + .cancel_action + .as_ref() + .map(|l| CancelAction::new(self.client.clone(), l.to_string())), + None => None, + } + } } impl Polling { diff --git a/hawkbit/tests/tests.rs b/hawkbit/tests/tests.rs index 8e19cd2..0da7ab2 100644 --- a/hawkbit/tests/tests.rs +++ b/hawkbit/tests/tests.rs @@ -219,7 +219,7 @@ async fn deployment() { } #[tokio::test] -async fn send_feedback() { +async fn send_deployment_feedback() { init(); let server = ServerBuilder::default().build(); @@ -233,7 +233,7 @@ async fn send_feedback() { let update = update.fetch().await.expect("failed to fetch update info"); // Send feedback without progress - let mut mock = target.expect_feedback( + let mut mock = target.expect_deployment_feedback( &deploy_id, Execution::Proceeding, Finished::None, @@ -250,7 +250,7 @@ async fn send_feedback() { mock.delete(); // Send feedback with progress - let mut mock = target.expect_feedback( + let mut mock = target.expect_deployment_feedback( &deploy_id, Execution::Closed, Finished::Success, @@ -460,3 +460,41 @@ async fn wrong_checksums() { } } } + +#[tokio::test] +async fn cancel_action() { + init(); + + let server = ServerBuilder::default().build(); + let (client, target) = add_target(&server, "Target1"); + target.cancel_action("10"); + + let reply = client.poll().await.expect("poll failed"); + assert!(reply.config_data_request().is_none()); + assert!(reply.update().is_none()); + let cancel_action = reply.cancel_action().expect("missing cancel action"); + + let id = cancel_action + .id() + .await + .expect("failed to fetch cancel action id"); + assert_eq!(id, "10"); + + assert_eq!(target.poll_hits(), 1); + assert_eq!(target.cancel_action_hits(), 1); + + let mut mock = target.expect_cancel_feedback( + &id, + Execution::Proceeding, + Finished::None, + vec!["Cancelling"], + ); + assert_eq!(mock.hits(), 0); + + cancel_action + .send_feedback(Execution::Proceeding, Finished::None, vec!["Cancelling"]) + .await + .expect("Failed to send feedback"); + assert_eq!(mock.hits(), 1); + mock.delete(); +} diff --git a/hawkbit_mock/src/ddi.rs b/hawkbit_mock/src/ddi.rs index 476ff4f..2a4392c 100644 --- a/hawkbit_mock/src/ddi.rs +++ b/hawkbit_mock/src/ddi.rs @@ -104,13 +104,14 @@ pub struct Target { poll: Cell, config_data: RefCell>, deployment: RefCell>, + cancel_action: RefCell>, } impl Target { fn new(name: &str, server: &Rc, tenant: &str) -> Self { let key = format!("Key{}", name); - let poll = Self::create_poll(server, tenant, name, &key, None, None); + let poll = Self::create_poll(server, tenant, name, &key, None, None, None); Target { name: name.to_string(), key, @@ -119,6 +120,7 @@ impl Target { poll: Cell::new(poll), config_data: RefCell::new(None), deployment: RefCell::new(None), + cancel_action: RefCell::new(None), } } @@ -129,6 +131,7 @@ impl Target { key: &str, expected_config_data: Option<&PendingAction>, deployment: Option<&PendingAction>, + cancel_action: Option<&PendingAction>, ) -> usize { let mut links = Map::new(); @@ -138,6 +141,9 @@ impl Target { if let Some(pending) = deployment { links.insert("deploymentBase".into(), json!({ "href": pending.path })); } + if let Some(pending) = cancel_action { + links.insert("cancelAction".into(), json!({ "href": pending.path })); + } let response = json!({ "config": { @@ -169,6 +175,7 @@ impl Target { &self.key, self.config_data.borrow().as_ref(), self.deployment.borrow().as_ref(), + self.cancel_action.borrow().as_ref(), )); let mut old = MockRef::new(old, &self.server); @@ -310,7 +317,7 @@ impl Target { self.update_poll(); } - /// Configure the server to expect feedback from the target. + /// Configure the server to expect deployment feedback from the target. /// One can then check the feedback has actually been received using /// `hits()` on the returned object. /// @@ -323,7 +330,7 @@ impl Target { /// /// let server = ServerBuilder::default().build(); /// let target = server.add_target("Target1"); - /// let mut mock = target.expect_feedback( + /// let mut mock = target.expect_deployment_feedback( /// "10", /// Execution::Closed, /// Finished::Success, @@ -335,7 +342,7 @@ impl Target { /// //Client send the feedback /// //assert_eq!(mock.hits(), 1); /// ``` - pub fn expect_feedback( + pub fn expect_deployment_feedback( &self, deployment_id: &str, execution: Execution, @@ -343,14 +350,133 @@ impl Target { progress: Option, details: Vec<&str>, ) -> MockRef<'_> { - let progress = progress.unwrap_or(serde_json::Value::Null); + self.server.mock(|when, then| { + let expected = match progress { + Some(progress) => json!({ + "id": deployment_id, + "status": { + "result": { + "progress": progress, + "finished": finished + }, + "execution": execution, + "details": details, + }, + }), + None => json!({ + "id": deployment_id, + "status": { + "result": { + "finished": finished + }, + "execution": execution, + "details": details, + }, + }), + }; + + when.method(POST) + .path(format!( + "/{}/controller/v1/{}/deploymentBase/{}/feedback", + self.tenant, self.name, deployment_id + )) + .header("Authorization", &format!("TargetToken {}", self.key)) + .header("Content-Type", "application/json") + .json_body(expected); + + then.status(200); + }) + } + + /// Push a cancel action update to the target. + /// One can then use [`Target::cancel_action_hits`] to check that the client + /// fetched the details about the cancel action. + /// + /// # Examples + /// + /// ``` + /// use hawkbit_mock::ddi::ServerBuilder; + /// + /// let server = ServerBuilder::default().build(); + /// let target = server.add_target("Target1"); + /// target.cancel_action("5"); + /// + /// // Client fetches details about the cancel action + /// //assert_eq!(target.cancel_action_hits(), 1); + /// ``` + pub fn cancel_action(&self, id: &str) { + let cancel_path = self.server.url(format!( + "/DEFAULT/controller/v1/{}/cancelAction/{}", + self.name, id + )); + let response = json!({ + "id": id, + "cancelAction": { + "stopId": id + } + }); + + let cancel_mock = self.server.mock(|when, then| { + when.method(GET) + .path(format!( + "/DEFAULT/controller/v1/{}/cancelAction/{}", + self.name, id + )) + .header("Authorization", &format!("TargetToken {}", self.key)); + + then.status(200) + .header("Content-Type", "application/json") + .json_body(response); + }); + + self.cancel_action.replace(Some(PendingAction { + server: self.server.clone(), + path: cancel_path, + mock: cancel_mock.id(), + })); + + self.update_poll(); + } + + /// Configure the server to expect cancel feedback from the target. + /// One can then check the feedback has actually been received using + /// `hits()` on the returned object. + /// + /// # Examples + /// + /// ``` + /// use hawkbit_mock::ddi::{ServerBuilder, DeploymentBuilder}; + /// use hawkbit::ddi::{Execution, Finished}; + /// use serde_json::json; + /// + /// let server = ServerBuilder::default().build(); + /// let target = server.add_target("Target1"); + /// target.cancel_action("10"); + /// + /// let mut mock = target.expect_cancel_feedback( + /// "10", + /// Execution::Closed, + /// Finished::Success, + /// vec!["Cancelled"], + /// ); + /// assert_eq!(mock.hits(), 0); + /// + /// //Client send the feedback + /// //assert_eq!(mock.hits(), 1); + /// ``` + pub fn expect_cancel_feedback( + &self, + cancel_id: &str, + execution: Execution, + finished: Finished, + details: Vec<&str>, + ) -> MockRef<'_> { self.server.mock(|when, then| { let expected = json!({ - "id": deployment_id, + "id": cancel_id, "status": { "result": { - "progress": progress, "finished": finished }, "execution": execution, @@ -360,8 +486,8 @@ impl Target { when.method(POST) .path(format!( - "/{}/controller/v1/{}/deploymentBase/{}/feedback", - self.tenant, self.name, deployment_id + "/{}/controller/v1/{}/cancelAction/{}/feedback", + self.tenant, self.name, cancel_id )) .header("Authorization", &format!("TargetToken {}", self.key)) .header("Content-Type", "application/json") @@ -392,6 +518,14 @@ impl Target { mock.hits() }) } + + /// Return the number of times the cancel action URL has been fetched by the client. + pub fn cancel_action_hits(&self) -> usize { + self.cancel_action.borrow().as_ref().map_or(0, |m| { + let mock = MockRef::new(m.mock, &self.server); + mock.hits() + }) + } } struct PendingAction {