-
Notifications
You must be signed in to change notification settings - Fork 1
Warn when the Corgea webapp is older than this CLI requires #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,14 @@ use std::path::Path; | |
|
|
||
| const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB | ||
| const API_BASE: &str = "/api/v1"; | ||
| /// Unversioned on purpose: the webapp serves its own version outside the | ||
| /// versioned API so clients can read it before agreeing on an API version. | ||
| const WEBAPP_VERSION_PATH: &str = "/api/version"; | ||
|
|
||
| /// Far below the shared client's timeout, because this request is advisory and | ||
| /// runs ahead of every authenticated command: a deployment that stalls this one | ||
| /// route must not hold the command the user actually asked for. | ||
| const WEBAPP_VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); | ||
|
|
||
| fn auth_headers(token: &str) -> HeaderMap { | ||
| let mut headers = HeaderMap::new(); | ||
|
|
@@ -122,6 +130,14 @@ impl DebugRequestBuilder { | |
| } | ||
| } | ||
|
|
||
| /// Override the shared client's timeout for this request alone. | ||
| pub fn timeout(self, timeout: std::time::Duration) -> Self { | ||
| Self { | ||
| inner: self.inner.timeout(timeout), | ||
| client: self.client, | ||
| } | ||
| } | ||
|
|
||
| pub fn send(self) -> reqwest::Result<reqwest::blocking::Response> { | ||
| use reqwest::cookie::CookieStore; | ||
|
|
||
|
|
@@ -1181,6 +1197,55 @@ pub fn verify_token(corgea_url: &str) -> Result<bool, Box<dyn Error>> { | |
| } | ||
| } | ||
|
|
||
| /// The version the webapp at `corgea_url` reports for itself. | ||
| /// | ||
| /// `Ok(None)` means the deployment did not tell us: either it predates | ||
| /// `/api/version` (404) or it could not determine its own version (null). | ||
| /// | ||
| /// A 404 is not evidence of an outdated webapp and callers must not treat it as | ||
| /// one. The endpoint ships in a release *above* `MIN_WEBAPP_VERSION`, so every | ||
| /// deployment sitting exactly at the floor answers 404 while being perfectly | ||
| /// compatible. A 404 only narrows the version to "older than the release that | ||
| /// added this route", which spans both sides of the floor. | ||
| /// | ||
| /// Deliberately skips `check_for_warnings`: this runs as a pre-flight before | ||
| /// the command the user asked for, and it must not be what terminates the | ||
| /// process. The command's own requests still surface deprecation signals. | ||
| pub fn get_webapp_version(corgea_url: &str) -> Result<Option<String>, Box<dyn Error>> { | ||
| let url = format!("{}{}", corgea_url, WEBAPP_VERSION_PATH); | ||
| let client = http_client(); | ||
| debug(&format!("Sending request to URL: {}", url)); | ||
|
|
||
| let response = client.get(&url).timeout(WEBAPP_VERSION_TIMEOUT).send()?; | ||
| let status = response.status(); | ||
|
|
||
| if status == StatusCode::NOT_FOUND { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] The documented rollout makes the default check unable to warn any normally-versioned outdated deployment. This code says There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with this finding and think it should be addressed. high: Default check cannot detect normally deployed outdated webapps The endpoint first ships above v1.71.3, while v1.71.3 is the minimum. Thus every normal release below the minimum returns 404 and is treated as unknown, while releases exposing the endpoint are already above the minimum. The outdated test models an artificial v1.70.0 deployment that nevertheless exposes the newer endpoint. Delay enabling this floor until the endpoint release is at or below the minimum, or distinguish 404 and provide an accurate unverifiable-compatibility warning. Proof or reproduction: |
||
| debug("Webapp does not serve /api/version"); | ||
| return Ok(None); | ||
| } | ||
|
|
||
| if !status.is_success() { | ||
| return Err(format!("Request failed with status: {}", status).into()); | ||
| } | ||
|
|
||
| let body_text = response.text()?; | ||
| let body: WebappVersionResponse = match serde_json::from_str(&body_text) { | ||
| Ok(json) => json, | ||
| Err(e) => { | ||
| debug(&format!( | ||
| "Failed to parse response as JSON: {}. Response body: {}", | ||
| e, body_text | ||
| )); | ||
| return Err("Failed to parse response".to_string().into()); | ||
| } | ||
| }; | ||
|
|
||
| Ok(body | ||
| .version | ||
| .map(|version| version.trim().to_string()) | ||
| .filter(|version| !version.is_empty())) | ||
| } | ||
|
|
||
| /// Evaluate a scan against blocking rules. | ||
| /// | ||
| /// `block_on` is a comma-separated list of CI rule slugs. When omitted the | ||
|
|
@@ -1351,6 +1416,13 @@ pub fn get_all_sca_issues( | |
| Ok(all_issues) | ||
| } | ||
|
|
||
| #[derive(Deserialize, Serialize, Debug)] | ||
| pub struct WebappVersionResponse { | ||
| /// Null on deployments that cannot determine their own version. | ||
| #[serde(default)] | ||
| pub version: Option<String>, | ||
| } | ||
|
|
||
| #[derive(Deserialize, Serialize, Debug)] | ||
| pub struct ScanResponse { | ||
| pub id: String, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| //! Compatibility pre-flight: warn when the Corgea webapp a command is about to | ||
| //! talk to is older than this CLI expects. | ||
| //! | ||
| //! Best effort throughout. A webapp that does not report its version, an | ||
| //! unreachable endpoint, or an unparsable version all leave the command to run | ||
| //! exactly as before — the check only ever adds a warning. | ||
|
|
||
| use crate::log::debug; | ||
| use crate::utils::api; | ||
| use crate::utils::generic::get_env_var_if_exists; | ||
| use regex::Regex; | ||
| use semver::Version; | ||
| use std::sync::LazyLock; | ||
|
|
||
| /// Oldest webapp release this CLI is built against. Raise it whenever the CLI | ||
| /// starts depending on a webapp change. | ||
| pub const MIN_WEBAPP_VERSION: &str = "v1.71.3"; | ||
|
|
||
| /// Overrides `MIN_WEBAPP_VERSION`, for testing a different floor without a | ||
| /// rebuild. | ||
| const MIN_VERSION_ENV_VAR: &str = "CORGEA_MIN_WEBAPP_VERSION"; | ||
|
|
||
| /// Escape hatch for anyone deliberately pinned to an older self-hosted webapp | ||
| /// who does not want the warning on every command. | ||
| const SKIP_ENV_VAR: &str = "CORGEA_SKIP_WEBAPP_VERSION_CHECK"; | ||
|
|
||
| /// Deployment versions are not plain semver: releases ship as `v1.71.3`, while | ||
| /// pre-release and per-customer builds add a suffix (`v1.71.3-beta`, | ||
| /// `v1.71.3-client-a`). Only the leading numeric part is comparable, so a | ||
| /// suffixed build counts as the release it was cut from rather than sorting | ||
| /// below it the way semver pre-release ordering would. | ||
| /// | ||
| /// Anchored, and the numbers must end the string or start a suffix. Matching a | ||
| /// dotted number anywhere would read something like `build-2026.08-v1.70.0` as | ||
| /// `2026.8.0` and call an outdated webapp current; a shape we do not recognize | ||
| /// has to be unknown instead. | ||
| static NUMERIC_VERSION: LazyLock<Regex> = LazyLock::new(|| { | ||
| Regex::new(r"^v?(\d+)\.(\d+)(?:\.(\d+))?(?:$|[-+])").expect("valid version regex") | ||
| }); | ||
|
|
||
| /// The `major.minor.patch` numbers leading `raw`, or `None` when it is not a | ||
| /// shape we recognize. A missing patch reads as `0`, so `v1.71` and `v1.71.0` | ||
| /// compare equal. | ||
| pub fn extract_version(raw: &str) -> Option<Version> { | ||
| let captures = NUMERIC_VERSION.captures(raw.trim())?; | ||
| let number = |group: usize| match captures.get(group) { | ||
| Some(digits) => digits.as_str().parse::<u64>().ok(), | ||
| None => Some(0), | ||
| }; | ||
| Some(Version::new(number(1)?, number(2)?, number(3)?)) | ||
| } | ||
|
|
||
| /// The warning to show for a webapp running `webapp_version`, or `None` when it | ||
| /// is new enough or when either version has no numbers to compare. | ||
| pub fn outdated_warning(corgea_url: &str, webapp_version: &str, minimum: &str) -> Option<String> { | ||
| let running = extract_version(webapp_version)?; | ||
| let required = extract_version(minimum)?; | ||
|
|
||
| if running >= required { | ||
| return None; | ||
| } | ||
|
|
||
| Some(format!( | ||
| "Warning: this Corgea CLI (v{cli}) requires Corgea webapp {minimum} or newer, \ | ||
| but {corgea_url} is running {webapp_version}. Commands may fail or return \ | ||
| incomplete results until the webapp is upgraded. \ | ||
| Set {SKIP_ENV_VAR}=1 to silence this warning.", | ||
| cli = env!("CARGO_PKG_VERSION"), | ||
| )) | ||
| } | ||
|
|
||
| /// The version floor to enforce: `CORGEA_MIN_WEBAPP_VERSION`, else the built-in | ||
| /// `MIN_WEBAPP_VERSION`. | ||
| fn min_webapp_version() -> String { | ||
| get_env_var_if_exists(MIN_VERSION_ENV_VAR).unwrap_or_else(|| MIN_WEBAPP_VERSION.to_string()) | ||
| } | ||
|
|
||
| fn check_disabled() -> bool { | ||
| matches!( | ||
| get_env_var_if_exists(SKIP_ENV_VAR) | ||
| .map(|value| value.trim().to_ascii_lowercase()) | ||
| .as_deref(), | ||
| Some("1") | Some("true") | Some("yes") | ||
| ) | ||
| } | ||
|
|
||
| /// Warn on stderr when the webapp at `corgea_url` is older than this CLI needs. | ||
| /// Call before running the command the user asked for. | ||
| pub fn warn_if_webapp_outdated(corgea_url: &str) { | ||
| if check_disabled() { | ||
| debug("Webapp version check disabled"); | ||
| return; | ||
| } | ||
|
|
||
| let webapp_version = match api::get_webapp_version(corgea_url) { | ||
| Ok(Some(version)) => version, | ||
| // Includes a 404 from a webapp predating the endpoint. Warning on that | ||
| // would flag every deployment sitting exactly at the floor, since the | ||
| // endpoint ships above it -- see `api::get_webapp_version`. | ||
| Ok(None) => { | ||
| debug("Webapp did not report a version; skipping compatibility check"); | ||
| return; | ||
| } | ||
| Err(e) => { | ||
| debug(&format!("Failed to read the webapp version: {}", e)); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| debug(&format!("Webapp reports version {}", webapp_version)); | ||
|
|
||
| if let Some(warning) = outdated_warning(corgea_url, &webapp_version, &min_webapp_version()) { | ||
| log::warn!("{}", warning); | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn extracts_the_numbers_from_release_and_suffixed_versions() { | ||
| assert_eq!(extract_version("v1.71.3"), Some(Version::new(1, 71, 3))); | ||
| assert_eq!(extract_version("1.71.3"), Some(Version::new(1, 71, 3))); | ||
| assert_eq!( | ||
| extract_version("v1.71.3-beta"), | ||
| Some(Version::new(1, 71, 3)) | ||
| ); | ||
| assert_eq!( | ||
| extract_version("v1.71.3-client-a"), | ||
| Some(Version::new(1, 71, 3)) | ||
| ); | ||
| assert_eq!( | ||
| extract_version("v1.71.3-main-a1b2c3d"), | ||
| Some(Version::new(1, 71, 3)) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_missing_patch_reads_as_zero() { | ||
| assert_eq!(extract_version("v1.71"), Some(Version::new(1, 71, 0))); | ||
| assert_eq!(extract_version("v2"), None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn versions_without_numbers_are_not_comparable() { | ||
| assert_eq!(extract_version(""), None); | ||
| assert_eq!(extract_version("unknown"), None); | ||
| assert_eq!(extract_version("main"), None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_version_buried_in_another_string_is_unknown() { | ||
| // Matching a dotted number anywhere would read the date here as | ||
| // 2026.8.0 and call this outdated webapp current. | ||
| assert_eq!(extract_version("build-2026.08-v1.70.0"), None); | ||
| assert_eq!(extract_version("corgea 1.70.0"), None); | ||
| assert_eq!(extract_version("1.71.3.4"), None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn surrounding_whitespace_is_tolerated() { | ||
| assert_eq!(extract_version(" v1.71.3\n"), Some(Version::new(1, 71, 3))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn warns_only_when_the_webapp_is_behind_the_minimum() { | ||
| let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3"); | ||
|
|
||
| assert!(warn("v1.71.2").is_some()); | ||
| assert!(warn("v1.70.9").is_some()); | ||
| assert!(warn("v0.9.0").is_some()); | ||
| assert!(warn("v1.71.3").is_none()); | ||
| assert!(warn("v1.71.4").is_none()); | ||
| assert!(warn("v2.0.0").is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_suffixed_build_counts_as_the_release_it_was_cut_from() { | ||
| // Plain semver sorts `1.71.3-beta` below `1.71.3`; comparing only the | ||
| // numeric part deliberately does not. | ||
| let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3"); | ||
|
|
||
| assert!(warn("v1.71.3-beta").is_none()); | ||
| assert!(warn("v1.71.3-client-a").is_none()); | ||
| assert!(warn("v1.71.2-client-a").is_some()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn unreadable_versions_never_warn() { | ||
| assert!(outdated_warning("https://corgea.test", "unknown", "v1.71.3").is_none()); | ||
| assert!(outdated_warning("https://corgea.test", "v1.0.0", "not-a-version").is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_warning_names_the_instance_the_minimum_and_the_escape_hatch() { | ||
| let warning = outdated_warning("https://corgea.test", "v1.70.0", "v1.71.3") | ||
| .expect("an outdated webapp warns"); | ||
|
|
||
| assert!(warning.contains("v1.71.3"), "{warning}"); | ||
| assert!(warning.contains("v1.70.0"), "{warning}"); | ||
| assert!(warning.contains("https://corgea.test"), "{warning}"); | ||
| assert!(warning.contains(env!("CARGO_PKG_VERSION")), "{warning}"); | ||
| assert!(warning.contains(SKIP_ENV_VAR), "{warning}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_default_minimum_is_a_readable_version() { | ||
| assert_eq!( | ||
| extract_version(MIN_WEBAPP_VERSION), | ||
| Some(Version::new(1, 71, 3)) | ||
| ); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.