Skip to content

Commit

Permalink
feat(rust): allow passing the API URL through the CLI (#1495)
Browse files Browse the repository at this point in the history
## Problem

We want to be able to use different remote installation servers 


## Solution

- Introduced global option (--api) for user to provide remote api url
- Introduced global option (--insecure) for user to explicitly allow connecting to remote insecure api
- Used newly introduced BaseHTTPClient to handle urls.

modified or refactored clients:
- auth, network, localization, users, software, product, storage, questions

## Testing

- *Tested manually*
  • Loading branch information
mchf authored Oct 16, 2024
2 parents ebf9860 + e3a9d77 commit 4cefd9b
Show file tree
Hide file tree
Showing 22 changed files with 216 additions and 199 deletions.
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,
}

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();
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

0 comments on commit 4cefd9b

Please sign in to comment.