Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ask cli #1457

Merged
merged 17 commits into from
Jul 16, 2024
Merged

Ask cli #1457

Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions rust/agama-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,28 +118,30 @@ async fn build_manager<'a>() -> anyhow::Result<ManagerClient<'a>> {
Ok(ManagerClient::new(conn).await?)
}

async fn run_command(cli: Cli) -> anyhow::Result<()> {
async fn run_command(cli: Cli) -> Result<(), ServiceError> {
match cli.command {
Commands::Config(subcommand) => {
let manager = build_manager().await?;
wait_for_services(&manager).await?;
run_config_cmd(subcommand).await
run_config_cmd(subcommand).await?
}
Commands::Probe => {
let manager = build_manager().await?;
wait_for_services(&manager).await?;
probe().await
probe().await?
}
Commands::Profile(subcommand) => Ok(run_profile_cmd(subcommand).await?),
Commands::Profile(subcommand) => run_profile_cmd(subcommand).await?,
Commands::Install => {
let manager = build_manager().await?;
install(&manager, 3).await
install(&manager, 3).await?
}
Commands::Questions(subcommand) => run_questions_cmd(subcommand).await,
Commands::Logs(subcommand) => run_logs_cmd(subcommand).await,
Commands::Auth(subcommand) => run_auth_cmd(subcommand).await,
Commands::Download { url } => crate::profile::download(&url, std::io::stdout()),
}
Commands::Questions(subcommand) => run_questions_cmd(subcommand).await?,
Commands::Logs(subcommand) => run_logs_cmd(subcommand).await?,
Commands::Auth(subcommand) => run_auth_cmd(subcommand).await?,
Commands::Download { url } => crate::profile::download(&url, std::io::stdout())?,
};

Ok(())
}

/// Represents the result of execution.
Expand Down
36 changes: 24 additions & 12 deletions rust/agama-cli/src/questions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use agama_lib::connection;
use agama_lib::proxies::Questions1Proxy;
use anyhow::Context;
use agama_lib::questions::http_client::HTTPClient;
use agama_lib::{connection, error::ServiceError};
use clap::{Args, Subcommand, ValueEnum};

#[derive(Subcommand, Debug)]
Expand All @@ -19,6 +19,8 @@ pub enum QuestionsCommands {
/// Path to a file containing the answers in YAML format.
path: String,
},
/// prints list of questions that is waiting for answer in YAML format
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we should use JSON for everything.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is fine for me if agreed on it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reason why I use yaml for it is that in answers file we used for automatic answers we also use yaml.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I know, we use JSON for profiles and YAML for the answers file. I am not saying that we should fix it now, but let's have into account in the future.

List,
}

#[derive(Args, Debug)]
Expand All @@ -35,30 +37,40 @@ pub enum Modes {
NonInteractive,
}

