Skip to content

Commit

Permalink
Expose the software service through the HTTP/JSON API (#1069)
Browse files Browse the repository at this point in the history
Trello:
https://trello.com/c/2MjaulMd/3566-3-expose-the-software-api-over-http

This PR exposes the public[^1] part of the software API through the
HTTP/JSON interface. It includes:

* `/software/patterns`: list of patterns, including whether they are
selected (and by whom).
* `/software/products`: list of products.
* `/software/probe`: starts the software probing.
* `/software/config`: describing the software configuration.

```json
{
  "patterns": [
    "gnome"
  ],
  "product": "Tumbleweed"
}
```

Additionally, it exposes the following events through the websocket:

* `PatternsChanged`, containing the patterns and who selected them
('user' or 'auto').
* `ProductChanged`, with the name of the new product.

[^1] The part of the D-Bus API that it is used by the web UI.

## Implementation details

In this phase of the development, we are still deciding the best way to
implement these HTTP/JSON interfaces. The implementation has two parts:

* The [HTTP/JSON interface
itself](https://github.com/openSUSE/agama/blob/http-software-srv/rust/agama-server/src/software/web.rs#L104),
which is implemented as a
[Router](https://docs.rs/axum/latest/axum/struct.Router.html) and a set
of small functions (one per each endpoing+verb).
* An [events
stream](https://github.com/openSUSE/agama/blob/http-software-srv/rust/agama-server/src/software/web.rs#L60)
which emits Event values (see
[stream](https://tokio.rs/tokio/tutorial/streams) in the Tokio
documentation).

About the service status, the zbus proxies are cached and can be cloned,
so it looks like a good idea to keep our clients as part of the state.

## In the future

There are a few improvements we could consider in the future:

* Split the events in different types depending on the service
(`SoftwareEvent`, `ManagerEvent`), so those services only know about
their types.
* Consolidate all errors under a common one (e.g.,
`Error::Software(SoftwareError)`, `Error::Manager(ManagerError)`, etc.)
to have a single `IntoResponse` implementation while keeping modules
isolation.
  • Loading branch information
imobachgs committed Mar 5, 2024
2 parents 3392064 + 4c0cb3b commit 7d5e54c
Show file tree
Hide file tree
Showing 14 changed files with 433 additions and 40 deletions.
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/agama-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ thiserror = "1.0.39"
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1.14"
url = "2.5.0"
utoipa = "4.2.0"
zbus = { version = "3", default-features = false, features = ["tokio"] }
2 changes: 1 addition & 1 deletion rust/agama-lib/src/product.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ mod proxies;
mod settings;
mod store;

pub use client::ProductClient;
pub use client::{Product, ProductClient};
pub use settings::ProductSettings;
pub use store::ProductStore;
3 changes: 2 additions & 1 deletion rust/agama-lib/src/product/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use zbus::Connection;
use super::proxies::RegistrationProxy;

/// Represents a software product
#[derive(Debug, Serialize)]
#[derive(Default, Debug, Serialize, utoipa::ToSchema)]
pub struct Product {
/// Product ID (eg., "ALP", "Tumbleweed", etc.)
pub id: String,
Expand All @@ -19,6 +19,7 @@ pub struct Product {
}

/// D-Bus client for the software service
#[derive(Clone)]
pub struct ProductClient<'a> {
product_proxy: SoftwareProductProxy<'a>,
registration_proxy: RegistrationProxy<'a>,
Expand Down
2 changes: 1 addition & 1 deletion rust/agama-lib/src/software.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ pub mod proxies;
mod settings;
mod store;

pub use client::SoftwareClient;
pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy};
pub use settings::SoftwareSettings;
pub use store::SoftwareStore;
70 changes: 66 additions & 4 deletions rust/agama-lib/src/software/client.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use super::proxies::Software1Proxy;
use crate::error::ServiceError;
use serde::Serialize;
use std::collections::HashMap;
use zbus::Connection;

/// Represents a software product
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct Pattern {
/// Pattern ID (eg., "aaa_base", "gnome")
pub id: String,
Expand All @@ -20,7 +21,35 @@ pub struct Pattern {
pub order: String,
}

/// Represents the reason why a pattern is selected.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub enum SelectedBy {
/// The pattern was selected by the user.
User = 0,
/// The pattern was selected automatically.
Auto = 1,
/// The pattern has not be selected.
None = 2,
}

#[derive(Debug, thiserror::Error)]
#[error("Unknown selected by value: '{0}'")]
pub struct UnknownSelectedBy(u8);

impl TryFrom<u8> for SelectedBy {
type Error = UnknownSelectedBy;

fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::User),
1 => Ok(Self::Auto),
_ => Err(UnknownSelectedBy(value)),
}
}
}

/// D-Bus client for the software service
#[derive(Clone)]
pub struct SoftwareClient<'a> {
software_proxy: Software1Proxy<'a>,
}
Expand Down Expand Up @@ -55,14 +84,35 @@ impl<'a> SoftwareClient<'a> {

/// Returns the ids of patterns selected by user
pub async fn user_selected_patterns(&self) -> Result<Vec<String>, ServiceError> {
const USER_SELECTED: u8 = 0;
let patterns: Vec<String> = self
.software_proxy
.selected_patterns()
.await?
.into_iter()
.filter(|(_id, reason)| *reason == USER_SELECTED)
.map(|(id, _reason)| id)
.filter_map(|(id, reason)| match SelectedBy::try_from(reason) {
Ok(reason) if reason == SelectedBy::User => Some(id),
Ok(_reason) => None,
Err(e) => {
log::warn!("Ignoring pattern {}. Error: {}", &id, e);
None
}
})
.collect();
Ok(patterns)
}

/// Returns the selected pattern and the reason each one selected.
pub async fn selected_patterns(&self) -> Result<HashMap<String, SelectedBy>, ServiceError> {
let patterns = self.software_proxy.selected_patterns().await?;
let patterns = patterns
.into_iter()
.filter_map(|(id, reason)| match SelectedBy::try_from(reason) {
Ok(reason) => Some((id, reason)),
Err(e) => {
log::warn!("Ignoring pattern {}. Error: {}", &id, e);
None
}
})
.collect();
Ok(patterns)
}
Expand All @@ -80,4 +130,16 @@ impl<'a> SoftwareClient<'a> {
Ok(())
}
}

/// Returns the required space for installing the selected patterns.
///
/// It returns a formatted string including the size and the unit.
pub async fn used_disk_space(&self) -> Result<String, ServiceError> {
Ok(self.software_proxy.used_disk_space().await?)
}

/// Starts the process to read the repositories data.
pub async fn probe(&self) -> Result<(), ServiceError> {
Ok(self.software_proxy.probe().await?)
}
}
37 changes: 24 additions & 13 deletions rust/agama-server/src/agama-web-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@ use agama_dbus_server::{
l10n::helpers,
web::{self, run_monitor},
};
use clap::{Parser, Subcommand};
use agama_lib::connection_to;
use clap::{Args, Parser, Subcommand};
use tokio::sync::broadcast::channel;
use tracing_subscriber::prelude::*;
use utoipa::OpenApi;

