Initial commit

This commit is contained in:
2025-11-20 06:40:32 -06:00
commit f43ac14e2a
9 changed files with 2344 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
/config.toml
/updates.seen

1730
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "updatecheck"
version = "0.1.0"
edition = "2024"
[dependencies]
#bytes = "1.11.0"
chrono = { version = "0.4.42", default-features = false, features = ["serde"] }
reqwest = { version = "0.12.24", features = ["json"] }
#ruzstd = "0.8.2"
serde = { version = "1.0.228", features = ["derive"] }
#serde-xml-rs = "0.8.2"
tokio = { version = "1.48.0", default-features = false, features = ["rt", "macros"] }
toml = "0.9.8"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }

2
rustfmt.toml Normal file
View File

@@ -0,0 +1,2 @@
match_block_trailing_comma = true
max_width = 79

130
src/config.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
static DEFAULT_CONFIG: &str = "config.toml";
static CONFIG_ENV_VAR: &str = "UPDATECHECK_CONFIG";
#[derive(Debug)]
pub enum Error {
Toml(toml::de::Error),
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Toml(e) => write!(f, "{e}"),
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Toml(e) => Some(e),
Self::Io(e) => Some(e),
}
}
}
impl From<toml::de::Error> for Error {
fn from(e: toml::de::Error) -> Self {
Self::Toml(e)
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
}
impl Default for HttpMethod {
fn default() -> Self {
Self::Post
}
}
#[derive(Debug, Deserialize)]
pub struct RequestHeader {
pub name: String,
pub value_file: Option<String>,
pub value: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateHandler {
pub url: String,
#[serde(default)]
pub method: HttpMethod,
#[serde(default = "default_updatehandler_coalesce")]
pub coalesce: bool,
#[serde(default)]
pub headers: Option<Vec<RequestHeader>>,
}
fn default_updatehandler_coalesce() -> bool {
true
}
#[derive(Debug, Deserialize)]
pub struct Watch {
pub packages: String,
pub on_update: UpdateHandler,
}
#[derive(Debug, Deserialize)]
pub struct StorageConfig {
#[serde(default = "default_storage_dir")]
pub dir: PathBuf,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
dir: default_storage_dir(),
}
}
}
fn default_storage_dir() -> PathBuf {
PathBuf::from(".")
}
#[derive(Debug, Deserialize)]
pub struct Configuration {
#[serde(rename = "watch")]
pub watches: Vec<Watch>,
#[serde(default)]
pub storage: StorageConfig,
}
impl Configuration {
pub fn load(path: impl AsRef<Path>) -> Result<Self, Error> {
let data = std::fs::read_to_string(path)?;
Ok(toml::from_str(&data)?)
}
}
pub fn load(path: Option<&Path>) -> Result<Configuration, Error> {
let path: Cow<'_, Path> = match path {
Some(p) => Cow::Borrowed(p),
None => match std::env::var_os(CONFIG_ENV_VAR) {
Some(v) => Cow::Owned(PathBuf::from(v)),
None => Cow::Borrowed(Path::new(DEFAULT_CONFIG)),
},
};
tracing::debug!("Loading configuration file from {}", path.display());
Configuration::load(path)
}

63
src/main.rs Normal file
View File

@@ -0,0 +1,63 @@
mod config;
mod notify;
mod tracker;
mod updates;
use std::path::PathBuf;
use notify::Notifier;
use tracker::UpdateTracker;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let args: Vec<_> = std::env::args_os().collect();
let config = match config::load(args.get(1).map(PathBuf::from).as_deref())
{
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to load configuration file: {e}");
std::process::exit(1);
},
};
let mut tracker = UpdateTracker::new(&config);
let notifier = Notifier::default();
for watch in config.watches {
let mut new_updates = vec![];
for update in updates::get_updates(Some(watch.packages)).await? {
match tracker.seen_update(&update).await {
Ok(true) => continue,
Ok(false) => (),
Err(e) => {
tracing::error!(
"Failed to check update tracker state: {e}"
);
continue;
},
};
tracing::info!(
"Found update {} ({}) for Fedora {} from {}",
update.alias,
update.title,
update.release.version,
update.date_stable,
);
if let Err(e) = tracker.record_update(&update).await {
tracing::error!("Failed to record update: {e}");
continue;
}
new_updates.push(update);
}
if let Err(e) = notifier.notify(&new_updates, &watch.on_update).await {
tracing::error!("Error sending update notification: {e}");
}
}
Ok(())
}

