From c777a7d26b13a1b89451f1938d6f04104ba8d42a Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Thu, 3 Sep 2026 01:55:32 +0200 Subject: [PATCH] fix(import): recognize a 500 unique-constraint conflict, report already_present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen is_conflict to also treat a 500 whose body names a unique- constraint violation as "already exists" — some already-deployed servers surface a duplicate realm name that way instead of a proper 409, which stopped a replay from converging on its first line. Add ImportReport.already_present, incremented at every skip site, so a converging replay is distinguishable from a run that did nothing. --- libs/ferriskey-cli-core/src/import/apply.rs | 103 ++++++++++++++++---- libs/ferriskey-cli-core/src/import/mod.rs | 3 + libs/ferriskey-cli-core/src/realm.rs | 1 + 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 5edf179..4caeadb 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -52,9 +52,12 @@ pub fn apply_blueprint( match client.create_realm(&ferriskey_cli_client::CreateRealmRequest { name: realm.to_owned() }) { Ok(_) => report.realm_created = true, - Err(e) if is_conflict(&e) => report - .warnings - .push(format!("realm '{realm}' already exists, reusing it")), + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report + .warnings + .push(format!("realm '{realm}' already exists, reusing it")); + } Err(e) => return Err(e.into()), } @@ -75,9 +78,12 @@ pub fn apply_blueprint( role_ids.insert(created.name, created.id); report.roles_created += 1; } - Err(e) if is_conflict(&e) => report - .warnings - .push(format!("realm role '{}' already exists", role.name)), + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report + .warnings + .push(format!("realm role '{}' already exists", role.name)); + } Err(e) => return Err(e.into()), } } @@ -114,10 +120,13 @@ pub fn apply_blueprint( }; match client.add_client_redirect(realm, &client_uuid, &request) { Ok(()) => report.redirects_created += 1, - Err(e) if is_conflict(&e) => report.warnings.push(format!( - "redirect '{uri}' already exists on client '{}'", - client_bp.client_id - )), + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "redirect '{uri}' already exists on client '{}'", + client_bp.client_id + )); + } Err(e) => return Err(e.into()), } } @@ -125,10 +134,13 @@ pub fn apply_blueprint( for role in &client_bp.roles { match client.create_client_role(realm, &client_uuid, &role_request(role)) { Ok(_) => report.client_roles_created += 1, - Err(e) if is_conflict(&e) => report.warnings.push(format!( - "client role '{}' already exists on client '{}'", - role.name, client_bp.client_id - )), + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "client role '{}' already exists on client '{}'", + role.name, client_bp.client_id + )); + } Err(e) => return Err(e.into()), } } @@ -142,6 +154,7 @@ pub fn apply_blueprint( Some(created.id) } Err(e) if is_conflict(&e) => { + report.already_present += 1; report .warnings .push(format!("user '{}' already exists, reusing it", user.username)); @@ -155,10 +168,13 @@ pub fn apply_blueprint( match role_ids.get(role_name) { Some(role_id) => match client.assign_user_role(realm, &user_id, role_id) { Ok(()) => report.role_assignments += 1, - Err(e) if is_conflict(&e) => report.warnings.push(format!( - "user '{}' already has role '{role_name}'", - user.username - )), + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "user '{}' already has role '{role_name}'", + user.username + )); + } Err(e) => return Err(e.into()), }, None => report.warnings.push(format!( @@ -186,6 +202,7 @@ fn resolve_client( Ok(Some(created.id)) } Err(e) if is_conflict(&e) => { + report.already_present += 1; report .warnings .push(format!("client '{}' already exists, reusing it", client_bp.client_id)); @@ -262,12 +279,19 @@ fn user_request(user: &super::UserBlueprint) -> CreateUserRequest { } /// Whether an API error means "this entity already exists" — treated as a skip. +/// +/// Some already-deployed servers surface a duplicate-key unique-constraint +/// violation as a raw `500` instead of a proper `409` (e.g. `realms_name_key` +/// on a duplicate realm name); recognizing it here lets an import converge on +/// replay without needing every server upgraded first. fn is_conflict(error: &FerriskeyClientError) -> bool { matches!( error, FerriskeyClientError::Api { status, body } if *status == StatusCode::CONFLICT || (*status == StatusCode::BAD_REQUEST && body.to_lowercase().contains("exist")) + || (*status == StatusCode::INTERNAL_SERVER_ERROR + && body.to_lowercase().contains("unique constraint")) ) } @@ -341,4 +365,47 @@ mod tests { let report = apply_blueprint(&client, &bp, true).unwrap(); assert!(!report.settings_applied); } + + fn api_error(status: StatusCode, body: &str) -> FerriskeyClientError { + FerriskeyClientError::Api { + status, + body: body.to_owned(), + } + } + + #[test] + fn is_conflict_recognizes_409() { + assert!(is_conflict(&api_error(StatusCode::CONFLICT, ""))); + } + + #[test] + fn is_conflict_recognizes_400_with_exist_in_body() { + assert!(is_conflict(&api_error( + StatusCode::BAD_REQUEST, + "realm already exists" + ))); + } + + #[test] + fn is_conflict_recognizes_500_unique_constraint_violation() { + // A raw Postgres unique-constraint violation surfaced as a 500 by + // older, not-yet-patched servers (e.g. a duplicate realm name). + assert!(is_conflict(&api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "duplicate key value violates unique constraint \"realms_name_key\"" + ))); + } + + #[test] + fn is_conflict_rejects_unrelated_500() { + assert!(!is_conflict(&api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error" + ))); + } + + #[test] + fn is_conflict_rejects_unrelated_400() { + assert!(!is_conflict(&api_error(StatusCode::BAD_REQUEST, "invalid input"))); + } } diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index 7e90a07..e5b642a 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -163,6 +163,9 @@ pub struct ImportReport { pub client_roles_created: usize, pub users_created: usize, pub role_assignments: usize, + /// Entities skipped because they already existed — distinguishes a + /// converging replay from a run that did nothing. + pub already_present: usize, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index 78ba99c..a360805 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -411,6 +411,7 @@ fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { println!(" client roles created: {}", report.client_roles_created); println!(" users created: {}", report.users_created); println!(" role assignments: {}", report.role_assignments); + println!(" already present: {}", report.already_present); if !report.warnings.is_empty() { println!(" warnings:"); for warning in &report.warnings {