Split main into multiple files
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Aode (lion) 2021-12-21 14:38:38 -06:00
parent 9ef11baff4
commit 78ec51ec12
9 changed files with 440 additions and 417 deletions

2
Cargo.lock generated
View file

@ -906,7 +906,7 @@ checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "release-checker"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"color-eyre",
"regex",

71
src/alpine.rs Normal file
View file

@ -0,0 +1,71 @@
use std::collections::HashSet;
#[derive(Debug)]
enum AlpineError {
NoPackage,
VersionMismatch,
}
impl std::fmt::Display for AlpineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPackage => write!(f, "No package found for query"),
Self::VersionMismatch => write!(f, "Multiple versions detected for architectures"),
}
}
}
impl std::error::Error for AlpineError {}
#[tracing::instrument]
pub(crate) async fn check_alpine_package(
package: String,
branch: String,
architectures: Vec<String>,
) -> color_eyre::eyre::Result<Option<String>> {
let alpine_client = reqwest::Client::builder()
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
let url = format!(
"https://pkgs.alpinelinux.org/packages?name={}&branch={}",
package, branch
);
let response = alpine_client.get(url).send().await?;
let text = response.text().await?;
let document = scraper::Html::parse_document(&text);
let tr_selector = scraper::Selector::parse("tr").expect("'tr' is valid selector");
let version_selector = scraper::Selector::parse("td.version strong a")
.expect("'td.version strong a' is valid selector");
let arch_selector =
scraper::Selector::parse("td.arch a").expect("'td.arch a' is valid selector");
let mut versions = HashSet::new();
for row in document.select(&tr_selector) {
if let Some(arch) = row.select(&arch_selector).next() {
if architectures
.iter()
.any(|architecture| architecture == arch.inner_html().trim())
{
if let Some(version) = row.select(&version_selector).next() {
versions.insert(version.inner_html().trim().to_string());
}
}
}
}
if versions.len() > 1 {
return Err(AlpineError::VersionMismatch.into());
}
if versions.is_empty() {
return Err(AlpineError::NoPackage.into());
}
Ok(versions.into_iter().next())
}

53
src/config.rs Normal file
View file

@ -0,0 +1,53 @@
use std::path::PathBuf;
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct CheckConfig {
pub(crate) project: ProjectConfig,
pub(crate) gitea: GiteaConfig,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "kind")]
pub(crate) enum ProjectConfig {
None,
#[serde(rename = "dockerhub")]
DockerHub {
namespace: String,
repository: String,
regex: String,
},
#[serde(rename = "alpine")]
Alpine {
package: String,
branch: String,
architectures: Vec<String>,
},
#[serde(rename = "git")]
Git {
repository: String,
#[serde(flatten)]
language: LanguageConfig,
},
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "language")]
pub(crate) enum LanguageConfig {
#[serde(rename = "rust")]
Rust { config: PathBuf },
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct GiteaConfig {
pub(crate) domain: String,
pub(crate) owner: String,
pub(crate) repo: String,
}

50
src/dockerhub.rs Normal file
View file

@ -0,0 +1,50 @@
#[derive(Debug)]
enum DockerError {
NoTag,
}
impl std::fmt::Display for DockerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoTag => write!(f, "No tag found for query"),
}
}
}
impl std::error::Error for DockerError {}
#[tracing::instrument]
pub(crate) async fn check_dockerhub_image(
namespace: String,
repository: String,
regex: String,
) -> color_eyre::eyre::Result<String> {
let regex = regex::Regex::new(&regex)?;
let client = reqwest::Client::builder()
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
#[derive(serde::Deserialize)]
struct TagResult {
name: String,
}
#[derive(serde::Deserialize)]
struct TagsResponse {
results: Vec<TagResult>,
}
let images_url = format!("https://registry.hub.docker.com/v2/repositories/{}/{}/tags?currently_tagged=true,ordering=last_updated,page_size=30,status=active", namespace, repository);
let tags: TagsResponse = client.get(images_url).send().await?.json().await?;
for result in tags.results {
if let Some(matched) = regex.find(&result.name) {
if matched.as_str() == result.name {
return Ok(result.name);
}
}
}
Err(DockerError::NoTag.into())
}

63
src/git.rs Normal file
View file

