Skip to content

Commit 71c0898

Browse files
committed
fix(supervisor-middleware): keep sandbox startup resilient to middleware outages
An unreachable operator-registered middleware service previously aborted sandbox startup via a hard error in load_policy, contradicting the per-request on_error contract and the resilient live-reload path. Retry the initial connect and, on failure, degrade to the built-in registry so matched requests are governed by each config's on_error (deny for fail_closed, allow for fail_open) instead of blocking the whole sandbox. The policy poll loop now reconciles the registry on every poll while an install is pending, so a recovered service is adopted without waiting for a config change; a failed reconcile also no longer blocks unrelated policy updates. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 7f887cc commit 71c0898

1 file changed

Lines changed: 109 additions & 50 deletions

File tree

  • crates/openshell-sandbox/src

crates/openshell-sandbox/src/lib.rs

Lines changed: 109 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ pub async fn run_sandbox(
128128
// Load policy and initialize OPA engine
129129
let openshell_endpoint_for_proxy = openshell_endpoint.clone();
130130
let sandbox_name_for_agg = sandbox.clone();
131-
let (policy, opa_engine, retained_proto) = load_policy(
131+
let (policy, opa_engine, retained_proto, middleware_install_pending) = load_policy(
132132
sandbox_id.clone(),
133133
sandbox,
134134
openshell_endpoint.clone(),
@@ -390,6 +390,7 @@ pub async fn run_sandbox(
390390
ocsf_enabled: poll_ocsf_enabled,
391391
provider_credentials: poll_provider_credentials,
392392
policy_local_ctx: poll_policy_local,
393+
middleware_install_pending,
393394
};
394395

395396
tokio::spawn(async move {
@@ -1337,6 +1338,11 @@ async fn load_policy(
13371338
SandboxPolicy,
13381339
Option<Arc<OpaEngine>>,
13391340
Option<openshell_core::proto::SandboxPolicy>,
1341+
// True when operator-registered middleware could not be connected at
1342+
// startup and the engine kept the built-in registry. The policy poll loop
1343+
// retries the install so a recovered service is picked up without a config
1344+
// change.
1345+
bool,
13401346
)> {
13411347
// File mode: load OPA engine from rego rules + YAML data (dev override)
13421348
if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) {
@@ -1366,7 +1372,8 @@ async fn load_policy(
13661372
process: config.process,
13671373
};
13681374
enrich_sandbox_baseline_paths(&mut policy);
1369-
return Ok((policy, Some(Arc::new(engine)), None));
1375+
// File mode has no operator-registered middleware to connect.
1376+
return Ok((policy, Some(Arc::new(engine)), None, false));
13701377
}
13711378

13721379
// gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data
@@ -1449,16 +1456,45 @@ async fn load_policy(
14491456
// engine is rebuilt with the real PID for symlink resolution.
14501457
info!("Creating OPA engine from proto policy data");
14511458
let engine = OpaEngine::from_proto(&proto_policy)?;
1452-
let middleware_registry =
1459+
// Connect operator-registered middleware services. A connect/describe
1460+
// failure must not abort sandbox startup: unlike the previous hard
1461+
// failure, we degrade to the built-in registry and let each request's
1462+
// `on_error` policy govern matched traffic (deny for fail_closed, allow
1463+
// for fail_open). The policy poll loop retries the install so a
1464+
// recovered service is picked up without a config change. This mirrors
1465+
// the resilient live-reload path.
1466+
let middleware_services = sandbox_config.supervisor_middleware_services.clone();
1467+
let middleware_install_pending = match grpc_retry("Middleware connect", || {
14531468
openshell_supervisor_middleware::MiddlewareRegistry::connect_services(
1454-
sandbox_config.supervisor_middleware_services,
1469+
middleware_services.clone(),
14551470
)
1456-
.await?;
1457-
engine.replace_middleware_registry(middleware_registry)?;
1471+
})
1472+
.await
1473+
.and_then(|registry| engine.replace_middleware_registry(registry))
1474+
{
1475+
Ok(()) => false,
1476+
Err(error) => {
1477+
ocsf_emit!(
1478+
ConfigStateChangeBuilder::new(ocsf_ctx())
1479+
.severity(SeverityId::Medium)
1480+
.status(StatusId::Failure)
1481+
.state(StateId::Other, "degraded")
1482+
.unmapped(
1483+
"supervisor_middleware_service_count",
1484+
serde_json::json!(middleware_services.len())
1485+
)
1486+
.message(format!(
1487+
"Supervisor middleware connect failed at startup; continuing with built-in middleware only, per-request on_error governs matched requests [error:{error}]"
1488+
))
1489+
.build()
1490+
);
1491+
true
1492+
}
1493+
};
14581494
let opa_engine = Some(Arc::new(engine));
14591495

14601496
let policy = SandboxPolicy::try_from(proto_policy.clone())?;
1461-
return Ok((policy, opa_engine, Some(proto_policy)));
1497+
return Ok((policy, opa_engine, Some(proto_policy), middleware_install_pending));
14621498
}
14631499

14641500
// No policy source available
@@ -1594,6 +1630,10 @@ struct PolicyPollLoopContext {
15941630
ocsf_enabled: Arc<std::sync::atomic::AtomicBool>,
15951631
provider_credentials: ProviderCredentialState,
15961632
policy_local_ctx: Option<Arc<openshell_supervisor_network::policy_local::PolicyLocalContext>>,
1633+
/// True when `load_policy` degraded to the built-in middleware registry
1634+
/// because operator services could not be connected at startup. The poll
1635+
/// loop retries the install until it succeeds.
1636+
middleware_install_pending: bool,
15971637
}
15981638

15991639
async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
@@ -1606,6 +1646,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
16061646
let mut current_provider_env_revision: u64 = ctx.provider_credentials.snapshot().revision;
16071647
let mut current_policy_hash = String::new();
16081648
let mut current_middleware_services = Vec::new();
1649+
// Set when a middleware install is outstanding (degraded at startup or a
1650+
// failed reload). Drives a retry on every poll, independent of the config
1651+
// revision, so a recovered operator service is picked up promptly.
1652+
let mut middleware_sync_pending = ctx.middleware_install_pending;
16091653
let mut current_settings: std::collections::HashMap<
16101654
String,
16111655
openshell_core::proto::EffectiveSetting,
@@ -1641,14 +1685,70 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
16411685
}
16421686
};
16431687

1688+
// Reconcile the supervisor middleware registry before evaluating the
1689+
// rest of the config. This runs independently of the config revision so
1690+
// an install that degraded at startup (or failed on an earlier poll) is
1691+
// retried here, letting a recovered operator service be picked up
1692+
// without waiting for a policy change. A failure keeps the
1693+
// last-known-good registry; the request path stays governed by each
1694+
// middleware's `on_error` policy, and a config change is still applied
1695+
// below rather than being blocked by the middleware outage.
1696+
if middleware_sync_pending
1697+
|| result.supervisor_middleware_services != current_middleware_services
1698+
{
1699+
match openshell_supervisor_middleware::MiddlewareRegistry::connect_services(
1700+
result.supervisor_middleware_services.clone(),
1701+
)
1702+
.await
1703+
.and_then(|registry| ctx.opa_engine.replace_middleware_registry(registry))
1704+
{
1705+
Ok(()) => {
1706+
current_middleware_services = result.supervisor_middleware_services.clone();
1707+
middleware_sync_pending = false;
1708+
ocsf_emit!(
1709+
ConfigStateChangeBuilder::new(ocsf_ctx())
1710+
.severity(SeverityId::Informational)
1711+
.status(StatusId::Success)
1712+
.state(StateId::Enabled, "loaded")
1713+
.unmapped(
1714+
"supervisor_middleware_service_count",
1715+
serde_json::json!(current_middleware_services.len())
1716+
)
1717+
.message(format!(
1718+
"Supervisor middleware registry reloaded [service_count:{}]",
1719+
current_middleware_services.len()
1720+
))
1721+
.build()
1722+
);
1723+
}
1724+
Err(error) => {
1725+
// Emit only on the transition into the failed state to avoid
1726+
// repeating the same finding on every poll during a
1727+
// sustained outage. The startup degrade path emits its own
1728+
// event.
1729+
if !middleware_sync_pending {
1730+
ocsf_emit!(
1731+
ConfigStateChangeBuilder::new(ocsf_ctx())
1732+
.severity(SeverityId::Medium)
1733+
.status(StatusId::Failure)
1734+
.state(StateId::Other, "failed")
1735+
.message(format!(
1736+
"Supervisor middleware registry reload failed, keeping last-known-good registry [error:{error}]"
1737+
))
1738+
.build()
1739+
);
1740+
}
1741+
middleware_sync_pending = true;
1742+
}
1743+
}
1744+
}
1745+
16441746
let provider_env_changed = result.provider_env_revision != current_provider_env_revision;
16451747
if result.config_revision == current_config_revision && !provider_env_changed {
16461748
continue;
16471749
}
16481750

16491751
let policy_changed = result.policy_hash != current_policy_hash;
1650-
let middleware_changed =
1651-
result.supervisor_middleware_services != current_middleware_services;
16521752

16531753
// Log which settings changed.
16541754
log_setting_changes(&current_settings, &result.settings);
@@ -1707,47 +1807,6 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
17071807
}
17081808
}
17091809

1710-
if middleware_changed {
1711-
match openshell_supervisor_middleware::MiddlewareRegistry::connect_services(
1712-
result.supervisor_middleware_services.clone(),
1713-
)
1714-
.await
1715-
.and_then(|registry| ctx.opa_engine.replace_middleware_registry(registry))
1716-
{
1717-
Ok(()) => {
1718-
current_middleware_services = result.supervisor_middleware_services.clone();
1719-
ocsf_emit!(
1720-
ConfigStateChangeBuilder::new(ocsf_ctx())
1721-
.severity(SeverityId::Informational)
1722-
.status(StatusId::Success)
1723-
.state(StateId::Enabled, "loaded")
1724-
.unmapped(
1725-
"supervisor_middleware_service_count",
1726-
serde_json::json!(current_middleware_services.len())
1727-
)
1728-
.message(format!(
1729-
"Supervisor middleware registry reloaded [service_count:{}]",
1730-
current_middleware_services.len()
1731-
))
1732-
.build()
1733-
);
1734-
}
1735-
Err(error) => {
1736-
ocsf_emit!(
1737-
ConfigStateChangeBuilder::new(ocsf_ctx())
1738-
.severity(SeverityId::Medium)
1739-
.status(StatusId::Failure)
1740-
.state(StateId::Other, "failed")
1741-
.message(format!(
1742-
"Supervisor middleware registry reload failed, keeping last-known-good registry [error:{error}]"
1743-
))
1744-
.build()
1745-
);
1746-
continue;
1747-
}
1748-
}
1749-
}
1750-
17511810
// Only reload OPA when the policy payload actually changed.
17521811
if policy_changed {
17531812
let Some(policy) = result.policy.as_ref() else {

0 commit comments

Comments
 (0)