diff --git a/src/argo_cd.rs b/src/argo_cd.rs index 468faaf..1654419 100644 --- a/src/argo_cd.rs +++ b/src/argo_cd.rs @@ -1,6 +1,9 @@ use log::{debug, info, warn}; -use reqwest::Client; +use reqwest::{Client, RequestBuilder}; +use serde::Deserialize; use std::env; +use std::thread::sleep; +use std::time::{Duration, Instant}; use tokio::runtime::{Builder, Runtime}; use urlencoding::encode; @@ -41,15 +44,7 @@ impl ArgoCD { ); let request_builder = self.client.post(url.as_str()).json("&body"); // TODO - - let vault_token = env::var(ARGO_CD_TOKEN); - let request_builder = match vault_token { - Ok(token) => request_builder.header("Authorization", format!("Bearer {}", token)), - Err(_) => { - warn!("You're accessing ArgoCD without authentication (missing {} environment variable)", ARGO_CD_TOKEN); - request_builder - } - }; + let request_builder = Self::enhance_with_authorization_token_if_applicable(request_builder); let request = request_builder .build() @@ -71,12 +66,79 @@ impl ArgoCD { } pub(crate) fn wait_for_rollout(&mut self) { - let timeout_seconds = self.argo_config.sync_timeout_seconds.unwrap_or(60u16); + let timeout_seconds: u64 = match self.argo_config.sync_timeout_seconds { + Some(seconds) => seconds as u64, + None => 60, + }; info!( "Waiting for rollout of ArgoCD application '{}' to finish - timeout is {} seconds", self.argo_config.application, timeout_seconds ); + + let url = format!( + "{}/api/v1/applications/{name}", + self.argo_config.base_url, + name = encode(self.argo_config.application.as_str()) + ); + + let request_builder = self.client.get(url.as_str()); + let request_builder = Self::enhance_with_authorization_token_if_applicable(request_builder); + + let request = request_builder + .build() + .expect("Failed to build ArgoCD sync status request"); + + let start_time = Instant::now(); + let timeout_duration = Duration::from_secs(timeout_seconds); + + loop { + if start_time.elapsed() > timeout_duration { + panic!("Timeout reached while waiting for ArgoCD rollout to complete"); + } + + let response = self + .rt + .block_on(self.client.execute(request.try_clone().unwrap())) + .expect("Failed to get ArgoCD sync status"); + + if response.status().is_success() { + let app_information: Application = self + .rt + .block_on(response.json()) + .expect("Failed to read ArgoCD sync status response"); + + if app_information.status.sync.status == "Synced" + && app_information.status.health.status == "Healthy" + { + info!("Application rollout completed successfully"); + return; + } else { + debug!( + "Application rollout not finished yet: {{ 'sync': '{}', 'health': '{}' }}", + app_information.status.sync.status, app_information.status.health.status + ); + } + } else { + debug!("Failed to get application status: {}", response.status()); + } + + // Wait for 5 seconds before checking again + sleep(Duration::from_secs(5)); + } + } + + fn enhance_with_authorization_token_if_applicable( + request_builder: RequestBuilder, + ) -> RequestBuilder { + let argocd_token = env::var(ARGO_CD_TOKEN); + match argocd_token { + Ok(token) => request_builder.header("Authorization", format!("Bearer {}", token)), + Err(_) => { + warn!("You're accessing ArgoCD without authentication (missing {} environment variable)", ARGO_CD_TOKEN); + request_builder + } + } } fn get_argocd_client(argo_config: ArgoConfig) -> Client { @@ -90,5 +152,26 @@ impl ArgoCD { } } +#[derive(Deserialize)] +struct Application { + status: ApplicationStatus, +} + +#[derive(Deserialize)] +struct ApplicationStatus { + sync: SyncStatus, + health: HealthStatus, +} + +#[derive(Deserialize)] +struct SyncStatus { + status: String, +} + +#[derive(Deserialize)] +struct HealthStatus { + status: String, +} + #[cfg(test)] mod tests {} diff --git a/src/cli.rs b/src/cli.rs index d77ce7d..bee0ca8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,7 +39,7 @@ pub(crate) struct RotateArgs { #[clap(flatten)] // Inherit arguments from BaseArgs pub(crate) base: BaseArgs, - /// Whether the CLI should write a recovery log (contains sensitive information!) or not + /// The length of the randomly generated alphanumeric password #[clap(short, long, default_value = "20")] pub(crate) password_length: usize, @@ -53,5 +53,4 @@ pub(crate) struct RotateArgs { pub(crate) struct InitVaultArgs { #[clap(flatten)] // Inherit arguments from BaseArgs pub(crate) base: BaseArgs, - // Additional arguments for vault initialization (if any) can be added here. } diff --git a/tests/rotate.rs b/tests/rotate.rs index c63cb36..675d1a5 100644 --- a/tests/rotate.rs +++ b/tests/rotate.rs @@ -85,8 +85,8 @@ async fn rotate_secrets() { " argo_cd: application: 'propeller' - danger_accept_insecure: true base_url: 'http://127.0.0.1:{argocd_port}' + danger_accept_insecure: true postgres: host: '{postgres_host}' port: {postgres_port} @@ -169,6 +169,85 @@ async fn rotate_secrets() { delete_argocd_deployment(); } +#[tokio::test] +#[timeout(120_000)] +async fn rotate_application_sync_timeout() { + deploy_argocd(); + + let postgres_container = common::postgres_container().await; + + let postgres_host = postgres_container.get_host().await.unwrap().to_string(); + let postgres_port = postgres_container + .get_host_port_ipv4(5432) + .await + .unwrap() + .to_string(); + + let vault_container = common::vault_container().await; + + let vault_host = vault_container.get_host().await.unwrap(); + let vault_port = vault_container.get_host_port_ipv4(8200).await.unwrap(); + + let http_client = Client::new(); + + let vault_url = format!("http://{vault_host}:{vault_port}/v1/secret/data/rotate/secrets"); + reset_vault_secret_path(&http_client, vault_url.as_str()).await; + + let mut postgres_client = connect_postgres_client( + postgres_host.as_str(), + postgres_port.as_str(), + "demo", + "demo_password", + ) + .await; + + reset_role_initial_password(&mut postgres_client, "user1").await; + reset_role_initial_password(&mut postgres_client, "user2").await; + + let (argocd_port, mut port_forward) = open_argocd_server_port_forward(); + let argocd_url = format!("http://localhost:{}", argocd_port); + + let argocd_token = get_argocd_access_token(argocd_url.as_str()).await; + create_argocd_application(argocd_url.as_str(), argocd_token.as_str()).await; + + Command::new(&*BIN_PATH) + .arg("rotate") + .arg("-c") + .arg(common::write_string_to_tempfile( + format!( + // language=yaml + " + argo_cd: + application: 'propeller' + base_url: 'http://127.0.0.1:{argocd_port}' + danger_accept_insecure: true + sync_timeout_seconds: 5 + postgres: + host: '{postgres_host}' + port: {postgres_port} + database: 'demo' + vault: + base_url: 'http://{vault_host}:{vault_port}' + path: 'rotate/secrets' +" + ) + .as_str(), + )) + .env("ARGO_CD_TOKEN", argocd_token) + .env("VAULT_TOKEN", "root-token") + .assert() + .failure() + .stderr(contains( + // The configured sync timeout of 5 seconds is no match for the 10 seconds sleep in the pre-sync hook + "Timeout reached while waiting for ArgoCD rollout to complete", + )); + + // Kill `kubectl port-forward` process + let _ = port_forward + .kill() + .expect("Failed to stop port forward-process"); +} + #[tokio::test] #[timeout(30_000)] async fn rotate_invalid_initialized_secret() {