Skip to content

Commit 6a067bb

Browse files
fix(k8s): harden operator mode and address review findings
Close the fail-open gap in operator mode when only operator_namespace_file is configured: the allowlist is now created unconditionally in operator mode (fail-closed from startup). Implement the namespace file watcher using the notify crate, following the TLS hot-reload pattern (parent-directory watch, 1s debounce, ConfigMap symlink-swap safe). The file format is a JSON array of namespace name strings. Additional fixes from the 10-reviewer audit: - Change allowlist rejection from InvalidArgument to FailedPrecondition so callers know the request may succeed later once the namespace is provisioned. - NamespaceValidator::Allowlist now holds the OperatorNamespaceAllowlist newtype instead of a raw Arc<RwLock<BTreeSet>>, eliminating silent denial on RwLock poison. - Verify LABEL_MANAGED_BY and LABEL_GATEWAY_ID ownership before deleting a managed namespace. - Replace fixed 5s sleep in operator e2e test with a 30s poll loop. - Add Helm validation for workspaceMode values. - Fix Helm README type column and description for operator fields. - Add insert/remove methods to OperatorNamespaceAllowlist; label watcher now uses them instead of reaching through shared(). - Reject configs with both operator_namespace_label and operator_namespace_file set. Signed-off-by: Derek Carr <decarr@redhat.com>
1 parent 628a4b7 commit 6a067bb

11 files changed

Lines changed: 245 additions & 81 deletions

File tree

Cargo.lock

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

crates/openshell-driver-kubernetes/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ tracing = { workspace = true }
3434
tracing-subscriber = { workspace = true }
3535
thiserror = { workspace = true }
3636
miette = { workspace = true }
37+
notify = "8"
3738

3839
[dev-dependencies]
3940
temp-env = "0.3"

