huskies: merge 898

This commit is contained in:
dave
2026-05-12 21:29:04 +00:00
parent d78dd9e8f9
commit 937792f208
10 changed files with 829 additions and 23 deletions
+11 -1
View File
@@ -19,6 +19,12 @@ pub struct GatewayConfig {
/// Map of project name → container URL.
#[serde(default)]
pub projects: BTreeMap<String, ProjectEntry>,
/// Map of sled_id → shared secret token for sled-uplink authentication.
///
/// Each entry allows a sled identified by `sled_id` to connect to
/// `/api/sled-uplink` using the given secret token as a bearer credential.
#[serde(default)]
pub sled_tokens: BTreeMap<String, String>,
}
/// Validate that a gateway config has at least one project.
@@ -113,6 +119,7 @@ url = "http://localhost:3002"
fn validate_config_rejects_empty() {
let config = GatewayConfig {
projects: BTreeMap::new(),
sled_tokens: BTreeMap::new(),
};
assert!(validate_config(&config).is_err());
}
@@ -132,7 +139,10 @@ url = "http://localhost:3002"
url: "http://a".into(),
},
);
let config = GatewayConfig { projects };
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
};
assert_eq!(validate_config(&config).unwrap(), "alpha");
}
+21 -14
View File
@@ -21,13 +21,23 @@ pub fn load_config(path: &Path) -> Result<GatewayConfig, String> {
/// Persist the current projects map to `<config_dir>/projects.toml`.
/// Silently ignores write errors or skips when `config_dir` is empty.
///
/// Existing `[sled_tokens]` entries are preserved so that adding or removing
/// projects via the UI does not wipe the sled authentication tokens.
pub async fn save_config(projects: &BTreeMap<String, ProjectEntry>, config_dir: &Path) {
if config_dir.as_os_str().is_empty() {
return;
}
let path = config_dir.join("projects.toml");
let sled_tokens = tokio::fs::read_to_string(&path)
.await
.ok()
.and_then(|data| toml::from_str::<GatewayConfig>(&data).ok())
.map(|c| c.sled_tokens)
.unwrap_or_default();
let config = GatewayConfig {
projects: projects.clone(),
sled_tokens,
};
if let Ok(data) = toml::to_string_pretty(&config) {
let _ = tokio::fs::write(&path, data).await;
@@ -518,27 +528,20 @@ pub fn spawn_gateway_bot(
gateway_project_urls: BTreeMap<String, String>,
port: u16,
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
perm_rx: std::sync::Arc<
tokio::sync::Mutex<
tokio::sync::mpsc::UnboundedReceiver<crate::http::context::PermissionForward>,
>,
>,
) -> (
Option<tokio::task::AbortHandle>,
tokio::sync::watch::Sender<Option<crate::rebuild::ShutdownReason>>,
) {
use crate::agents::AgentPool;
use crate::services::Services;
use tokio::sync::{broadcast, mpsc};
let (watcher_tx, _) = broadcast::channel(16);
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
// Keep the sender alive for the gateway's lifetime so the matrix bot's
// `permission_listener` task doesn't exit immediately with
// "perm_rx channel closed". Previously `_perm_tx` was dropped when
// `spawn_gateway_bot` returned, closing the channel before the
// listener could even register. Story 898 (sled→gateway WS uplink)
// will eventually wire in a real sender; for now the leak keeps the
// channel open with no senders writing to it, matching the original
// intent of "listener watches forever, waiting for requests".
std::mem::forget(perm_tx);
let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx));
use tokio::sync::broadcast;
let (watcher_tx, _) = broadcast::channel::<crate::io::watcher::WatcherEvent>(16);
let (shutdown_tx, shutdown_rx) =
tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
// shutdown_tx is intentionally NOT forgotten — the caller holds it and
@@ -611,6 +614,9 @@ mod tests {
let active = std::sync::Arc::new(tokio::sync::RwLock::new("proj".to_string()));
let (event_tx, _) = tokio::sync::broadcast::channel(4);
let (_perm_tx, perm_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::http::context::PermissionForward>();
let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx));
let (handle, shutdown_tx) = spawn_gateway_bot(
tmp.path(),
active,
@@ -618,6 +624,7 @@ mod tests {
std::collections::BTreeMap::new(),
3001,
Some(event_tx),
perm_rx,
);
// No bot.toml in tmp → no abort handle spawned.
+33 -1
View File
@@ -22,6 +22,7 @@ pub use io::{
spawn_gateway_notification_poller,
};
use crate::http::context::PermissionForward;
use crate::rebuild::ShutdownReason;
use io::Client;
use std::collections::{BTreeMap, HashMap};
@@ -29,6 +30,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::RwLock;
use tokio::sync::mpsc;
pub use crate::crdt_state::NodePresenceView;
@@ -122,6 +124,22 @@ pub struct GatewayState {
///
/// Call `event_tx.subscribe()` to obtain a receiver for outbound fan-out.
pub event_tx: tokio::sync::broadcast::Sender<GatewayStatusEvent>,
/// Sender end of the gateway's permission channel.
///
/// The sled-uplink handler uses this to inject `perm_request` messages
/// received from connected sleds into the gateway's Matrix bot permission
/// pipeline.
pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
/// Receiver end of the gateway's permission channel (shared with the Matrix bot).
///
/// The Matrix bot's `permission_listener` holds this locked for its lifetime;
/// the sled-uplink WS handler sends requests via `perm_tx`.
pub perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
/// Reversed sled-token map: token → sled_id.
///
/// Built at startup from [`GatewayConfig::sled_tokens`] (which maps
/// sled_id → token). The handler looks up incoming tokens in O(1).
pub sled_tokens: HashMap<String, String>,
}
impl GatewayState {
@@ -141,6 +159,12 @@ impl GatewayState {
.filter(|p| gateway_config.projects.contains_key(p))
.unwrap_or(first_from_config);
let (event_tx, _) = tokio::sync::broadcast::channel(EVENT_CHANNEL_CAPACITY);
let (perm_tx, perm_rx) = mpsc::unbounded_channel::<PermissionForward>();
let sled_tokens: HashMap<String, String> = gateway_config
.sled_tokens
.iter()
.map(|(sled_id, token)| (token.clone(), sled_id.clone()))
.collect();
Ok(Self {
projects: Arc::new(RwLock::new(gateway_config.projects)),
active_project: Arc::new(RwLock::new(first)),
@@ -151,6 +175,9 @@ impl GatewayState {
bot_handle: Arc::new(TokioMutex::new(None)),
bot_shutdown_tx: Arc::new(TokioMutex::new(None)),
event_tx,
perm_tx,
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
sled_tokens,
})
}
@@ -477,6 +504,7 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) ->
gateway_project_urls,
state.port,
Some(state.event_tx.clone()),
Arc::clone(&state.perm_rx),
);
*handle = new_handle;
*state.bot_shutdown_tx.lock().await = Some(new_shutdown_tx);
@@ -502,13 +530,17 @@ mod tests {
},
);
}
GatewayConfig { projects }
GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
}
}
#[test]
fn gateway_state_rejects_empty_config() {
let config = GatewayConfig {
projects: BTreeMap::new(),
sled_tokens: BTreeMap::new(),
};
assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err());
}