Skip to content

Commit 001b312

Browse files
committed
support partial jwt reloads
1 parent 58b4b22 commit 001b312

1 file changed

Lines changed: 168 additions & 12 deletions

File tree

crates/signer/src/service.rs

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,6 @@ async fn handle_reload(
632632
) -> Result<impl IntoResponse, SignerModuleError> {
633633
debug!(event = "reload", "New request");
634634

635-
// Regenerate the config
636635
let config = match StartSignerConfig::load_from_env() {
637636
Ok(config) => config,
638637
Err(err) => {
@@ -641,7 +640,6 @@ async fn handle_reload(
641640
}
642641
};
643642

644-
// Start a new manager with the updated config
645643
let new_manager = match start_manager(config).await {
646644
Ok(manager) => manager,
647645
Err(err) => {
@@ -650,17 +648,24 @@ async fn handle_reload(
650648
}
651649
};
652650

653-
// Update the JWT configs if provided in the request
651+
apply_reload(state, request, new_manager).await
652+
}
653+
654+
/// Applies a reload request to the signing state. Separated from
655+
/// `handle_reload` so the business logic can be tested without requiring a
656+
/// live environment (config file, env vars, keystore on disk).
657+
async fn apply_reload(
658+
state: SigningState,
659+
request: ReloadRequest,
660+
new_manager: SigningManager,
661+
) -> Result<StatusCode, SignerModuleError> {
662+
// Update the JWT configs if provided in the request. Only the provided
663+
// modules are updated; omitted modules keep their existing secrets.
654664
if let Some(jwt_secrets) = request.jwt_secrets {
655665
let mut jwt_configs = state.jwts.write();
656-
let mut new_configs = HashMap::new();
657666
for (module_id, jwt_secret) in jwt_secrets {
658-
if let Some(signing_id) = jwt_configs.get(&module_id).map(|cfg| cfg.signing_id) {
659-
new_configs.insert(module_id.clone(), ModuleSigningConfig {
660-
module_name: module_id,
661-
jwt_secret,
662-
signing_id,
663-
});
667+
if let Some(cfg) = jwt_configs.get_mut(&module_id) {
668+
cfg.jwt_secret = jwt_secret;
664669
} else {
665670
let error_message = format!(
666671
"Module {module_id} signing ID not found in commit-boost config, cannot reload"
@@ -669,10 +674,8 @@ async fn handle_reload(
669674
return Err(SignerModuleError::RequestError(error_message));
670675
}
671676
}
672-
*jwt_configs = new_configs;
673677
}
674678

675-
// Update the rest of the state once everything has passed
676679
if let Some(admin_secret) = request.admin_secret {
677680
*state.admin_secret.write() = admin_secret;
678681
}
@@ -722,3 +725,156 @@ async fn start_manager(config: StartSignerConfig) -> eyre::Result<SigningManager
722725
}
723726
}
724727
}
728+
729+
#[cfg(test)]
730+
mod tests {
731+
use alloy::primitives::b256;
732+
use parking_lot::RwLock as ParkingRwLock;
733+
734+
use super::*;
735+
use crate::manager::local::LocalSigningManager;
736+
737+
fn make_signing_config(
738+
module_name: &str,
739+
secret: &str,
740+
signing_id: B256,
741+
) -> ModuleSigningConfig {
742+
ModuleSigningConfig {
743+
module_name: ModuleId(module_name.to_string()),
744+
jwt_secret: secret.to_string(),
745+
signing_id,
746+
}
747+
}
748+
749+
fn make_state(jwts: HashMap<ModuleId, ModuleSigningConfig>) -> SigningState {
750+
SigningState {
751+
manager: Arc::new(RwLock::new(SigningManager::Local(
752+
LocalSigningManager::new(Chain::Holesky, None).unwrap(),
753+
))),
754+
jwts: Arc::new(ParkingRwLock::new(jwts)),
755+
admin_secret: Arc::new(ParkingRwLock::new("admin".to_string())),
756+
jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())),
757+
jwt_auth_fail_limit: 3,
758+
jwt_auth_fail_timeout: Duration::from_secs(60),
759+
reverse_proxy: ReverseProxyHeaderSetup::None,
760+
}
761+
}
762+
763+
fn empty_manager() -> SigningManager {
764+
SigningManager::Local(LocalSigningManager::new(Chain::Holesky, None).unwrap())
765+
}
766+
767+
/// Partial reload must update only the provided modules and leave omitted
768+
/// modules with their existing secrets.
769+
#[tokio::test]
770+
async fn test_partial_reload_preserves_omitted_modules() {
771+
let module_a = ModuleId("module-a".to_string());
772+
let module_b = ModuleId("module-b".to_string());
773+
let signing_id_a =
774+
b256!("0101010101010101010101010101010101010101010101010101010101010101");
775+
let signing_id_b =
776+
b256!("0202020202020202020202020202020202020202020202020202020202020202");
777+
778+
let state = make_state(HashMap::from([
779+
(module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)),
780+
(module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)),
781+
]));
782+
783+
let request = ReloadRequest {
784+
jwt_secrets: Some(HashMap::from([(module_a.clone(), "rotated-secret-a".to_string())])),
785+
admin_secret: None,
786+
};
787+
788+
let result = apply_reload(state.clone(), request, empty_manager()).await;
789+
assert!(result.is_ok(), "apply_reload should succeed");
790+
791+
let jwts = state.jwts.read();
792+
assert_eq!(
793+
jwts[&module_a].jwt_secret, "rotated-secret-a",
794+
"module_a secret should be updated"
795+
);
796+
assert_eq!(
797+
jwts[&module_b].jwt_secret, "secret-b",
798+
"module_b secret must be preserved when omitted"
799+
);
800+
}
801+
802+
/// A full reload (all modules provided) should update every module.
803+
#[tokio::test]
804+
async fn test_full_reload_updates_all_modules() {
805+
let module_a = ModuleId("module-a".to_string());
806+
let module_b = ModuleId("module-b".to_string());
807+
let signing_id_a =
808+
b256!("0101010101010101010101010101010101010101010101010101010101010101");
809+
let signing_id_b =
810+
b256!("0202020202020202020202020202020202020202020202020202020202020202");
811+
812+
let state = make_state(HashMap::from([
813+
(module_a.clone(), make_signing_config("module-a", "secret-a", signing_id_a)),
814+
(module_b.clone(), make_signing_config("module-b", "secret-b", signing_id_b)),
815+
]));
816+
817+
let request = ReloadRequest {
818+
jwt_secrets: Some(HashMap::from([
819+
(module_a.clone(), "new-secret-a".to_string()),
820+
(module_b.clone(), "new-secret-b".to_string()),
821+
])),
822+
admin_secret: None,
823+
};
824+
825+
apply_reload(state.clone(), request, empty_manager()).await.unwrap();
826+
827+
let jwts = state.jwts.read();
828+
assert_eq!(jwts[&module_a].jwt_secret, "new-secret-a");
829+
assert_eq!(jwts[&module_b].jwt_secret, "new-secret-b");
830+
}
831+
832+
/// Reload with an unknown module ID in jwt_secrets should return an error
833+
/// and leave the existing state unchanged.
834+
#[tokio::test]
835+
async fn test_reload_unknown_module_returns_error() {
836+
let module_a = ModuleId("module-a".to_string());
837+
let signing_id_a =
838+
b256!("0101010101010101010101010101010101010101010101010101010101010101");
839+
840+
let state = make_state(HashMap::from([(
841+
module_a.clone(),
842+
make_signing_config("module-a", "secret-a", signing_id_a),
843+
)]));
844+
845+
let request = ReloadRequest {
846+
jwt_secrets: Some(HashMap::from([(
847+
ModuleId("unknown-module".to_string()),
848+
"some-secret".to_string(),
849+
)])),
850+
admin_secret: None,
851+
};
852+
853+
let result = apply_reload(state.clone(), request, empty_manager()).await;
854+
assert!(result.is_err(), "unknown module should return an error");
855+
856+
// Existing module must be untouched
857+
let jwts = state.jwts.read();
858+
assert_eq!(jwts[&module_a].jwt_secret, "secret-a");
859+
}
860+
861+
/// Reload with no jwt_secrets should leave all module secrets unchanged.
862+
#[tokio::test]
863+
async fn test_reload_without_jwt_secrets_preserves_all() {
864+
let module_a = ModuleId("module-a".to_string());
865+
let signing_id_a =
866+
b256!("0101010101010101010101010101010101010101010101010101010101010101");
867+
868+
let state = make_state(HashMap::from([(
869+
module_a.clone(),
870+
make_signing_config("module-a", "secret-a", signing_id_a),
871+
)]));
872+
873+
let request = ReloadRequest { jwt_secrets: None, admin_secret: None };
874+
875+
apply_reload(state.clone(), request, empty_manager()).await.unwrap();
876+
877+
let jwts = state.jwts.read();
878+
assert_eq!(jwts[&module_a].jwt_secret, "secret-a");
879+
}
880+
}

0 commit comments

Comments
 (0)