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");
+    }
+}