async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> anyhow::Result<()> {
// TODO: how to print dbus error in that anyhow?
async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> Result<(), ServiceError> {
proxy
.set_interactive(value == Modes::Interactive)
.await
.context("Failed to set mode for answering questions.")
.map_err(|e| e.into())
}

async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> anyhow::Result<()> {
// TODO: how to print dbus error in that anyhow?
async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), ServiceError> {
proxy
.add_answer_file(path.as_str())
.await
.context("Failed to set answers from answers file")
.map_err(|e| e.into())
}

pub async fn run(subcommand: QuestionsCommands) -> anyhow::Result<()> {
async fn list_questions() -> Result<(), ServiceError> {
let client = HTTPClient::new().await?;
let questions = client.list_questions().await?;
// FIXME: that conversion to anyhow error is nasty, but we do not expect issue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have a generic error for these cases. But let's keep it as it is now. At some point we should improve our error types.

// when questions are already read from json
// FIXME: if performance is bad, we can skip converting json from http to struct and then
// serialize it, but it won't be pretty string
let questions_json =
serde_json::to_string_pretty(&questions).map_err(Into::<anyhow::Error>::into)?;
println!("{}", questions_json);
Ok(())
}

pub async fn run(subcommand: QuestionsCommands) -> Result<(), ServiceError> {
let connection = connection().await?;
let proxy = Questions1Proxy::new(&connection)
.await
.context("Failed to connect to Questions service")?;
let proxy = Questions1Proxy::new(&connection).await?;

match subcommand {
QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await,
QuestionsCommands::Answers { path } => set_answers(proxy, path).await,
QuestionsCommands::List => list_questions().await,
}
}
133 changes: 133 additions & 0 deletions rust/agama-lib/src/base_http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use anyhow::Context;
use reqwest::{header, Client, Response};
use serde::{de::DeserializeOwned, Serialize};

use crate::{auth::AuthToken, error::ServiceError};

/// Base that all http clients should use.
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
///
/// It provides several features including automatic base url switching,
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
/// websocket events listening or object constructions.
///
/// Usage should be just thin layer in domain specific client.
///
/// ```no_run
/// use agama_lib::questions::model::Question;
/// use agama_lib::base_http_client::BaseHTTPClient;
/// use agama_lib::error::ServiceError;
///
/// async fn get_questions() -> Result<Vec<Question>, ServiceError> {
/// let client = BaseHTTPClient::new()?;
/// client.get("/questions").await
/// }
/// ```
pub struct BaseHTTPClient {
client: Client,
pub base_url: String,
}

const API_URL: &str = "http://localhost/api";

impl BaseHTTPClient {
// if there is need for client without authorization, create new constructor for it
pub fn new() -> Result<Self, ServiceError> {
let token = AuthToken::find().context("You are not logged in")?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message is correct (I hope) but even better would be a hint how to fix the situation: "... Try: agama auth login"?


let mut headers = header::HeaderMap::new();
// just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token
let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str())
.map_err(|e| anyhow::Error::new(e))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still no convinced of using anyhow, especially in a library. But we can postpone that until we refactor the ServiceError stuff.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

me neither...I basically use it for trash error which should not happen instead of unwrap


headers.insert(header::AUTHORIZATION, value);

let client = Client::builder().default_headers(headers).build()?;

Ok(Self {
client,
base_url: API_URL.to_string(), // TODO: add support for remote server
})
}

const NO_TEXT: &'static str = "No text";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about the "No text" value. Why not an empty string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To see that error from backend does not provide any text. For me less confusing to write explicitelly that no text received from backend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the const definition closer to the method that uses it. That also makes it more apparent what it should say

/// Simple wrapper around Response to get object from response.
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
/// If complete Response is needed use get_response method.
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ServiceError> {
let response = self.get_response(path).await?;
if response.status().is_success() {
response.json::<T>().await.map_err(|e| e.into())
} else {
Err(self.build_backend_error(response).await)
}
}

/// Calls GET method on given path and return Response that can be further
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
/// processed. If only simple object from json is required, use method get.
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
pub async fn get_response(&self, path: &str) -> Result<Response, ServiceError> {
self.client
.get(self.url(path))
.send()
.await
.map_err(|e| e.into())
}

fn url(&self, path: &str) -> String {
self.base_url.clone() + path
}

/// post object to given path and report error if response is not success
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
pub async fn post(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stupid question, is it normal that a successful HTTP POST returns no useful text? At least in Agama?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, response is optional in POST. I check what we have in agama-web-server openapi and it is like half to half if we return something or not...if we need it more often we can create variant that also return object...I am just not sure what will be good name :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, a POST call does not need to return anything. I am not convinced about using "no text" string. Why not just return an empty body? In any case, we can evolve this API when we use it.

let response = self.post_response(path, object).await?;
if response.status().is_success() {
Ok(())
} else {
Err(self.build_backend_error(response).await)
}
}

/// post object to given path and returns server response. Reports error only if failed to send
/// request, but if server returns e.g. 500, it will be in Ok result.
/// In general unless specific response handling is needed, simple post should be used.
pub async fn post_response(
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
&self,
path: &str,
object: &impl Serialize,
) -> Result<Response, ServiceError> {
self.client
.post(self.url(path))
.json(object)
.send()
.await
.map_err(|e| e.into())
}

/// delete call on given path and report error if failed
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
pub async fn delete(&self, path: &str) -> Result<(), ServiceError> {
let response = self.delete_response(path).await?;
if response.status().is_success() {
Ok(())
} else {
Err(self.build_backend_error(response).await)
}
}

/// delete call on given path and returns server response. Reports error only if failed to send
/// request, but if server returns e.g. 500, it will be in Ok result.
/// In general unless specific response handling is needed, simple delete should be used.
/// TODO: do not need variant with request body? if so, then create additional method.
pub async fn delete_response(&self, path: &str) -> Result<Response, ServiceError> {
self.client
.delete(self.url(path))
.send()
.await
.map_err(|e| e.into())
}

pub async fn build_backend_error(&self, response: Response) -> ServiceError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you might want to implement something like From<Response> (or TryFrom).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it be asynchronous? As it is not simple conversion but it also ask for text.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it is a bit against documentation of traits which mention that it should be obvious to convert...which I am not sure about this case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, async could be a problem. Let's keep it as it is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About when to implement those traits, I am usually unsure 😅

let code = response.status().as_u16();
let text = response
.text()
.await
.unwrap_or_else(|_| Self::NO_TEXT.to_string());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"No text" -> "(failed to extract error text from HTTP response)" is perhaps too verbose but I would appreciate it when debugging it as an operator

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I check quite deeply when call to text can fail and to be honest I do not find it either in documentation neither from code. So lets use your suggestion.

ServiceError::BackendError(code, text)
}
}
4 changes: 4 additions & 0 deletions rust/agama-lib/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub enum ServiceError {
DBusProtocol(#[from] zbus::fdo::Error),
#[error("Unexpected type on D-Bus '{0}'")]
ZVariant(#[from] zvariant::Error),
#[error("Failed to communicate with HTTP backend '{0}'")]
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
HTTPError(#[from] reqwest::Error),
// it's fine to say only "Error" because the original
// specific error will be printed too
#[error("Error: {0}")]
Expand All @@ -33,6 +35,8 @@ pub enum ServiceError {
UnsuccessfulAction(String),
#[error("Unknown installation phase: '{0}")]
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
UnknownInstallationPhase(u32),
#[error("Backend call failed with status '{0}' and text '{1}'")]
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
BackendError(u16, String),
}

#[derive(Error, Debug)]
Expand Down
1 change: 1 addition & 0 deletions rust/agama-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
//! As said, those modules might implement additional stuff, like specific types, clients, etc.

pub mod auth;
pub mod base_http_client;
pub mod error;
pub mod install_settings;
pub mod localization;
Expand Down
4 changes: 4 additions & 0 deletions rust/agama-lib/src/questions.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
//! Data model for Agama questions

use std::collections::HashMap;
pub mod http_client;
pub mod model;

/// Basic generic question that fits question without special needs
/// NOTE: structs below is for dbus usage and holds complete questions data
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
/// for user side data model see questions::model
#[derive(Clone, Debug)]
pub struct GenericQuestion {
/// numeric id used to identify question on D-Bus
Expand Down
69 changes: 69 additions & 0 deletions rust/agama-lib/src/questions/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use std::time::Duration;

use reqwest::StatusCode;
use tokio::time::sleep;

use crate::{base_http_client::BaseHTTPClient, error::ServiceError};

use super::model::{self, Answer, Question};

pub struct HTTPClient {
jreidinger marked this conversation as resolved.
Show resolved Hide resolved
client: BaseHTTPClient,
}

impl HTTPClient {
pub async fn new() -> Result<Self, ServiceError> {
Ok(Self {
client: BaseHTTPClient::new()?,
})
}

pub async fn list_questions(&self) -> Result<Vec<model::Question>, ServiceError> {
self.client.get("/questions").await
}

/// Creates question and return newly created question including id
pub async fn create_question(&self, question: &Question) -> Result<Question, ServiceError> {
let response = self.client.post_response("/questions", question).await?;
if response.status().is_success() {
let question = response.json().await?;
Ok(question)
} else {
Err(self.client.build_backend_error(response).await)
}
}

/// non blocking varient of checking if question has already answer
pub async fn try_answer(&self, question_id: u32) -> Result<Option<Answer>, ServiceError> {
let path = format!("/questions/{}/answer", question_id);
let response = self.client.get_response(path.as_str()).await?;
if response.status() == StatusCode::NOT_FOUND {
Ok(None)
} else if response.status().is_success() {
let answer = response.json().await?;
Ok(answer)
} else {
Err(self.client.build_backend_error(response).await)
}
}

/// Blocking variant of getting answer for given question.
pub async fn get_answer(&self, question_id: u32) -> Result<Answer, ServiceError> {
loop {
let answer = self.try_answer(question_id).await?;
if let Some(result) = answer {
return Ok(result);
}
let duration = Duration::from_secs(1);
sleep(duration).await;
// TODO: use websocket to get events instead of polling, but be aware of race condition that
// auto answer can answer faster before we connect to socket. So ask for answer
// and meanwhile start checking for events
}
}

pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> {
let path = format!("/questions/{}/answer", question_id);
self.client.delete(path.as_str()).await
}
}
Loading