Skip to content

Commit 604b9f6

Browse files
pimlockFrostGod
andauthored
feat(config): deliver gateway configuration over the supervisor session (#4320)
Supervisors poll the gateway for configuration every 10 seconds. This PR adds an opt-in push mode (config_delivery_mode = "push"): supervisors still start by polling, and once their ConnectSupervisor session is accepted, the gateway delivers every later change over it and supervisors apply it and report the result. poll stays the default, and older supervisors and gateways keep polling. --------- Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> Signed-off-by: divesh <dgude@nvidia.com> Co-authored-by: divesh <dgude@nvidia.com>
1 parent 41c518f commit 604b9f6

63 files changed

Lines changed: 18665 additions & 6464 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/e2e-docker-test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ on:
2929
required: false
3030
type: string
3131
default: >-
32-
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
32+
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
3333
3434
permissions:
3535
actions: read

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎TESTING.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ Suites:
246246
the selected deployment.
247247
- Docker suite (`--features e2e-docker`) - includes Docker-only coverage such as Dockerfile image builds, Docker preflight checks, and managed Docker gateway start.
248248
- Docker GPU suite (`--features e2e-docker-gpu`) - Docker suite plus GPU sandbox smoke coverage.
249+
- Configuration push suite (`--features e2e-config-push`) - CLI conformance and the policy tests against a Docker gateway with `config_delivery_mode = "push"`. Run it with `mise run e2e:rust:push`.
249250
- VM suite (`--features e2e-vm`) - runs e2e tests on a VM.
250251
- Kubernetes credential-driver suite (`--features e2e-kubernetes-credential-drivers`) - targeted Kubernetes Secrets and Vault provider credential storage coverage.
251252

‎crates/openshell-cli/src/commands/provider.rs‎

Lines changed: 90 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ pub async fn sandbox_provider_list(
129129
Ok(())
130130
}
131131

132+
/// Attempts for an attach or detach whose resource version went stale between
133+
/// the command's read and its write.
134+
const PROVIDER_ATTACHMENT_CAS_ATTEMPTS: u32 = 5;
135+
132136
/// Save a provider attachment and optionally wait for its installed authority.
133137
pub async fn sandbox_provider_attach(
134138
server: &str,
@@ -141,45 +145,53 @@ pub async fn sandbox_provider_attach(
141145
readiness.validate()?;
142146
let mut client = grpc_client(server, tls).await?;
143147

144-
// Fetch current sandbox to get resource_version for CAS
145-
let sandbox = client
146-
.get_sandbox(GetSandboxRequest {
147-
name: (name).to_string(),
148-
workspace_scope: Some(openshell_core::proto::workspace_selector(
149-
(workspace).to_string(),
150-
)),
151-
})
152-
.await
153-
.map_err(|status| miette!("provider attachment lookup failed ({})", status.code()))?
154-
.into_inner()
155-
.sandbox
156-
.ok_or_else(|| miette::miette!("sandbox not found"))?;
148+
// The resource version only guards this command's own read, so a
149+
// conflict from a concurrent status write is retried with a fresh read.
150+
let mut attempt = 1;
151+
let (sandbox, response) = loop {
152+
let sandbox = client
153+
.get_sandbox(GetSandboxRequest {
154+
name: (name).to_string(),
155+
workspace_scope: Some(openshell_core::proto::workspace_selector(
156+
(workspace).to_string(),
157+
)),
158+
})
159+
.await
160+
.map_err(|status| miette!("provider attachment lookup failed ({})", status.code()))?
161+
.into_inner()
162+
.sandbox
163+
.ok_or_else(|| miette::miette!("sandbox not found"))?;
157164

158-
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
165+
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
159166

160-
let response = match client
161-
.attach_sandbox_provider(AttachSandboxProviderRequest {
162-
request_id: String::new(),
163-
sandbox: (name).to_string(),
164-
workspace_scope: Some(openshell_core::proto::workspace_selector(
165-
(workspace).to_string(),
166-
)),
167-
provider: provider.to_string(),
168-
expected_resource_version: resource_version,
169-
})
170-
.await
171-
{
172-
Ok(response) => response.into_inner(),
173-
// Explicit post-save uncertainty takes precedence over a generic retry hint.
174-
Err(status)
175-
if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) =>
167+
match client
168+
.attach_sandbox_provider(AttachSandboxProviderRequest {
169+
request_id: String::new(),
170+
sandbox: (name).to_string(),
171+
workspace_scope: Some(openshell_core::proto::workspace_selector(
172+
(workspace).to_string(),
173+
)),
174+
provider: provider.to_string(),
175+
expected_resource_version: resource_version,
176+
})
177+
.await
176178
{
177-
return Err(miette::miette!(
178-
"Failed to attach provider: sandbox was modified by another operation.\n\
179-
Please retry the command."
180-
));
179+
Ok(response) => break (sandbox, response.into_inner()),
180+
// Explicit post-save uncertainty takes precedence over a generic retry hint.
181+
Err(status)
182+
if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) =>
183+
{
184+
if attempt < PROVIDER_ATTACHMENT_CAS_ATTEMPTS {
185+
attempt += 1;
186+
continue;
187+
}
188+
return Err(miette::miette!(
189+
"Failed to attach provider: sandbox was modified by another operation.\n\
190+
Please retry the command."
191+
));
192+
}
193+
Err(error) => return Err(provider_mutation_error(&error, "attachment")),
181194
}
182-
Err(error) => return Err(provider_mutation_error(&error, "attachment")),
183195
};
184196

185197
let receipt = response.receipt.ok_or_else(|| {
@@ -216,45 +228,53 @@ pub async fn sandbox_provider_detach(
216228
readiness.validate()?;
217229
let mut client = grpc_client(server, tls).await?;
218230

219-
// Fetch current sandbox to get resource_version for CAS
220-
let sandbox = client
221-
.get_sandbox(GetSandboxRequest {
222-
name: (name).to_string(),
223-
workspace_scope: Some(openshell_core::proto::workspace_selector(
224-
(workspace).to_string(),
225-
)),
226-
})
227-
.await
228-
.map_err(|status| miette!("provider detachment lookup failed ({})", status.code()))?
229-
.into_inner()
230-
.sandbox
231-
.ok_or_else(|| miette::miette!("sandbox not found"))?;
231+
// The resource version only guards this command's own read, so a
232+
// conflict from a concurrent status write is retried with a fresh read.
233+
let mut attempt = 1;
234+
let (sandbox, response) = loop {
235+
let sandbox = client
236+
.get_sandbox(GetSandboxRequest {
237+
name: (name).to_string(),
238+
workspace_scope: Some(openshell_core::proto::workspace_selector(
239+
(workspace).to_string(),
240+
)),
241+
})
242+
.await
243+
.map_err(|status| miette!("provider detachment lookup failed ({})", status.code()))?
244+
.into_inner()
245+
.sandbox
246+
.ok_or_else(|| miette::miette!("sandbox not found"))?;
232247

233-
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
248+
let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version);
234249

235-
let response = match client
236-
.detach_sandbox_provider(DetachSandboxProviderRequest {
237-
request_id: String::new(),
238-
sandbox: (name).to_string(),
239-
workspace_scope: Some(openshell_core::proto::workspace_selector(
240-
(workspace).to_string(),
241-
)),
242-
provider: provider.to_string(),
243-
expected_resource_version: resource_version,
244-
})
245-
.await
246-
{
247-
Ok(response) => response.into_inner(),
248-
// Explicit post-save uncertainty takes precedence over a generic retry hint.
249-
Err(status)
250-
if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) =>
250+
match client
251+
.detach_sandbox_provider(DetachSandboxProviderRequest {
252+
request_id: String::new(),
253+
sandbox: (name).to_string(),
254+
workspace_scope: Some(openshell_core::proto::workspace_selector(
255+
(workspace).to_string(),
256+
)),
257+
provider: provider.to_string(),
258+
expected_resource_version: resource_version,
259+
})
260+
.await
251261
{
252-
return Err(miette::miette!(
253-
"Failed to detach provider: sandbox was modified by another operation.\n\
254-
Please retry the command."
255-
));
262+
Ok(response) => break (sandbox, response.into_inner()),
263+
// Explicit post-save uncertainty takes precedence over a generic retry hint.
264+
Err(status)
265+
if status.code() == Code::Aborted && !provider_mutation_is_uncertain(&status) =>
266+
{
267+
if attempt < PROVIDER_ATTACHMENT_CAS_ATTEMPTS {
268+
attempt += 1;
269+
continue;
270+
}
271+
return Err(miette::miette!(
272+
"Failed to detach provider: sandbox was modified by another operation.\n\
273+
Please retry the command."
274+
));
275+
}
276+
Err(error) => return Err(provider_mutation_error(&error, "detachment")),
256277
}
257-
Err(error) => return Err(provider_mutation_error(&error, "detachment")),
258278
};
259279

260280
let receipt = response.receipt.ok_or_else(|| miette!("gateway did not return a provider receipt; saved detachment cannot establish revocation"))?;

‎crates/openshell-cli/tests/ensure_providers_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ impl TestOpenShell {
8080

8181
#[tonic::async_trait]
8282
impl OpenShell for TestOpenShell {
83+
async fn peer_notify_config_update(
84+
&self,
85+
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
86+
) -> Result<Response<openshell_core::proto::PeerNotifyConfigUpdateResponse>, Status> {
87+
Err(Status::unimplemented("not used by this test server"))
88+
}
89+
8390
async fn peer_report_provider_readiness(
8491
&self,
8592
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/mtls_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ struct TestOpenShell;
3232

3333
#[tonic::async_trait]
3434
impl OpenShell for TestOpenShell {
35+
async fn peer_notify_config_update(
36+
&self,
37+
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
38+
) -> Result<Response<openshell_core::proto::PeerNotifyConfigUpdateResponse>, Status> {
39+
Err(Status::unimplemented("not used by this test server"))
40+
}
41+
3542
async fn peer_report_provider_readiness(
3643
&self,
3744
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ struct ProviderState {
111111
readiness_sequence: Arc<AtomicU64>,
112112
corrupt_mutation_receipt: Arc<Mutex<Option<ReceiptCorruption>>>,
113113
fail_mutation_after_save: Arc<Mutex<Option<Status>>>,
114+
/// Plain resource-version conflicts to return before an attachment saves.
115+
sandbox_provider_conflicts: Arc<AtomicU64>,
114116
global_settings: Arc<Mutex<HashMap<String, SettingValue>>>,
115117
}
116118

@@ -233,6 +235,13 @@ impl TestOpenShell {
233235

234236
#[tonic::async_trait]
235237
impl OpenShell for TestOpenShell {
238+
async fn peer_notify_config_update(
239+
&self,
240+
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
241+
) -> Result<Response<openshell_core::proto::PeerNotifyConfigUpdateResponse>, Status> {
242+
Err(Status::unimplemented("not used by this test server"))
243+
}
244+
236245
async fn peer_report_provider_readiness(
237246
&self,
238247
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,
@@ -422,6 +431,18 @@ impl OpenShell for TestOpenShell {
422431
sandbox_name: sandbox_name.clone(),
423432
provider: request.provider.clone(),
424433
});
434+
if self
435+
.state
436+
.sandbox_provider_conflicts
437+
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
438+
left.checked_sub(1)
439+
})
440+
.is_ok()
441+
{
442+
return Err(Status::aborted(
443+
"sandbox was modified concurrently (current resource_version: 2)",
444+
));
445+
}
425446
if !self
426447
.state
427448
.providers
@@ -484,6 +505,18 @@ impl OpenShell for TestOpenShell {
484505
sandbox_name: sandbox_name.clone(),
485506
provider: request.provider.clone(),
486507
});
508+
if self
509+
.state
510+
.sandbox_provider_conflicts
511+
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
512+
left.checked_sub(1)
513+
})
514+
.is_ok()
515+
{
516+
return Err(Status::aborted(
517+
"sandbox was modified concurrently (current resource_version: 2)",
518+
));
519+
}
487520
let mut sandbox_providers = self.state.sandbox_providers.lock().await;
488521
let providers = sandbox_providers.entry(sandbox_name.clone()).or_default();
489522
let before_len = providers.len();
@@ -6175,3 +6208,95 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() {
61756208
})
61766209
.await;
61776210
}
6211+
6212+
#[tokio::test]
6213+
async fn provider_attachment_retries_resource_version_conflicts() {
6214+
let server = run_server().await;
6215+
seed_readiness_provider(&server).await;
6216+
for action in ["attach", "detach"] {
6217+
let sandbox_name = "conflicted";
6218+
server.state.sandbox_providers.lock().await.insert(
6219+
sandbox_name.to_string(),
6220+
if action == "attach" {
6221+
Vec::new()
6222+
} else {
6223+
vec![READINESS_PROVIDER.to_string()]
6224+
},
6225+
);
6226+
server.state.sandbox_provider_requests.lock().await.clear();
6227+
server
6228+
.state
6229+
.sandbox_provider_conflicts
6230+
.store(2, Ordering::SeqCst);
6231+
let output = run_readiness_cli(
6232+
&server,
6233+
&[
6234+
"sandbox",
6235+
"provider",
6236+
action,
6237+
sandbox_name,
6238+
READINESS_PROVIDER,
6239+
],
6240+
)
6241+
.await;
6242+
assert!(
6243+
output.status.success(),
6244+
"{action}: {}",
6245+
String::from_utf8_lossy(&output.stderr)
6246+
);
6247+
assert_eq!(
6248+
server.state.sandbox_provider_requests.lock().await.len(),
6249+
3,
6250+
"{action} retried each conflict once"
6251+
);
6252+
assert_eq!(
6253+
server
6254+
.state
6255+
.sandbox_providers
6256+
.lock()
6257+
.await
6258+
.get(sandbox_name)
6259+
.unwrap()
6260+
.contains(&READINESS_PROVIDER.to_string()),
6261+
action == "attach"
6262+
);
6263+
}
6264+
}
6265+
6266+
#[tokio::test]
6267+
async fn provider_attachment_reports_persistent_conflicts() {
6268+
let server = run_server().await;
6269+
seed_readiness_provider(&server).await;
6270+
server
6271+
.state
6272+
.sandbox_providers
6273+
.lock()
6274+
.await
6275+
.insert("contended".to_string(), Vec::new());
6276+
server
6277+
.state
6278+
.sandbox_provider_conflicts
6279+
.store(u64::MAX, Ordering::SeqCst);
6280+
let output = run_readiness_cli(
6281+
&server,
6282+
&[
6283+
"sandbox",
6284+
"provider",
6285+
"attach",
6286+
"contended",
6287+
READINESS_PROVIDER,
6288+
],
6289+
)
6290+
.await;
6291+
assert!(!output.status.success());
6292+
assert!(
6293+
String::from_utf8_lossy(&output.stderr).contains("Please retry the command"),
6294+
"{}",
6295+
String::from_utf8_lossy(&output.stderr)
6296+
);
6297+
assert_eq!(server.state.sandbox_provider_requests.lock().await.len(), 5);
6298+
server
6299+
.state
6300+
.sandbox_provider_conflicts
6301+
.store(0, Ordering::SeqCst);
6302+
}

0 commit comments

Comments
 (0)