#[derive(Subcommand, Debug)]
enum Commands {
/// Start the API server.
Serve {
// Address to listen on (":::3000" listens for both IPv6 and IPv4
// connections unless manually disabled in /proc/sys/net/ipv6/bindv6only)
#[arg(long, default_value = ":::3000")]
address: String,
},
Serve(ServeArgs),
/// Display the API documentation in OpenAPI format.
Openapi,
}

#[derive(Debug, Args)]
pub struct ServeArgs {
// Address to listen on (":::3000" listens for both IPv6 and IPv4
// connections unless manually disabled in /proc/sys/net/ipv6/bindv6only)
#[arg(long, default_value = ":::3000")]
address: String,
// Agama D-Bus address
#[arg(long, default_value = "unix:path=/run/agama/bus")]
dbus_address: String,
}

#[derive(Parser, Debug)]
#[command(
version,
Expand All @@ -33,22 +40,26 @@ struct Cli {
}

/// Start serving the API.
async fn serve_command(address: &str) -> anyhow::Result<()> {
///
/// `args`: command-line arguments.
async fn serve_command(args: ServeArgs) -> anyhow::Result<()> {
let journald = tracing_journald::layer().expect("could not connect to journald");
tracing_subscriber::registry().with(journald).init();

let listener = tokio::net::TcpListener::bind(address)
let listener = tokio::net::TcpListener::bind(&args.address)
.await
.unwrap_or_else(|_| panic!("could not listen on {}", address));
.unwrap_or_else(|_| panic!("could not listen on {}", &args.address));

let (tx, _) = channel(16);
run_monitor(tx.clone()).await?;

let config = web::ServiceConfig::load().unwrap();
let service = web::service(config, tx);
let config = web::ServiceConfig::load()?;
let dbus = connection_to(&args.dbus_address).await?;
let service = web::service(config, tx, dbus).await?;
axum::serve(listener, service)
.await
.expect("could not mount app on listener");

Ok(())
}

Expand All @@ -60,7 +71,7 @@ fn openapi_command() -> anyhow::Result<()> {

async fn run_command(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Commands::Serve { address } => serve_command(&address).await,
Commands::Serve(args) => serve_command(args).await,
Commands::Openapi => openapi_command(),
}
}
Expand Down
1 change: 1 addition & 0 deletions rust/agama-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ pub mod error;
pub mod l10n;
pub mod network;
pub mod questions;
pub mod software;
pub mod web;
pub use web::service;
2 changes: 2 additions & 0 deletions rust/agama-server/src/software.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod web;
pub use web::{software_service, software_stream};
Loading

0 comments on commit 7d5e54c

Please sign in to comment.