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

feat(rust): allow passing the API URL through the CLI #1495

Merged
merged 36 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7a7e128
Some basic support for global options in agama-cli
mchf Jul 17, 2024
927c338
auth commmand rewritten using BaseHTTPClient
mchf Jul 24, 2024
89e1d9b
Formatting
mchf Jul 24, 2024
8c6f985
Agama CLI auth login command fully transformed to use new http client
mchf Aug 8, 2024
38e3e2b
Formatting
mchf Aug 1, 2024
bf1613d
Cleanup
mchf Aug 1, 2024
2715ba7
Put custom url to use
mchf Aug 8, 2024
e218002
Minor fine tuning on user provided url when needed.
mchf Aug 2, 2024
96f2ece
Formatting
mchf Aug 8, 2024
eb89128
Adapted to changes in agama-lib's base_http_client
mchf Aug 14, 2024
8a2ac5e
Formatting
mchf Aug 14, 2024
fe150a9
Some comments
mchf Aug 22, 2024
dbd7aab
Turned NetworkClient structure to use BaseHTTPClient internally.
mchf Aug 26, 2024
0358e49
Formatting
mchf Aug 26, 2024
fb56ac7
Small refactoring: share the HTTP Client initialization for CLI cmds
mchf Aug 27, 2024
a32b6a8
Formatting
mchf Aug 29, 2024
989d118
Adapted LocalizationStore to accept base http client created outside
mchf Aug 29, 2024
c7a03ad
FIXME revert: install dbus-1-daemon
mvidner Aug 29, 2024
f62c5f0
Made UsersStore to use new http client internally
mchf Aug 30, 2024
0ccdbd8
After merge fixups
mchf Sep 30, 2024
5725953
Replaced remaining pieces (software, product, storage) to use http
mchf Oct 2, 2024
23bd685
Automatically accept self-signed certificates (including other invalid!)
mchf Oct 3, 2024
1f23cd6
Cleaning some mess
mchf Oct 3, 2024
c627ad9
Ask user if insecure connection to api server is allowed
mchf Oct 7, 2024
0960b46
Minor doc
mchf Oct 7, 2024
fa45946
Minor tweaks
mchf Oct 7, 2024
23828db
Small refactoring in remote api detection
mchf Oct 7, 2024
ae7227c
Minor tweaks from the review
mchf Oct 10, 2024
3040bd9
Added import missed in rebase, formatting
mchf Oct 16, 2024
7db1db1
Updated changelog
mchf Oct 10, 2024
fdc35d9
Minor tweaks inspired by the review
mchf Oct 14, 2024
6abd98f
Introduced --insecure option. Simplified check on remote API
mchf Oct 14, 2024
8fa7850
Refactoring: simplified BaseHTTPClient interface
mchf Oct 16, 2024
4e69f83
Formatting
mchf Oct 16, 2024
d634766
Adapted Questions subcommand to work with new --api option
mchf Oct 16, 2024
e3a9d77
(One of the) final polishing(s)
mchf Oct 16, 2024
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
74 changes: 37 additions & 37 deletions rust/agama-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,42 @@
// To contact SUSE LLC about this file by physical or electronic mail, you may
// find current contact information at www.suse.com.

use agama_lib::auth::AuthToken;
use agama_lib::{auth::AuthToken, error::ServiceError};
use clap::Subcommand;

use crate::error::CliError;
use agama_lib::base_http_client::BaseHTTPClient;
use inquire::Password;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use std::collections::HashMap;
use std::io::{self, IsTerminal};

const DEFAULT_AUTH_URL: &str = "http://localhost/api/auth";
/// HTTP client to handle authentication
struct AuthHTTPClient {
api: BaseHTTPClient,
mchf marked this conversation as resolved.
Show resolved Hide resolved
}

impl AuthHTTPClient {
pub fn load(client: BaseHTTPClient) -> Result<Self, ServiceError> {
Ok(Self { api: client })
}

/// Query web server for JWT
pub async fn authenticate(&self, password: String) -> anyhow::Result<String> {
let mut auth_body = HashMap::new();

auth_body.insert("password", password);

let response = self
.api
.post::<HashMap<String, String>>("/auth", &auth_body)
.await?;

match response.get("token") {
Some(token) => Ok(token.clone()),
None => Err(anyhow::anyhow!("Failed to get authentication token")),
}
}
}

