diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9b1ec100a..8b2071078 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -867,19 +867,28 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.11" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3" +checksum = "b0956a43b323ac1afaffc053ed5c4b7c1f1800bacd1683c353aabbb752515dd3" dependencies = [ "clap_builder", "clap_derive", ] +[[package]] +name = "clap-markdown" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ebc67e6266e14f8b31541c2f204724fa2ac7ad5c17d6f5908fbb92a60f42cff" +dependencies = [ + "clap", +] + [[package]] name = "clap_builder" -version = "4.5.11" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa" +checksum = "4d72166dd41634086d5803a47eb71ae740e61d84709c36f3c34110173db3961b" dependencies = [ "anstream", "anstyle", @@ -888,11 +897,20 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_complete" +version = "4.5.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8937760c3f4c60871870b8c3ee5f9b30771f792a7045c48bcbba999d7d6b3b8e" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" -version = "4.5.11" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -906,6 +924,16 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +[[package]] +name = "clap_mangen" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17415fd4dfbea46e3274fcd8d368284519b358654772afb700dc2e8d2b24eeb" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.2" @@ -3288,6 +3316,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + [[package]] name = "ron" version = "0.8.1" @@ -4756,6 +4790,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "agama-cli", + "clap", + "clap-markdown", + "clap_complete", + "clap_mangen", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a13f148f3..d2ce142e3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["agama-cli", "agama-server", "agama-lib", "agama-locale-data"] +members = [ + "agama-cli", + "agama-server", + "agama-lib", + "agama-locale-data", + "xtask", +] resolver = "2" [workspace.package] diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs new file mode 100644 index 000000000..cc0b77f71 --- /dev/null +++ b/rust/agama-cli/src/lib.rs @@ -0,0 +1,179 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// 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; + +mod auth; +mod commands; +mod config; +mod error; +mod logs; +mod profile; +mod progress; +mod questions; + +use crate::error::CliError; +use agama_lib::error::ServiceError; +use agama_lib::manager::ManagerClient; +use agama_lib::progress::ProgressMonitor; +use auth::run as run_auth_cmd; +use commands::Commands; +use config::run as run_config_cmd; +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::{ + process::{ExitCode, Termination}, + thread::sleep, + time::Duration, +}; + +/// Agama's command-line interface +/// +/// This program allows inspecting or changing Agama's configuration, handling installation +/// profiles, starting the installation, monitoring the process, etc. +/// +/// Please, use the "help" command to learn more. +#[derive(Parser)] +#[command(name = "agama", about, long_about, max_term_width = 100)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +async fn probe() -> anyhow::Result<()> { + let another_manager = build_manager().await?; + let probe = tokio::spawn(async move { + let _ = another_manager.probe().await; + }); + show_progress().await?; + + Ok(probe.await?) +} + +/// Starts the installation process +/// +/// Before starting, it makes sure that the manager is idle. +/// +/// * `manager`: the manager client. +async fn install(manager: &ManagerClient<'_>, max_attempts: u8) -> anyhow::Result<()> { + if manager.is_busy().await { + println!("Agama's manager is busy. Waiting until it is ready..."); + } + + // Make sure that the manager is ready + manager.wait().await?; + + if !manager.can_install().await? { + return Err(CliError::Validation)?; + } + + let progress = tokio::spawn(async { show_progress().await }); + // Try to start the installation up to max_attempts times. + let mut attempts = 1; + loop { + match manager.install().await { + Ok(()) => break, + Err(e) => { + eprintln!( + "Could not start the installation process: {e}. Attempt {}/{}.", + attempts, max_attempts + ); + } + } + if attempts == max_attempts { + eprintln!("Giving up."); + return Err(CliError::Installation)?; + } + attempts += 1; + sleep(Duration::from_secs(1)); + } + let _ = progress.await; + Ok(()) +} + +async fn show_progress() -> Result<(), ServiceError> { + // wait 1 second to give other task chance to start, so progress can display something + tokio::time::sleep(Duration::from_secs(1)).await; + let conn = agama_lib::connection().await?; + let mut monitor = ProgressMonitor::new(conn).await.unwrap(); + let presenter = InstallerProgress::new(); + monitor + .run(presenter) + .await + .expect("failed to monitor the progress"); + Ok(()) +} + +async fn wait_for_services(manager: &ManagerClient<'_>) -> Result<(), ServiceError> { + let services = manager.busy_services().await?; + // TODO: having it optional + if !services.is_empty() { + eprintln!("The Agama service is busy. Waiting for it to be available..."); + show_progress().await? + } + Ok(()) +} + +async fn build_manager<'a>() -> anyhow::Result> { + let conn = agama_lib::connection().await?; + Ok(ManagerClient::new(conn).await?) +} + +pub 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? + } + Commands::Probe => { + let manager = build_manager().await?; + wait_for_services(&manager).await?; + probe().await? + } + Commands::Profile(subcommand) => run_profile_cmd(subcommand).await?, + Commands::Install => { + let manager = build_manager().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())?, + }; + + Ok(()) +} + +/// Represents the result of execution. +pub enum CliResult { + /// Successful execution. + Ok = 0, + /// Something went wrong. + Error = 1, +} + +impl Termination for CliResult { + fn report(self) -> ExitCode { + ExitCode::from(self as u8) + } +} diff --git a/rust/agama-cli/src/main.rs b/rust/agama-cli/src/main.rs index 881c24410..41d0f3809 100644 --- a/rust/agama-cli/src/main.rs +++ b/rust/agama-cli/src/main.rs @@ -18,166 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_cli::{run_command, Cli, CliResult}; use clap::Parser; -mod auth; -mod commands; -mod config; -mod error; -mod logs; -mod profile; -mod progress; -mod questions; - -use crate::error::CliError; -use agama_lib::error::ServiceError; -use agama_lib::manager::ManagerClient; -use agama_lib::progress::ProgressMonitor; -use auth::run as run_auth_cmd; -use commands::Commands; -use config::run as run_config_cmd; -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::{ - process::{ExitCode, Termination}, - thread::sleep, - time::Duration, -}; - -/// Agama's command-line interface -/// -/// This program allows inspecting or changing Agama's configuration, handling installation -/// profiles, starting the installation, monitoring the process, etc. -/// -/// Please, use the "help" command to learn more. -#[derive(Parser)] -#[command(name = "agama", about, long_about, max_term_width = 100)] -struct Cli { - #[command(subcommand)] - pub command: Commands, -} - -async fn probe() -> anyhow::Result<()> { - let another_manager = build_manager().await?; - let probe = tokio::spawn(async move { - let _ = another_manager.probe().await; - }); - show_progress().await?; - - Ok(probe.await?) -} - -/// Starts the installation process -/// -/// Before starting, it makes sure that the manager is idle. -/// -/// * `manager`: the manager client. -async fn install(manager: &ManagerClient<'_>, max_attempts: u8) -> anyhow::Result<()> { - if manager.is_busy().await { - println!("Agama's manager is busy. Waiting until it is ready..."); - } - - // Make sure that the manager is ready - manager.wait().await?; - - if !manager.can_install().await? { - return Err(CliError::Validation)?; - } - - let progress = tokio::spawn(async { show_progress().await }); - // Try to start the installation up to max_attempts times. - let mut attempts = 1; - loop { - match manager.install().await { - Ok(()) => break, - Err(e) => { - eprintln!( - "Could not start the installation process: {e}. Attempt {}/{}.", - attempts, max_attempts - ); - } - } - if attempts == max_attempts { - eprintln!("Giving up."); - return Err(CliError::Installation)?; - } - attempts += 1; - sleep(Duration::from_secs(1)); - } - let _ = progress.await; - Ok(()) -} - -async fn show_progress() -> Result<(), ServiceError> { - // wait 1 second to give other task chance to start, so progress can display something - tokio::time::sleep(Duration::from_secs(1)).await; - let conn = agama_lib::connection().await?; - let mut monitor = ProgressMonitor::new(conn).await.unwrap(); - let presenter = InstallerProgress::new(); - monitor - .run(presenter) - .await - .expect("failed to monitor the progress"); - Ok(()) -} - -async fn wait_for_services(manager: &ManagerClient<'_>) -> Result<(), ServiceError> { - let services = manager.busy_services().await?; - // TODO: having it optional - if !services.is_empty() { - eprintln!("The Agama service is busy. Waiting for it to be available..."); - show_progress().await? - } - Ok(()) -} - -async fn build_manager<'a>() -> anyhow::Result> { - let conn = agama_lib::connection().await?; - Ok(ManagerClient::new(conn).await?) -} - -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? - } - Commands::Probe => { - let manager = build_manager().await?; - wait_for_services(&manager).await?; - probe().await? - } - Commands::Profile(subcommand) => run_profile_cmd(subcommand).await?, - Commands::Install => { - let manager = build_manager().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())?, - }; - - Ok(()) -} - -/// Represents the result of execution. -pub enum CliResult { - /// Successful execution. - Ok = 0, - /// Something went wrong. - Error = 1, -} - -impl Termination for CliResult { - fn report(self) -> ExitCode { - ExitCode::from(self as u8) - } -} - #[tokio::main] async fn main() -> CliResult { let cli = Cli::parse(); diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 3a80c4fb0..e5cde79d9 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -57,7 +57,7 @@ pub enum ProfileCommands { /// Evaluate a profile, injecting the hardware information from D-Bus /// /// For an example of Jsonnet-based profile, see - /// + /// https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/examples/profile.jsonnet Evaluate { /// Path to jsonnet file. path: PathBuf, diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index a32924896..6a0a15630 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -35,7 +35,7 @@ pub enum QuestionsCommands { /// mode or change the answer in automatic mode. /// /// Please check Agama documentation for more details and examples: - /// + /// https://github.com/openSUSE/agama/blob/master/doc/questions.md Answers { /// Path to a file containing the answers in YAML format. path: String, diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs index def063a4d..896ca0c6d 100644 --- a/rust/agama-lib/src/auth.rs +++ b/rust/agama-lib/src/auth.rs @@ -186,7 +186,7 @@ impl Display for AuthToken { /// Claims that are included in the token. /// -/// See for reference. +/// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. #[derive(Debug, Serialize, Deserialize)] pub struct TokenClaims { pub exp: i64, diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 52475a811..b260d3477 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -41,6 +41,7 @@ Requires: jsonnet Requires: lshw # required by "agama logs store" Requires: gzip +# required to compress the manual pages Requires: tar # required for translating the keyboards descriptions BuildRequires: xkeyboard-config-lang @@ -73,6 +74,39 @@ Url: https://github.com/opensuse/agama %description -n agama-cli Command line program to interact with the Agama installer. +%package -n agama-cli-bash-completion +Summary: Bash Completion for %{name}-cli +Group: System/Shells +Supplements: (%{name}-cli and bash-completion) +Requires: %{name}-cli = %{version} +Requires: bash-completion +BuildArch: noarch + +%description -n agama-cli-bash-completion +Bash command-line completion support for %{name}. + +%package -n agama-cli-fish-completion +Summary: Fish Completion for %{name}-cli +Group: System/Shells +Supplements: (%{name}-cli and fish) +Requires: %{name}-cli = %{version} +Requires: fish +BuildArch: noarch + +%description -n agama-cli-fish-completion +Fish command-line completion support for %{name}-cli. + +%package -n agama-cli-zsh-completion +Summary: Zsh Completion for %{name}-cli +Group: System/Shells +Supplements: (%{name}-cli and zsh) +Requires: %{name}-cli = %{version} +Requires: zsh +BuildArch: noarch + +%description -n agama-cli-zsh-completion +Zsh command-line completion support for %{name}-cli. + %prep %autosetup -a1 -n agama # Remove exec bits to prevent an issue in fedora shebang checking. Uncomment only if required. @@ -80,6 +114,9 @@ Command line program to interact with the Agama installer. %build %{cargo_build} +cargo run --package xtask -- manpages +gzip out/man/* +cargo run --package xtask -- completions %install install -D -d -m 0755 %{buildroot}%{_bindir} @@ -94,6 +131,15 @@ install --directory %{buildroot}%{_datadir}/dbus-1/agama-services install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/org.opensuse.Agama1.service install -D -m 0644 %{_builddir}/agama/share/agama-web-server.service %{buildroot}%{_unitdir}/agama-web-server.service +# install manpages +mkdir -p %{buildroot}%{_mandir}/man1 +install -m 0644 %{_builddir}/agama/out/man/* %{buildroot}%{_mandir}/man1/ + +# install shell completion scripts +install -Dm644 %{_builddir}/agama/out/shell/%{name}.bash %{buildroot}%{_datadir}/bash-completion/completions/%{name} +install -Dm644 %{_builddir}/agama/out/shell/_%{name} %{buildroot}%{_datadir}/zsh/site-functions/_%{name} +install -Dm644 %{_builddir}/agama/out/shell/%{name}.fish %{buildroot}%{_datadir}/fish/vendor_completions.d/%{name}.fish + %check PATH=$PWD/share/bin:$PATH %ifarch aarch64 @@ -129,5 +175,17 @@ echo $PATH %dir %{_datadir}/agama-cli %{_datadir}/agama-cli/agama.libsonnet %{_datadir}/agama-cli/profile.schema.json +%{_mandir}/man1/agama*1%{?ext_man} + +%files -n agama-cli-bash-completion +%{_datadir}/bash-completion/* + +%files -n agama-cli-fish-completion +%dir %{_datadir}/fish +%{_datadir}/fish/* + +%files -n agama-cli-zsh-completion +%dir %{_datadir}/zsh +%{_datadir}/zsh/* %changelog diff --git a/rust/xtask/Cargo.toml b/rust/xtask/Cargo.toml new file mode 100644 index 000000000..eeb44fa15 --- /dev/null +++ b/rust/xtask/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "xtask" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-cli = { path = "../agama-cli" } +clap = { version = "4.5.17", default-features = false } +clap-markdown = "0.1.4" +clap_complete = "4.5.28" +clap_mangen = "0.2.23" diff --git a/rust/xtask/README.md b/rust/xtask/README.md new file mode 100644 index 000000000..cdf17a48c --- /dev/null +++ b/rust/xtask/README.md @@ -0,0 +1,29 @@ +# Agama project tasks + +This package implements a set of project tasks following the [xtask +pattern](https://github.com/matklad/cargo-xtask). This pattern allows writing the typical +maintenance tasks using Rust code. + +## Defined tasks + +- `manpages`: generates manpages for the command-line interface. +- `completions`: generates shell completion snippets for Bash, Fish and Zsh. +- `markdown`: generates a manual page for the command-line interface in Markdown format. Useful to + be included in our website. + +## Running a task + +To run a task, just type `cargo xtask TASK` where `TASK` is the name of the task (check the [Defined +tasks](#defined-tasks) section). + +```shell +cargo xtask manpages +``` + +Most of the artifacts are generated in an `out` directory. You can modify the target by setting the +`OUT_DIR` environment variable. + +## Writing a new task + +Tasks are defined using regular Rust code. Check the [main.rs](src/main.rs) file for further +information. diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs new file mode 100644 index 000000000..ac8f09dfa --- /dev/null +++ b/rust/xtask/src/main.rs @@ -0,0 +1,79 @@ +use std::{env, path::PathBuf}; + +mod tasks { + use std::{fs::File, io::Write}; + + use agama_cli::Cli; + use clap::CommandFactory; + use clap_complete::aot; + use clap_markdown::MarkdownOptions; + + use crate::create_output_dir; + + /// Generate auto-completion snippets for common shells. + pub fn generate_completions() -> std::io::Result<()> { + let out_dir = create_output_dir("shell")?; + + let mut cmd = Cli::command(); + clap_complete::generate_to(aot::Bash, &mut cmd, "agama", &out_dir)?; + clap_complete::generate_to(aot::Fish, &mut cmd, "agama", &out_dir)?; + clap_complete::generate_to(aot::Zsh, &mut cmd, "agama", &out_dir)?; + + println!("Generate shell completions at {}", out_dir.display()); + Ok(()) + } + + /// Generate Agama's CLI documentation in markdown format. + pub fn generate_markdown() -> std::io::Result<()> { + let out_dir = create_output_dir("markdown")?; + + let options = MarkdownOptions::new() + .title("Command-line reference".to_string()) + .show_footer(false); + let markdown = clap_markdown::help_markdown_custom::(&options); + + let filename = out_dir.join("agama.md"); + let mut file = File::create(&filename)?; + file.write_all(markdown.as_bytes())?; + + println!("Generate Markdown documentation at {}", filename.display()); + Ok(()) + } + + /// Generate Agama's CLI man pages. + pub fn generate_manpages() -> std::io::Result<()> { + let out_dir = create_output_dir("man")?; + + let cmd = Cli::command(); + clap_mangen::generate_to(cmd, &out_dir)?; + + println!("Generate manpages documentation at {}", out_dir.display()); + Ok(()) + } +} + +fn create_output_dir(name: &str) -> std::io::Result { + let out_dir = std::env::var_os("OUT_DIR") + .map(PathBuf::from) + .unwrap_or(PathBuf::from("out")) + .join(name); + std::fs::create_dir_all(&out_dir)?; + Ok(out_dir) +} + +fn main() -> std::io::Result<()> { + let Some(task) = env::args().nth(1) else { + eprintln!("You must specify a xtask"); + std::process::exit(1); + }; + + match task.as_str() { + "completions" => tasks::generate_completions(), + "markdown" => tasks::generate_markdown(), + "manpages" => tasks::generate_manpages(), + other => { + eprintln!("Unknown task '{}'", other); + std::process::exit(1); + } + } +}