forked from lix-project/lix-installer
60e5fff623
* Switch to flakehub * Diagnostics: support user-defined attribution. Allows a user to specify an additional value to associate their diagnostics with that value. nix-installer doesn't generate or store these values, and most users have no need for it. * Respond to feedback
278 lines
9.2 KiB
Rust
278 lines
9.2 KiB
Rust
/*! Diagnostic reporting functionality
|
|
|
|
When enabled with the `diagnostics` feature (default) this module provides automated install success/failure reporting to an endpoint.
|
|
|
|
That endpoint can be a URL such as `https://our.project.org/nix-installer/diagnostics` or `file:///home/$USER/diagnostic.json` which receives a [`DiagnosticReport`] in JSON format.
|
|
*/
|
|
|
|
use std::{path::PathBuf, time::Duration};
|
|
|
|
use os_release::OsRelease;
|
|
use reqwest::Url;
|
|
|
|
use crate::{
|
|
action::ActionError, parse_ssl_cert, planner::PlannerError, settings::InstallSettingsError,
|
|
CertificateError, NixInstallerError,
|
|
};
|
|
|
|
/// The static of an action attempt
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
|
|
pub enum DiagnosticStatus {
|
|
Cancelled,
|
|
Success,
|
|
Pending,
|
|
Failure,
|
|
}
|
|
|
|
/// The action attempted
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Copy)]
|
|
pub enum DiagnosticAction {
|
|
Install,
|
|
Uninstall,
|
|
}
|
|
|
|
/// A report sent to an endpoint
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
|
|
pub struct DiagnosticReport {
|
|
pub attribution: Option<String>,
|
|
pub version: String,
|
|
pub planner: String,
|
|
pub configured_settings: Vec<String>,
|
|
pub os_name: String,
|
|
pub os_version: String,
|
|
pub triple: String,
|
|
pub is_ci: bool,
|
|
pub action: DiagnosticAction,
|
|
pub status: DiagnosticStatus,
|
|
/// Generally this includes the [`strum::IntoStaticStr`] representation of the error, we take special care not to include parameters of the error (which may include secrets)
|
|
pub failure_chain: Option<Vec<String>>,
|
|
}
|
|
|
|
/// A preparation of data to be sent to the `endpoint`.
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Default)]
|
|
pub struct DiagnosticData {
|
|
attribution: Option<String>,
|
|
version: String,
|
|
planner: String,
|
|
configured_settings: Vec<String>,
|
|
os_name: String,
|
|
os_version: String,
|
|
triple: String,
|
|
is_ci: bool,
|
|
endpoint: Option<Url>,
|
|
ssl_cert_file: Option<PathBuf>,
|
|
/// Generally this includes the [`strum::IntoStaticStr`] representation of the error, we take special care not to include parameters of the error (which may include secrets)
|
|
failure_chain: Option<Vec<String>>,
|
|
}
|
|
|
|
impl DiagnosticData {
|
|
pub fn new(
|
|
attribution: Option<String>,
|
|
endpoint: Option<String>,
|
|
planner: String,
|
|
configured_settings: Vec<String>,
|
|
ssl_cert_file: Option<PathBuf>,
|
|
) -> Result<Self, DiagnosticError> {
|
|
let endpoint = match endpoint {
|
|
Some(endpoint) => diagnostic_endpoint_parser(&endpoint)?,
|
|
None => None,
|
|
};
|
|
let (os_name, os_version) = match OsRelease::new() {
|
|
Ok(os_release) => (os_release.name, os_release.version),
|
|
Err(_) => ("unknown".into(), "unknown".into()),
|
|
};
|
|
let is_ci = is_ci::cached()
|
|
|| std::env::var("NIX_INSTALLER_CI").unwrap_or_else(|_| "0".into()) == "1";
|
|
Ok(Self {
|
|
attribution,
|
|
endpoint,
|
|
version: env!("CARGO_PKG_VERSION").into(),
|
|
planner,
|
|
configured_settings,
|
|
os_name,
|
|
os_version,
|
|
triple: target_lexicon::HOST.to_string(),
|
|
is_ci,
|
|
ssl_cert_file: ssl_cert_file.and_then(|v| v.canonicalize().ok()),
|
|
failure_chain: None,
|
|
})
|
|
}
|
|
|
|
pub fn failure(mut self, err: &NixInstallerError) -> Self {
|
|
let mut failure_chain = vec![];
|
|
let diagnostic = err.diagnostic();
|
|
failure_chain.push(diagnostic);
|
|
|
|
let mut walker: &dyn std::error::Error = &err;
|
|
while let Some(source) = walker.source() {
|
|
if let Some(downcasted) = source.downcast_ref::<ActionError>() {
|
|
let downcasted_diagnostic = downcasted.kind().diagnostic();
|
|
failure_chain.push(downcasted_diagnostic);
|
|
}
|
|
if let Some(downcasted) = source.downcast_ref::<Box<ActionError>>() {
|
|
let downcasted_diagnostic = downcasted.kind().diagnostic();
|
|
failure_chain.push(downcasted_diagnostic);
|
|
}
|
|
if let Some(downcasted) = source.downcast_ref::<PlannerError>() {
|
|
let downcasted_diagnostic = downcasted.diagnostic();
|
|
failure_chain.push(downcasted_diagnostic);
|
|
}
|
|
if let Some(downcasted) = source.downcast_ref::<InstallSettingsError>() {
|
|
let downcasted_diagnostic = downcasted.diagnostic();
|
|
failure_chain.push(downcasted_diagnostic);
|
|
}
|
|
if let Some(downcasted) = source.downcast_ref::<DiagnosticError>() {
|
|
let downcasted_diagnostic = downcasted.diagnostic();
|
|
failure_chain.push(downcasted_diagnostic);
|
|
}
|
|
|
|
walker = source;
|
|
}
|
|
|
|
self.failure_chain = Some(failure_chain);
|
|
self
|
|
}
|
|
|
|
pub fn report(&self, action: DiagnosticAction, status: DiagnosticStatus) -> DiagnosticReport {
|
|
let Self {
|
|
attribution,
|
|
version,
|
|
planner,
|
|
configured_settings,
|
|
os_name,
|
|
os_version,
|
|
triple,
|
|
is_ci,
|
|
endpoint: _,
|
|
ssl_cert_file: _,
|
|
failure_chain,
|
|
} = self;
|
|
DiagnosticReport {
|
|
attribution: attribution.clone(),
|
|
version: version.clone(),
|
|
planner: planner.clone(),
|
|
configured_settings: configured_settings.clone(),
|
|
os_name: os_name.clone(),
|
|
os_version: os_version.clone(),
|
|
triple: triple.clone(),
|
|
is_ci: *is_ci,
|
|
action,
|
|
status,
|
|
failure_chain: failure_chain.clone(),
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(level = "debug", skip_all)]
|
|
pub async fn send(
|
|
self,
|
|
action: DiagnosticAction,
|
|
status: DiagnosticStatus,
|
|
) -> Result<(), DiagnosticError> {
|
|
let serialized = serde_json::to_string_pretty(&self.report(action, status))?;
|
|
|
|
let endpoint = match self.endpoint {
|
|
Some(endpoint) => endpoint,
|
|
None => return Ok(()),
|
|
};
|
|
|
|
match endpoint.scheme() {
|
|
"https" | "http" => {
|
|
tracing::debug!("Sending diagnostic to `{endpoint}`");
|
|
let mut buildable_client = reqwest::Client::builder();
|
|
if let Some(ssl_cert_file) = &self.ssl_cert_file {
|
|
let ssl_cert = parse_ssl_cert(ssl_cert_file).await.ok();
|
|
if let Some(ssl_cert) = ssl_cert {
|
|
buildable_client = buildable_client.add_root_certificate(ssl_cert);
|
|
}
|
|
}
|
|
let client = buildable_client.build().map_err(DiagnosticError::Reqwest)?;
|
|
|
|
let res = client
|
|
.post(endpoint.clone())
|
|
.body(serialized)
|
|
.header("Content-Type", "application/json")
|
|
.timeout(Duration::from_millis(3000))
|
|
.send()
|
|
.await;
|
|
|
|
if let Err(_err) = res {
|
|
tracing::info!("Failed to send diagnostic to `{endpoint}`, continuing")
|
|
}
|
|
},
|
|
"file" => {
|
|
let path = endpoint.path();
|
|
tracing::debug!("Writing diagnostic to `{path}`");
|
|
let res = tokio::fs::write(path, serialized).await;
|
|
|
|
if let Err(_err) = res {
|
|
tracing::info!("Failed to send diagnostic to `{path}`, continuing")
|
|
}
|
|
},
|
|
_ => return Err(DiagnosticError::UnknownUrlScheme),
|
|
};
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[non_exhaustive]
|
|
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
|
|
pub enum DiagnosticError {
|
|
#[error("Unknown url scheme")]
|
|
UnknownUrlScheme,
|
|
#[error("Request error")]
|
|
Reqwest(
|
|
#[from]
|
|
#[source]
|
|
reqwest::Error,
|
|
),
|
|
/// Parsing URL
|
|
#[error("Parsing URL")]
|
|
Parse(
|
|
#[source]
|
|
#[from]
|
|
url::ParseError,
|
|
),
|
|
#[error("Write path `{0}`")]
|
|
Write(std::path::PathBuf, #[source] std::io::Error),
|
|
#[error("Serializing receipt")]
|
|
Serializing(
|
|
#[from]
|
|
#[source]
|
|
serde_json::Error,
|
|
),
|
|
#[error(transparent)]
|
|
Certificate(#[from] CertificateError),
|
|
}
|
|
|
|
pub trait ErrorDiagnostic {
|
|
fn diagnostic(&self) -> String;
|
|
}
|
|
|
|
impl ErrorDiagnostic for DiagnosticError {
|
|
fn diagnostic(&self) -> String {
|
|
let static_str: &'static str = (self).into();
|
|
static_str.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn diagnostic_endpoint_parser(input: &str) -> Result<Option<Url>, DiagnosticError> {
|
|
match Url::parse(input) {
|
|
Ok(v) => match v.scheme() {
|
|
"https" | "http" | "file" => Ok(Some(v)),
|
|
_ => Err(DiagnosticError::UnknownUrlScheme),
|
|
},
|
|
Err(url_error) if url_error == url::ParseError::RelativeUrlWithoutBase => {
|
|
match Url::parse(&format!("file://{input}")) {
|
|
Ok(v) => Ok(Some(v)),
|
|
Err(file_error) => Err(file_error)?,
|
|
}
|
|
},
|
|
Err(url_error) => Err(url_error)?,
|
|
}
|
|
}
|
|
|
|
pub fn diagnostic_endpoint_validator(input: &str) -> Result<String, DiagnosticError> {
|
|
let _ = diagnostic_endpoint_parser(input)?;
|
|
Ok(input.to_string())
|
|
}
|