Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/sequent-core/src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub mod date_time;
pub mod integrity_check;
pub mod mime;
pub mod normalize_vote;
pub mod version;

#[cfg(feature = "reports")]
pub mod temp_path;

Expand Down
176 changes: 176 additions & 0 deletions packages/sequent-core/src/util/version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// SPDX-FileCopyrightText: 2025 Sequent Tech Inc <legal@sequentech.io>
//
// SPDX-License-Identifier: AGPL-3.0-only

use anyhow::{anyhow, Result};
use tracing::{info, instrument};

pub const DEV_APP_VERSION: &str = "dev";
pub const ENV_VAR_APP_VERSION: &str = "APP_VERSION";
pub const ENV_VAR_APP_HASH: &str = "APP_HASH";

pub fn check_version_compatibility(
imported_version: &str,
current_version: &str,
) -> Result<()> {
info!(
"Checking version compatibility - Current: {}, Imported: {}",
current_version, imported_version
);

// If current version is DEV_APP_VERSION, allow any import
if current_version == DEV_APP_VERSION {
info!("Current version is 'dev', allowing import");
return Ok(());
}

if imported_version == DEV_APP_VERSION {
info!("Imported version is 'dev' while system is not in dev mode, rejecting import");
return Err(anyhow!("Imported version is 'dev', which is not compatible with current version {}. Please use a different version.", current_version));
}

let current_major_parsed = extract_major(&current_version)
.ok_or_else(|| anyhow!("Could not parse current version"))?;
let imported_major_parsed = extract_major(imported_version)
.ok_or_else(|| anyhow!("Could not parse imported version"))?;

if current_major_parsed < imported_major_parsed {
return Err(anyhow!(
"Version mismatch: Imported version {} is not compatible with current version {}. Please upgrade your system.",
imported_version,
current_version
));
}
Ok(())
}

fn extract_major(input: &str) -> Option<u64> {
// Trim optional 'v' or 'V' prefix
let trimmed = input.trim_start_matches(|c| c == 'v' || c == 'V');

// We take characters from the start as long as they are digits.
// This stops at the first dot '.', hyphen '-', or any non-digit.
let major_str: String =
trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();

// Parse the result into a u64
// If the string was empty (e.g., input was "invalid"), this returns None.
major_str.parse::<u64>().ok()
}

#[cfg(test)]
mod tests {
use super::*;

// ==========================================
// Public API Tests: check_version_compatibility
// ==========================================

#[test]
fn test_current_version_is_dev() {
// If current system is DEV_APP_VERSION, it should accept anything
assert!(check_version_compatibility("1.0.0", DEV_APP_VERSION).is_ok());
assert!(
check_version_compatibility("99.99.99", DEV_APP_VERSION).is_ok()
);
assert!(
check_version_compatibility(DEV_APP_VERSION, DEV_APP_VERSION)
.is_ok()
);
}

#[test]
fn test_imported_version_is_dev_rejected() {
// If importing DEV_APP_VERSION into a non-dev system, it must fail
let result = check_version_compatibility(DEV_APP_VERSION, "1.0.0");
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Imported version is 'dev', which is not compatible with current version 1.0.0. Please use a different version."
);
}

#[test]
fn test_exact_match_versions() {
assert!(check_version_compatibility("1.0.0", "1.0.0").is_ok());
assert!(check_version_compatibility("2.5.1", "2.5.1").is_ok());
}

#[test]
fn test_backward_compatibility() {
// Importing an OLDER version into a NEWER system should be OK
// Imported: 1, Current: 2
assert!(check_version_compatibility("1.0.0", "2.0.0").is_ok());

// Imported: 10, Current: 11
assert!(check_version_compatibility("10.5.5", "11.0.0").is_ok());
}

#[test]
fn test_forward_compatibility_rejection() {
// Importing a NEWER version into an OLDER system should FAIL
// Imported: 2, Current: 1
let result = check_version_compatibility("2.0.0", "1.0.0");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not compatible"));
}

#[test]
fn test_parsing_failures() {
// Invalid current version
let res_current = check_version_compatibility("1.0.0", "invalid_ver");
assert!(res_current.is_err());
assert!(res_current
.unwrap_err()
.to_string()
.contains("Could not parse current version"));

// Invalid imported version
let res_imported = check_version_compatibility("invalid_ver", "1.0.0");
assert!(res_imported.is_err());
assert!(res_imported
.unwrap_err()
.to_string()
.contains("Could not parse imported version"));
}

#[test]
fn test_version_prefixes() {
// Handling 'v' or 'V' prefixes
// Imported v1 (1) into Current 1 -> OK
assert!(check_version_compatibility("v1.0.0", "1.0.0").is_ok());

// Imported V2 (2) into Current v1 (1) -> Error
assert!(check_version_compatibility("V2.0.0", "v1.0.0").is_err());
}

// ==========================================
// Internal Helper Tests: extract_major
// ==========================================

