Add tool for bumping images
Skip-Release-Notes
Change-Id: I500b01ef58b54680e52c04f3c4576b640e07e1be
Depends-On: https://review.vexxhost.dev/c/atmosphere/+/557
(cherry picked from commit 7f6414c93f48dca1677c67a19ad4b5de6b233209)
diff --git a/crates/imagebumper/Cargo.toml b/crates/imagebumper/Cargo.toml
new file mode 100644
index 0000000..b8af954
--- /dev/null
+++ b/crates/imagebumper/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "imagebumper"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+async-trait = "0.1.86"
+clap = { version = "4.5.29", features = ["derive"] }
+env_logger = { version = "0.11.6", features = ["unstable-kv"] }
+gitea-sdk = "0.5.0"
+log = { version = "0.4.25", features = ["kv"] }
+octocrab = "0.43.0"
+regex = "1.11.1"
+reqwest = { version = "0.12.12", features = ["json", "native-tls-vendored"] }
+serde_json = "1.0.138"
+tokio = { version = "1.43.0", features = ["fs", "macros", "rt-multi-thread"] }
+url = "2.5.4"
diff --git a/crates/imagebumper/src/clients/github.rs b/crates/imagebumper/src/clients/github.rs
new file mode 100644
index 0000000..b8ac5f3
--- /dev/null
+++ b/crates/imagebumper/src/clients/github.rs
@@ -0,0 +1,36 @@
+use crate::RepositoryClient;
+use async_trait::async_trait;
+use octocrab::Octocrab;
+use std::error::Error;
+use std::sync::Arc;
+
+pub struct Client {
+ client: Arc<Octocrab>,
+}
+
+impl Client {
+ pub fn new() -> Self {
+ Client {
+ client: octocrab::instance(),
+ }
+ }
+}
+
+#[async_trait]
+impl RepositoryClient for Client {
+ async fn get_latest_commit(
+ &self,
+ repository: &crate::repository::Repository,
+ branch: &str,
+ ) -> Result<String, Box<dyn Error>> {
+ let commits = self
+ .client
+ .repos(repository.owner.clone(), repository.name.clone())
+ .list_commits()
+ .branch(branch)
+ .send()
+ .await?;
+
+ Ok(commits.items[0].sha.clone())
+ }
+}
diff --git a/crates/imagebumper/src/clients/mod.rs b/crates/imagebumper/src/clients/mod.rs
new file mode 100644
index 0000000..8c2bfde
--- /dev/null
+++ b/crates/imagebumper/src/clients/mod.rs
@@ -0,0 +1,19 @@
+pub mod github;
+pub mod opendev;
+
+use crate::clients::github::Client as GitHubClient;
+use crate::clients::opendev::Client as OpenDevClient;
+
+pub struct ClientSet {
+ pub github: GitHubClient,
+ pub opendev: OpenDevClient,
+}
+
+impl ClientSet {
+ pub fn new() -> Self {
+ ClientSet {
+ github: GitHubClient::new(),
+ opendev: OpenDevClient::new(),
+ }
+ }
+}
diff --git a/crates/imagebumper/src/clients/opendev.rs b/crates/imagebumper/src/clients/opendev.rs
new file mode 100644
index 0000000..252ed60
--- /dev/null
+++ b/crates/imagebumper/src/clients/opendev.rs
@@ -0,0 +1,35 @@
+use crate::RepositoryClient;
+use async_trait::async_trait;
+use gitea_sdk::Auth;
+use gitea_sdk::Client as GiteaClient;
+use std::error::Error;
+
+pub struct Client {
+ client: GiteaClient,
+}
+
+impl Client {
+ pub fn new() -> Self {
+ Client {
+ client: GiteaClient::new("https://opendev.org", Auth::None::<String>),
+ }
+ }
+}
+
+#[async_trait]
+impl RepositoryClient for Client {
+ async fn get_latest_commit(
+ &self,
+ repository: &crate::repository::Repository,
+ branch: &str,
+ ) -> Result<String, Box<dyn Error>> {
+ let branch_info = self
+ .client
+ .repos(repository.owner.clone(), repository.name.clone())
+ .get_branch(branch)
+ .send(&self.client)
+ .await?;
+
+ Ok(branch_info.commit.id)
+ }
+}
diff --git a/crates/imagebumper/src/lib.rs b/crates/imagebumper/src/lib.rs
new file mode 100644
index 0000000..6142e02
--- /dev/null
+++ b/crates/imagebumper/src/lib.rs
@@ -0,0 +1,15 @@
+pub mod repository;
+pub mod clients;
+
+use async_trait::async_trait;
+use std::any::Any;
+use std::error::Error;
+
+#[async_trait]
+pub trait RepositoryClient: Any + Send + Sync {
+ async fn get_latest_commit(
+ &self,
+ repository: &crate::repository::Repository,
+ branch: &str,
+ ) -> Result<String, Box<dyn Error>>;
+}
diff --git a/crates/imagebumper/src/main.rs b/crates/imagebumper/src/main.rs
new file mode 100644
index 0000000..9958331
--- /dev/null
+++ b/crates/imagebumper/src/main.rs
@@ -0,0 +1,176 @@
+use clap::Parser;
+use imagebumper::clients::ClientSet;
+use imagebumper::repository::Repository;
+use log::error;
+use log::{info, warn};
+use regex::Regex;
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use tokio::fs;
+use tokio::io::AsyncWriteExt;
+
+#[derive(Parser, Debug)]
+#[clap(author, version, about)]
+struct Args {
+ #[clap(short, long)]
+ branch: String,
+
+ #[clap(required = true)]
+ files: Vec<PathBuf>,
+}
+
+fn get_repo_map(clientset: &ClientSet) -> HashMap<&'static str, Repository> {
+ let mut map = HashMap::new();
+
+ map.insert(
+ "BARBICAN_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/barbican.git").unwrap(),
+ );
+ map.insert(
+ "CINDER_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/cinder.git").unwrap(),
+ );
+ map.insert(
+ "DESIGNATE_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/designate.git").unwrap(),
+ );
+ map.insert(
+ "GLANCE_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/glance.git").unwrap(),
+ );
+ map.insert(
+ "HEAT_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/heat.git").unwrap(),
+ );
+ map.insert(
+ "HORIZON_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/horizon.git").unwrap(),
+ );
+ map.insert(
+ "IRONIC_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/ironic.git").unwrap(),
+ );
+ map.insert(
+ "KEYSTONE_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/keystone.git").unwrap(),
+ );
+ map.insert(
+ "KUBERNETES_ENTRYPOINT_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/airship/kubernetes-entrypoint").unwrap(),
+ );
+ map.insert(
+ "MAGNUM_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/magnum.git").unwrap(),
+ );
+ map.insert(
+ "MANILA_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/manila.git").unwrap(),
+ );
+ map.insert(
+ "NETOFFLOAD_GIT_REF",
+ Repository::from_url(clientset, "https://github.com/vexxhost/netoffload.git").unwrap(),
+ );
+ map.insert(
+ "NEUTRON_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/neutron.git").unwrap(),
+ );
+ map.insert(
+ "NEUTRON_VPNAAS_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/neutron-vpnaas.git").unwrap(),
+ );
+ map.insert(
+ "NETWORKING_BAREMETAL_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/networking-baremetal.git").unwrap(),
+ );
+ map.insert(
+ "POLICY_SERVER_GIT_REF",
+ Repository::from_url(clientset, "https://github.com/vexxhost/neutron-policy-server.git").unwrap(),
+ );
+ map.insert(
+ "LOG_PASER_GIT_REF",
+ Repository::from_url(clientset, "https://github.com/vexxhost/neutron-ovn-network-logging-parser.git")
+ .unwrap(),
+ );
+ map.insert(
+ "NOVA_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/nova.git").unwrap(),
+ );
+ map.insert(
+ "SCHEDULER_FILTERS_GIT_REF",
+ Repository::from_url(clientset, "https://github.com/vexxhost/nova-scheduler-filters.git").unwrap(),
+ );
+ map.insert(
+ "OCTAVIA_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/octavia.git").unwrap(),
+ );
+ map.insert(
+ "REQUIREMENTS_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/requirements.git").unwrap(),
+ );
+ map.insert(
+ "PLACEMENT_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/placement.git").unwrap(),
+ );
+ map.insert(
+ "STAFFELN_GIT_REF",
+ Repository::from_url(clientset, "https://github.com/vexxhost/staffeln.git").unwrap(),
+ );
+ map.insert(
+ "TEMPEST_GIT_REF",
+ Repository::from_url(clientset, "https://opendev.org/openstack/tempest.git").unwrap(),
+ );
+
+ map
+}
+
+async fn update_dockerfile(clientset: &ClientSet, path: &Path, branch: &str) -> Result<(), Box<dyn std::error::Error>> {
+ let content = fs::read_to_string(path).await?;
+ let re = Regex::new(r"(ARG\s+(\w+_GIT_REF)=)(\S+)")?;
+ let mut new_content = content.clone();
+
+ for cap in re.captures_iter(&content) {
+ let arg_name = cap.get(2).unwrap().as_str();
+ if let Some(repo) = get_repo_map(clientset).get(arg_name) {
+ let new_git_ref = match repo.get_latest_commit(branch).await {
+ Ok(commit) => commit,
+ Err(e) => {
+ error!(arg = arg_name, error = e.to_string().as_str().trim(); "Failed to get latest commit");
+ continue;
+ }
+ };
+
+ new_content = new_content.replace(
+ &format!("{}{}", &cap[1], &cap[3]),
+ &format!("{}{}", &cap[1], new_git_ref),
+ );
+
+ info!(arg = arg_name, path = path.to_str(), ref = new_git_ref.as_str(); "Updated Dockerfile");
+ } else {
+ error!(arg = arg_name; "No repository URL found.");
+ }
+ }
+
+ if new_content != content {
+ let mut file = fs::File::create(path).await?;
+ file.write_all(new_content.as_bytes()).await?;
+ }
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ env_logger::init();
+ let args = Args::parse();
+
+ let clientset = ClientSet::new();
+
+ for file_path in args.files {
+ if file_path.is_file() {
+ update_dockerfile(&clientset, &file_path, &args.branch).await?;
+ } else {
+ warn!("{:?} is not a file, skipping", file_path);
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/imagebumper/src/repository.rs b/crates/imagebumper/src/repository.rs
new file mode 100644
index 0000000..2f1a274
--- /dev/null
+++ b/crates/imagebumper/src/repository.rs
@@ -0,0 +1,66 @@
+use crate::clients::ClientSet;
+use crate::RepositoryClient;
+use std::error::Error;
+use url::Url;
+
+pub struct Repository<'a> {
+ pub owner: String,
+ pub name: String,
+ client: &'a dyn RepositoryClient,
+}
+
+impl<'a> Repository<'a> {
+ pub fn from_url(clientset: &'a ClientSet, url: &str) -> Result<Self, Box<dyn Error>> {
+ let url = url.trim_end_matches(".git");
+ let parsed_url = Url::parse(url)?;
+ let hostname = parsed_url.host_str().ok_or("Invalid repository URL")?;
+ let parts: Vec<&str> = parsed_url
+ .path_segments()
+ .ok_or("Invalid repository URL")?
+ .collect();
+ if parts.len() < 2 {
+ return Err("Invalid repository URL".into());
+ }
+
+ let client: &dyn RepositoryClient = match hostname {
+ "opendev.org" => &clientset.opendev,
+ "github.com" => &clientset.github,
+ _ => return Err("Unsupported repository host".into()),
+ };
+
+ Ok(Repository {
+ owner: parts[parts.len() - 2].to_string(),
+ name: parts[parts.len() - 1].to_string(),
+ client,
+ })
+ }
+
+ pub async fn get_latest_commit(&self, branch: &str) -> Result<String, Box<dyn Error>> {
+ self.client.get_latest_commit(self, branch).await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_from_url_for_opendev() {
+ let clientset = ClientSet::new();
+ let repo =
+ Repository::from_url(&clientset, "https://opendev.org/openstack/nova.git").unwrap();
+
+ assert_eq!(repo.owner, "openstack");
+ assert_eq!(repo.name, "nova");
+ }
+
+ #[tokio::test]
+ async fn test_from_url_for_github() {
+ let clientset = ClientSet::new();
+ let repo =
+ Repository::from_url(&clientset, "https://github.com/vexxhost/atmosphere.git").unwrap();
+
+ assert_eq!(repo.owner, "vexxhost");
+ assert_eq!(repo.name, "atmosphere");
+ }
+}