#[derive(Subcommand, Debug)]
pub enum AuthCommands {
Expand All @@ -43,9 +70,11 @@ pub enum AuthCommands {
}

/// Main entry point called from agama CLI main loop
pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> {
pub async fn run(client: BaseHTTPClient, subcommand: AuthCommands) -> anyhow::Result<()> {
let auth_client = AuthHTTPClient::load(client)?;

match subcommand {
AuthCommands::Login => login(read_password()?).await,
AuthCommands::Login => login(auth_client, read_password()?).await,
AuthCommands::Logout => logout(),
AuthCommands::Show => show(),
}
Expand All @@ -57,6 +86,7 @@ pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> {
/// user.
fn read_password() -> Result<String, CliError> {
let stdin = io::stdin();

let password = if stdin.is_terminal() {
ask_password()?
} else {
Expand All @@ -77,40 +107,10 @@ fn ask_password() -> Result<String, CliError> {
.map_err(CliError::InteractivePassword)
}

/// Necessary http request header for authenticate
fn authenticate_headers() -> HeaderMap {
let mut headers = HeaderMap::new();

headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));

headers
}

/// Query web server for JWT
async fn get_jwt(url: String, password: String) -> anyhow::Result<String> {
let client = reqwest::Client::new();
let response = client
.post(url)
.headers(authenticate_headers())
.body(format!("{{\"password\": \"{}\"}}", password))
.send()
.await?;
let body = response
.json::<std::collections::HashMap<String, String>>()
.await?;
let value = body.get("token");

if let Some(token) = value {
return Ok(token.clone());
}

Err(anyhow::anyhow!("Failed to get authentication token"))
}

/// Logs into the installation web server and stores JWT for later use.
async fn login(password: String) -> anyhow::Result<()> {
async fn login(client: AuthHTTPClient, password: String) -> anyhow::Result<()> {
// 1) ask web server for JWT
let res = get_jwt(DEFAULT_AUTH_URL.to_string(), password).await?;
let res = client.authenticate(password).await?;
let token = AuthToken::new(&res);
Ok(token.write_user_token()?)
}
Expand Down
14 changes: 5 additions & 9 deletions rust/agama-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ use std::{
};

use crate::show_progress;
use agama_lib::{auth::AuthToken, install_settings::InstallSettings, Store as SettingsStore};
use agama_lib::{
base_http_client::BaseHTTPClient, install_settings::InstallSettings, Store as SettingsStore,
};
use anyhow::anyhow;
use clap::Subcommand;
use std::io::Write;
Expand Down Expand Up @@ -59,14 +61,8 @@ pub enum ConfigCommands {
},
}

pub async fn run(subcommand: ConfigCommands) -> anyhow::Result<()> {
let Some(token) = AuthToken::find() else {
println!("You need to login for generating a valid token: agama auth login");
return Ok(());
};

let client = agama_lib::http_client(token.as_str())?;
let store = SettingsStore::new(client).await?;
pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> anyhow::Result<()> {
let store = SettingsStore::new(http_client).await?;

match subcommand {
ConfigCommands::Show => {
Expand Down
76 changes: 68 additions & 8 deletions rust/agama-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// To contact SUSE LLC about this file by physical or electronic mail, you may
// find current contact information at www.suse.com.

use clap::Parser;
use clap::{Args, Parser};

mod auth;
mod commands;
Expand All @@ -30,22 +30,38 @@ mod progress;
mod questions;

use crate::error::CliError;
use agama_lib::base_http_client::BaseHTTPClient;
use agama_lib::{
error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, transfer::Transfer,
};
use auth::run as run_auth_cmd;
use commands::Commands;
use config::run as run_config_cmd;
use inquire::Confirm;
use logs::run as run_logs_cmd;
use profile::run as run_profile_cmd;
use progress::InstallerProgress;
use questions::run as run_questions_cmd;
use std::{
collections::HashMap,
process::{ExitCode, Termination},
thread::sleep,
time::Duration,
};

/// Agama's CLI global options
#[derive(Args)]
pub struct GlobalOpts {
#[clap(long, default_value = "http://localhost/api")]
/// URI pointing to Agama's remote API. If not provided, default https://localhost/api is
/// used
pub api: String,

#[clap(long, default_value = "false")]
/// Whether to accept invalid (self-signed, ...) certificates or not
pub insecure: bool,
}

/// Agama's command-line interface
///
/// This program allows inspecting or changing Agama's configuration, handling installation
Expand All @@ -55,6 +71,9 @@ use std::{
#[derive(Parser)]
#[command(name = "agama", about, long_about, max_term_width = 100)]
pub struct Cli {
#[clap(flatten)]
pub opts: GlobalOpts,

#[command(subcommand)]
pub command: Commands,
}
Expand Down Expand Up @@ -138,13 +157,50 @@ async fn build_manager<'a>() -> anyhow::Result<ManagerClient<'a>> {
Ok(ManagerClient::new(conn).await?)
}

/// True if use of the remote API is allowed (yes by default when the API is secure, the user is
/// asked if the API is insecure - e.g. when it uses self-signed certificate)
async fn allowed_insecure_api(use_insecure: bool, api_url: String) -> Result<bool, ServiceError> {
// fake client used for remote site detection
let mut ping_client = BaseHTTPClient::default();
Copy link
Contributor

Choose a reason for hiding this comment

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

It is a surprise that there is no constructor to set the URL.

ping_client.base_url = api_url;

// decide whether access to remote site has to be insecure (self-signed certificate or not)
match ping_client.get::<HashMap<String, String>>("/ping").await {
// Problem with http remote API reachability
Err(ServiceError::HTTPError(_)) => Ok(use_insecure || Confirm::new("There was a problem with the remote API and it is treated as insecure. Do you want to continue?")
.with_default(false)
.prompt()
.unwrap_or(false)),
// another error
Err(e) => Err(e),
// success doesn't bother us here
Ok(_) => Ok(false)
}
}

pub async fn run_command(cli: Cli) -> Result<(), ServiceError> {
// somehow check whether we need to ask user for self-signed certificate acceptance
let api_url = cli.opts.api.trim_end_matches('/').to_string();

let mut client = BaseHTTPClient::default();

client.base_url = api_url.clone();

if allowed_insecure_api(cli.opts.insecure, api_url.clone()).await? {
client = client.insecure();
}

// we need to distinguish commands on those which assume that authentication JWT is already
// available and those which not (or don't need it)
client = if let Commands::Auth(_) = cli.command {
client.unauthenticated()?
} else {
// this deals with authentication need inside
client.authenticated()?
};

match cli.command {
Commands::Config(subcommand) => {
let manager = build_manager().await?;
wait_for_services(&manager).await?;
run_config_cmd(subcommand).await?
}
Commands::Config(subcommand) => run_config_cmd(client, subcommand).await?,
Commands::Probe => {
let manager = build_manager().await?;
wait_for_services(&manager).await?;
Expand All @@ -155,10 +211,14 @@ pub async fn run_command(cli: Cli) -> Result<(), ServiceError> {
let manager = build_manager().await?;
install(&manager, 3).await?
}
Commands::Questions(subcommand) => run_questions_cmd(subcommand).await?,
Commands::Questions(subcommand) => run_questions_cmd(client, subcommand).await?,
// TODO: logs command was originally designed with idea that agama's cli and agama
// installation runs on the same machine, so it is unable to do remote connection
Commands::Logs(subcommand) => run_logs_cmd(subcommand).await?,
Commands::Auth(subcommand) => run_auth_cmd(subcommand).await?,
Commands::Download { url } => Transfer::get(&url, std::io::stdout())?,
Commands::Auth(subcommand) => {
run_auth_cmd(client, subcommand).await?;
}
};

Ok(())
Expand Down
6 changes: 2 additions & 4 deletions rust/agama-cli/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// find current contact information at www.suse.com.

use agama_lib::{
auth::AuthToken,
base_http_client::BaseHTTPClient,
install_settings::InstallSettings,
profile::{AutoyastProfile, ProfileEvaluator, ProfileValidator, ValidationResult},
transfer::Transfer,
Expand Down Expand Up @@ -153,9 +153,7 @@ async fn import(url_string: String, dir: Option<PathBuf>) -> anyhow::Result<()>
}

async fn store_settings<P: AsRef<Path>>(path: P) -> anyhow::Result<()> {
let token = AuthToken::find().context("You are not logged in")?;
let client = agama_lib::http_client(token.as_str())?;
let store = SettingsStore::new(client).await?;
let store = SettingsStore::new(BaseHTTPClient::default().authenticated()?).await?;
let settings = InstallSettings::from_file(&path)?;
store.store(&settings).await?;
Ok(())
Expand Down
19 changes: 11 additions & 8 deletions rust/agama-cli/src/questions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

use agama_lib::proxies::Questions1Proxy;
use agama_lib::questions::http_client::HTTPClient;
use agama_lib::{connection, error::ServiceError};
use agama_lib::{base_http_client::BaseHTTPClient, connection, error::ServiceError};
use clap::{Args, Subcommand, ValueEnum};

// TODO: use for answers also JSON to be consistent
Expand Down Expand Up @@ -74,8 +74,8 @@ async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), Ser
.map_err(|e| e.into())
}

async fn list_questions() -> Result<(), ServiceError> {
let client = HTTPClient::new()?;
async fn list_questions(client: BaseHTTPClient) -> Result<(), ServiceError> {
let client = HTTPClient::new(client)?;
let questions = client.list_questions().await?;
// 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
Expand All @@ -85,8 +85,8 @@ async fn list_questions() -> Result<(), ServiceError> {
Ok(())
}

async fn ask_question() -> Result<(), ServiceError> {
let client = HTTPClient::new()?;
async fn ask_question(client: BaseHTTPClient) -> Result<(), ServiceError> {
let client = HTTPClient::new(client)?;
let question = serde_json::from_reader(std::io::stdin())?;

let created_question = client.create_question(&question).await?;
Expand All @@ -104,14 +104,17 @@ async fn ask_question() -> Result<(), ServiceError> {
Ok(())
}

pub async fn run(subcommand: QuestionsCommands) -> Result<(), ServiceError> {
pub async fn run(
client: BaseHTTPClient,
subcommand: QuestionsCommands,
) -> Result<(), ServiceError> {
let connection = connection().await?;
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,
QuestionsCommands::Ask => ask_question().await,
QuestionsCommands::List => list_questions(client).await,
QuestionsCommands::Ask => ask_question(client).await,
}
}
Loading