@ -0,0 +1,63 @@
use crate::config::LanguageConfig;
use std::path::PathBuf;
#[derive(Clone, Debug, serde::Deserialize)]
struct CargoToml {
package: CargoPackage,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct CargoPackage {
version: String,
}
struct RemoveOnDrop<'a>(&'a str);
impl<'a> Drop for RemoveOnDrop<'a> {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(self.0);
}
}
#[derive(Debug)]
enum GitError {
Clone,
}
impl std::fmt::Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Clone => write!(f, "Failed to clone repository"),
}
}
}
impl std::error::Error for GitError {}
#[tracing::instrument]
pub(crate) async fn check_git_project(
repository: String,
language: LanguageConfig,
) -> color_eyre::eyre::Result<String> {
static CHECK_REPO: &str = "checked-project";
let _remover = RemoveOnDrop(CHECK_REPO);
let output = tokio::process::Command::new("git")
.args(["clone", &repository, CHECK_REPO])
.output()
.await?;
if !output.status.success() {
return Err(GitError::Clone.into());
}
match language {
LanguageConfig::Rust { config } => {
let config_path = PathBuf::from(CHECK_REPO).join(config);
let config_string = tokio::fs::read_to_string(&config_path).await?;
let rust_config: CargoToml = toml::from_str(&config_string)?;
Ok(format!("v{}", rust_config.package.version))
}
}
}

97
src/gitea.rs Normal file
View file

@ -0,0 +1,97 @@
use crate::{config::GiteaConfig, revision::Revision};
use reqwest::{
header::{HeaderMap, HeaderValue},
Client, StatusCode,
};
#[derive(Clone, Debug, serde::Serialize)]
struct GiteaTag {
tag_name: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct GiteaTagResponse {
name: String,
}
#[derive(Debug)]
enum TagError {
Status(StatusCode),
}
impl std::fmt::Display for TagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Status(code) => {
write!(f, "Failed to create tag, invalid response status: {}", code)
}
}
}
}
impl std::error::Error for TagError {}
#[tracing::instrument(skip_all)]
pub(crate) fn build_client(gitea_token: &str) -> color_eyre::eyre::Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("token {}", gitea_token))?,
);
let client = reqwest::Client::builder()
.default_headers(headers)
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
Ok(client)
}
#[tracing::instrument(skip(client))]
pub(crate) async fn get_previous_revision(
client: &Client,
config: &GiteaConfig,
) -> color_eyre::eyre::Result<Revision> {
let tags_url = format!(
"https://{}/api/v1/repos/{}/{}/tags?page=1&limit=4",
config.domain, config.owner, config.repo
);
let tags_response = client.get(tags_url).send().await?;
let tags: Vec<GiteaTagResponse> = tags_response.json().await?;
let revision = if let Some(tag) = tags.get(0) {
tracing::info!("tag: {}", tag.name);
tag.name.parse()?
} else {
Revision::default()
};
Ok(revision)
}
#[tracing::instrument(skip(client))]
pub(crate) async fn create_tag(
client: &Client,
config: &GiteaConfig,
revision: &Revision,
) -> color_eyre::eyre::Result<()> {
let tag_url = format!(
"https://{}/api/v1/repos/{}/{}/tags",
config.domain, config.owner, config.repo
);
let response = client
.post(tag_url)
.json(&GiteaTag {
tag_name: revision.to_string(),
})
.send()
.await?;
if !response.status().is_success() {
return Err(TagError::Status(response.status()).into());
}
Ok(())
}

17
src/init_tracing.rs Normal file
View file

@ -0,0 +1,17 @@
use tracing::subscriber::set_global_default;
use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{EnvFilter, Registry};
pub(crate) fn init_tracing() -> color_eyre::eyre::Result<()> {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let format_layer = tracing_subscriber::fmt::layer().pretty();
let subscriber = Registry::default()
.with(env_filter)
.with(format_layer)
.with(ErrorLayer::default());
set_global_default(subscriber)?;
Ok(())
}

View file