215
src/notify.rs Normal file
View File

@@ -0,0 +1,215 @@
use std::str::FromStr;
use chrono::DateTime;
use chrono::Utc;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use serde::Serialize;
use crate::config::{RequestHeader, UpdateHandler};
use crate::updates::Update;
#[derive(Debug)]
pub enum Error {
Header(HeaderError),
Request(reqwest::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Header(e) => write!(f, "{e}"),
Self::Request(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Header(e) => Some(e),
Self::Request(e) => Some(e),
}
}
}
impl From<HeaderError> for Error {
fn from(e: HeaderError) -> Self {
Self::Header(e)
}
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
Self::Request(e)
}
}
#[derive(Debug)]
pub enum HeaderError {
InvalidName(reqwest::header::InvalidHeaderName),
InvalidValue(reqwest::header::InvalidHeaderValue),
Io(std::io::Error),
}
impl std::fmt::Display for HeaderError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::InvalidName(e) => write!(f, "{e}"),
Self::InvalidValue(e) => write!(f, "{e}"),
Self::Io(e) => {
write!(f, "Error reading header value from file: {e}")
},
}
}
}
impl std::error::Error for HeaderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidName(e) => Some(e),
Self::InvalidValue(e) => Some(e),
Self::Io(e) => Some(e),
}
}
}
impl From<reqwest::header::InvalidHeaderName> for HeaderError {
fn from(e: reqwest::header::InvalidHeaderName) -> Self {
Self::InvalidName(e)
}
}
impl From<reqwest::header::InvalidHeaderValue> for HeaderError {
fn from(e: reqwest::header::InvalidHeaderValue) -> Self {
Self::InvalidValue(e)
}
}
impl From<std::io::Error> for HeaderError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Serialize)]
struct BuildInfo {
nvr: String,
}
#[derive(Debug, Serialize)]
struct UpdateNotification {
id: String,
title: String,
release: String,
date: DateTime<Utc>,
builds: Vec<BuildInfo>,
}
impl From<&Update> for UpdateNotification {
fn from(update: &Update) -> Self {
UpdateNotification {
id: update.alias.clone(),
title: update.title.clone(),
release: update.release.version.clone(),
date: update.date_stable,
builds: update
.builds
.iter()
.map(|b| BuildInfo {
nvr: b.nvr.to_string(),
})
.collect(),
}
}
}
pub struct Notifier {
client: reqwest::Client,
}
impl Default for Notifier {
fn default() -> Self {
let mut headers = HeaderMap::new();
headers.insert(
"User-Agent",
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
.parse()
.unwrap(),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.unwrap();
Self::new(client)
}
}
impl Notifier {
pub fn new(client: reqwest::Client) -> Self {
Self { client }
}
pub async fn notify(
&self,
updates: &[Update],
handler: &UpdateHandler,
) -> Result<(), Error> {
use crate::config::HttpMethod;
let notifications: Vec<_> =
updates.iter().map(UpdateNotification::from).collect();
let mut headermap = HeaderMap::new();
if let Some(headers) = &handler.headers {
request_headers(headers, &mut headermap)?;
}
if handler.coalesce {
let request = match handler.method {
HttpMethod::Get => self.client.get(&handler.url),
HttpMethod::Post => {
self.client.post(&handler.url).json(&notifications)
},
};
request
.headers(headermap)
.send()
.await?
.error_for_status()?;
} else {
for notif in notifications {
let request = match handler.method {
HttpMethod::Get => self.client.get(&handler.url),
HttpMethod::Post => {
self.client.post(&handler.url).json(&notif)
},
};
request
.headers(headermap.clone())
.send()
.await?
.error_for_status()?;
}
}
Ok(())
}
}
fn request_headers(
headers: &Vec<RequestHeader>,
headermap: &mut HeaderMap,
) -> Result<(), HeaderError> {
for header in headers {
let value = if let Some(filename) = &header.value_file {
let value = &std::fs::read_to_string(filename)?;
HeaderValue::from_str(value.trim())?
} else if let Some(value) = &header.value {
HeaderValue::from_str(value)?
} else {
HeaderValue::from_static("")
};
headermap.insert(HeaderName::from_str(&header.name)?, value);
}
Ok(())
}

117
src/tracker.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use tokio::sync::RwLock;
use crate::config::Configuration;
use crate::updates::Update;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub struct UpdateTracker {
state_file: PathBuf,
seen: RwLock<Option<HashSet<String>>>,
}
impl UpdateTracker {
pub fn new(config: &Configuration) -> Self {
let mut state_file = config.storage.dir.clone();
state_file.push("updates.seen");
Self {
state_file,
seen: Default::default(),
}
}
pub async fn seen_update(&self, update: &Update) -> Result<bool, Error> {
tracing::debug!("Checking if update {} has been seen", update.alias);
let lock = self.seen.read().await;
let seen = if let Some(seen) = &*lock {
seen.contains(&update.alias)
} else {
drop(lock);
let mut lock = self.seen.write().await;
let seen = load_seen(&self.state_file)?;
let r = seen.contains(&update.alias);
lock.replace(seen);
r
};
if seen {
tracing::debug!("Update {} has been seen before", update.alias);
} else {
tracing::debug!(
"Update {} has not been seen before",
update.alias
);
}
Ok(seen)
}
pub async fn record_update(
&mut self,
update: &Update,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::Write;
tracing::info!("Recording update {} as seen", update.alias);
let mut f = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&self.state_file)?;
f.write_all(
format!("{} {}\n", update.alias, update.date_stable).as_bytes(),
)?;
Ok(())
}
}
fn load_seen(
path: impl AsRef<Path>,
) -> Result<HashSet<String>, std::io::Error> {
use std::io::BufRead;
use std::io::ErrorKind::NotFound;
tracing::info!("Reading seen updates from {}", path.as_ref().display());
let mut seen = HashSet::new();
let f = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == NotFound => return Ok(seen),
Err(e) => return Err(e),
};
let mut reader = std::io::BufReader::new(f);
loop {
let mut buf = String::new();
let n = reader.read_line(&mut buf)?;
if n == 0 {
break;
}
if let Some(alias) = buf.split_whitespace().next() {
seen.insert(alias.into());
}
}
Ok(seen)
}

