Skip to content

Commit ec49209

Browse files
authored
fix(providers): stabilize provider environment revisions (#4122)
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 046fd2a commit ec49209

2 files changed

Lines changed: 136 additions & 5 deletions

File tree

‎crates/openshell-server/src/grpc/policy.rs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13779,6 +13779,48 @@ mod tests {
1377913779
);
1378013780
}
1378113781

13782+
#[tokio::test]
13783+
async fn annotated_profile_has_stable_provider_environment_revision() {
13784+
let state = test_server_state().await;
13785+
let mut profile = openshell_providers::example_profiles::load("github").to_proto();
13786+
profile.annotations = (0..8)
13787+
.map(|index| (format!("key-{index}"), format!("value-{index}")))
13788+
.collect();
13789+
state
13790+
.store
13791+
.put_message(&crate::provider_profile_sources::stored_provider_profile(
13792+
profile,
13793+
))
13794+
.await
13795+
.unwrap();
13796+
state
13797+
.store
13798+
.put_message(&test_provider("work-github", "github"))
13799+
.await
13800+
.unwrap();
13801+
let sandbox = test_sandbox(
13802+
"sb-stable-provider-revision",
13803+
"stable-provider-revision",
13804+
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
13805+
vec!["work-github".to_string()],
13806+
);
13807+
state.store.put_message(&sandbox).await.unwrap();
13808+
13809+
let mut revisions = HashSet::new();
13810+
for _ in 0..16 {
13811+
let config = load_sandbox_config(&state, &sandbox).await.unwrap();
13812+
let environment = load_sandbox_provider_environment(&state, &sandbox, true)
13813+
.await
13814+
.unwrap();
13815+
assert_eq!(
13816+
config.provider_env_revision,
13817+
environment.provider_env_revision
13818+
);
13819+
revisions.insert(config.provider_env_revision);
13820+
}
13821+
assert_eq!(revisions.len(), 1);
13822+
}
13823+
1378213824
#[tokio::test]
1378313825
async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() {
1378413826
use openshell_core::proto::{

‎crates/openshell-server/src/provider_profile_sources.rs‎

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use std::sync::Arc;
99
use async_trait::async_trait;
1010
use openshell_core::GatewayProviderProfileSourceConfig;
1111
use openshell_core::mcp::normalize_provider_profile_mcp_fields;
12-
use openshell_core::proto::ProviderProfile;
12+
use openshell_core::policy_identity::canonical_rule_bytes;
13+
use openshell_core::proto::{NetworkPolicyRule, ProviderProfile};
1314
use openshell_gateway_interceptors::{
1415
GatewayInterceptorProfileSource, GatewayInterceptorRuntime,
1516
ProviderProfileSourceSnapshot as InterceptorProfileSnapshot,
@@ -102,7 +103,7 @@ impl ProviderProfileSource for UserProviderProfileSource {
102103
if let Some(profile) = stored.profile {
103104
let mut profile = profile_response_payload(profile, resource_version);
104105
normalize_provider_profile_mcp_fields(&mut profile);
105-
hasher.update(profile.encode_to_vec());
106+
hasher.update(canonical_provider_profile_bytes(&profile));
106107
profiles.push(ScopedSnapshotProfile {
107108
scope: ProfileScope::Platform,
108109
profile,
@@ -123,7 +124,7 @@ impl ProviderProfileSource for UserProviderProfileSource {
123124
if let Some(profile) = stored.profile {
124125
let mut profile = profile_response_payload(profile, resource_version);
125126
normalize_provider_profile_mcp_fields(&mut profile);
126-
hasher.update(profile.encode_to_vec());
127+
hasher.update(canonical_provider_profile_bytes(&profile));
127128
profiles.push(ScopedSnapshotProfile {
128129
scope: ProfileScope::Workspace,
129130
profile,
@@ -495,7 +496,38 @@ fn hash_scoped_profile_revision(entry: &ScopedProfileEntry, hasher: &mut Sha256)
495496
b"source-managed"
496497
};
497498
hasher.update(ownership_tag);
498-
hasher.update(entry.response.encode_to_vec());
499+
hasher.update(canonical_provider_profile_bytes(&entry.response));
500+
}
501+
502+
/// Keep profile revision inputs stable across protobuf map iteration orders.
503+
fn canonical_provider_profile_bytes(profile: &ProviderProfile) -> Vec<u8> {
504+
let mut map_free = profile.clone();
505+
map_free.annotations.clear();
506+
map_free.endpoints.clear();
507+
508+
let mut out = Vec::new();
509+
append_canonical_bytes(&mut out, &map_free.encode_to_vec());
510+
let mut annotations = profile.annotations.iter().collect::<Vec<_>>();
511+
annotations.sort_by_key(|(key, _)| key.as_str());
512+
out.extend_from_slice(&(annotations.len() as u64).to_le_bytes());
513+
for (key, value) in annotations {
514+
append_canonical_bytes(&mut out, key.as_bytes());
515+
append_canonical_bytes(&mut out, value.as_bytes());
516+
}
517+
out.extend_from_slice(&(profile.endpoints.len() as u64).to_le_bytes());
518+
for endpoint in &profile.endpoints {
519+
let rule = NetworkPolicyRule {
520+
endpoints: vec![endpoint.clone()],
521+
..Default::default()
522+
};
523+
append_canonical_bytes(&mut out, &canonical_rule_bytes(&rule));
524+
}
525+
out
526+
}
527+
528+
fn append_canonical_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
529+
out.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
530+
out.extend_from_slice(bytes);
499531
}
500532

501533
fn scope_to_string(scope: ProfileScope) -> &'static str {
@@ -709,7 +741,7 @@ fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String {
709741
let mut hasher = Sha256::new();
710742
hasher.update(b"openshell-provider-profile-snapshot-v1");
711743
for profile in profiles {
712-
hasher.update(profile.encode_to_vec());
744+
hasher.update(canonical_provider_profile_bytes(&profile));
713745
}
714746
format!("sha256:{:x}", hasher.finalize())
715747
}
@@ -1617,6 +1649,63 @@ mod tests {
16171649
}
16181650
}
16191651

1652+
#[tokio::test]
1653+
async fn unchanged_annotated_profile_has_stable_revisions() {
1654+
use std::collections::HashSet;
1655+
1656+
let store = crate::persistence::test_store().await;
1657+
let mut stored = stored_profile_in_workspace("annotated-api", "default");
1658+
stored.profile.as_mut().unwrap().annotations = (0..8)
1659+
.map(|index| (format!("key-{index}"), format!("value-{index}")))
1660+
.collect();
1661+
store.put_message(&stored).await.unwrap();
1662+
1663+
let sources = ProviderProfileSources::with_default_sources();
1664+
let mut source_revisions = HashSet::new();
1665+
let mut profile_revisions = HashSet::new();
1666+
for _ in 0..32 {
1667+
let catalog = sources.snapshot_catalog(&store, "default").await.unwrap();
1668+
source_revisions.insert(catalog.revision().to_string());
1669+
let mut hasher = Sha256::new();
1670+
catalog.hash_type_profile_revision_for_scope("annotated-api", "default", &mut hasher);
1671+
profile_revisions.insert(hasher.finalize().to_vec());
1672+
}
1673+
1674+
assert_eq!(source_revisions.len(), 1);
1675+
assert_eq!(profile_revisions.len(), 1);
1676+
}
1677+
1678+
#[test]
1679+
fn canonical_profile_bytes_sort_nested_endpoint_maps() {
1680+
use openshell_core::proto::GraphqlOperation;
1681+
1682+
let mut first = profile("mapped-endpoint");
1683+
let endpoint = first.endpoints.first_mut().unwrap();
1684+
endpoint.graphql_persisted_queries = (0..8)
1685+
.map(|index| (format!("query-{index}"), GraphqlOperation::default()))
1686+
.collect();
1687+
let mut second = first.clone();
1688+
second.endpoints[0].graphql_persisted_queries = (0..8)
1689+
.rev()
1690+
.map(|index| (format!("query-{index}"), GraphqlOperation::default()))
1691+
.collect();
1692+
1693+
assert_eq!(
1694+
canonical_provider_profile_bytes(&first),
1695+
canonical_provider_profile_bytes(&second)
1696+
);
1697+
assert!(
1698+
second.endpoints[0]
1699+
.graphql_persisted_queries
1700+
.remove("query-0")
1701+
.is_some()
1702+
);
1703+
assert_ne!(
1704+
canonical_provider_profile_bytes(&first),
1705+
canonical_provider_profile_bytes(&second)
1706+
);
1707+
}
1708+
16201709
#[tokio::test]
16211710
async fn cross_workspace_duplicate_profile_ids_do_not_collide() {
16221711
let store = crate::persistence::test_store().await;

0 commit comments

Comments
 (0)