@ -1,419 +1,17 @@
use std::collections::HashSet;
use std::fmt::Display;
use std::num::ParseIntError;
use std::path::PathBuf;
use std::str::FromStr;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::{Client, StatusCode};
use tracing::subscriber::set_global_default;
use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{EnvFilter, Registry};
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct CheckConfig {
project: ProjectConfig,
gitea: GiteaConfig,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "kind")]
enum ProjectConfig {
None,
#[serde(rename = "dockerhub")]
DockerHub {
namespace: String,
repository: String,
regex: String,
},
#[serde(rename = "alpine")]
Alpine {
package: String,
branch: String,
architectures: Vec<String>,
},
#[serde(rename = "git")]
Git {
repository: String,
#[serde(flatten)]
language: LanguageConfig,
},
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "language")]
enum LanguageConfig {
#[serde(rename = "rust")]
Rust { config: PathBuf },
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct GiteaConfig {
domain: String,
owner: String,
repo: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct CargoToml {
package: CargoPackage,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct CargoPackage {
version: String,
}
#[derive(Clone, Debug, Default)]
struct Revision {
version: String,
revision: usize,
}
impl Revision {
fn next(self, version_string: Option<String>) -> Self {
if let Some(version_string) = version_string {
if self.version == version_string {
Revision {
revision: self.revision + 1,
..self
}
} else {
Revision {
version: version_string,
revision: 0,
}
}
} else {
Revision {
revision: self.revision + 1,
..self
}
}
}
}
impl FromStr for Revision {
type Err = RevisionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((version, revision)) = s.rsplit_once('-') {
let revision = revision
.trim_start_matches('r')
.parse()
.map_err(RevisionError::Revision)?;
Ok(Revision {
version: version.to_owned(),
revision,
})
} else {
Ok(Revision {
version: s.to_string(),
revision: 0,
})
}
}
}
impl Display for Revision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-r{}", self.version, self.revision)
}
}
#[derive(Clone, Debug, serde::Serialize)]
struct GiteaTag {
tag_name: String,
}
#[derive(Clone, Debug, serde::Deserialize)]
struct GiteaTagResponse {
name: String,
}
struct RemoveOnDrop<'a>(&'a str);
impl<'a> Drop for RemoveOnDrop<'a> {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(self.0);
}
}
#[derive(Debug)]
enum GitError {
Clone,
}
impl Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Clone => write!(f, "Failed to clone repository"),
}
}
}
impl std::error::Error for GitError {}
#[derive(Debug)]
enum TagError {
Status(StatusCode),
}
impl Display for TagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Status(code) => {
write!(f, "Failed to create tag, invalid response status: {}", code)
}
}
}
}
impl std::error::Error for TagError {}
#[derive(Debug)]
enum RevisionError {
Revision(ParseIntError),
}
impl Display for RevisionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Revision(e) => write!(f, "Failed to parse revision number, {}", e),
}
}
}
impl std::error::Error for RevisionError {}
#[derive(Debug)]
enum AlpineError {
NoPackage,
VersionMismatch,
}
impl Display for AlpineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPackage => write!(f, "No package found for query"),
Self::VersionMismatch => write!(f, "Multiple versions detected for architectures"),
}
}
}
impl std::error::Error for AlpineError {}
#[derive(Debug)]
enum DockerError {
NoTag,
}
impl Display for DockerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoTag => write!(f, "No tag found for query"),
}
}
}
impl std::error::Error for DockerError {}
#[tracing::instrument]
async fn check_git_project(
repository: String,
language: LanguageConfig,
) -> color_eyre::eyre::Result<String> {
static CHECK_REPO: &str = "checked-project";
let _remover = RemoveOnDrop(CHECK_REPO);
let output = tokio::process::Command::new("git")
.args(["clone", &repository, CHECK_REPO])
.output()
.await?;
if !output.status.success() {
return Err(GitError::Clone.into());
}
match language {
LanguageConfig::Rust { config } => {
let config_path = PathBuf::from(CHECK_REPO).join(config);
let config_string = tokio::fs::read_to_string(&config_path).await?;
let rust_config: CargoToml = toml::from_str(&config_string)?;
Ok(format!("v{}", rust_config.package.version))
}
}
}
#[tracing::instrument]
async fn check_alpine_package(
package: String,
branch: String,
architectures: Vec<String>,
) -> color_eyre::eyre::Result<Option<String>> {
let alpine_client = reqwest::Client::builder()
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
let url = format!(
"https://pkgs.alpinelinux.org/packages?name={}&branch={}",
package, branch
);
let response = alpine_client.get(url).send().await?;
let text = response.text().await?;
let document = scraper::Html::parse_document(&text);
let tr_selector = scraper::Selector::parse("tr").expect("'tr' is valid selector");
let version_selector = scraper::Selector::parse("td.version strong a")
.expect("'td.version strong a' is valid selector");
let arch_selector =
scraper::Selector::parse("td.arch a").expect("'td.arch a' is valid selector");
let mut versions = HashSet::new();
for row in document.select(&tr_selector) {
if let Some(arch) = row.select(&arch_selector).next() {
if architectures
.iter()
.any(|architecture| architecture == arch.inner_html().trim())
{
if let Some(version) = row.select(&version_selector).next() {
versions.insert(version.inner_html().trim().to_string());
}
}
}
}
if versions.len() > 1 {
return Err(AlpineError::VersionMismatch.into());
}
if versions.is_empty() {
return Err(AlpineError::NoPackage.into());
}
Ok(versions.into_iter().next())
}
#[tracing::instrument]
async fn check_dockerhub_image(
namespace: String,
repository: String,
regex: String,
) -> color_eyre::eyre::Result<String> {
let regex = regex::Regex::new(&regex)?;
let client = reqwest::Client::builder()
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
#[derive(serde::Deserialize)]
struct TagResult {
name: String,
}
#[derive(serde::Deserialize)]
struct TagsResponse {
results: Vec<TagResult>,
}
let images_url = format!("https://registry.hub.docker.com/v2/repositories/{}/{}/tags?currently_tagged=true,ordering=last_updated,page_size=30,status=active", namespace, repository);
let tags: TagsResponse = client.get(images_url).send().await?.json().await?;
for result in tags.results {
if let Some(matched) = regex.find(&result.name) {
if matched.as_str() == result.name {
return Ok(result.name);
}
}
}
Err(DockerError::NoTag.into())
}
#[tracing::instrument(skip_all)]
fn build_client(gitea_token: &str) -> color_eyre::eyre::Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("token {}", gitea_token))?,
);
let client = reqwest::Client::builder()
.default_headers(headers)
.user_agent("release-checker (+https://git.asonix.dog/asonix/release-checker)")
.build()?;
Ok(client)
}
#[tracing::instrument(skip(client))]
async fn get_previous_revision(
client: &Client,
config: &GiteaConfig,
) -> color_eyre::eyre::Result<Revision> {
let tags_url = format!(
"https://{}/api/v1/repos/{}/{}/tags?page=1&limit=4",
config.domain, config.owner, config.repo
);
let tags_response = client.get(tags_url).send().await?;
let tags: Vec<GiteaTagResponse> = tags_response.json().await?;
let revision = if let Some(tag) = tags.get(0) {
tracing::info!("tag: {}", tag.name);
tag.name.parse()?
} else {
Revision::default()
};
Ok(revision)
}
#[tracing::instrument(skip(client))]
async fn create_tag(
client: &Client,
config: &GiteaConfig,
revision: &Revision,
) -> color_eyre::eyre::Result<()> {
let tag_url = format!(
"https://{}/api/v1/repos/{}/{}/tags",
config.domain, config.owner, config.repo
);
let response = client
.post(tag_url)
.json(&GiteaTag {
tag_name: revision.to_string(),
})
.send()
.await?;
if !response.status().is_success() {
return Err(TagError::Status(response.status()).into());
}
Ok(())
}
fn init_tracing() -> color_eyre::eyre::Result<()> {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let format_layer = tracing_subscriber::fmt::layer().pretty();
let subscriber = Registry::default()
.with(env_filter)
.with(format_layer)
.with(ErrorLayer::default());
set_global_default(subscriber)?;
Ok(())
}
mod alpine;
mod config;
mod dockerhub;
mod git;
mod gitea;
mod init_tracing;
mod revision;
use alpine::check_alpine_package;
use config::{CheckConfig, ProjectConfig};
use dockerhub::check_dockerhub_image;
use git::check_git_project;
use gitea::{build_client, create_tag, get_previous_revision};
use init_tracing::init_tracing;
async fn check_project(project: ProjectConfig) -> color_eyre::eyre::Result<Option<String>> {
match project {

74
src/revision.rs Normal file
View file

@ -0,0 +1,74 @@
use std::{num::ParseIntError, str::FromStr};
#[derive(Clone, Debug, Default)]
pub(crate) struct Revision {
version: String,
revision: usize,
}
#[derive(Debug)]
pub(crate) enum RevisionError {
Revision(ParseIntError),
}
impl Revision {
pub(crate) fn next(self, version_string: Option<String>) -> Self {
if let Some(version_string) = version_string {
if self.version == version_string {
Revision {
revision: self.revision + 1,
..self
}
} else {
Revision {
version: version_string,
revision: 0,
}
}
} else {
Revision {
revision: self.revision + 1,
..self
}
}
}
}
impl FromStr for Revision {
type Err = RevisionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((version, revision)) = s.rsplit_once('-') {
let revision = revision
.trim_start_matches('r')
.parse()
.map_err(RevisionError::Revision)?;
Ok(Revision {
version: version.to_owned(),
revision,
})
} else {
Ok(Revision {
version: s.to_string(),
revision: 0,
})
}
}
}
impl std::fmt::Display for Revision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-r{}", self.version, self.revision)
}
}
impl std::fmt::Display for RevisionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Revision(e) => write!(f, "Failed to parse revision number, {}", e),
}
}
}
impl std::error::Error for RevisionError {}