68
src/updates.rs Normal file
View File

@@ -0,0 +1,68 @@
use std::borrow::Cow;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Deserializer;
static BODHI_URL: &str = "https://bodhi.fedoraproject.org/";
#[derive(Debug, Deserialize)]
pub struct BuildInfo {
pub nvr: String,
}
#[derive(Debug, Deserialize)]
pub struct Release {
pub version: String,
}
#[derive(Debug, Deserialize)]
pub struct Update {
pub title: String,
pub release: Release,
pub alias: String,
#[serde(deserialize_with = "deserialize_date")]
pub date_stable: DateTime<Utc>,
pub builds: Vec<BuildInfo>,
}
fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let dt = NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S")
.map_err(serde::de::Error::custom)?;
Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
}
#[derive(Debug, Deserialize)]
struct UpdatesResponse {
updates: Vec<Update>,
}
pub async fn get_updates(
packages: Option<impl AsRef<str>>,
) -> Result<Vec<Update>, reqwest::Error> {
let url: Cow<'_, str> = std::env::var("BODHI_URL")
.map(Cow::Owned)
.unwrap_or_else(|_| Cow::Borrowed(BODHI_URL));
let mut url = reqwest::Url::parse(&url)
.unwrap()
.join("/updates/")
.unwrap();
{
let mut query = url.query_pairs_mut();
query.append_pair("rows_per_page", "10");
query.append_pair("status", "stable");
query.append_pair("releases", "__current__");
if let Some(packages) = packages {
query.append_pair("packages", packages.as_ref());
}
}
tracing::info!("Retrieving updates from {url}");
let updates: UpdatesResponse = reqwest::get(url).await?.json().await?;
Ok(updates.updates)
}