#[test]
fn test_extract_major_logic() {
// Standard semver
assert_eq!(extract_major("1.2.3"), Some(1));
assert_eq!(extract_major("10.0.0"), Some(10));
assert_eq!(extract_major("0.5.9"), Some(0));

// With prefixes
assert_eq!(extract_major("v1.2.3"), Some(1));
assert_eq!(extract_major("V2.0.0"), Some(2));

// With suffixes (alpha, beta, rc)
assert_eq!(extract_major("1.0.0-alpha"), Some(1));
assert_eq!(extract_major("3.0.0-rc1"), Some(3));
assert_eq!(extract_major("v4-beta"), Some(4));

// Edge cases
assert_eq!(extract_major("2"), Some(2)); // Just a number
assert_eq!(extract_major("not_a_number"), None);
assert_eq!(extract_major(""), None);
assert_eq!(extract_major("v"), None);

// Ensure it stops at non-digits
assert_eq!(extract_major("5startswithnumber"), Some(5));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use sequent_core::services::s3;
use sequent_core::temp_path::generate_temp_file;
use sequent_core::types::hasura::core::KeysCeremony;
use sequent_core::types::hasura::core::{Candidate, Contest, Election};
use sequent_core::util::version::{DEV_APP_VERSION, ENV_VAR_APP_VERSION};
use std::collections::HashMap;
use std::env;
use std::fs::File;
Expand Down Expand Up @@ -140,6 +141,9 @@ pub async fn read_export_data(
vec![]
};

let version =
std::env::var(ENV_VAR_APP_VERSION).unwrap_or_else(|_| DEV_APP_VERSION.to_string());

let import_election_event_schema = ImportElectionEventSchema {
tenant_id: Uuid::parse_str(&tenant_id)?,
keycloak_event_realm: Some(realm),
Expand All @@ -153,6 +157,7 @@ pub async fn read_export_data(
reports: export_reports,
keys_ceremonies: Some(export_keys_ceremonies),
applications: Some(export_applications),
version,
};

let images_files_path =
Expand All @@ -175,7 +180,7 @@ pub async fn generate_encrypted_zip(

pub async fn write_export_document(data: ImportElectionEventSchema) -> Result<NamedTempFile> {
// Serialize the data into JSON string
let data_str = serde_json::to_string(&data)?;
let data_str = serde_json::to_string_pretty(&data)?;
let data_bytes = data_str.into_bytes();

// Create and write the data into a temporary file
Expand Down
15 changes: 14 additions & 1 deletion packages/windmill/src/services/import/import_election_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ use sequent_core::types::hasura::core::Document;
use sequent_core::types::hasura::core::KeysCeremony;
use sequent_core::types::hasura::core::TasksExecution;
use sequent_core::util::mime::{get_mime_types, matches_mime};
use sequent_core::util::version::{
check_version_compatibility, DEV_APP_VERSION, ENV_VAR_APP_VERSION,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
Expand Down Expand Up @@ -104,6 +107,13 @@ pub struct ImportElectionEventSchema {
pub reports: Vec<Report>,
pub keys_ceremonies: Option<Vec<KeysCeremony>>,
pub applications: Option<Vec<Application>>,
#[serde(default = "default_version")]
pub version: String,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could make older election events import fails if version is not present. We could either add a default value to version or make the parameter optional for backwards compatibilit.

}

// Set the default version of an imported election event to be compatible with version 9, which is the first version to include this feature.
fn default_version() -> String {
"9.0.0".to_string()
}

#[instrument(err)]
Expand Down Expand Up @@ -469,6 +479,9 @@ pub async fn get_election_event_schema(
tenant_id: String,
) -> Result<(ImportElectionEventSchema, HashMap<String, String>)> {
let original_data: ImportElectionEventSchema = deserialize_str(data_str)?;
let current_version =
std::env::var(ENV_VAR_APP_VERSION).unwrap_or_else(|_| DEV_APP_VERSION.to_string());
check_version_compatibility(&original_data.version, &current_version)?;
replace_ids(data_str, &original_data, id, tenant_id.clone())
}

Expand All @@ -488,7 +501,7 @@ pub async fn process_election_event_file(
tenant_id.clone(),
)
.await
.with_context(|| format!("Error getting document for election event ID {election_event_id} and tenant ID {tenant_id}"))?;
.map_err(|err| anyhow!("Error getting document for election event ID {election_event_id} and tenant ID {tenant_id}: {err}"))?;

let election_ids: Vec<String> = data
.elections
Expand Down
5 changes: 3 additions & 2 deletions packages/windmill/src/services/reports/report_variables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use sequent_core::types::hasura::core::{Area, Election, ElectionEvent};
use sequent_core::types::keycloak::AREA_ID_ATTR_NAME;
use sequent_core::types::scheduled_event::ScheduledEvent;
use sequent_core::util::temp_path::*;
use sequent_core::util::version::{ENV_VAR_APP_HASH, ENV_VAR_APP_VERSION};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
Expand All @@ -50,11 +51,11 @@ pub struct ExecutionAnnotations {
}

pub fn get_app_hash() -> String {
env::var("APP_HASH").unwrap_or("-".to_string())
env::var(ENV_VAR_APP_HASH).unwrap_or("-".to_string())
}

pub fn get_app_version() -> String {
env::var("APP_VERSION").unwrap_or("-".to_string())
env::var(ENV_VAR_APP_VERSION).unwrap_or("-".to_string())
}

#[derive(Debug)]
Expand Down
Loading