mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-18 05:39:50 +08:00
feat(supervisor): stage gateway configuration snapshot delivery
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
@@ -654,6 +654,24 @@ successful create therefore yields an immediately usable provider; failures roll
|
||||
back the provider record. Service-account JSON and private keys remain gateway-side
|
||||
refresh bootstrap material; sandboxes receive minted access tokens instead.
|
||||
|
||||
## Supervisor configuration routing
|
||||
|
||||
Committed configuration mutations publish component and scope identifiers,
|
||||
never configuration payloads, to a bounded coalescing scheduler. The scheduler
|
||||
builds the latest full snapshot for each affected active sandbox. An async
|
||||
router owns session lookup, message sizing, sequence allocation, and enqueue.
|
||||
Its local implementation uses the process-local supervisor registry. A future
|
||||
HA implementation can resolve the gateway that owns a session and forward the
|
||||
same typed message without changing mutation handlers.
|
||||
|
||||
Polling remains authoritative during the first rollout stage. Snapshot build,
|
||||
fanout, or enqueue failure cannot fail a mutation that already committed.
|
||||
Provider snapshots may contain credentials and must not be
|
||||
persisted or included in logs.
|
||||
|
||||
See [sandbox configuration delivery](sandbox.md#supervisor-configuration-delivery)
|
||||
for bootstrap, revision, and supervisor application semantics.
|
||||
|
||||
## Supervisor Relay
|
||||
|
||||
Sandbox workloads maintain an outbound supervisor session to the gateway. This
|
||||
|
||||
@@ -473,6 +473,44 @@ the structured 403 and authors the narrowest rule. Mechanistically mapping L7
|
||||
would either over-broaden rules or require path-templating logic that rots
|
||||
quickly.
|
||||
|
||||
## Supervisor Configuration Delivery
|
||||
|
||||
The gateway and supervisor must implement the same internal supervisor protocol
|
||||
revision. The gateway includes a configuration bootstrap when it accepts a
|
||||
`ConnectSupervisor` session and can send complete component replacements on the
|
||||
same stream after policy, settings, or provider state changes.
|
||||
Bootstrap construction does not gate the session while polling remains
|
||||
authoritative. These payloads describe the latest effective state rather than
|
||||
the mutation that produced it. The gateway assigns ordering sequences within
|
||||
each session and component, while each snapshot retains its own content
|
||||
revision.
|
||||
|
||||
Bootstrap components are independent read projections, not one atomic database
|
||||
snapshot. The sandbox configuration carries the provider-environment revision
|
||||
it was built against. The gateway retries bootstrap construction when that
|
||||
revision does not match the provider snapshot. Later component updates and
|
||||
polling repair changes committed while the other projections were being built.
|
||||
|
||||
Configuration delivery goes through a gateway-owned routing boundary rather
|
||||
than exposing local supervisor channels to mutation handlers. The current
|
||||
implementation routes only to a supervisor connected to the same gateway
|
||||
process. The asynchronous router contract can resolve a remote owner later
|
||||
without changing publishers. Provider payloads can contain
|
||||
credentials, so the gateway does not persist or render complete stream
|
||||
messages in logs.
|
||||
|
||||
The supervisor currently parses and ignores stream-delivered configuration.
|
||||
Polling remains the only path that changes runtime state and repairs dropped or
|
||||
unavailable delivery. The gateway serializes construction per sandbox and
|
||||
component, and coalesces repeated mutations into the latest full snapshot. An
|
||||
enqueue result means only that the local stream queue accepted the message. A
|
||||
bounded scope fanout scheduler coalesces repeated workspace and global changes.
|
||||
Snapshot construction has a deadline, and the gateway rejects encoded stream
|
||||
messages that approach the transport decoder limit. A
|
||||
later migration will apply these payloads directly and acknowledge their exact
|
||||
revisions before removing supervisor polling. At that point, the gateway will
|
||||
require a valid bootstrap before marking a session ready.
|
||||
|
||||
## Policy Revision Acknowledgement
|
||||
|
||||
When the supervisor loads a sandbox-scoped policy from the gateway, it retains
|
||||
|
||||
@@ -94,3 +94,10 @@ pub fn all_workspaces_selector() -> WorkspaceSelector {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact protocol revision required between a gateway and its supervisor.
|
||||
///
|
||||
/// The supervisor stream is an internal, version-locked deployment contract.
|
||||
/// Bump this when either peer can no longer honor the previous stream
|
||||
/// semantics.
|
||||
pub const SUPERVISOR_PROTOCOL_REVISION: u32 = 1;
|
||||
|
||||
@@ -989,6 +989,18 @@ impl ComputeRuntime {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Err(status) = Box::pin(crate::grpc::policy::initialize_policy_history(
|
||||
self.store.as_ref(),
|
||||
&sandbox,
|
||||
crate::grpc::policy::InitialPolicyHistoryStatus::Pending,
|
||||
))
|
||||
.await
|
||||
{
|
||||
let _ = self.store.delete(Sandbox::object_type(), &sandbox_id).await;
|
||||
self.sandbox_index.remove_sandbox(&sandbox_id);
|
||||
return Err(status);
|
||||
}
|
||||
|
||||
if let Some(token) = sandbox_token
|
||||
&& let Some(spec) = driver_sandbox.spec.as_mut()
|
||||
{
|
||||
@@ -1026,6 +1038,10 @@ impl ComputeRuntime {
|
||||
Ok(sandbox)
|
||||
}
|
||||
Err(status) if status.code() == Code::AlreadyExists => {
|
||||
let _ = self
|
||||
.store
|
||||
.delete_by_scope(POLICY_OBJECT_TYPE, sandbox.object_id())
|
||||
.await;
|
||||
let _ = self
|
||||
.store
|
||||
.delete(Sandbox::object_type(), sandbox.object_id())
|
||||
@@ -1034,6 +1050,10 @@ impl ComputeRuntime {
|
||||
Err(Status::already_exists("sandbox already exists"))
|
||||
}
|
||||
Err(status) if status.code() == Code::FailedPrecondition => {
|
||||
let _ = self
|
||||
.store
|
||||
.delete_by_scope(POLICY_OBJECT_TYPE, sandbox.object_id())
|
||||
.await;
|
||||
let _ = self
|
||||
.store
|
||||
.delete(Sandbox::object_type(), sandbox.object_id())
|
||||
@@ -1042,6 +1062,10 @@ impl ComputeRuntime {
|
||||
Err(Status::failed_precondition(status.message().to_string()))
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = self
|
||||
.store
|
||||
.delete_by_scope(POLICY_OBJECT_TYPE, sandbox.object_id())
|
||||
.await;
|
||||
let _ = self
|
||||
.store
|
||||
.delete(Sandbox::object_type(), sandbox.object_id())
|
||||
@@ -4938,6 +4962,7 @@ pub async fn new_test_runtime_with_driver(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::policy_store::PolicyStoreExt;
|
||||
use futures::stream;
|
||||
use openshell_core::proto::compute::v1::{
|
||||
CreateSandboxResponse, DeleteSandboxResponse, GetCapabilitiesResponse, GetSandboxRequest,
|
||||
@@ -11240,6 +11265,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sandbox_persists_initial_policy_revision() {
|
||||
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
|
||||
let mut sandbox = sandbox_record(
|
||||
"sb-initial-policy",
|
||||
"initial-policy",
|
||||
SandboxPhase::Provisioning,
|
||||
);
|
||||
let policy = openshell_core::proto::SandboxPolicy::default();
|
||||
sandbox.spec = Some(SandboxSpec {
|
||||
policy: Some(policy.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
runtime.create_sandbox(sandbox, None, false).await.unwrap();
|
||||
|
||||
let revision = runtime
|
||||
.store
|
||||
.get_latest_policy("sb-initial-policy")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("initial policy revision");
|
||||
assert_eq!(revision.version, 1);
|
||||
assert_eq!(
|
||||
revision.policy_hash,
|
||||
crate::grpc::policy::deterministic_policy_hash(&policy)
|
||||
);
|
||||
assert_eq!(revision.status, "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn created_sandbox_is_immediately_visible_to_label_selectors() {
|
||||
let runtime = test_runtime(Arc::new(TestDriver::default())).await;
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Build and route complete supervisor configuration snapshots.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use metrics::counter;
|
||||
use openshell_core::proto::{
|
||||
ConfigBootstrap, ProviderEnvironmentSnapshot, Sandbox, SandboxConfigSnapshot,
|
||||
};
|
||||
use tonic::{Code, Status};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ServerState;
|
||||
use crate::grpc::policy::{build_provider_environment_snapshot, build_sandbox_config_snapshot};
|
||||
use crate::persistence::ObjectWorkspace;
|
||||
use crate::supervisor_session::SupervisorSessionRegistry;
|
||||
|
||||
/// Leaves headroom below tonic's default 4 MiB decode limit for framing and
|
||||
/// future envelope fields.
|
||||
pub const MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES: usize = 3 * 1024 * 1024;
|
||||
const CONFIG_SNAPSHOT_BUILD_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const MAX_ACTIVE_FANOUT_WORKERS: usize = 64;
|
||||
|
||||
/// One complete configuration component awaiting delivery to a supervisor.
|
||||
#[derive(Clone)]
|
||||
pub enum SupervisorConfigMessage {
|
||||
SandboxConfig(Box<SandboxConfigSnapshot>),
|
||||
ProviderEnvironment(ProviderEnvironmentSnapshot),
|
||||
}
|
||||
|
||||
impl SupervisorConfigMessage {
|
||||
pub(crate) fn component_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SandboxConfig(_) => "sandbox_config",
|
||||
Self::ProviderEnvironment(_) => "provider_environment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SupervisorConfigMessage {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::SandboxConfig(_) => "SandboxConfig(<redacted>)",
|
||||
Self::ProviderEnvironment(_) => "ProviderEnvironment(<redacted>)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of routing one configuration snapshot toward a supervisor session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryDisposition {
|
||||
Enqueued,
|
||||
NoActiveSession,
|
||||
QueueFull,
|
||||
SessionClosed,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
/// Transport boundary for configuration delivery.
|
||||
#[tonic::async_trait]
|
||||
pub trait SupervisorConfigRouter: fmt::Debug + Send + Sync {
|
||||
async fn deliver(
|
||||
&self,
|
||||
sandbox_id: &str,
|
||||
message: SupervisorConfigMessage,
|
||||
) -> DeliveryDisposition;
|
||||
|
||||
async fn routable_sandbox_ids(&self) -> Vec<String>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LocalSupervisorConfigRouter {
|
||||
sessions: Arc<SupervisorSessionRegistry>,
|
||||
}
|
||||
|
||||
impl LocalSupervisorConfigRouter {
|
||||
#[must_use]
|
||||
pub fn new(sessions: Arc<SupervisorSessionRegistry>) -> Self {
|
||||
Self { sessions }
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl SupervisorConfigRouter for LocalSupervisorConfigRouter {
|
||||
async fn deliver(
|
||||
&self,
|
||||
sandbox_id: &str,
|
||||
message: SupervisorConfigMessage,
|
||||
) -> DeliveryDisposition {
|
||||
self.sessions.deliver_config(sandbox_id, message)
|
||||
}
|
||||
|
||||
async fn routable_sandbox_ids(&self) -> Vec<String> {
|
||||
self.sessions.connected_sandbox_ids()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ConfigComponents {
|
||||
pub sandbox_config: bool,
|
||||
pub provider_environment: bool,
|
||||
}
|
||||
|
||||
impl ConfigComponents {
|
||||
pub const ALL: Self = Self {
|
||||
sandbox_config: true,
|
||||
provider_environment: true,
|
||||
};
|
||||
|
||||
pub const SANDBOX_AND_PROVIDER: Self = Self {
|
||||
sandbox_config: true,
|
||||
provider_environment: true,
|
||||
};
|
||||
|
||||
pub const SANDBOX_CONFIG: Self = Self {
|
||||
sandbox_config: true,
|
||||
provider_environment: false,
|
||||
};
|
||||
|
||||
fn selected(self) -> impl Iterator<Item = ConfigComponentKind> {
|
||||
[
|
||||
(self.sandbox_config, ConfigComponentKind::SandboxConfig),
|
||||
(
|
||||
self.provider_environment,
|
||||
ConfigComponentKind::ProviderEnvironment,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(selected, component)| selected.then_some(component))
|
||||
}
|
||||
|
||||
fn only(component: ConfigComponentKind) -> Self {
|
||||
Self {
|
||||
sandbox_config: component == ConfigComponentKind::SandboxConfig,
|
||||
provider_environment: component == ConfigComponentKind::ProviderEnvironment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum ConfigComponentKind {
|
||||
SandboxConfig,
|
||||
ProviderEnvironment,
|
||||
}
|
||||
|
||||
impl ConfigComponentKind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::SandboxConfig => "sandbox_config",
|
||||
Self::ProviderEnvironment => "provider_environment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct DeliveryKey {
|
||||
sandbox_id: String,
|
||||
component: ConfigComponentKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum FanoutScope {
|
||||
Workspace(String),
|
||||
AllConnected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct FanoutKey {
|
||||
scope: FanoutScope,
|
||||
component: ConfigComponentKind,
|
||||
}
|
||||
|
||||
/// Coalesces publications and runs one worker per sandbox and component.
|
||||
///
|
||||
/// The map entry is also the worker lease. Its boolean is set when another
|
||||
/// mutation arrives during a build or route operation. The worker then rebuilds
|
||||
/// the current full snapshot once, regardless of how many mutations arrived.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ConfigDeliveryQueue {
|
||||
pending: Mutex<HashMap<DeliveryKey, bool>>,
|
||||
fanout_pending: Mutex<HashMap<FanoutKey, bool>>,
|
||||
}
|
||||
|
||||
impl ConfigDeliveryQueue {
|
||||
fn enqueue(&self, key: DeliveryKey) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
match pending.entry(key) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
*entry.get_mut() = true;
|
||||
false
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(true);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take(&self, key: &DeliveryKey) {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if let Some(changed) = pending.get_mut(key) {
|
||||
*changed = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_pass(&self, key: &DeliveryKey) -> bool {
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if pending.get(key).is_some_and(|changed| !changed) {
|
||||
pending.remove(key);
|
||||
false
|
||||
} else {
|
||||
pending.contains_key(key)
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_fanout(&self, key: FanoutKey) -> FanoutEnqueue {
|
||||
let mut pending = self.fanout_pending.lock().unwrap();
|
||||
if let Some(changed) = pending.get_mut(&key) {
|
||||
*changed = true;
|
||||
return FanoutEnqueue::Coalesced;
|
||||
}
|
||||
if pending.len() >= MAX_ACTIVE_FANOUT_WORKERS {
|
||||
FanoutEnqueue::Full
|
||||
} else {
|
||||
pending.insert(key, true);
|
||||
FanoutEnqueue::StartWorker
|
||||
}
|
||||
}
|
||||
|
||||
fn take_fanout(&self, key: &FanoutKey) {
|
||||
let mut pending = self.fanout_pending.lock().unwrap();
|
||||
if let Some(changed) = pending.get_mut(key) {
|
||||
*changed = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_fanout_pass(&self, key: &FanoutKey) -> bool {
|
||||
let mut pending = self.fanout_pending.lock().unwrap();
|
||||
if pending.get(key).is_some_and(|changed| !changed) {
|
||||
pending.remove(key);
|
||||
false
|
||||
} else {
|
||||
pending.contains_key(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FanoutEnqueue {
|
||||
StartWorker,
|
||||
Coalesced,
|
||||
Full,
|
||||
}
|
||||
|
||||
pub async fn build_config_bootstrap(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox: &Sandbox,
|
||||
) -> Result<ConfigBootstrap, Status> {
|
||||
tokio::time::timeout(
|
||||
CONFIG_SNAPSHOT_BUILD_TIMEOUT,
|
||||
build_consistent_config_bootstrap(state, sandbox),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("supervisor configuration bootstrap timed out"))?
|
||||
}
|
||||
|
||||
async fn build_consistent_config_bootstrap(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox: &Sandbox,
|
||||
) -> Result<ConfigBootstrap, Status> {
|
||||
const MAX_BUILD_ATTEMPTS: usize = 3;
|
||||
for _ in 0..MAX_BUILD_ATTEMPTS {
|
||||
// Components are independent projections. The provider revision is a
|
||||
// fence for the only overlapping input between sandbox configuration
|
||||
// and provider environment state.
|
||||
let (sandbox_config, provider_environment) = tokio::join!(
|
||||
build_sandbox_config_snapshot(state, sandbox),
|
||||
build_provider_environment_snapshot(state, sandbox, true),
|
||||
);
|
||||
let bootstrap = ConfigBootstrap {
|
||||
sandbox_config: Some(sandbox_config?),
|
||||
provider_environment: Some(provider_environment?),
|
||||
};
|
||||
if bootstrap_revisions_match(&bootstrap) {
|
||||
return Ok(bootstrap);
|
||||
}
|
||||
counter!("openshell_supervisor_config_bootstrap_revision_mismatches_total").increment(1);
|
||||
}
|
||||
Err(Status::aborted(
|
||||
"configuration changed while building supervisor bootstrap",
|
||||
))
|
||||
}
|
||||
|
||||
fn bootstrap_revisions_match(bootstrap: &ConfigBootstrap) -> bool {
|
||||
bootstrap
|
||||
.sandbox_config
|
||||
.as_ref()
|
||||
.zip(bootstrap.provider_environment.as_ref())
|
||||
.is_some_and(|(sandbox, provider)| {
|
||||
sandbox.provider_env_revision == provider.provider_env_revision
|
||||
})
|
||||
}
|
||||
|
||||
pub fn publish_sandbox_components(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox_id: &str,
|
||||
components: ConfigComponents,
|
||||
) {
|
||||
enqueue_sandbox(state, sandbox_id, components);
|
||||
}
|
||||
|
||||
fn enqueue_sandbox(state: &Arc<ServerState>, sandbox_id: &str, components: ConfigComponents) {
|
||||
for component in components.selected() {
|
||||
let key = DeliveryKey {
|
||||
sandbox_id: sandbox_id.to_string(),
|
||||
component,
|
||||
};
|
||||
if state.config_delivery_queue.enqueue(key.clone()) {
|
||||
let state = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
state.config_delivery_queue.take(&key);
|
||||
publish_sandbox_component_now(&state, &key).await;
|
||||
if !state.config_delivery_queue.finish_pass(&key) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_sandbox_component_now(state: &Arc<ServerState>, key: &DeliveryKey) {
|
||||
let sandbox = match state.store.get_message::<Sandbox>(&key.sandbox_id).await {
|
||||
Ok(Some(sandbox)) => sandbox,
|
||||
Ok(None) => return,
|
||||
Err(_) => {
|
||||
record_build_failure(&key.sandbox_id, "sandbox", Code::Internal);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let component = key.component.name();
|
||||
let build = async {
|
||||
match key.component {
|
||||
ConfigComponentKind::SandboxConfig => build_sandbox_config_snapshot(state, &sandbox)
|
||||
.await
|
||||
.map(|snapshot| SupervisorConfigMessage::SandboxConfig(Box::new(snapshot))),
|
||||
ConfigComponentKind::ProviderEnvironment => {
|
||||
build_provider_environment_snapshot(state, &sandbox, true)
|
||||
.await
|
||||
.map(SupervisorConfigMessage::ProviderEnvironment)
|
||||
}
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(CONFIG_SNAPSHOT_BUILD_TIMEOUT, build).await {
|
||||
Ok(Ok(message)) => {
|
||||
let disposition = state
|
||||
.supervisor_config_router()
|
||||
.deliver(&key.sandbox_id, message)
|
||||
.await;
|
||||
record_delivery(component, disposition);
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
record_build_failure(&key.sandbox_id, component, error.code());
|
||||
}
|
||||
Err(_) => {
|
||||
record_build_failure(&key.sandbox_id, component, Code::DeadlineExceeded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish_workspace_components(
|
||||
state: &Arc<ServerState>,
|
||||
workspace: &str,
|
||||
components: ConfigComponents,
|
||||
) {
|
||||
enqueue_fanout(
|
||||
state,
|
||||
FanoutScope::Workspace(workspace.to_string()),
|
||||
components,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn publish_all_connected(state: &Arc<ServerState>, components: ConfigComponents) {
|
||||
enqueue_fanout(state, FanoutScope::AllConnected, components);
|
||||
}
|
||||
|
||||
fn enqueue_fanout(state: &Arc<ServerState>, scope: FanoutScope, components: ConfigComponents) {
|
||||
for component in components.selected() {
|
||||
let key = FanoutKey {
|
||||
scope: scope.clone(),
|
||||
component,
|
||||
};
|
||||
match state.config_delivery_queue.enqueue_fanout(key.clone()) {
|
||||
FanoutEnqueue::StartWorker => {
|
||||
let state = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
state.config_delivery_queue.take_fanout(&key);
|
||||
publish_fanout_now(&state, &key).await;
|
||||
if !state.config_delivery_queue.finish_fanout_pass(&key) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
FanoutEnqueue::Coalesced => {}
|
||||
FanoutEnqueue::Full => {
|
||||
counter!("openshell_supervisor_config_fanout_total", "outcome" => "queue_full")
|
||||
.increment(1);
|
||||
warn!(
|
||||
component = component.name(),
|
||||
"supervisor configuration fanout queue is full"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_fanout_now(state: &Arc<ServerState>, key: &FanoutKey) {
|
||||
let sandbox_ids = state
|
||||
.supervisor_config_router()
|
||||
.routable_sandbox_ids()
|
||||
.await;
|
||||
for sandbox_id in sandbox_ids {
|
||||
if let FanoutScope::Workspace(workspace) = &key.scope {
|
||||
let sandbox = match state.store.get_message::<Sandbox>(&sandbox_id).await {
|
||||
Ok(Some(sandbox)) => sandbox,
|
||||
Ok(None) => continue,
|
||||
Err(_) => {
|
||||
record_build_failure(&sandbox_id, "sandbox", Code::Internal);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if sandbox.object_workspace() != workspace {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
enqueue_sandbox(state, &sandbox_id, ConfigComponents::only(key.component));
|
||||
}
|
||||
}
|
||||
|
||||
fn record_delivery(component: &'static str, disposition: DeliveryDisposition) {
|
||||
let outcome = match disposition {
|
||||
DeliveryDisposition::Enqueued => "enqueued",
|
||||
DeliveryDisposition::NoActiveSession => "no_active_session",
|
||||
DeliveryDisposition::QueueFull => "queue_full",
|
||||
DeliveryDisposition::SessionClosed => "session_closed",
|
||||
DeliveryDisposition::PayloadTooLarge => "payload_too_large",
|
||||
};
|
||||
counter!(
|
||||
"openshell_supervisor_config_deliveries_total",
|
||||
"component" => component,
|
||||
"outcome" => outcome,
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn record_build_failure(sandbox_id: &str, component: &'static str, error_code: Code) {
|
||||
counter!(
|
||||
"openshell_supervisor_config_snapshot_failures_total",
|
||||
"component" => component,
|
||||
)
|
||||
.increment(1);
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
component,
|
||||
?error_code,
|
||||
"failed to build supervisor configuration snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::grpc::{OpenShellService, test_support::test_server_state};
|
||||
use openshell_core::proto::{
|
||||
GatewayMessage, ObjectMeta, SandboxSpec, SupervisorHello, SupervisorMessage,
|
||||
gateway_message, open_shell_client::OpenShellClient, open_shell_server::OpenShellServer,
|
||||
supervisor_message,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream};
|
||||
|
||||
fn key(sandbox_id: &str, component: ConfigComponentKind) -> DeliveryKey {
|
||||
DeliveryKey {
|
||||
sandbox_id: sandbox_id.to_string(),
|
||||
component,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_coalesces_repeated_component_changes_while_worker_is_active() {
|
||||
let queue = ConfigDeliveryQueue::default();
|
||||
let key = key("sb-1", ConfigComponentKind::SandboxConfig);
|
||||
assert!(queue.enqueue(key.clone()));
|
||||
queue.take(&key);
|
||||
assert!(!queue.enqueue(key.clone()));
|
||||
assert!(!queue.enqueue(key.clone()));
|
||||
assert!(queue.finish_pass(&key));
|
||||
queue.take(&key);
|
||||
assert!(!queue.finish_pass(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_runs_components_and_sandboxes_independently() {
|
||||
let queue = ConfigDeliveryQueue::default();
|
||||
assert!(queue.enqueue(key("sb-1", ConfigComponentKind::SandboxConfig)));
|
||||
assert!(queue.enqueue(key("sb-1", ConfigComponentKind::ProviderEnvironment)));
|
||||
assert!(queue.enqueue(key("sb-2", ConfigComponentKind::SandboxConfig)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_queue_coalesces_and_bounds_distinct_scopes() {
|
||||
let queue = ConfigDeliveryQueue::default();
|
||||
let first = FanoutKey {
|
||||
scope: FanoutScope::Workspace("workspace-0".into()),
|
||||
component: ConfigComponentKind::SandboxConfig,
|
||||
};
|
||||
assert_eq!(
|
||||
queue.enqueue_fanout(first.clone()),
|
||||
FanoutEnqueue::StartWorker
|
||||
);
|
||||
queue.take_fanout(&first);
|
||||
assert_eq!(
|
||||
queue.enqueue_fanout(first.clone()),
|
||||
FanoutEnqueue::Coalesced
|
||||
);
|
||||
assert!(queue.finish_fanout_pass(&first));
|
||||
|
||||
for index in 1..MAX_ACTIVE_FANOUT_WORKERS {
|
||||
assert_eq!(
|
||||
queue.enqueue_fanout(FanoutKey {
|
||||
scope: FanoutScope::Workspace(format!("workspace-{index}")),
|
||||
component: ConfigComponentKind::SandboxConfig,
|
||||
}),
|
||||
FanoutEnqueue::StartWorker
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
queue.enqueue_fanout(FanoutKey {
|
||||
scope: FanoutScope::Workspace("overflow".into()),
|
||||
component: ConfigComponentKind::SandboxConfig,
|
||||
}),
|
||||
FanoutEnqueue::Full
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_message_debug_output_redacts_payloads() {
|
||||
let message = SupervisorConfigMessage::ProviderEnvironment(ProviderEnvironmentSnapshot {
|
||||
values: vec![openshell_core::proto::ProviderEnvironmentValue {
|
||||
name: "TOKEN".into(),
|
||||
value: "secret-marker".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!format!("{message:?}").contains("secret-marker"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_acceptance_precedes_live_configuration_updates() {
|
||||
let state = test_server_state().await;
|
||||
state
|
||||
.store
|
||||
.put_message(&Sandbox {
|
||||
metadata: Some(ObjectMeta {
|
||||
id: "sandbox".into(),
|
||||
name: "sandbox".into(),
|
||||
workspace: "default".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
spec: Some(SandboxSpec::default()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(OpenShellServer::new(OpenShellService::new(Arc::clone(
|
||||
&state,
|
||||
))))
|
||||
.serve_with_incoming(TcpListenerStream::new(listener)),
|
||||
);
|
||||
let mut client = OpenShellClient::connect(format!("http://{address}"))
|
||||
.await
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
tx.send(SupervisorMessage {
|
||||
payload: Some(supervisor_message::Payload::Hello(SupervisorHello {
|
||||
sandbox_id: "sandbox".into(),
|
||||
instance_id: "instance".into(),
|
||||
protocol_revision: openshell_core::proto::SUPERVISOR_PROTOCOL_REVISION,
|
||||
})),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut stream = client
|
||||
.connect_supervisor(ReceiverStream::new(rx))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
let first = tokio::time::timeout(Duration::from_secs(5), stream.message())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
first.payload,
|
||||
Some(gateway_message::Payload::SessionAccepted(_))
|
||||
));
|
||||
|
||||
publish_sandbox_components(&state, "sandbox", ConfigComponents::SANDBOX_CONFIG);
|
||||
let update = tokio::time::timeout(Duration::from_secs(5), stream.message())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
update,
|
||||
GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::ConfigUpdate(_))
|
||||
}
|
||||
));
|
||||
|
||||
drop(tx);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_requires_matching_provider_revision_fence() {
|
||||
let mut bootstrap = ConfigBootstrap {
|
||||
sandbox_config: Some(SandboxConfigSnapshot {
|
||||
provider_env_revision: 7,
|
||||
..Default::default()
|
||||
}),
|
||||
provider_environment: Some(ProviderEnvironmentSnapshot {
|
||||
provider_env_revision: 8,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
assert!(!bootstrap_revisions_match(&bootstrap));
|
||||
bootstrap
|
||||
.provider_environment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.provider_env_revision = 7;
|
||||
assert!(bootstrap_revisions_match(&bootstrap));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ use crate::storage_proto::StoredProviderCredentialRefreshState;
|
||||
#[cfg(test)]
|
||||
use crate::storage_proto::StoredProviderProfile;
|
||||
use openshell_core::net::{is_always_blocked_ip, is_internal_ip};
|
||||
#[cfg(test)]
|
||||
use openshell_core::proto::StaticCredentialBinding;
|
||||
use openshell_core::proto::policy_merge_operation;
|
||||
use openshell_core::proto::setting_value;
|
||||
use openshell_core::proto::{
|
||||
@@ -42,11 +44,12 @@ use openshell_core::proto::{
|
||||
GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse,
|
||||
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse,
|
||||
ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, PolicyChunk, PolicyMergeOperation,
|
||||
PolicySource, PolicyStatus, PushSandboxLogsRequest, PushSandboxLogsResponse,
|
||||
PolicySource, PolicyStatus, ProviderEnvironmentSnapshot, ProviderEnvironmentValue,
|
||||
ProviderEnvironmentValueClassification, PushSandboxLogsRequest, PushSandboxLogsResponse,
|
||||
RejectDraftChunkRequest, RejectDraftChunkResponse, ReportPolicyStatusRequest,
|
||||
ReportPolicyStatusResponse, SandboxLogLine, SandboxPolicyRevision, SettingScope, SettingValue,
|
||||
SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest,
|
||||
UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse,
|
||||
ReportPolicyStatusResponse, SandboxConfigSnapshot, SandboxLogLine, SandboxPolicyRevision,
|
||||
SettingScope, SettingValue, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse,
|
||||
UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse,
|
||||
};
|
||||
use openshell_core::proto::{
|
||||
L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox,
|
||||
@@ -1538,6 +1541,11 @@ async fn auto_approve_chunk(
|
||||
return Err(status);
|
||||
}
|
||||
};
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
|
||||
|
||||
let now_ms = current_time_ms();
|
||||
@@ -2374,6 +2382,18 @@ pub(super) async fn handle_get_sandbox_config(
|
||||
|
||||
let sandbox =
|
||||
super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?;
|
||||
let snapshot = build_sandbox_config_snapshot(state, &sandbox).await?;
|
||||
Ok(Response::new(sandbox_config_response(snapshot)))
|
||||
}
|
||||
|
||||
/// Build the complete effective configuration for one persisted sandbox.
|
||||
///
|
||||
/// This is a read-only projection shared by polling and stream delivery.
|
||||
pub async fn build_sandbox_config_snapshot(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox: &Sandbox,
|
||||
) -> Result<SandboxConfigSnapshot, Status> {
|
||||
let sandbox_id = sandbox.object_id().to_string();
|
||||
let workspace = sandbox.object_workspace().to_string();
|
||||
let sandbox_provider_names = sandbox
|
||||
.spec
|
||||
@@ -2398,87 +2418,49 @@ pub(super) async fn handle_get_sandbox_config(
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?;
|
||||
|
||||
let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) =
|
||||
global_policy
|
||||
{
|
||||
let version = latest
|
||||
.as_ref()
|
||||
.map(|record| u32::try_from(record.version).unwrap_or(0))
|
||||
.filter(|version| *version > 0)
|
||||
.unwrap_or(1);
|
||||
let hash = deterministic_policy_hash(&global_policy);
|
||||
(Some(global_policy), version, hash, PolicySource::Global)
|
||||
} else if let Some(record) = latest {
|
||||
let (policy, hash) = canonical_policy_record_identity(&record)?;
|
||||
debug!(
|
||||
sandbox_id = %sandbox_id,
|
||||
version = record.version,
|
||||
"GetSandboxConfig served from policy history"
|
||||
);
|
||||
(
|
||||
Some(policy),
|
||||
u32::try_from(record.version).unwrap_or(0),
|
||||
hash,
|
||||
PolicySource::Sandbox,
|
||||
)
|
||||
} else {
|
||||
// Lazy backfill: no policy history exists yet.
|
||||
let spec = sandbox
|
||||
.spec
|
||||
.as_ref()
|
||||
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
||||
let (mut policy, version, mut policy_hash, policy_source) =
|
||||
if let Some(global_policy) = global_policy {
|
||||
let version = latest
|
||||
.as_ref()
|
||||
.map(|record| u32::try_from(record.version).unwrap_or(0))
|
||||
.filter(|version| *version > 0)
|
||||
.unwrap_or(1);
|
||||
let hash = deterministic_policy_hash(&global_policy);
|
||||
(Some(global_policy), version, hash, PolicySource::Global)
|
||||
} else if let Some(record) = latest {
|
||||
let (policy, hash) = canonical_policy_record_identity(&record)?;
|
||||
debug!(
|
||||
sandbox_id = %sandbox_id,
|
||||
version = record.version,
|
||||
"GetSandboxConfig served from policy history"
|
||||
);
|
||||
(
|
||||
Some(policy),
|
||||
u32::try_from(record.version).unwrap_or(0),
|
||||
hash,
|
||||
PolicySource::Sandbox,
|
||||
)
|
||||
} else {
|
||||
// Older sandboxes may have policy only in the sandbox spec. Reading a
|
||||
// snapshot must not create policy history, so project that baseline as
|
||||
// version 1 until the startup repair persists it.
|
||||
let spec = sandbox
|
||||
.spec
|
||||
.as_ref()
|
||||
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
||||
|
||||
match spec.policy.clone() {
|
||||
None => {
|
||||
debug!(
|
||||
sandbox_id = %sandbox_id,
|
||||
"GetSandboxConfig: no policy configured, returning empty response"
|
||||
);
|
||||
(None, 0, String::new(), PolicySource::Sandbox)
|
||||
}
|
||||
Some(spec_policy) => {
|
||||
// Stored specs may predate the current schema. Validate before
|
||||
// creating policy history so malformed state is never copied or
|
||||
// marked loaded, and hash the canonical representation.
|
||||
let spec_policy = validate_and_canonicalize_stored_policy(
|
||||
spec_policy,
|
||||
STORED_POLICY_SOURCE_SPEC,
|
||||
)?;
|
||||
let hash = deterministic_policy_hash(&spec_policy);
|
||||
let payload = spec_policy.encode_to_vec();
|
||||
let policy_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
if let Err(e) = state
|
||||
.store
|
||||
.put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
error = %e,
|
||||
"Failed to backfill policy version 1"
|
||||
);
|
||||
} else if let Err(e) = state
|
||||
.store
|
||||
.update_policy_status(&sandbox_id, 1, "loaded", None, None)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
error = %e,
|
||||
"Failed to mark backfilled policy as loaded"
|
||||
);
|
||||
match spec.policy.clone() {
|
||||
None => (None, 0, String::new(), PolicySource::Sandbox),
|
||||
Some(spec_policy) => {
|
||||
let spec_policy = validate_and_canonicalize_stored_policy(
|
||||
spec_policy,
|
||||
STORED_POLICY_SOURCE_SPEC,
|
||||
)?;
|
||||
let hash = deterministic_policy_hash(&spec_policy);
|
||||
(Some(spec_policy), 1, hash, PolicySource::Sandbox)
|
||||
}
|
||||
|
||||
info!(
|
||||
sandbox_id = %sandbox_id,
|
||||
"GetSandboxConfig served from spec (backfilled version 1)"
|
||||
);
|
||||
|
||||
(Some(spec_policy), 1, hash, PolicySource::Sandbox)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
||||
let sandbox_settings =
|
||||
@@ -2585,7 +2567,7 @@ pub(super) async fn handle_get_sandbox_config(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Response::new(GetSandboxConfigResponse {
|
||||
Ok(SandboxConfigSnapshot {
|
||||
policy,
|
||||
version,
|
||||
policy_hash,
|
||||
@@ -2602,7 +2584,112 @@ pub(super) async fn handle_get_sandbox_config(
|
||||
.as_str()
|
||||
.to_string(),
|
||||
extension_authentication_enabled: state.sandbox_jwt_issuer.is_some(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_config_response(snapshot: SandboxConfigSnapshot) -> GetSandboxConfigResponse {
|
||||
GetSandboxConfigResponse {
|
||||
policy: snapshot.policy,
|
||||
version: snapshot.version,
|
||||
policy_hash: snapshot.policy_hash,
|
||||
settings: snapshot.settings,
|
||||
config_revision: snapshot.config_revision,
|
||||
policy_source: snapshot.policy_source,
|
||||
global_policy_version: snapshot.global_policy_version,
|
||||
provider_env_revision: snapshot.provider_env_revision,
|
||||
supervisor_middleware_services: snapshot.supervisor_middleware_services,
|
||||
workspace: snapshot.workspace,
|
||||
policy_validation_failure_mode: snapshot.policy_validation_failure_mode,
|
||||
extension_authentication_enabled: snapshot.extension_authentication_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum InitialPolicyHistoryStatus {
|
||||
Pending,
|
||||
Loaded,
|
||||
}
|
||||
|
||||
impl InitialPolicyHistoryStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Loaded => "loaded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the version-one policy baseline if this sandbox still has no policy
|
||||
/// history. This never modifies an existing revision or apply result.
|
||||
pub async fn initialize_policy_history(
|
||||
store: &Store,
|
||||
sandbox: &Sandbox,
|
||||
status: InitialPolicyHistoryStatus,
|
||||
) -> Result<(), Status> {
|
||||
let Some(policy) = sandbox.spec.as_ref().and_then(|spec| spec.policy.as_ref()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if store
|
||||
.get_latest_policy(sandbox.object_id())
|
||||
.await
|
||||
.map_err(|error| Status::internal(format!("read policy history failed: {error}")))?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let policy =
|
||||
validate_and_canonicalize_stored_policy(policy.clone(), STORED_POLICY_SOURCE_SPEC)?;
|
||||
store
|
||||
.put_initial_policy_revision(
|
||||
&PolicyRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sandbox_id: sandbox.object_id().to_string(),
|
||||
version: 1,
|
||||
policy_payload: policy.encode_to_vec(),
|
||||
policy_hash: deterministic_policy_hash(&policy),
|
||||
status: status.as_str().to_string(),
|
||||
load_error: None,
|
||||
created_at_ms: current_time_ms(),
|
||||
loaded_at_ms: None,
|
||||
provenance: HashMap::new(),
|
||||
},
|
||||
sandbox.object_workspace(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| Status::internal(format!("initialize policy history failed: {error}")))
|
||||
}
|
||||
|
||||
/// Create policy-history baselines for sandboxes written by older gateways.
|
||||
///
|
||||
/// Snapshot reads stay pure once this startup repair has completed.
|
||||
pub async fn backfill_legacy_policy_history(state: &Arc<ServerState>) -> Result<(), Status> {
|
||||
const PAGE_SIZE: u32 = 1000;
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let sandboxes = state
|
||||
.store
|
||||
.list_all_messages::<Sandbox>(PAGE_SIZE, offset)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
Status::internal(format!("list sandboxes for policy repair failed: {error}"))
|
||||
})?;
|
||||
if sandboxes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let count = u32::try_from(sandboxes.len()).unwrap_or(PAGE_SIZE);
|
||||
for sandbox in sandboxes {
|
||||
initialize_policy_history(
|
||||
state.store.as_ref(),
|
||||
&sandbox,
|
||||
InitialPolicyHistoryStatus::Loaded,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if count < PAGE_SIZE {
|
||||
return Ok(());
|
||||
}
|
||||
offset = offset.saturating_add(count);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3105,6 +3192,19 @@ pub(super) async fn handle_get_sandbox_provider_environment(
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
||||
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
||||
let snapshot =
|
||||
build_provider_environment_snapshot(state, &sandbox, supports_static_credential_bindings)
|
||||
.await?;
|
||||
Ok(Response::new(provider_environment_response(snapshot)))
|
||||
}
|
||||
|
||||
/// Build the complete provider environment for one persisted sandbox.
|
||||
pub async fn build_provider_environment_snapshot(
|
||||
state: &Arc<ServerState>,
|
||||
sandbox: &Sandbox,
|
||||
supports_static_credential_bindings: bool,
|
||||
) -> Result<ProviderEnvironmentSnapshot, Status> {
|
||||
let sandbox_id = sandbox.object_id().to_string();
|
||||
let workspace = sandbox.object_workspace().to_string();
|
||||
|
||||
let spec = sandbox
|
||||
@@ -3127,7 +3227,7 @@ pub(super) async fn handle_get_sandbox_provider_environment(
|
||||
state.as_ref(),
|
||||
&provider_profile_catalog,
|
||||
&workspace,
|
||||
&sandbox,
|
||||
sandbox,
|
||||
&sandbox_id,
|
||||
)
|
||||
.await?;
|
||||
@@ -3192,21 +3292,81 @@ pub(super) async fn handle_get_sandbox_provider_environment(
|
||||
"GetSandboxProviderEnvironment request completed successfully"
|
||||
);
|
||||
|
||||
let non_secret_environment_keys = provider_environment
|
||||
let mut keys = provider_environment
|
||||
.environment
|
||||
.keys()
|
||||
.filter(|key| !provider_environment.static_credential_keys.contains(*key))
|
||||
.cloned()
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort();
|
||||
let mut values = Vec::with_capacity(keys.len());
|
||||
for name in keys {
|
||||
let value = provider_environment
|
||||
.environment
|
||||
.remove(&name)
|
||||
.expect("provider environment key came from the same map");
|
||||
let is_static_credential = provider_environment.static_credential_keys.contains(&name);
|
||||
let static_credential_binding = provider_environment
|
||||
.static_credential_bindings
|
||||
.remove(&name);
|
||||
if is_static_credential && static_credential_binding.is_none() {
|
||||
return Err(Status::failed_precondition(format!(
|
||||
"static provider credential '{name}' has no endpoint binding"
|
||||
)));
|
||||
}
|
||||
values.push(ProviderEnvironmentValue {
|
||||
name: name.clone(),
|
||||
value,
|
||||
expires_at_ms: provider_environment.credential_expires_at_ms.remove(&name),
|
||||
classification: if is_static_credential {
|
||||
ProviderEnvironmentValueClassification::StaticCredential.into()
|
||||
} else {
|
||||
ProviderEnvironmentValueClassification::NonSecret.into()
|
||||
},
|
||||
static_credential_binding,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Response::new(GetSandboxProviderEnvironmentResponse {
|
||||
environment: provider_environment.environment,
|
||||
Ok(ProviderEnvironmentSnapshot {
|
||||
provider_env_revision,
|
||||
credential_expires_at_ms: provider_environment.credential_expires_at_ms,
|
||||
values,
|
||||
dynamic_credentials: provider_environment.dynamic_credentials,
|
||||
static_credential_bindings: provider_environment.static_credential_bindings,
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_environment_response(
|
||||
snapshot: ProviderEnvironmentSnapshot,
|
||||
) -> GetSandboxProviderEnvironmentResponse {
|
||||
let mut environment = HashMap::with_capacity(snapshot.values.len());
|
||||
let mut credential_expires_at_ms = HashMap::new();
|
||||
let mut static_credential_bindings = HashMap::new();
|
||||
let mut non_secret_environment_keys = Vec::new();
|
||||
for value in snapshot.values {
|
||||
environment.insert(value.name.clone(), value.value);
|
||||
if let Some(expires_at_ms) = value.expires_at_ms {
|
||||
credential_expires_at_ms.insert(value.name.clone(), expires_at_ms);
|
||||
}
|
||||
match ProviderEnvironmentValueClassification::try_from(value.classification)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
ProviderEnvironmentValueClassification::NonSecret => {
|
||||
non_secret_environment_keys.push(value.name);
|
||||
}
|
||||
ProviderEnvironmentValueClassification::StaticCredential => {
|
||||
if let Some(binding) = value.static_credential_binding {
|
||||
static_credential_bindings.insert(value.name, binding);
|
||||
}
|
||||
}
|
||||
ProviderEnvironmentValueClassification::Unspecified => {}
|
||||
}
|
||||
}
|
||||
GetSandboxProviderEnvironmentResponse {
|
||||
environment,
|
||||
provider_env_revision: snapshot.provider_env_revision,
|
||||
credential_expires_at_ms,
|
||||
dynamic_credentials: snapshot.dynamic_credentials,
|
||||
static_credential_bindings,
|
||||
non_secret_environment_keys,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3358,6 +3518,10 @@ async fn handle_update_config_inner(
|
||||
if changed {
|
||||
global_settings.revision = global_settings.revision.wrapping_add(1);
|
||||
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
||||
crate::config_delivery::publish_all_connected(
|
||||
state,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
}
|
||||
return Ok(update_config_response(
|
||||
u32::try_from(current.version).unwrap_or(0),
|
||||
@@ -3414,6 +3578,10 @@ async fn handle_update_config_inner(
|
||||
if changed {
|
||||
global_settings.revision = global_settings.revision.wrapping_add(1);
|
||||
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
||||
crate::config_delivery::publish_all_connected(
|
||||
state,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(update_config_response(
|
||||
@@ -3458,6 +3626,10 @@ async fn handle_update_config_inner(
|
||||
|
||||
global_settings.revision = global_settings.revision.wrapping_add(1);
|
||||
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
||||
crate::config_delivery::publish_all_connected(
|
||||
state,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_CONFIG,
|
||||
);
|
||||
|
||||
if req.delete_setting
|
||||
&& key == POLICY_SETTING_KEY
|
||||
@@ -3530,6 +3702,11 @@ async fn handle_update_config_inner(
|
||||
&sandbox_settings,
|
||||
)
|
||||
.await?;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_CONFIG,
|
||||
);
|
||||
}
|
||||
|
||||
response_annotations = persist_update_config_annotations(
|
||||
@@ -3574,6 +3751,11 @@ async fn handle_update_config_inner(
|
||||
&sandbox_settings,
|
||||
)
|
||||
.await?;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_CONFIG,
|
||||
);
|
||||
}
|
||||
|
||||
response_annotations = persist_update_config_annotations(
|
||||
@@ -3632,6 +3814,11 @@ async fn handle_update_config_inner(
|
||||
Some(&atomic_context),
|
||||
)
|
||||
.await?;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
response_annotations = if let Some(updated_sandbox) = updated_sandbox {
|
||||
sandbox_metadata_annotations(&updated_sandbox)
|
||||
} else {
|
||||
@@ -3851,6 +4038,11 @@ async fn handle_update_config_inner(
|
||||
})?
|
||||
};
|
||||
response_annotations = committed_annotations;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
state.sandbox_watch_bus.notify(&sandbox_id);
|
||||
|
||||
if backfill_policy.is_some() {
|
||||
@@ -3897,6 +4089,12 @@ async fn handle_update_config_inner(
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?;
|
||||
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
|
||||
let _ = state
|
||||
.store
|
||||
.supersede_older_policies(&sandbox_id, next_version)
|
||||
@@ -4891,6 +5089,11 @@ async fn handle_approve_draft_chunk_inner(
|
||||
return Err(status);
|
||||
}
|
||||
};
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
|
||||
|
||||
let now_ms = current_time_ms();
|
||||
@@ -5015,6 +5218,11 @@ async fn handle_reject_draft_chunk_inner(
|
||||
require_no_global_policy(state).await?;
|
||||
let (version, hash) =
|
||||
remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
emit_gateway_policy_audit_log(
|
||||
&sandbox_id,
|
||||
sandbox.object_name(),
|
||||
@@ -5306,6 +5514,14 @@ async fn handle_approve_all_draft_chunks_inner(
|
||||
}
|
||||
};
|
||||
|
||||
if !accepted.is_empty() {
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
for (chunk, _, chunk_summary) in &accepted {
|
||||
let now_ms = current_time_ms();
|
||||
clear_pending_application_error(state, &chunk.id).await;
|
||||
@@ -5507,6 +5723,11 @@ async fn handle_undo_draft_chunk_inner(
|
||||
);
|
||||
|
||||
let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
|
||||
// Clear any prior rejection_reason on the way back to "pending" so an
|
||||
// agent reading the chunk via policy.local cannot see a stale guidance
|
||||
@@ -5862,7 +6083,7 @@ fn canonical_policy_bytes(policy: &ProtoSandboxPolicy) -> Vec<u8> {
|
||||
|
||||
/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`, recursively
|
||||
/// sorting every protobuf map while preserving repeated-field order.
|
||||
fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
|
||||
pub fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
|
||||
hex::encode(Sha256::digest(canonical_policy_bytes(policy)))
|
||||
}
|
||||
|
||||
@@ -7438,7 +7659,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_sandbox_config_backfills_canonical_spec_policy_bytes_and_hash() {
|
||||
async fn startup_repair_backfills_canonical_spec_policy_bytes_and_hash() {
|
||||
let state = test_server_state().await;
|
||||
let sandbox_id = "stored-canonical-backfill";
|
||||
let raw = mcp_policy_with_versions(&["2025-11-25", "2025-03-26", "2025-06-18"]);
|
||||
@@ -7473,6 +7694,15 @@ mod tests {
|
||||
assert_eq!(response.policy_hash, canonical_hash);
|
||||
assert_eq!(response.version, 1);
|
||||
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_latest_policy(sandbox_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
backfill_legacy_policy_history(&state).await.unwrap();
|
||||
let persisted = state
|
||||
.store
|
||||
.get_latest_policy(sandbox_id)
|
||||
@@ -7485,7 +7715,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_sandbox_config_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() {
|
||||
async fn startup_repair_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() {
|
||||
let state = test_server_state().await;
|
||||
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
|
||||
.expect("explicit default MCP policy must canonicalize");
|
||||
@@ -7531,6 +7761,15 @@ mod tests {
|
||||
"{case}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_latest_policy(&sandbox_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
backfill_legacy_policy_history(&state).await.unwrap();
|
||||
let persisted = state
|
||||
.store
|
||||
.get_latest_policy(&sandbox_id)
|
||||
@@ -10594,6 +10833,114 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_policy_update_does_not_publish_configuration() {
|
||||
let state = test_server_state().await;
|
||||
let mut sandbox = test_sandbox(
|
||||
"sb-rejected-policy",
|
||||
"rejected-policy",
|
||||
ProtoSandboxPolicy::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
sandbox.spec.as_mut().unwrap().policy = None;
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
state.supervisor_sessions.register(
|
||||
"sb-rejected-policy".to_string(),
|
||||
"session-1".to_string(),
|
||||
tx,
|
||||
shutdown_tx,
|
||||
);
|
||||
|
||||
let error = handle_update_config(
|
||||
&state,
|
||||
with_user(Request::new(UpdateConfigRequest {
|
||||
name: "rejected-policy".to_string(),
|
||||
workspace: "default".to_string(),
|
||||
policy: Some(test_policy_with_rule("rejected", "api.example.com")),
|
||||
expected_resource_version: u64::MAX,
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("stale resource version must reject the update");
|
||||
|
||||
assert_eq!(error.code(), Code::Aborted);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_latest_policy("sb-rejected-policy")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_policy_update_publishes_complete_snapshot() {
|
||||
let state = test_server_state().await;
|
||||
let mut sandbox = test_sandbox(
|
||||
"sb-published-policy",
|
||||
"published-policy",
|
||||
ProtoSandboxPolicy::default(),
|
||||
Vec::new(),
|
||||
);
|
||||
sandbox.spec.as_mut().unwrap().policy = None;
|
||||
state.store.put_message(&sandbox).await.unwrap();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
|
||||
let (shutdown_tx, _shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
state.supervisor_sessions.register(
|
||||
"sb-published-policy".to_string(),
|
||||
"session-1".to_string(),
|
||||
tx,
|
||||
shutdown_tx,
|
||||
);
|
||||
|
||||
handle_update_config(
|
||||
&state,
|
||||
with_user(Request::new(UpdateConfigRequest {
|
||||
name: "published-policy".to_string(),
|
||||
workspace: "default".to_string(),
|
||||
policy: Some(test_policy_with_rule("published", "api.example.com")),
|
||||
..Default::default()
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let persisted = state
|
||||
.store
|
||||
.get_latest_policy("sb-published-policy")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("committed policy");
|
||||
let snapshot = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
let message = rx.recv().await.expect("configuration channel closed");
|
||||
let Some(openshell_core::proto::gateway_message::Payload::ConfigUpdate(update)) =
|
||||
message.payload
|
||||
else {
|
||||
panic!("expected ConfigUpdate");
|
||||
};
|
||||
if let Some(openshell_core::proto::config_update::Component::SandboxConfig(
|
||||
snapshot,
|
||||
)) = update.component
|
||||
{
|
||||
break snapshot;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("configuration publication timed out");
|
||||
assert_eq!(snapshot.version, u32::try_from(persisted.version).unwrap());
|
||||
assert_eq!(snapshot.policy_hash, persisted.policy_hash);
|
||||
assert!(snapshot.policy.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_config_accepts_sigv4_covered_by_endpointful_aws_profile() {
|
||||
let state = test_server_state().await;
|
||||
@@ -10921,23 +11268,68 @@ mod tests {
|
||||
.contains_key("_provider_work_github")
|
||||
);
|
||||
|
||||
let persisted = state
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_latest_policy("sb-jit")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"snapshot reads must not create policy history"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_policy_history_repair_is_idempotent() {
|
||||
let state = test_server_state().await;
|
||||
let policy = test_policy_with_rule("legacy", "legacy.example.com");
|
||||
state
|
||||
.store
|
||||
.get_latest_policy("sb-jit")
|
||||
.put_message(&test_sandbox(
|
||||
"sb-legacy-policy",
|
||||
"legacy-policy",
|
||||
policy.clone(),
|
||||
Vec::new(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
backfill_legacy_policy_history(&state).await.unwrap();
|
||||
let initial = state
|
||||
.store
|
||||
.get_latest_policy("sb-legacy-policy")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("sandbox policy should be lazily backfilled");
|
||||
let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice())
|
||||
.expect("persisted sandbox policy should decode");
|
||||
assert!(
|
||||
persisted_policy
|
||||
.network_policies
|
||||
.contains_key("sandbox_only")
|
||||
.expect("legacy policy baseline");
|
||||
assert_eq!(initial.status, "loaded");
|
||||
state
|
||||
.store
|
||||
.update_policy_status("sb-legacy-policy", 1, "failed", Some("apply failed"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
backfill_legacy_policy_history(&state).await.unwrap();
|
||||
let repaired_again = state
|
||||
.store
|
||||
.get_latest_policy("sb-legacy-policy")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("legacy policy baseline");
|
||||
assert_eq!(repaired_again.version, 1);
|
||||
assert_eq!(
|
||||
repaired_again.policy_hash,
|
||||
deterministic_policy_hash(&policy)
|
||||
);
|
||||
assert!(
|
||||
!persisted_policy
|
||||
.network_policies
|
||||
.contains_key("_provider_work_github")
|
||||
assert_eq!(repaired_again.status, "failed");
|
||||
assert_eq!(repaired_again.load_error.as_deref(), Some("apply failed"));
|
||||
assert_eq!(
|
||||
state
|
||||
.store
|
||||
.list_policies("sb-legacy-policy", 10, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11062,24 +11454,14 @@ mod tests {
|
||||
assert_eq!(persisted_provider.r#type, provider.r#type);
|
||||
assert_eq!(persisted_provider.credentials, provider.credentials);
|
||||
|
||||
let persisted_policy = state
|
||||
.store
|
||||
.get_latest_policy("sb-custom-policy-update")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("sandbox policy should be lazily backfilled");
|
||||
let persisted_policy =
|
||||
ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice())
|
||||
.expect("persisted sandbox policy should decode");
|
||||
assert!(
|
||||
persisted_policy
|
||||
.network_policies
|
||||
.contains_key("sandbox_only")
|
||||
);
|
||||
assert!(
|
||||
!persisted_policy
|
||||
.network_policies
|
||||
.contains_key("_provider_work_custom")
|
||||
state
|
||||
.store
|
||||
.get_latest_policy("sb-custom-policy-update")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"config and profile reads must not create policy history"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20869,4 +21251,34 @@ mod tests {
|
||||
response.unwrap_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_stream_values_expand_to_legacy_polling_response() {
|
||||
let response = provider_environment_response(ProviderEnvironmentSnapshot {
|
||||
provider_env_revision: 9,
|
||||
values: vec![
|
||||
ProviderEnvironmentValue {
|
||||
name: "REGION".into(),
|
||||
value: "west".into(),
|
||||
classification: ProviderEnvironmentValueClassification::NonSecret.into(),
|
||||
..Default::default()
|
||||
},
|
||||
ProviderEnvironmentValue {
|
||||
name: "TOKEN".into(),
|
||||
value: "secret".into(),
|
||||
expires_at_ms: Some(123),
|
||||
classification: ProviderEnvironmentValueClassification::StaticCredential.into(),
|
||||
static_credential_binding: Some(StaticCredentialBinding::default()),
|
||||
},
|
||||
],
|
||||
dynamic_credentials: HashMap::new(),
|
||||
});
|
||||
|
||||
assert_eq!(response.provider_env_revision, 9);
|
||||
assert_eq!(response.environment["REGION"], "west");
|
||||
assert_eq!(response.environment["TOKEN"], "secret");
|
||||
assert_eq!(response.credential_expires_at_ms["TOKEN"], 123);
|
||||
assert_eq!(response.non_secret_environment_keys, ["REGION"]);
|
||||
assert!(response.static_credential_bindings.contains_key("TOKEN"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2434,6 +2434,21 @@ async fn authorize_and_resolve_profile_workspace(
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_provider_change(state: &Arc<ServerState>, workspace: &str) {
|
||||
if workspace.is_empty() {
|
||||
crate::config_delivery::publish_all_connected(
|
||||
state,
|
||||
crate::config_delivery::ConfigComponents::ALL,
|
||||
);
|
||||
} else {
|
||||
crate::config_delivery::publish_workspace_components(
|
||||
state,
|
||||
workspace,
|
||||
crate::config_delivery::ConfigComponents::ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_create_provider(
|
||||
state: &Arc<ServerState>,
|
||||
request: Request<CreateProviderRequest>,
|
||||
@@ -2493,6 +2508,7 @@ pub(super) async fn handle_create_provider(
|
||||
LifecycleOperation::Create,
|
||||
TelemetryOutcome::Success,
|
||||
);
|
||||
publish_provider_change(state, &workspace);
|
||||
Ok(Response::new(ProviderResponse {
|
||||
provider: Some(provider),
|
||||
}))
|
||||
@@ -2755,6 +2771,7 @@ pub(super) async fn handle_import_provider_profiles(
|
||||
stored.profile.unwrap_or_default(),
|
||||
resource_version,
|
||||
));
|
||||
publish_provider_change(state, &workspace);
|
||||
}
|
||||
|
||||
Ok(Response::new(ImportProviderProfilesResponse {
|
||||
@@ -2885,6 +2902,7 @@ pub(super) async fn handle_update_provider_profiles(
|
||||
}
|
||||
let resource_version = stored_profile_resource_version(&stored);
|
||||
let profile = profile_response_payload(stored.profile.unwrap_or_default(), resource_version);
|
||||
publish_provider_change(state, &workspace);
|
||||
|
||||
Ok(Response::new(UpdateProviderProfilesResponse {
|
||||
diagnostics: Vec::new(),
|
||||
@@ -2974,6 +2992,9 @@ pub(super) async fn handle_delete_provider_profile(
|
||||
.delete_by_name(StoredProviderProfile::object_type(), &workspace, &id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("delete provider profile failed: {e}")))?;
|
||||
if deleted {
|
||||
publish_provider_change(state, &workspace);
|
||||
}
|
||||
|
||||
Ok(Response::new(DeleteProviderProfileResponse { deleted }))
|
||||
}
|
||||
@@ -3754,6 +3775,7 @@ pub(super) async fn handle_update_provider(
|
||||
LifecycleOperation::Update,
|
||||
TelemetryOutcome::Success,
|
||||
);
|
||||
publish_provider_change(state, &workspace);
|
||||
Ok(Response::new(ProviderResponse {
|
||||
provider: Some(provider),
|
||||
}))
|
||||
@@ -4673,8 +4695,17 @@ pub(super) async fn handle_configure_provider_refresh(
|
||||
profile_workspace: String::new(),
|
||||
credential_handles: HashMap::new(),
|
||||
};
|
||||
update_provider_record_with_catalog(state.store.as_ref(), &catalog, &workspace, updated)
|
||||
.await?;
|
||||
let result = update_provider_record_with_catalog(
|
||||
state.store.as_ref(),
|
||||
&catalog,
|
||||
&workspace,
|
||||
updated,
|
||||
)
|
||||
.await;
|
||||
publish_provider_change(state, &workspace);
|
||||
result?;
|
||||
} else {
|
||||
publish_provider_change(state, &workspace);
|
||||
}
|
||||
|
||||
Ok(Response::new(ConfigureProviderRefreshResponse {
|
||||
@@ -4718,6 +4749,7 @@ pub(super) async fn handle_rotate_provider_credential(
|
||||
credential_key,
|
||||
)
|
||||
.await?;
|
||||
publish_provider_change(state, &workspace);
|
||||
|
||||
Ok(Response::new(RotateProviderCredentialResponse {
|
||||
status: Some(crate::provider_refresh::refresh_status_from_state(
|
||||
@@ -4800,14 +4832,13 @@ pub(super) async fn handle_delete_provider_refresh(
|
||||
credential_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A refresh co-manages the expiry of its primary credential and every pinned
|
||||
// additional output. Clear each expiry this refresh still owns, leaving
|
||||
// independently updated ones in place. The equality check and removal run
|
||||
// inside the CAS closure so they see the current stored provider — deciding
|
||||
// from the snapshot read above would let a concurrent rotation or provider
|
||||
// update land between the read and the write and then be clobbered (CWE-362).
|
||||
if let Some(refresh_state) = existing_refresh_state
|
||||
let expiry_cleanup = if let Some(refresh_state) = existing_refresh_state
|
||||
&& refresh_state.expires_at_ms > 0
|
||||
{
|
||||
let refresh_expires_at_ms = refresh_state.expires_at_ms;
|
||||
@@ -4824,8 +4855,15 @@ pub(super) async fn handle_delete_provider_refresh(
|
||||
Status::internal(format!(
|
||||
"clear refresh-owned credential expiries failed: {e}"
|
||||
))
|
||||
})?;
|
||||
})
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if deleted_refresh_state {
|
||||
publish_provider_change(state, &workspace);
|
||||
}
|
||||
expiry_cleanup?;
|
||||
|
||||
Ok(Response::new(DeleteProviderRefreshResponse {
|
||||
deleted: deleted_refresh_state,
|
||||
@@ -4866,6 +4904,9 @@ pub(super) async fn handle_delete_provider(
|
||||
LifecycleOperation::Delete,
|
||||
outcome,
|
||||
);
|
||||
if deleted {
|
||||
publish_provider_change(state, &workspace);
|
||||
}
|
||||
Ok(Response::new(DeleteProviderResponse { deleted }))
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -1175,6 +1175,14 @@ pub(super) async fn handle_attach_sandbox_provider(
|
||||
.map_err(|e| super::persistence_error_to_status(e, "attach sandbox provider"))?;
|
||||
|
||||
let attached = attached.load(Ordering::Relaxed);
|
||||
if attached {
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
state.sandbox_watch_bus.notify(&sandbox_id);
|
||||
}
|
||||
|
||||
info!(
|
||||
sandbox_name = %request.sandbox_name,
|
||||
@@ -1274,6 +1282,14 @@ pub(super) async fn handle_detach_sandbox_provider(
|
||||
.map_err(|e| super::persistence_error_to_status(e, "detach sandbox provider"))?;
|
||||
|
||||
let detached = detached.load(Ordering::Relaxed);
|
||||
if detached {
|
||||
crate::config_delivery::publish_sandbox_components(
|
||||
state,
|
||||
&sandbox_id,
|
||||
crate::config_delivery::ConfigComponents::SANDBOX_AND_PROVIDER,
|
||||
);
|
||||
state.sandbox_watch_bus.notify(&sandbox_id);
|
||||
}
|
||||
|
||||
info!(
|
||||
sandbox_name = %request.sandbox_name,
|
||||
|
||||
@@ -17,6 +17,7 @@ mod auth;
|
||||
pub mod certgen;
|
||||
pub mod cli;
|
||||
mod compute;
|
||||
mod config_delivery;
|
||||
pub mod config_file;
|
||||
mod credentials;
|
||||
mod defaults;
|
||||
@@ -301,6 +302,11 @@ pub struct ServerState {
|
||||
/// Set once graceful gateway shutdown begins so stream handlers can
|
||||
/// distinguish expected transport closes from runtime failures.
|
||||
pub(crate) gateway_shutting_down: AtomicBool,
|
||||
/// Per-sandbox scheduler for coalesced supervisor configuration delivery.
|
||||
pub(crate) config_delivery_queue: config_delivery::ConfigDeliveryQueue,
|
||||
|
||||
/// Routing boundary for local or remote supervisor configuration delivery.
|
||||
pub(crate) supervisor_config_router: Arc<dyn config_delivery::SupervisorConfigRouter>,
|
||||
|
||||
/// Validated built-in and operator-registered supervisor middleware.
|
||||
pub middleware_registry: Arc<MiddlewareRegistry>,
|
||||
@@ -356,6 +362,12 @@ fn is_benign_connection_close(error: &(dyn std::error::Error + 'static)) -> bool
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
/// Return the configuration delivery boundary for supervisor sessions.
|
||||
#[must_use]
|
||||
pub fn supervisor_config_router(&self) -> Arc<dyn config_delivery::SupervisorConfigRouter> {
|
||||
Arc::clone(&self.supervisor_config_router)
|
||||
}
|
||||
|
||||
/// Create new server state.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -404,6 +416,9 @@ impl ServerState {
|
||||
.oidc
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |oidc| oidc.admin_role.clone());
|
||||
let supervisor_config_router: Arc<dyn config_delivery::SupervisorConfigRouter> = Arc::new(
|
||||
config_delivery::LocalSupervisorConfigRouter::new(Arc::clone(&supervisor_sessions)),
|
||||
);
|
||||
Self {
|
||||
config,
|
||||
store,
|
||||
@@ -418,6 +433,8 @@ impl ServerState {
|
||||
settings_mutex: tokio::sync::Mutex::new(()),
|
||||
supervisor_sessions,
|
||||
gateway_shutting_down: AtomicBool::new(false),
|
||||
config_delivery_queue: config_delivery::ConfigDeliveryQueue::default(),
|
||||
supervisor_config_router,
|
||||
extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(),
|
||||
middleware_registry: Arc::new(MiddlewareRegistry::default()),
|
||||
oidc_cache,
|
||||
@@ -680,6 +697,10 @@ pub(crate) async fn run_server(
|
||||
|
||||
let state = Arc::new(state);
|
||||
|
||||
grpc::policy::backfill_legacy_policy_history(&state)
|
||||
.await
|
||||
.map_err(|error| Error::execution(error.to_string()))?;
|
||||
|
||||
// Reconcile local-driver running intent before watchers spawn so their
|
||||
// first snapshots observe the post-start backend state. Explicitly stopped
|
||||
// sandboxes remain stopped.
|
||||
|
||||
@@ -945,6 +945,50 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn put_initial_policy_revision(
|
||||
&self,
|
||||
record: &PolicyRecord,
|
||||
workspace: &str,
|
||||
) -> PersistenceResult<()> {
|
||||
let wrapped_payload = policy_payload_from_record(record)?;
|
||||
let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?;
|
||||
|
||||
let sandbox_exists = sqlx::query(
|
||||
"SELECT id FROM objects WHERE object_type = 'sandbox' AND id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(&record.sandbox_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?
|
||||
.is_some();
|
||||
|
||||
if sandbox_exists {
|
||||
sqlx::query(
|
||||
r"
|
||||
INSERT INTO objects (
|
||||
object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms, workspace
|
||||
)
|
||||
SELECT $1, $2, $3, 1, $4, $5, $6, $6, $7
|
||||
WHERE NOT EXISTS (SELECT 1 FROM objects WHERE object_type = $1 AND scope = $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
",
|
||||
)
|
||||
.bind(POLICY_OBJECT_TYPE)
|
||||
.bind(&record.id)
|
||||
.bind(&record.sandbox_id)
|
||||
.bind(&record.status)
|
||||
.bind(wrapped_payload)
|
||||
.bind(record.created_at_ms)
|
||||
.bind(workspace)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(|e| map_db_error(&e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn put_policy_revision_atomic(
|
||||
&self,
|
||||
write: &AtomicPolicyRevisionWrite,
|
||||
|
||||
@@ -1084,6 +1084,36 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn put_initial_policy_revision(
|
||||
&self,
|
||||
record: &PolicyRecord,
|
||||
workspace: &str,
|
||||
) -> PersistenceResult<()> {
|
||||
let wrapped_payload = policy_payload_from_record(record)?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO "objects" (
|
||||
"object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms", "workspace"
|
||||
)
|
||||
SELECT ?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?7
|
||||
WHERE EXISTS (SELECT 1 FROM "objects" WHERE "object_type" = 'sandbox' AND "id" = ?3)
|
||||
AND NOT EXISTS (SELECT 1 FROM "objects" WHERE "object_type" = ?1 AND "scope" = ?3)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(POLICY_OBJECT_TYPE)
|
||||
.bind(&record.id)
|
||||
.bind(&record.sandbox_id)
|
||||
.bind(&record.status)
|
||||
.bind(wrapped_payload)
|
||||
.bind(record.created_at_ms)
|
||||
.bind(workspace)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| map_db_error(&e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn put_policy_revision_atomic(
|
||||
&self,
|
||||
write: &AtomicPolicyRevisionWrite,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::{ObjectListQuery, ObjectType, PersistenceError, Store, generate_name, test_store};
|
||||
use super::{
|
||||
ObjectListQuery, ObjectType, PersistenceError, PolicyRecord, Store, generate_name, test_store,
|
||||
};
|
||||
use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt};
|
||||
use openshell_core::proto::datamodel::v1::ObjectMeta as ProtoObjectMeta;
|
||||
use openshell_core::proto::{ObjectForTest, Sandbox, SandboxPolicy, SandboxSpec};
|
||||
@@ -1091,6 +1093,85 @@ fn policy_test_sandbox(id: &str, name: &str) -> Sandbox {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_policy_history_is_insert_only() {
|
||||
assert_initial_policy_history_is_insert_only(&test_store().await).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENSHELL_TEST_POSTGRES_URL pointing to a test database"]
|
||||
async fn postgres_initial_policy_history_is_insert_only() {
|
||||
let url = std::env::var("OPENSHELL_TEST_POSTGRES_URL").expect("test database URL");
|
||||
let store = Store::connect(&url).await.unwrap();
|
||||
assert_initial_policy_history_is_insert_only(&store).await;
|
||||
}
|
||||
|
||||
async fn assert_initial_policy_history_is_insert_only(store: &Store) {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let sandbox = policy_test_sandbox(&id, &id);
|
||||
let record = PolicyRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sandbox_id: id.clone(),
|
||||
version: 1,
|
||||
policy_payload: SandboxPolicy::default().encode_to_vec(),
|
||||
policy_hash: "initial-hash".into(),
|
||||
status: "loaded".into(),
|
||||
load_error: None,
|
||||
created_at_ms: 1,
|
||||
loaded_at_ms: None,
|
||||
provenance: StdHashMap::new(),
|
||||
};
|
||||
|
||||
store
|
||||
.put_initial_policy_revision(&record, "default")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.get_latest_policy(&id).await.unwrap().is_none());
|
||||
|
||||
store.put_message(&sandbox).await.unwrap();
|
||||
let (first, second) = tokio::join!(
|
||||
store.put_initial_policy_revision(&record, "default"),
|
||||
store.put_initial_policy_revision(&record, "default"),
|
||||
);
|
||||
first.unwrap();
|
||||
second.unwrap();
|
||||
let initial = store.get_latest_policy(&id).await.unwrap().unwrap();
|
||||
assert_eq!(initial.status, "loaded");
|
||||
assert_eq!(initial.policy_hash, record.policy_hash);
|
||||
assert_eq!(store.list_policies(&id, 10, 0).await.unwrap().len(), 1);
|
||||
|
||||
store
|
||||
.update_policy_status(&id, 1, "failed", Some("apply failed"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.put_initial_policy_revision(&record, "default")
|
||||
.await
|
||||
.unwrap();
|
||||
let failed = store.get_latest_policy(&id).await.unwrap().unwrap();
|
||||
assert_eq!(failed.status, "failed");
|
||||
assert_eq!(failed.load_error.as_deref(), Some("apply failed"));
|
||||
|
||||
store
|
||||
.put_policy_revision(
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
&id,
|
||||
"default",
|
||||
2,
|
||||
&record.policy_payload,
|
||||
"new-hash",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.put_initial_policy_revision(&record, "default")
|
||||
.await
|
||||
.unwrap();
|
||||
let latest = store.get_latest_policy(&id).await.unwrap().unwrap();
|
||||
assert_eq!(latest.version, 2);
|
||||
assert_eq!(store.list_policies(&id, 10, 0).await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection() {
|
||||
let store = test_store().await;
|
||||
|
||||
@@ -94,6 +94,14 @@ pub fn project_policy_revision_onto_sandbox(
|
||||
}
|
||||
|
||||
pub trait PolicyStoreExt {
|
||||
/// Insert version-one policy history when the sandbox still has no policy
|
||||
/// revisions. Existing history and apply status are left untouched.
|
||||
async fn put_initial_policy_revision(
|
||||
&self,
|
||||
record: &PolicyRecord,
|
||||
workspace: &str,
|
||||
) -> PersistenceResult<()>;
|
||||
|
||||
async fn put_policy_revision(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -217,6 +225,17 @@ pub trait PolicyStoreExt {
|
||||
}
|
||||
|
||||
impl PolicyStoreExt for Store {
|
||||
async fn put_initial_policy_revision(
|
||||
&self,
|
||||
record: &PolicyRecord,
|
||||
workspace: &str,
|
||||
) -> PersistenceResult<()> {
|
||||
match self {
|
||||
Self::Postgres(store) => store.put_initial_policy_revision(record, workspace).await,
|
||||
Self::Sqlite(store) => store.put_initial_policy_revision(record, workspace).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_policy_revision(
|
||||
&self,
|
||||
id: &str,
|
||||
|
||||
@@ -1809,14 +1809,25 @@ pub fn spawn_refresh_worker(state: std::sync::Arc<crate::ServerState>, interval:
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(err) = run_refresh_worker_tick(
|
||||
match run_refresh_worker_tick(
|
||||
state.store.as_ref(),
|
||||
Some(&state.credentials),
|
||||
Some(&state.compute),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "provider credential refresh worker tick failed");
|
||||
Ok(workspaces) => {
|
||||
for workspace in workspaces {
|
||||
crate::config_delivery::publish_workspace_components(
|
||||
&state,
|
||||
&workspace,
|
||||
crate::config_delivery::ConfigComponents::ALL,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "provider credential refresh worker tick failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1835,7 +1846,8 @@ async fn run_refresh_worker_tick(
|
||||
store: &Store,
|
||||
credentials: Option<&crate::credentials::CredentialRuntime>,
|
||||
compute: Option<&crate::compute::ComputeRuntime>,
|
||||
) -> Result<(), Status> {
|
||||
) -> Result<std::collections::HashSet<String>, Status> {
|
||||
let mut changed_workspaces = std::collections::HashSet::new();
|
||||
let now_ms = current_time_ms();
|
||||
let states = list_all_refresh_states(store).await.inspect_err(|_| {
|
||||
crate::otel_tracing::mark_error(&tracing::Span::current());
|
||||
@@ -1885,6 +1897,8 @@ async fn run_refresh_worker_tick(
|
||||
error = %err,
|
||||
"failed to finalize tombstoned provider refresh; retrying on the next sweep"
|
||||
);
|
||||
} else {
|
||||
changed_workspaces.insert(state.object_workspace().to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1968,9 +1982,11 @@ async fn run_refresh_worker_tick(
|
||||
error = %err,
|
||||
"provider credential refresh failed"
|
||||
);
|
||||
} else {
|
||||
changed_workspaces.insert(state.object_workspace().to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(changed_workspaces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -7,21 +7,29 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use metrics::counter;
|
||||
use prost::Message;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use openshell_core::proto::SUPERVISOR_PROTOCOL_REVISION;
|
||||
use openshell_core::proto::{
|
||||
GatewayMessage, RelayFrame, RelayInit, RelayOpen, ReportMainProcessExitRequest,
|
||||
ConfigUpdate, GatewayMessage, RelayFrame, RelayInit, RelayOpen, ReportMainProcessExitRequest,
|
||||
ReportMainProcessExitResponse, Sandbox, SandboxPhase, SessionAccepted, SshRelayTarget,
|
||||
SupervisorMessage, gateway_message, relay_open, supervisor_message,
|
||||
SupervisorMessage, config_update, gateway_message, relay_open, supervisor_message,
|
||||
};
|
||||
use openshell_core::transport_errors::is_expected_transport_close_status;
|
||||
|
||||
use crate::ServerState;
|
||||
use crate::auth::principal::Principal;
|
||||
use crate::config_delivery::{
|
||||
DeliveryDisposition, MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES, SupervisorConfigMessage,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::config_delivery::{LocalSupervisorConfigRouter, SupervisorConfigRouter};
|
||||
|
||||
const HEARTBEAT_INTERVAL_SECS: u32 = 15;
|
||||
const RELAY_PENDING_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
@@ -52,6 +60,7 @@ struct LiveSession {
|
||||
/// removing a session that has since been superseded by a reconnect.
|
||||
session_id: String,
|
||||
tx: mpsc::Sender<GatewayMessage>,
|
||||
config_sequences: ConfigSequences,
|
||||
/// Fires when this session is superseded by a reconnect so the old session
|
||||
/// task can exit promptly — dropping its own `tx` clone and closing the
|
||||
/// outbound stream. Without this, a concurrent `open_relay` that grabbed
|
||||
@@ -65,6 +74,12 @@ struct LiveSession {
|
||||
connected_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ConfigSequences {
|
||||
sandbox_config: u64,
|
||||
provider_environment: u64,
|
||||
}
|
||||
|
||||
/// Holds a oneshot sender that will deliver the upgraded relay stream or a
|
||||
/// target-open failure reported by the supervisor.
|
||||
type RelayStreamSender = oneshot::Sender<Result<tokio::io::DuplexStream, Status>>;
|
||||
@@ -127,6 +142,7 @@ impl SupervisorSessionRegistry {
|
||||
sandbox_id,
|
||||
session_id,
|
||||
tx,
|
||||
config_sequences: ConfigSequences::default(),
|
||||
shutdown,
|
||||
terminal_delivery_finalized: false,
|
||||
connected_at: Instant::now(),
|
||||
@@ -233,6 +249,65 @@ impl SupervisorSessionRegistry {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn connected_sandbox_ids(&self) -> Vec<String> {
|
||||
self.sessions.lock().unwrap().keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn deliver_config(
|
||||
&self,
|
||||
sandbox_id: &str,
|
||||
message: SupervisorConfigMessage,
|
||||
) -> DeliveryDisposition {
|
||||
let component_name = message.component_name();
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let Some(session) = sessions.get_mut(sandbox_id) else {
|
||||
return DeliveryDisposition::NoActiveSession;
|
||||
};
|
||||
let sequence = match &message {
|
||||
SupervisorConfigMessage::SandboxConfig(_) => {
|
||||
&mut session.config_sequences.sandbox_config
|
||||
}
|
||||
SupervisorConfigMessage::ProviderEnvironment(_) => {
|
||||
&mut session.config_sequences.provider_environment
|
||||
}
|
||||
};
|
||||
*sequence = sequence.saturating_add(1);
|
||||
let component_sequence = *sequence;
|
||||
|
||||
let component = match message {
|
||||
SupervisorConfigMessage::SandboxConfig(snapshot) => {
|
||||
config_update::Component::SandboxConfig(*snapshot)
|
||||
}
|
||||
SupervisorConfigMessage::ProviderEnvironment(snapshot) => {
|
||||
config_update::Component::ProviderEnvironment(snapshot)
|
||||
}
|
||||
};
|
||||
let gateway_message = GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::ConfigUpdate(ConfigUpdate {
|
||||
update_id: Uuid::new_v4().to_string(),
|
||||
component_sequence,
|
||||
component: Some(component),
|
||||
})),
|
||||
};
|
||||
|
||||
if gateway_message.encoded_len() > MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES {
|
||||
return DeliveryDisposition::PayloadTooLarge;
|
||||
}
|
||||
|
||||
match session.tx.try_send(gateway_message) {
|
||||
Ok(()) => DeliveryDisposition::Enqueued,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
component = component_name,
|
||||
"supervisor configuration queue is full"
|
||||
);
|
||||
DeliveryDisposition::QueueFull
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => DeliveryDisposition::SessionClosed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_current_session(&self, sandbox_id: &str, session_id: &str) -> bool {
|
||||
self.sessions
|
||||
.lock()
|
||||
@@ -480,17 +555,13 @@ pub fn spawn_relay_reaper(state: Arc<ServerState>, interval: Duration) {
|
||||
async fn require_persisted_sandbox(
|
||||
store: &Arc<crate::persistence::Store>,
|
||||
sandbox_id: &str,
|
||||
) -> Result<(), Status> {
|
||||
) -> Result<Sandbox, Status> {
|
||||
let sandbox = store
|
||||
.get_message::<Sandbox>(sandbox_id)
|
||||
.await
|
||||
.map_err(|err| Status::internal(format!("failed to load sandbox: {err}")))?;
|
||||
|
||||
if sandbox.is_none() {
|
||||
return Err(Status::not_found("sandbox not found"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
sandbox.ok_or_else(|| Status::not_found("sandbox not found"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -739,10 +810,35 @@ pub async fn handle_connect_supervisor(
|
||||
if sandbox_id.is_empty() {
|
||||
return Err(Status::invalid_argument("sandbox_id is required"));
|
||||
}
|
||||
validate_protocol_revision(hello.protocol_revision)?;
|
||||
if let Some(principal) = principal.as_ref() {
|
||||
crate::auth::guard::ensure_sandbox_principal_scope(principal, &sandbox_id)?;
|
||||
}
|
||||
require_persisted_sandbox(&state.store, &sandbox_id).await?;
|
||||
let sandbox = require_persisted_sandbox(&state.store, &sandbox_id).await?;
|
||||
|
||||
let bootstrap = match crate::config_delivery::build_config_bootstrap(state, &sandbox).await {
|
||||
Ok(bootstrap) => {
|
||||
counter!(
|
||||
"openshell_supervisor_config_bootstrap_total",
|
||||
"outcome" => "built"
|
||||
)
|
||||
.increment(1);
|
||||
Some(bootstrap)
|
||||
}
|
||||
Err(error) => {
|
||||
counter!(
|
||||
"openshell_supervisor_config_bootstrap_total",
|
||||
"outcome" => "build_failed"
|
||||
)
|
||||
.increment(1);
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
error_code = ?error.code(),
|
||||
"failed to build supervisor configuration bootstrap"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
info!(
|
||||
@@ -752,9 +848,35 @@ pub async fn handle_connect_supervisor(
|
||||
"supervisor session: accepted"
|
||||
);
|
||||
|
||||
// Step 2: Create and register the outbound channel.
|
||||
// Step 2: Queue SessionAccepted before the session becomes routable. This
|
||||
// keeps a concurrent ConfigUpdate from becoming the first stream message.
|
||||
let (tx, rx) = mpsc::channel::<GatewayMessage>(64);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let mut accepted = GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted {
|
||||
session_id: session_id.clone(),
|
||||
heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS,
|
||||
bootstrap,
|
||||
protocol_revision: SUPERVISOR_PROTOCOL_REVISION,
|
||||
})),
|
||||
};
|
||||
if accepted.encoded_len() > MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES {
|
||||
counter!(
|
||||
"openshell_supervisor_config_bootstrap_total",
|
||||
"outcome" => "payload_too_large"
|
||||
)
|
||||
.increment(1);
|
||||
let Some(gateway_message::Payload::SessionAccepted(accepted_payload)) =
|
||||
accepted.payload.as_mut()
|
||||
else {
|
||||
unreachable!("constructed SessionAccepted payload")
|
||||
};
|
||||
accepted_payload.bootstrap = None;
|
||||
}
|
||||
if tx.send(accepted).await.is_err() {
|
||||
return Err(Status::internal("failed to send session accepted"));
|
||||
}
|
||||
|
||||
let superseded = state.supervisor_sessions.register(
|
||||
sandbox_id.clone(),
|
||||
session_id.clone(),
|
||||
@@ -769,22 +891,6 @@ pub async fn handle_connect_supervisor(
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Send SessionAccepted.
|
||||
let accepted = GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted {
|
||||
session_id: session_id.clone(),
|
||||
heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS,
|
||||
})),
|
||||
};
|
||||
if tx.send(accepted).await.is_err() {
|
||||
// Only evict ourselves — a faster reconnect may already have
|
||||
// superseded this registration.
|
||||
state
|
||||
.supervisor_sessions
|
||||
.remove_if_current(&sandbox_id, &session_id);
|
||||
return Err(Status::internal("failed to send session accepted"));
|
||||
}
|
||||
|
||||
if superseded {
|
||||
state
|
||||
.supervisor_sessions
|
||||
@@ -854,6 +960,16 @@ pub async fn handle_connect_supervisor(
|
||||
Ok(Response::new(stream))
|
||||
}
|
||||
|
||||
fn validate_protocol_revision(supervisor_revision: u32) -> Result<(), Status> {
|
||||
if supervisor_revision == SUPERVISOR_PROTOCOL_REVISION {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Status::failed_precondition(format!(
|
||||
"supervisor protocol revision mismatch: gateway requires {SUPERVISOR_PROTOCOL_REVISION}, supervisor offered {supervisor_revision}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_report_main_process_exit(
|
||||
state: &Arc<ServerState>,
|
||||
request: Request<ReportMainProcessExitRequest>,
|
||||
@@ -1016,6 +1132,22 @@ fn handle_supervisor_message(
|
||||
"supervisor session: relay closed by supervisor"
|
||||
);
|
||||
}
|
||||
Some(supervisor_message::Payload::ConfigUpdateResult(result)) => {
|
||||
debug!(
|
||||
sandbox_id = %sandbox_id,
|
||||
session_id = %session_id,
|
||||
component_sequence = result.component_sequence,
|
||||
"supervisor session: ignoring configuration result while polling remains authoritative"
|
||||
);
|
||||
}
|
||||
Some(supervisor_message::Payload::ConfigBootstrapResult(result)) => {
|
||||
debug!(
|
||||
sandbox_id = %sandbox_id,
|
||||
session_id = %session_id,
|
||||
result_count = result.results.len(),
|
||||
"supervisor session: ignoring bootstrap result while polling remains authoritative"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
sandbox_id = %sandbox_id,
|
||||
@@ -1036,6 +1168,256 @@ mod tests {
|
||||
use crate::auth::identity::{Identity, IdentityProvider};
|
||||
use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal};
|
||||
use crate::persistence::Store;
|
||||
use openshell_core::proto::{
|
||||
ProviderEnvironmentSnapshot, ProviderEnvironmentValue, SandboxConfigRevision,
|
||||
SandboxConfigSnapshot,
|
||||
};
|
||||
use prost::Message;
|
||||
|
||||
#[test]
|
||||
fn configuration_stream_messages_round_trip() {
|
||||
let bootstrap = GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted {
|
||||
session_id: "session-1".into(),
|
||||
heartbeat_interval_secs: 15,
|
||||
bootstrap: Some(openshell_core::proto::ConfigBootstrap {
|
||||
sandbox_config: Some(SandboxConfigSnapshot::default()),
|
||||
provider_environment: Some(ProviderEnvironmentSnapshot::default()),
|
||||
}),
|
||||
protocol_revision: SUPERVISOR_PROTOCOL_REVISION,
|
||||
})),
|
||||
};
|
||||
let updates = [
|
||||
config_update::Component::SandboxConfig(SandboxConfigSnapshot::default()),
|
||||
config_update::Component::ProviderEnvironment(ProviderEnvironmentSnapshot::default()),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, component)| GatewayMessage {
|
||||
payload: Some(gateway_message::Payload::ConfigUpdate(ConfigUpdate {
|
||||
update_id: format!("update-{index}"),
|
||||
component_sequence: u64::try_from(index + 1).unwrap(),
|
||||
component: Some(component),
|
||||
})),
|
||||
});
|
||||
|
||||
for original in std::iter::once(bootstrap).chain(updates) {
|
||||
let decoded = GatewayMessage::decode(original.encode_to_vec().as_slice()).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
let result = SupervisorMessage {
|
||||
payload: Some(supervisor_message::Payload::ConfigUpdateResult(
|
||||
openshell_core::proto::ConfigUpdateResult {
|
||||
update_id: "update-1".into(),
|
||||
component_sequence: 4,
|
||||
result: Some(openshell_core::proto::ConfigComponentApplyResult {
|
||||
component: openshell_core::proto::ConfigComponent::SandboxConfig.into(),
|
||||
requested_revision: Some(openshell_core::proto::ConfigSnapshotRevision {
|
||||
component: Some(
|
||||
openshell_core::proto::config_snapshot_revision::Component::SandboxConfig(
|
||||
SandboxConfigRevision {
|
||||
config_revision: 7,
|
||||
policy_version: 3,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
applied_revision: Some(openshell_core::proto::ConfigSnapshotRevision {
|
||||
component: Some(
|
||||
openshell_core::proto::config_snapshot_revision::Component::SandboxConfig(
|
||||
SandboxConfigRevision {
|
||||
config_revision: 7,
|
||||
policy_version: 3,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
outcome: openshell_core::proto::ConfigApplyOutcome::Applied.into(),
|
||||
failure: None,
|
||||
}),
|
||||
},
|
||||
)),
|
||||
};
|
||||
let decoded = SupervisorMessage::decode(result.encode_to_vec().as_slice()).unwrap();
|
||||
assert_eq!(decoded, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_protocol_revision_must_match_exactly() {
|
||||
assert!(validate_protocol_revision(SUPERVISOR_PROTOCOL_REVISION).is_ok());
|
||||
let error = validate_protocol_revision(SUPERVISOR_PROTOCOL_REVISION + 1).unwrap_err();
|
||||
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
|
||||
assert!(error.message().contains("revision mismatch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_router_reports_missing_session() {
|
||||
let router = LocalSupervisorConfigRouter::new(Arc::new(SupervisorSessionRegistry::new()));
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"missing",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::NoActiveSession
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_router_assigns_sequences_per_component() {
|
||||
let registry = Arc::new(SupervisorSessionRegistry::new());
|
||||
let router = LocalSupervisorConfigRouter::new(Arc::clone(®istry));
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
|
||||
registry.register("sb-1".into(), "session-1".into(), tx, shutdown_tx);
|
||||
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::Enqueued
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::Enqueued
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::ProviderEnvironment(
|
||||
ProviderEnvironmentSnapshot::default(),
|
||||
),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::Enqueued
|
||||
);
|
||||
|
||||
let first = rx.recv().await.expect("first config update");
|
||||
let second = rx.recv().await.expect("second config update");
|
||||
let third = rx.recv().await.expect("provider config update");
|
||||
let sequence = |message: GatewayMessage| match message.payload {
|
||||
Some(gateway_message::Payload::ConfigUpdate(update)) => update.component_sequence,
|
||||
other => panic!("expected config update, got {other:?}"),
|
||||
};
|
||||
assert_eq!(sequence(first), 1);
|
||||
assert_eq!(sequence(second), 2);
|
||||
assert_eq!(sequence(third), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_router_uses_replacement_session() {
|
||||
let registry = Arc::new(SupervisorSessionRegistry::new());
|
||||
let router = LocalSupervisorConfigRouter::new(Arc::clone(®istry));
|
||||
let (old_tx, mut old_rx) = mpsc::channel(2);
|
||||
let (old_shutdown_tx, _old_shutdown_rx) = oneshot::channel();
|
||||
registry.register("sb-1".into(), "old-session".into(), old_tx, old_shutdown_tx);
|
||||
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::Enqueued
|
||||
);
|
||||
let old_update = old_rx.recv().await.expect("old-session config update");
|
||||
let Some(gateway_message::Payload::ConfigUpdate(old_update)) = old_update.payload else {
|
||||
panic!("expected config update");
|
||||
};
|
||||
assert_eq!(old_update.component_sequence, 1);
|
||||
|
||||
let (new_tx, mut new_rx) = mpsc::channel(1);
|
||||
let (new_shutdown_tx, _new_shutdown_rx) = oneshot::channel();
|
||||
assert!(registry.register("sb-1".into(), "new-session".into(), new_tx, new_shutdown_tx,));
|
||||
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::Enqueued
|
||||
);
|
||||
assert!(old_rx.try_recv().is_err());
|
||||
let new_update = new_rx.try_recv().expect("new-session config update");
|
||||
let Some(gateway_message::Payload::ConfigUpdate(new_update)) = new_update.payload else {
|
||||
panic!("expected config update");
|
||||
};
|
||||
assert_eq!(new_update.component_sequence, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_router_reports_full_and_closed_queues() {
|
||||
let registry = Arc::new(SupervisorSessionRegistry::new());
|
||||
let router = LocalSupervisorConfigRouter::new(Arc::clone(®istry));
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
tx.try_send(GatewayMessage::default()).unwrap();
|
||||
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
|
||||
registry.register("sb-1".into(), "session-1".into(), tx, shutdown_tx);
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::QueueFull
|
||||
);
|
||||
|
||||
drop(rx);
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::SandboxConfig(Box::default()),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::SessionClosed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_router_rejects_oversized_messages() {
|
||||
let registry = Arc::new(SupervisorSessionRegistry::new());
|
||||
let router = LocalSupervisorConfigRouter::new(Arc::clone(®istry));
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
|
||||
registry.register("sb-1".into(), "session-1".into(), tx, shutdown_tx);
|
||||
|
||||
let snapshot = ProviderEnvironmentSnapshot {
|
||||
values: vec![ProviderEnvironmentValue {
|
||||
name: "TOKEN".into(),
|
||||
value: "x".repeat(MAX_SUPERVISOR_CONFIG_MESSAGE_BYTES),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
router
|
||||
.deliver(
|
||||
"sb-1",
|
||||
SupervisorConfigMessage::ProviderEnvironment(snapshot),
|
||||
)
|
||||
.await,
|
||||
DeliveryDisposition::PayloadTooLarge
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn test_store() -> Arc<Store> {
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use openshell_core::proto::SUPERVISOR_PROTOCOL_REVISION;
|
||||
use openshell_core::proto::open_shell_client::OpenShellClient;
|
||||
use openshell_core::proto::{
|
||||
FinalizeMainProcessExitRequest, GatewayMessage, RelayFrame, RelayInit, RelayOpen,
|
||||
@@ -354,6 +355,7 @@ async fn run_single_session(
|
||||
payload: Some(supervisor_message::Payload::Hello(SupervisorHello {
|
||||
sandbox_id: config.sandbox_id.clone(),
|
||||
instance_id: config.instance_id.clone(),
|
||||
protocol_revision: SUPERVISOR_PROTOCOL_REVISION,
|
||||
})),
|
||||
})
|
||||
.await
|
||||
@@ -381,6 +383,7 @@ async fn run_single_session(
|
||||
};
|
||||
|
||||
let heartbeat_secs = accepted.heartbeat_interval_secs.max(5);
|
||||
validate_gateway_protocol_revision(accepted.protocol_revision)?;
|
||||
let event = session_established_event(
|
||||
openshell_ocsf::ctx::ctx(),
|
||||
&config.endpoint,
|
||||
@@ -388,6 +391,15 @@ async fn run_single_session(
|
||||
heartbeat_secs,
|
||||
);
|
||||
ocsf_emit!(event);
|
||||
|
||||
if accepted.bootstrap.is_some() {
|
||||
debug!(
|
||||
sandbox_id = %config.sandbox_id,
|
||||
session_id = %accepted.session_id,
|
||||
"supervisor session: ignoring configuration bootstrap while polling remains active"
|
||||
);
|
||||
}
|
||||
|
||||
// Main loop: receive gateway messages + send heartbeats.
|
||||
let mut heartbeat_interval =
|
||||
tokio::time::interval(Duration::from_secs(u64::from(heartbeat_secs)));
|
||||
@@ -432,6 +444,19 @@ async fn run_single_session(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_gateway_protocol_revision(
|
||||
gateway_revision: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if gateway_revision == SUPERVISOR_PROTOCOL_REVISION {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"supervisor protocol revision mismatch: supervisor requires {SUPERVISOR_PROTOCOL_REVISION}, gateway offered {gateway_revision}"
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Report the canonical process result and wait for durable handling.
|
||||
pub async fn report_main_process_exit(
|
||||
endpoint: &str,
|
||||
@@ -487,6 +512,15 @@ fn handle_gateway_message(msg: &GatewayMessage, context: &GatewayMessageContext<
|
||||
Some(gateway_message::Payload::Heartbeat(_)) => {
|
||||
// Gateway heartbeat — nothing to do.
|
||||
}
|
||||
Some(gateway_message::Payload::ConfigUpdate(update)) => {
|
||||
// Stage 1 accepts pushed configuration but leaves polling as the
|
||||
// only path that changes runtime state.
|
||||
debug!(
|
||||
sandbox_id = %context.sandbox_id,
|
||||
component_sequence = update.component_sequence,
|
||||
"supervisor session: ignoring configuration update while polling remains active"
|
||||
);
|
||||
}
|
||||
Some(gateway_message::Payload::RelayOpen(open)) => {
|
||||
let channel_id = open.channel_id.clone();
|
||||
let relay_open = open.clone();
|
||||
@@ -828,6 +862,14 @@ fn normalize_tcp_target_host(target: &TcpRelayTarget) -> Result<String, String>
|
||||
mod target_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gateway_protocol_revision_must_match_exactly() {
|
||||
assert!(validate_gateway_protocol_revision(SUPERVISOR_PROTOCOL_REVISION).is_ok());
|
||||
let error = validate_gateway_protocol_revision(SUPERVISOR_PROTOCOL_REVISION + 1)
|
||||
.expect_err("version skew must be rejected");
|
||||
assert!(error.to_string().contains("revision mismatch"));
|
||||
}
|
||||
|
||||
fn tcp(host: &str, port: u32) -> TcpRelayTarget {
|
||||
TcpRelayTarget {
|
||||
host: host.to_string(),
|
||||
|
||||
@@ -106,7 +106,8 @@ disable_tls = false
|
||||
# Shared driver defaults. These inherit into [openshell.drivers.<name>] tables
|
||||
# when the driver-specific table does not override them.
|
||||
default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"
|
||||
# Defaults to the gateway version; override to pin a specific build.
|
||||
# Defaults to the gateway version. Custom builds must match the gateway's
|
||||
# internal supervisor protocol revision; mismatched peers reject the session.
|
||||
# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:<version>"
|
||||
client_tls_secret_name = "openshell-client-tls"
|
||||
service_account_name = "openshell-sandbox"
|
||||
|
||||
+129
-3
@@ -512,9 +512,10 @@ service OpenShell {
|
||||
//
|
||||
// The supervisor opens this stream at startup and keeps it alive for the
|
||||
// sandbox lifetime. The gateway uses it to coordinate relay channels for
|
||||
// SSH connect, ExecSandbox, and targetable sandbox services. Raw service
|
||||
// bytes flow over RelayStream calls (separate HTTP/2 streams on the same
|
||||
// connection), not over this stream.
|
||||
// SSH connect, ExecSandbox, targetable sandbox services, and configuration
|
||||
// delivery. Peers must report the same exact protocol_revision during the
|
||||
// handshake. Raw service bytes flow over RelayStream calls (separate HTTP/2
|
||||
// streams on the same connection), not over this stream.
|
||||
rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage) {
|
||||
option (openshell.options.v1.authorization) = {
|
||||
auth_mode: "sandbox"
|
||||
@@ -2180,6 +2181,30 @@ message GetSandboxProviderEnvironmentResponse {
|
||||
repeated string non_secret_environment_keys = 6;
|
||||
}
|
||||
|
||||
enum ProviderEnvironmentValueClassification {
|
||||
PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_UNSPECIFIED = 0;
|
||||
PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_NON_SECRET = 1;
|
||||
PROVIDER_ENVIRONMENT_VALUE_CLASSIFICATION_STATIC_CREDENTIAL = 2;
|
||||
}
|
||||
|
||||
// One environment value and all metadata that shares its key.
|
||||
message ProviderEnvironmentValue {
|
||||
string name = 1;
|
||||
string value = 2 [(openshell.options.v1.secret) = true];
|
||||
optional int64 expires_at_ms = 3;
|
||||
ProviderEnvironmentValueClassification classification = 4;
|
||||
StaticCredentialBinding static_credential_binding = 5;
|
||||
}
|
||||
|
||||
// Complete provider environment state delivered to a supervisor. Dynamic
|
||||
// credentials are endpoint selectors rather than environment values and stay
|
||||
// in their own collection.
|
||||
message ProviderEnvironmentSnapshot {
|
||||
uint64 provider_env_revision = 1;
|
||||
repeated ProviderEnvironmentValue values = 2;
|
||||
map<string, ProviderProfileCredential> dynamic_credentials = 3;
|
||||
}
|
||||
|
||||
message ExchangeProviderSubjectTokenRequest {
|
||||
// The sandbox ID. Must match the authenticated sandbox principal.
|
||||
string sandbox_id = 1;
|
||||
@@ -2458,6 +2483,9 @@ message GetSandboxLogsResponse {
|
||||
// Supervisor session messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// These messages form an internal, version-locked deployment protocol between
|
||||
// the gateway and the supervisor. They are not a public sandbox client API.
|
||||
|
||||
// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream.
|
||||
message SupervisorMessage {
|
||||
oneof payload {
|
||||
@@ -2465,6 +2493,8 @@ message SupervisorMessage {
|
||||
SupervisorHeartbeat heartbeat = 2;
|
||||
RelayOpenResult relay_open_result = 3;
|
||||
RelayClose relay_close = 4;
|
||||
ConfigUpdateResult config_update_result = 5;
|
||||
ConfigBootstrapResult config_bootstrap_result = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2476,6 +2506,7 @@ message GatewayMessage {
|
||||
GatewayHeartbeat heartbeat = 3;
|
||||
RelayOpen relay_open = 4;
|
||||
RelayClose relay_close = 5;
|
||||
ConfigUpdate config_update = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2485,6 +2516,8 @@ message SupervisorHello {
|
||||
string sandbox_id = 1;
|
||||
// Supervisor instance ID (e.g. boot id or process epoch).
|
||||
string instance_id = 2;
|
||||
// Exact internal stream protocol revision implemented by this supervisor.
|
||||
uint32 protocol_revision = 3;
|
||||
}
|
||||
|
||||
// Gateway accepts the supervisor session.
|
||||
@@ -2493,6 +2526,99 @@ message SessionAccepted {
|
||||
string session_id = 1;
|
||||
// Recommended heartbeat interval in seconds.
|
||||
uint32 heartbeat_interval_secs = 2;
|
||||
// Complete gateway-owned configuration. During the staged rollout this may
|
||||
// be omitted only when the gateway cannot build the projection.
|
||||
ConfigBootstrap bootstrap = 3;
|
||||
// Exact internal stream protocol revision implemented by this gateway.
|
||||
uint32 protocol_revision = 4;
|
||||
}
|
||||
|
||||
// Complete gateway-owned configuration for a supervisor session.
|
||||
message ConfigBootstrap {
|
||||
openshell.sandbox.v1.SandboxConfigSnapshot sandbox_config = 1;
|
||||
ProviderEnvironmentSnapshot provider_environment = 2;
|
||||
}
|
||||
|
||||
// A complete replacement snapshot for exactly one configuration component.
|
||||
message ConfigUpdate {
|
||||
// Opaque non-empty identifier scoped to the active supervisor session.
|
||||
string update_id = 1;
|
||||
// Monotonic within one session and component. Snapshot revisions are
|
||||
// content identities and must only be compared for equality.
|
||||
uint64 component_sequence = 2;
|
||||
oneof component {
|
||||
openshell.sandbox.v1.SandboxConfigSnapshot sandbox_config = 3;
|
||||
ProviderEnvironmentSnapshot provider_environment = 4;
|
||||
}
|
||||
}
|
||||
|
||||
enum ConfigComponent {
|
||||
CONFIG_COMPONENT_UNSPECIFIED = 0;
|
||||
CONFIG_COMPONENT_SANDBOX_CONFIG = 1;
|
||||
CONFIG_COMPONENT_PROVIDER_ENVIRONMENT = 2;
|
||||
}
|
||||
|
||||
// Identifies one component snapshot revision. Revisions are equality tokens,
|
||||
// not members of one shared ordering domain.
|
||||
message ConfigSnapshotRevision {
|
||||
oneof component {
|
||||
SandboxConfigRevision sandbox_config = 1;
|
||||
uint64 provider_environment = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Identity needed to correlate effective sandbox configuration with the
|
||||
// policy-history row whose apply status the gateway records.
|
||||
message SandboxConfigRevision {
|
||||
uint64 config_revision = 1;
|
||||
uint32 policy_version = 2;
|
||||
openshell.sandbox.v1.PolicySource policy_source = 3;
|
||||
uint32 global_policy_version = 4;
|
||||
}
|
||||
|
||||
enum ConfigApplyOutcome {
|
||||
CONFIG_APPLY_OUTCOME_UNSPECIFIED = 0;
|
||||
CONFIG_APPLY_OUTCOME_APPLIED = 1;
|
||||
CONFIG_APPLY_OUTCOME_IGNORED_DUPLICATE = 2;
|
||||
CONFIG_APPLY_OUTCOME_IGNORED_STALE = 3;
|
||||
CONFIG_APPLY_OUTCOME_RETAINED_LOCAL_OVERRIDE = 4;
|
||||
CONFIG_APPLY_OUTCOME_DEGRADED = 5;
|
||||
CONFIG_APPLY_OUTCOME_FAILED_RETAINED_LAST_KNOWN_GOOD = 6;
|
||||
CONFIG_APPLY_OUTCOME_FAILED_CLOSED = 7;
|
||||
CONFIG_APPLY_OUTCOME_UNSUPPORTED = 8;
|
||||
}
|
||||
|
||||
// Sanitized application failure. Messages must not contain configuration
|
||||
// payloads, credentials, or provider values.
|
||||
message ConfigApplyFailure {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
bool retryable = 3;
|
||||
}
|
||||
|
||||
message ConfigComponentApplyResult {
|
||||
ConfigComponent component = 1;
|
||||
// Revision extracted from the received snapshot.
|
||||
ConfigSnapshotRevision requested_revision = 2;
|
||||
// Revision active after this attempt. Omitted when the received snapshot
|
||||
// was not installed or a local override has no gateway revision.
|
||||
ConfigSnapshotRevision applied_revision = 3;
|
||||
ConfigApplyOutcome outcome = 4;
|
||||
ConfigApplyFailure failure = 5;
|
||||
}
|
||||
|
||||
// Application result for one ConfigUpdate.
|
||||
message ConfigUpdateResult {
|
||||
// Echoes ConfigUpdate.update_id for session-local correlation.
|
||||
string update_id = 1;
|
||||
// Echoes ConfigUpdate.component_sequence.
|
||||
uint64 component_sequence = 2;
|
||||
ConfigComponentApplyResult result = 3;
|
||||
}
|
||||
|
||||
// Aggregate application result for the SessionAccepted bootstrap.
|
||||
message ConfigBootstrapResult {
|
||||
repeated ConfigComponentApplyResult results = 1;
|
||||
}
|
||||
|
||||
// Gateway rejects the supervisor session.
|
||||
|
||||
+17
-1
@@ -356,13 +356,29 @@ message EffectiveSetting {
|
||||
SettingScope scope = 2;
|
||||
}
|
||||
|
||||
// Source used for the policy payload in GetSandboxConfigResponse.
|
||||
// Source used for a sandbox configuration payload.
|
||||
enum PolicySource {
|
||||
POLICY_SOURCE_UNSPECIFIED = 0;
|
||||
POLICY_SOURCE_SANDBOX = 1;
|
||||
POLICY_SOURCE_GLOBAL = 2;
|
||||
}
|
||||
|
||||
// Complete effective sandbox configuration delivered to a supervisor.
|
||||
message SandboxConfigSnapshot {
|
||||
SandboxPolicy policy = 1;
|
||||
uint32 version = 2;
|
||||
string policy_hash = 3;
|
||||
map<string, EffectiveSetting> settings = 4;
|
||||
uint64 config_revision = 5;
|
||||
PolicySource policy_source = 6;
|
||||
uint32 global_policy_version = 7;
|
||||
uint64 provider_env_revision = 8;
|
||||
repeated SupervisorMiddlewareService supervisor_middleware_services = 9;
|
||||
string workspace = 10;
|
||||
string policy_validation_failure_mode = 11;
|
||||
bool extension_authentication_enabled = 12;
|
||||
}
|
||||
|
||||
// Response containing effective sandbox settings and policy.
|
||||
message GetSandboxConfigResponse {
|
||||
// The sandbox policy configuration.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,9 +234,10 @@ type OpenShellClient interface {
|
||||
//
|
||||
// The supervisor opens this stream at startup and keeps it alive for the
|
||||
// sandbox lifetime. The gateway uses it to coordinate relay channels for
|
||||
// SSH connect, ExecSandbox, and targetable sandbox services. Raw service
|
||||
// bytes flow over RelayStream calls (separate HTTP/2 streams on the same
|
||||
// connection), not over this stream.
|
||||
// SSH connect, ExecSandbox, targetable sandbox services, and configuration
|
||||
// delivery. Peers must report the same exact protocol_revision during the
|
||||
// handshake. Raw service bytes flow over RelayStream calls (separate HTTP/2
|
||||
// streams on the same connection), not over this stream.
|
||||
ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error)
|
||||
// Persist the canonical main process result before the supervisor exits.
|
||||
ReportMainProcessExit(ctx context.Context, in *ReportMainProcessExitRequest, opts ...grpc.CallOption) (*ReportMainProcessExitResponse, error)
|
||||
@@ -1224,9 +1225,10 @@ type OpenShellServer interface {
|
||||
//
|
||||
// The supervisor opens this stream at startup and keeps it alive for the
|
||||
// sandbox lifetime. The gateway uses it to coordinate relay channels for
|
||||
// SSH connect, ExecSandbox, and targetable sandbox services. Raw service
|
||||
// bytes flow over RelayStream calls (separate HTTP/2 streams on the same
|
||||
// connection), not over this stream.
|
||||
// SSH connect, ExecSandbox, targetable sandbox services, and configuration
|
||||
// delivery. Peers must report the same exact protocol_revision during the
|
||||
// handshake. Raw service bytes flow over RelayStream calls (separate HTTP/2
|
||||
// streams on the same connection), not over this stream.
|
||||
ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error
|
||||
// Persist the canonical main process result before the supervisor exits.
|
||||
ReportMainProcessExit(context.Context, *ReportMainProcessExitRequest) (*ReportMainProcessExitResponse, error)
|
||||
|
||||
@@ -75,7 +75,7 @@ func (SettingScope) EnumDescriptor() ([]byte, []int) {
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
// Source used for the policy payload in GetSandboxConfigResponse.
|
||||
// Source used for a sandbox configuration payload.
|
||||
type PolicySource int32
|
||||
|
||||
const (
|
||||
@@ -1801,6 +1801,139 @@ func (x *EffectiveSetting) GetScope() SettingScope {
|
||||
return SettingScope_SETTING_SCOPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
// Complete effective sandbox configuration delivered to a supervisor.
|
||||
type SandboxConfigSnapshot struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Policy *SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"`
|
||||
Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"`
|
||||
PolicyHash string `protobuf:"bytes,3,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"`
|
||||
Settings map[string]*EffectiveSetting `protobuf:"bytes,4,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
ConfigRevision uint64 `protobuf:"varint,5,opt,name=config_revision,json=configRevision,proto3" json:"config_revision,omitempty"`
|
||||
PolicySource PolicySource `protobuf:"varint,6,opt,name=policy_source,json=policySource,proto3,enum=openshell.sandbox.v1.PolicySource" json:"policy_source,omitempty"`
|
||||
GlobalPolicyVersion uint32 `protobuf:"varint,7,opt,name=global_policy_version,json=globalPolicyVersion,proto3" json:"global_policy_version,omitempty"`
|
||||
ProviderEnvRevision uint64 `protobuf:"varint,8,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"`
|
||||
SupervisorMiddlewareServices []*SupervisorMiddlewareService `protobuf:"bytes,9,rep,name=supervisor_middleware_services,json=supervisorMiddlewareServices,proto3" json:"supervisor_middleware_services,omitempty"`
|
||||
Workspace string `protobuf:"bytes,10,opt,name=workspace,proto3" json:"workspace,omitempty"`
|
||||
PolicyValidationFailureMode string `protobuf:"bytes,11,opt,name=policy_validation_failure_mode,json=policyValidationFailureMode,proto3" json:"policy_validation_failure_mode,omitempty"`
|
||||
ExtensionAuthenticationEnabled bool `protobuf:"varint,12,opt,name=extension_authentication_enabled,json=extensionAuthenticationEnabled,proto3" json:"extension_authentication_enabled,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) Reset() {
|
||||
*x = SandboxConfigSnapshot{}
|
||||
mi := &file_sandbox_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SandboxConfigSnapshot) ProtoMessage() {}
|
||||
|
||||
func (x *SandboxConfigSnapshot) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_sandbox_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SandboxConfigSnapshot.ProtoReflect.Descriptor instead.
|
||||
func (*SandboxConfigSnapshot) Descriptor() ([]byte, []int) {
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetPolicy() *SandboxPolicy {
|
||||
if x != nil {
|
||||
return x.Policy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetVersion() uint32 {
|
||||
if x != nil {
|
||||
return x.Version
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetPolicyHash() string {
|
||||
if x != nil {
|
||||
return x.PolicyHash
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetSettings() map[string]*EffectiveSetting {
|
||||
if x != nil {
|
||||
return x.Settings
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetConfigRevision() uint64 {
|
||||
if x != nil {
|
||||
return x.ConfigRevision
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetPolicySource() PolicySource {
|
||||
if x != nil {
|
||||
return x.PolicySource
|
||||
}
|
||||
return PolicySource_POLICY_SOURCE_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetGlobalPolicyVersion() uint32 {
|
||||
if x != nil {
|
||||
return x.GlobalPolicyVersion
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetProviderEnvRevision() uint64 {
|
||||
if x != nil {
|
||||
return x.ProviderEnvRevision
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetSupervisorMiddlewareServices() []*SupervisorMiddlewareService {
|
||||
if x != nil {
|
||||
return x.SupervisorMiddlewareServices
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetWorkspace() string {
|
||||
if x != nil {
|
||||
return x.Workspace
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetPolicyValidationFailureMode() string {
|
||||
if x != nil {
|
||||
return x.PolicyValidationFailureMode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SandboxConfigSnapshot) GetExtensionAuthenticationEnabled() bool {
|
||||
if x != nil {
|
||||
return x.ExtensionAuthenticationEnabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Response containing effective sandbox settings and policy.
|
||||
type GetSandboxConfigResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -1843,7 +1976,7 @@ type GetSandboxConfigResponse struct {
|
||||
|
||||
func (x *GetSandboxConfigResponse) Reset() {
|
||||
*x = GetSandboxConfigResponse{}
|
||||
mi := &file_sandbox_proto_msgTypes[21]
|
||||
mi := &file_sandbox_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1855,7 +1988,7 @@ func (x *GetSandboxConfigResponse) String() string {
|
||||
func (*GetSandboxConfigResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_sandbox_proto_msgTypes[21]
|
||||
mi := &file_sandbox_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1868,7 +2001,7 @@ func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use GetSandboxConfigResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) {
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{21}
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy {
|
||||
@@ -1988,7 +2121,7 @@ type SupervisorMiddlewareService struct {
|
||||
|
||||
func (x *SupervisorMiddlewareService) Reset() {
|
||||
*x = SupervisorMiddlewareService{}
|
||||
mi := &file_sandbox_proto_msgTypes[22]
|
||||
mi := &file_sandbox_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -2000,7 +2133,7 @@ func (x *SupervisorMiddlewareService) String() string {
|
||||
func (*SupervisorMiddlewareService) ProtoMessage() {}
|
||||
|
||||
func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_sandbox_proto_msgTypes[22]
|
||||
mi := &file_sandbox_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -2013,7 +2146,7 @@ func (x *SupervisorMiddlewareService) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SupervisorMiddlewareService.ProtoReflect.Descriptor instead.
|
||||
func (*SupervisorMiddlewareService) Descriptor() ([]byte, []int) {
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{22}
|
||||
return file_sandbox_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *SupervisorMiddlewareService) GetName() string {
|
||||
@@ -2220,7 +2353,25 @@ const file_sandbox_proto_rawDesc = "" +
|
||||
"\x05value\"\x86\x01\n" +
|
||||
"\x10EffectiveSetting\x128\n" +
|
||||
"\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" +
|
||||
"\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xd1\x06\n" +
|
||||
"\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xcb\x06\n" +
|
||||
"\x15SandboxConfigSnapshot\x12;\n" +
|
||||
"\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" +
|
||||
"\vpolicy_hash\x18\x03 \x01(\tR\n" +
|
||||
"policyHash\x12U\n" +
|
||||
"\bsettings\x18\x04 \x03(\v29.openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntryR\bsettings\x12'\n" +
|
||||
"\x0fconfig_revision\x18\x05 \x01(\x04R\x0econfigRevision\x12G\n" +
|
||||
"\rpolicy_source\x18\x06 \x01(\x0e2\".openshell.sandbox.v1.PolicySourceR\fpolicySource\x122\n" +
|
||||
"\x15global_policy_version\x18\a \x01(\rR\x13globalPolicyVersion\x122\n" +
|
||||
"\x15provider_env_revision\x18\b \x01(\x04R\x13providerEnvRevision\x12w\n" +
|
||||
"\x1esupervisor_middleware_services\x18\t \x03(\v21.openshell.sandbox.v1.SupervisorMiddlewareServiceR\x1csupervisorMiddlewareServices\x12\x1c\n" +
|
||||
"\tworkspace\x18\n" +
|
||||
" \x01(\tR\tworkspace\x12C\n" +
|
||||
"\x1epolicy_validation_failure_mode\x18\v \x01(\tR\x1bpolicyValidationFailureMode\x12H\n" +
|
||||
" extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x1ac\n" +
|
||||
"\rSettingsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12<\n" +
|
||||
"\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xd1\x06\n" +
|
||||
"\x18GetSandboxConfigResponse\x12;\n" +
|
||||
"\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" +
|
||||
@@ -2269,7 +2420,7 @@ func file_sandbox_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
|
||||
var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 32)
|
||||
var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
|
||||
var file_sandbox_proto_goTypes = []any{
|
||||
(SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope
|
||||
(PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource
|
||||
@@ -2294,60 +2445,67 @@ var file_sandbox_proto_goTypes = []any{
|
||||
(*GetGatewayConfigResponse)(nil), // 20: openshell.sandbox.v1.GetGatewayConfigResponse
|
||||
(*SettingValue)(nil), // 21: openshell.sandbox.v1.SettingValue
|
||||
(*EffectiveSetting)(nil), // 22: openshell.sandbox.v1.EffectiveSetting
|
||||
(*GetSandboxConfigResponse)(nil), // 23: openshell.sandbox.v1.GetSandboxConfigResponse
|
||||
(*SupervisorMiddlewareService)(nil), // 24: openshell.sandbox.v1.SupervisorMiddlewareService
|
||||
nil, // 25: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry
|
||||
nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry
|
||||
nil, // 27: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry
|
||||
nil, // 28: openshell.sandbox.v1.L7DenyRule.QueryEntry
|
||||
nil, // 29: openshell.sandbox.v1.L7DenyRule.ParamsEntry
|
||||
nil, // 30: openshell.sandbox.v1.L7Allow.QueryEntry
|
||||
nil, // 31: openshell.sandbox.v1.L7Allow.ParamsEntry
|
||||
nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry
|
||||
nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry
|
||||
(*structpb.Struct)(nil), // 34: google.protobuf.Struct
|
||||
(*SandboxConfigSnapshot)(nil), // 23: openshell.sandbox.v1.SandboxConfigSnapshot
|
||||
(*GetSandboxConfigResponse)(nil), // 24: openshell.sandbox.v1.GetSandboxConfigResponse
|
||||
(*SupervisorMiddlewareService)(nil), // 25: openshell.sandbox.v1.SupervisorMiddlewareService
|
||||
nil, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry
|
||||
nil, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry
|
||||
nil, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry
|
||||
nil, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry
|
||||
nil, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry
|
||||
nil, // 31: openshell.sandbox.v1.L7Allow.QueryEntry
|
||||
nil, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry
|
||||
nil, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry
|
||||
nil, // 34: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry
|
||||
nil, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry
|
||||
(*structpb.Struct)(nil), // 36: google.protobuf.Struct
|
||||
}
|
||||
var file_sandbox_proto_depIdxs = []int32{
|
||||
3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy
|
||||
4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy
|
||||
5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy
|
||||
25, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry
|
||||
26, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry
|
||||
26, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry
|
||||
27, // 4: openshell.sandbox.v1.SandboxPolicy.network_middlewares:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry
|
||||
10, // 5: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint
|
||||
17, // 6: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary
|
||||
34, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct
|
||||
36, // 7: openshell.sandbox.v1.NetworkMiddlewareConfig.config:type_name -> google.protobuf.Struct
|
||||
8, // 8: openshell.sandbox.v1.NetworkMiddlewareConfig.endpoints:type_name -> openshell.sandbox.v1.MiddlewareEndpointSelector
|
||||
14, // 9: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule
|
||||
13, // 10: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule
|
||||
27, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry
|
||||
28, // 11: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry
|
||||
11, // 12: openshell.sandbox.v1.NetworkEndpoint.mcp:type_name -> openshell.sandbox.v1.McpOptions
|
||||
9, // 13: openshell.sandbox.v1.NetworkEndpoint.credential_binding:type_name -> openshell.sandbox.v1.NetworkCredentialBinding
|
||||
28, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry
|
||||
29, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry
|
||||
29, // 14: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry
|
||||
30, // 15: openshell.sandbox.v1.L7DenyRule.params:type_name -> openshell.sandbox.v1.L7DenyRule.ParamsEntry
|
||||
15, // 16: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow
|
||||
30, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry
|
||||
31, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry
|
||||
32, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry
|
||||
31, // 17: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry
|
||||
32, // 18: openshell.sandbox.v1.L7Allow.params:type_name -> openshell.sandbox.v1.L7Allow.ParamsEntry
|
||||
33, // 19: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry
|
||||
21, // 20: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue
|
||||
0, // 21: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope
|
||||
2, // 22: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy
|
||||
33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry
|
||||
1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource
|
||||
24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService
|
||||
6, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule
|
||||
7, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig
|
||||
12, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation
|
||||
16, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 31: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
21, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue
|
||||
22, // 34: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting
|
||||
35, // [35:35] is the sub-list for method output_type
|
||||
35, // [35:35] is the sub-list for method input_type
|
||||
35, // [35:35] is the sub-list for extension type_name
|
||||
35, // [35:35] is the sub-list for extension extendee
|
||||
0, // [0:35] is the sub-list for field type_name
|
||||
2, // 22: openshell.sandbox.v1.SandboxConfigSnapshot.policy:type_name -> openshell.sandbox.v1.SandboxPolicy
|
||||
34, // 23: openshell.sandbox.v1.SandboxConfigSnapshot.settings:type_name -> openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry
|
||||
1, // 24: openshell.sandbox.v1.SandboxConfigSnapshot.policy_source:type_name -> openshell.sandbox.v1.PolicySource
|
||||
25, // 25: openshell.sandbox.v1.SandboxConfigSnapshot.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService
|
||||
2, // 26: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy
|
||||
35, // 27: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry
|
||||
1, // 28: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource
|
||||
25, // 29: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService
|
||||
6, // 30: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule
|
||||
7, // 31: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig
|
||||
12, // 32: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation
|
||||
16, // 33: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 34: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 35: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
16, // 36: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher
|
||||
21, // 37: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue
|
||||
22, // 38: openshell.sandbox.v1.SandboxConfigSnapshot.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting
|
||||
22, // 39: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting
|
||||
40, // [40:40] is the sub-list for method output_type
|
||||
40, // [40:40] is the sub-list for method input_type
|
||||
40, // [40:40] is the sub-list for extension type_name
|
||||
40, // [40:40] is the sub-list for extension extendee
|
||||
0, // [0:40] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_sandbox_proto_init() }
|
||||
@@ -2368,7 +2526,7 @@ func file_sandbox_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)),
|
||||
NumEnums: 2,
|
||||
NumMessages: 32,
|
||||
NumMessages: 34,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ export * from './gen/datamodel_pb.js';
|
||||
// OpenShellClient / SandboxClient (`.raw` and `.transport`). These are the
|
||||
// uncurated wire types; import them from '@nvidia/openshell-sdk/raw'. The
|
||||
// curated entry point stays free of generated types so its surface does not
|
||||
// shift when the proto regenerates. The four generated modules export disjoint
|
||||
// shift when the proto regenerates. The generated modules export disjoint
|
||||
// symbol names, so a flat re-export is unambiguous.
|
||||
export * from './gen/openshell_pb.js';
|
||||
export * from './gen/options_pb.js';
|
||||
|
||||
@@ -16,6 +16,8 @@ The target deployment flow is:
|
||||
1. Operator starts or deploys the gateway with system packages, systemd, or Helm. The CLI does not start, stop, or destroy gateway services.
|
||||
2. Operator configures the compute driver.
|
||||
3. Operator provides the CLI and supervisor authentication material required by the deployment mode: edge or OIDC user auth, optional CLI mTLS, and gateway-minted sandbox JWTs.
|
||||
|
||||
If supervisor sessions fail with a protocol revision mismatch, check that custom supervisor images match the gateway release. Gateway and supervisor require the same internal protocol revision; authentication success does not make mismatched versions compatible. See the published [gateway configuration reference](https://docs.nvidia.com/openshell/latest/reference/gateway-config).
|
||||
4. The CLI registers a reachable gateway endpoint with `openshell gateway add`.
|
||||
5. The gateway creates sandboxes through the selected compute driver.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user