crates/openshell-driver-kubernetes/src/config.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,8 @@ pub struct KubernetesComputeConfig {
293293
/// this label and builds the allowlist dynamically.
294294
#[serde(default, skip_serializing_if = "Option::is_none")]
295295
pub operator_namespace_label: Option<String>,
296-
/// Path to a drop-in JSON file mapping workspace names to namespace names.
297-
/// Hot-reloaded on change. Delivered via `ConfigMap` volume mount.
296+
/// Path to a JSON file containing an array of namespace names allowed in
297+
/// operator mode. Hot-reloaded on change. Delivered via `ConfigMap` volume mount.
298298
#[serde(default, skip_serializing_if = "Option::is_none")]
299299
pub operator_namespace_file: Option<String>,
300300
/// Kubernetes `ServiceAccount` assigned to sandbox pods and accepted by
@@ -623,10 +623,16 @@ impl KubernetesComputeConfig {
623623
WorkspaceMode::Operator => {
624624
if self.operator_namespace_label.is_none() && self.operator_namespace_file.is_none()
625625
{
626-
return Err("operator workspace mode requires at least one of \
626+
return Err("operator workspace mode requires exactly one of \
627627
operator_namespace_label or operator_namespace_file"
628628
.into());
629629
}
630+
if self.operator_namespace_label.is_some() && self.operator_namespace_file.is_some()
631+
{
632+
return Err("operator workspace mode requires exactly one of \
633+
operator_namespace_label or operator_namespace_file, not both"
634+
.into());
635+
}
630636
if let Some(ref label) = self.operator_namespace_label
631637
&& label.is_empty()
632638
{
@@ -738,6 +744,22 @@ impl OperatorNamespaceAllowlist {
738744
.contains(namespace)
739745
}
740746

747+
/// Insert a namespace into the allowlist. Returns `true` if it was new.
748+
pub fn insert(&self, name: String) -> bool {
749+
self.inner
750+
.write()
751+
.expect("allowlist lock poisoned")
752+
.insert(name)
753+
}
754+
755+
/// Remove a namespace from the allowlist. Returns `true` if it was present.
756+
pub fn remove(&self, name: &str) -> bool {
757+
self.inner
758+
.write()
759+
.expect("allowlist lock poisoned")
760+
.remove(name)
761+
}
762+
741763
/// Return a clone of the inner `Arc` for sharing with background tasks.
742764
#[must_use]
743765
pub fn shared(&self) -> Arc<RwLock<BTreeSet<String>>> {

crates/openshell-driver-kubernetes/src/driver.rs

Lines changed: 170 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ use openshell_core::proto::compute::v1::{
4141
use openshell_core::proto_struct::{struct_to_json_object, value_to_json};
4242
use serde::Deserialize;
4343
use std::collections::{BTreeMap, HashSet};
44-
use std::path::Path;
44+
use std::path::{Path, PathBuf};
4545
use std::pin::Pin;
4646
use std::sync::Arc;
4747
use std::time::Duration;
@@ -487,15 +487,21 @@ impl KubernetesComputeDriver {
487487
Client::try_from(watch_kube_config).map_err(KubernetesDriverError::from_kube)?;
488488

489489
let operator_allowlist = if matches!(config.workspace_mode, WorkspaceMode::Operator) {
490-
config.operator_namespace_label.as_ref().map(|label| {
491-
let allowlist = OperatorNamespaceAllowlist::new();
490+
let allowlist = OperatorNamespaceAllowlist::new();
491+
492+
if let Some(ref label) = config.operator_namespace_label {
492493
spawn_namespace_label_watcher(
493494
watch_client.clone(),
494495
label.clone(),
495496
allowlist.clone(),
496497
);
497-
allowlist
498-
})
498+
}
499+
500+
if let Some(ref path) = config.operator_namespace_file {
501+
spawn_namespace_file_watcher(path.into(), allowlist.clone());
502+
}
503+
504+
Some(allowlist)
499505
} else {
500506
None
501507
};
@@ -673,6 +679,36 @@ impl KubernetesComputeDriver {
673679
}
674680

675681
let ns_api: Api<Namespace> = Api::all(self.client.clone());
682+
683+
let ns = match tokio::time::timeout(KUBE_API_TIMEOUT, ns_api.get(&ns_name)).await {
684+
Ok(Ok(ns)) => ns,
685+
Ok(Err(KubeError::Api(api))) if api.code == 404 => {
686+
debug!(namespace = %ns_name, "managed namespace already deleted");
687+
return Ok(());
688+
}
689+
Ok(Err(e)) => return Err(KubernetesDriverError::from_kube(e)),
690+
Err(_) => {
691+
return Err(KubernetesDriverError::Message(format!(
692+
"timeout getting namespace {ns_name}"
693+
)));
694+
}
695+
};
696+
697+
let labels = ns.metadata.labels.as_ref();
698+
let is_owned = labels
699+
.and_then(|l| l.get(LABEL_MANAGED_BY))
700+
.is_some_and(|v| v == LABEL_MANAGED_BY_VALUE)
701+
&& labels
702+
.and_then(|l| l.get(LABEL_GATEWAY_ID))
703+
.is_some_and(|v| v == &self.config.gateway_id);
704+
if !is_owned {
705+
debug!(
706+
namespace = %ns_name,
707+
"namespace not owned by this gateway, skipping delete"
708+
);
709+
return Ok(());
710+
}
711+
676712
match tokio::time::timeout(
677713
KUBE_API_TIMEOUT,
678714
ns_api.delete(&ns_name, &DeleteParams::default()),
@@ -1070,7 +1106,7 @@ impl KubernetesComputeDriver {
10701106
if let Some(ref allowlist) = self.operator_allowlist
10711107
&& !allowlist.contains(workspace)
10721108
{
1073-
return Err(KubernetesDriverError::InvalidArgument(format!(
1109+
return Err(KubernetesDriverError::Precondition(format!(
10741110
"workspace '{workspace}' is not in the operator namespace allowlist"
10751111
)));
10761112
}
@@ -3523,33 +3559,20 @@ fn spawn_namespace_label_watcher(
35233559
loop {
35243560
match stream.try_next().await {
35253561
Ok(Some(Event::Applied(ns))) => {
3526-
if let Some(name) = ns.metadata.name.as_deref() {
3527-
let inner = allowlist.shared();
3528-
let mut guard = inner.write().expect("allowlist lock poisoned");
3529-
if guard.insert(name.to_string()) {
3530-
let count = guard.len();
3531-
drop(guard);
3532-
info!(
3533-
namespace = name,
3534-
total = count,
3535-
"operator namespace added to allowlist"
3536-
);
3537-
}
3562+
if let Some(name) = ns.metadata.name.as_deref()
3563+
&& allowlist.insert(name.to_string())
3564+
{
3565+
info!(namespace = name, "operator namespace added to allowlist");
35383566
}
35393567
}
35403568
Ok(Some(Event::Deleted(ns))) => {
3541-
if let Some(name) = ns.metadata.name.as_deref() {
3542-
let inner = allowlist.shared();
3543-
let mut guard = inner.write().expect("allowlist lock poisoned");
3544-
if guard.remove(name) {
3545-
let count = guard.len();
3546-
drop(guard);
3547-
info!(
3548-
namespace = name,
3549-
total = count,
3550-
"operator namespace removed from allowlist"
3551-
);
3552-
}
3569+
if let Some(name) = ns.metadata.name.as_deref()
3570+
&& allowlist.remove(name)
3571+
{
3572+
info!(
3573+
namespace = name,
3574+
"operator namespace removed from allowlist"
3575+
);
35533576
}
35543577
}
35553578
Ok(Some(Event::Restarted(namespaces))) => {
@@ -3581,10 +3604,126 @@ fn spawn_namespace_label_watcher(
35813604

35823605
info!(
35833606
label_selector = %label_selector,
3584-
"operator namespace label watcher started"
3607+
"operator namespace label watcher spawned"
35853608
);
35863609
}
35873610

3611+
fn load_namespace_file(path: &Path) -> Result<std::collections::BTreeSet<String>, String> {
3612+
let contents = std::fs::read_to_string(path)
3613+
.map_err(|e| format!("failed to read {}: {e}", path.display()))?;
3614+
let names: Vec<String> = serde_json::from_str(&contents)
3615+
.map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
3616+
Ok(names.into_iter().collect())
3617+
}
3618+
3619+
fn spawn_namespace_file_watcher(path: PathBuf, allowlist: OperatorNamespaceAllowlist) {
3620+
match load_namespace_file(&path) {
3621+
Ok(names) => {
3622+
let count = names.len();
3623+
allowlist.replace(names);
3624+
info!(
3625+
path = %path.display(),
3626+
total = count,
3627+
"operator namespace allowlist loaded from file"
3628+
);
3629+
}
3630+
Err(err) => {
3631+
warn!(
3632+
error = %err,
3633+
"failed to load initial operator namespace file, allowlist empty"
3634+
);
3635+
}
3636+
}
3637+
3638+
let watch_dir = path
3639+
.parent()
3640+
.unwrap_or_else(|| Path::new("."))
3641+
.to_path_buf();
3642+
let debounce = Duration::from_secs(1);
3643+
3644+
tokio::spawn(async move {
3645+
let (tx, mut rx) = mpsc::unbounded_channel();
3646+
3647+
let mut watcher =
3648+
match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
3649+
if let Ok(event) = res
3650+
&& matches!(
3651+
event.kind,
3652+
notify::EventKind::Modify(_) | notify::EventKind::Create(_)
3653+
)
3654+
{
3655+
let _ = tx.send(());
3656+
}
3657+
}) {
3658+
Ok(w) => w,
3659+
Err(e) => {
3660+
warn!(
3661+
error = %e,
3662+
"failed to start operator namespace file watcher, hot-reload disabled"
3663+
);
3664+
return;
3665+
}
3666+
};
3667+
3668+
if let Err(e) = notify::Watcher::watch(
3669+
&mut watcher,
3670+
&watch_dir,
3671+
notify::RecursiveMode::NonRecursive,
3672+
) {
3673+
warn!(
3674+
error = %e,
3675+
dir = %watch_dir.display(),
3676+
"failed to watch operator namespace file directory, hot-reload disabled"
3677+
);
3678+
return;
3679+
}
3680+
3681+
info!(
3682+
path = %path.display(),
3683+
"operator namespace file watcher started"
3684+
);
3685+
3686+
loop {
3687+
let got_event = rx.recv().await.is_some();
3688+
if !got_event {
3689+
warn!("operator namespace file watcher disconnected");
3690+
break;
3691+
}
3692+
3693+
loop {
3694+
tokio::select! {
3695+
() = tokio::time::sleep(debounce) => {
3696+
match load_namespace_file(&path) {
3697+
Ok(names) => {
3698+
let count = names.len();
3699+
allowlist.replace(names);
3700+
info!(
3701+
total = count,
3702+
"operator namespace allowlist reloaded from file"
3703+
);
3704+
}
3705+
Err(err) => {
3706+
warn!(
3707+
error = %err,
3708+
"failed to reload operator namespace file, keeping existing allowlist"
3709+
);
3710+
}
3711+
}
3712+
break;
3713+
}
3714+
r = rx.recv() => {
3715+
if r.is_some() {
3716+
continue;
3717+
}
3718+
warn!("operator namespace file watcher disconnected");
3719+
return;
3720+
}
3721+
}
3722+
}
3723+
}
3724+
});
3725+
}
3726+
35883727
#[cfg(test)]
35893728
mod tests {
35903729
use super::*;

crates/openshell-server/src/auth/k8s_sa.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
2626
use kube::Error as KubeError;
2727
use kube::api::{Api, ApiResource, PostParams};
2828
use kube::core::{DynamicObject, gvk::GroupVersionKind};
29-
use std::collections::BTreeSet;
30-
use std::sync::{Arc, RwLock};
29+
use openshell_driver_kubernetes::OperatorNamespaceAllowlist;
30+
use std::sync::Arc;
3131
use tonic::Status;
3232
use tracing::{debug, info, warn};
3333

@@ -146,15 +146,15 @@ pub enum NamespaceValidator {
146146
/// (`openshell-{gateway_id}-`).
147147
Prefix(String),
148148
/// Operator mode: accept namespaces in the dynamic allowlist.
149-
Allowlist(Arc<RwLock<BTreeSet<String>>>),
149+
Allowlist(OperatorNamespaceAllowlist),
150150
}
151151

152152
impl NamespaceValidator {
153153
pub fn accepts(&self, namespace: &str) -> bool {
154154
match self {
155155
Self::Exact(expected) => namespace == expected,
156156
Self::Prefix(prefix) => namespace.starts_with(prefix.as_str()),
157-
Self::Allowlist(set) => set.read().is_ok_and(|s| s.contains(namespace)),
157+
Self::Allowlist(al) => al.contains(namespace),
158158
}
159159
}
160160
}
@@ -842,11 +842,11 @@ mod tests {
842842

843843
#[test]
844844
fn namespace_validator_allowlist_accepts_known_namespaces() {
845-
let set = Arc::new(RwLock::new(BTreeSet::from([
845+
let al = OperatorNamespaceAllowlist::from_set(std::collections::BTreeSet::from([
846846
"ns-a".to_string(),
847847
"ns-b".to_string(),
848-
])));
849-
let v = NamespaceValidator::Allowlist(set);
848+
]));
849+
let v = NamespaceValidator::Allowlist(al);
850850
assert!(v.accepts("ns-a"));
851851
assert!(v.accepts("ns-b"));
852852
assert!(!v.accepts("ns-c"));

crates/openshell-server/src/compute/mod.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -750,19 +750,11 @@ impl ComputeRuntime {
750750
sandbox_watch_bus: SandboxWatchBus,
751751
tracing_log_bus: TracingLogBus,
752752
supervisor_sessions: Arc<SupervisorSessionRegistry>,
753-
) -> Result<
754-
(
755-
Self,
756-
Option<Arc<std::sync::RwLock<std::collections::BTreeSet<String>>>>,
757-
),
758-
ComputeError,
759-
> {
753+
) -> Result<(Self, Option<OperatorNamespaceAllowlist>), ComputeError> {
760754
let driver = KubernetesComputeDriver::new(config)
761755
.await
762756
.map_err(|err| ComputeError::Message(err.to_string()))?;
763-
let operator_allowlist_arc = driver
764-
.operator_allowlist()
765-
.map(OperatorNamespaceAllowlist::shared);
757+
let operator_allowlist_arc = driver.operator_allowlist().cloned();
766758
let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new(driver));
767759
let runtime = Self::from_driver(
768760
ComputeDriverKind::Kubernetes.as_str().to_string(),

0 commit comments

Comments
 (0)