huskies: merge 899

This commit is contained in:
dave
2026-05-12 23:11:34 +00:00
parent 0f0cf59329
commit cd214d7246
9 changed files with 1105 additions and 218 deletions
+144 -27
View File
@@ -7,20 +7,56 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A single project entry in `projects.toml`.
///
/// Phase 2 (story 899): `url` is now optional — a project served exclusively
/// via the sled-uplink WebSocket does not need an HTTP base URL. The `url`
/// field is deprecated for removal in a future release; configure
/// `auth_token` instead and rely on the WS uplink for all traffic.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProjectEntry {
/// Base URL of the project's huskies container (e.g. `http://localhost:3001`).
pub url: String,
///
/// **Deprecated** (story 899) — when a sled connects via the uplink WS the
/// gateway routes all MCP traffic over that connection instead. The URL is
/// used as a fallback when no live uplink exists. Omit for WS-only projects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Shared-secret token used to authenticate this project's sled when it
/// connects to `/api/sled-uplink`. Takes precedence over the top-level
/// `[sled_tokens]` table for projects that set this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_token: Option<String>,
}
impl ProjectEntry {
/// Convenience constructor for entries that only have a URL (e.g. in tests
/// and existing `projects.toml` files that have not yet been migrated to
/// the WS-uplink model).
pub fn with_url(url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
auth_token: None,
}
}
/// Returns `true` if this entry has a configured HTTP base URL.
pub fn has_url(&self) -> bool {
self.url.as_ref().is_some_and(|u| !u.is_empty())
}
}
/// Top-level `projects.toml` config.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
/// Map of project name → container URL.
/// Map of project name → container configuration.
#[serde(default)]
pub projects: BTreeMap<String, ProjectEntry>,
/// Map of sled_id → shared secret token for sled-uplink authentication.
///
/// **Deprecated** (story 899) — move tokens into per-project
/// `auth_token` fields instead. The gateway still honours entries here for
/// one release to provide a smooth migration window.
///
/// 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)]
@@ -40,18 +76,22 @@ pub fn validate_config(config: &GatewayConfig) -> Result<String, String> {
/// Validate that a project name exists in the given project map.
///
/// Returns the project's URL on success.
/// Returns the project's URL (may be empty for WS-uplink-only projects) on
/// success.
pub fn validate_project_exists(
projects: &BTreeMap<String, ProjectEntry>,
name: &str,
) -> Result<String, String> {
projects.get(name).map(|p| p.url.clone()).ok_or_else(|| {
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
format!(
"unknown project '{name}'. Available: {}",
available.join(", ")
)
})
projects
.get(name)
.map(|p| p.url.clone().unwrap_or_default())
.ok_or_else(|| {
let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect();
format!(
"unknown project '{name}'. Available: {}",
available.join(", ")
)
})
}
/// Escape a string as a TOML quoted string.
@@ -104,8 +144,29 @@ url = "http://localhost:3002"
"#;
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.projects.len(), 2);
assert_eq!(config.projects["huskies"].url, "http://localhost:3001");
assert_eq!(config.projects["robot-studio"].url, "http://localhost:3002");
assert_eq!(
config.projects["huskies"].url.as_deref(),
Some("http://localhost:3001")
);
assert_eq!(
config.projects["robot-studio"].url.as_deref(),
Some("http://localhost:3002")
);
}
#[test]
fn parse_project_without_url_is_valid() {
let toml_str = r#"
[projects.ws-only]
auth_token = "secret"
"#;
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.projects.len(), 1);
assert!(config.projects["ws-only"].url.is_none());
assert_eq!(
config.projects["ws-only"].auth_token.as_deref(),
Some("secret")
);
}
#[test]
@@ -127,18 +188,8 @@ url = "http://localhost:3002"
#[test]
fn validate_config_returns_first_project_name() {
let mut projects = BTreeMap::new();
projects.insert(
"beta".into(),
ProjectEntry {
url: "http://b".into(),
},
);
projects.insert(
"alpha".into(),
ProjectEntry {
url: "http://a".into(),
},
);
projects.insert("beta".into(), ProjectEntry::with_url("http://b"));
projects.insert("alpha".into(), ProjectEntry::with_url("http://a"));
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
@@ -147,14 +198,26 @@ url = "http://localhost:3002"
}
#[test]
fn validate_project_exists_succeeds() {
fn validate_config_accepts_ws_only_project() {
let mut projects = BTreeMap::new();
projects.insert(
"p1".into(),
"ws-only".into(),
ProjectEntry {
url: "http://p1".into(),
url: None,
auth_token: Some("secret".into()),
},
);
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn validate_project_exists_succeeds() {
let mut projects = BTreeMap::new();
projects.insert("p1".into(), ProjectEntry::with_url("http://p1"));
assert_eq!(
validate_project_exists(&projects, "p1").unwrap(),
"http://p1"
@@ -167,6 +230,36 @@ url = "http://localhost:3002"
assert!(validate_project_exists(&projects, "missing").is_err());
}
#[test]
fn validate_project_exists_ws_only_returns_empty_url() {
let mut projects = BTreeMap::new();
projects.insert(
"ws".into(),
ProjectEntry {
url: None,
auth_token: Some("tok".into()),
},
);
assert_eq!(validate_project_exists(&projects, "ws").unwrap(), "");
}
#[test]
fn project_entry_with_url_constructor() {
let e = ProjectEntry::with_url("http://example.com");
assert_eq!(e.url.as_deref(), Some("http://example.com"));
assert!(e.auth_token.is_none());
assert!(e.has_url());
}
#[test]
fn project_entry_has_url_false_when_none() {
let e = ProjectEntry {
url: None,
auth_token: Some("tok".into()),
};
assert!(!e.has_url());
}
#[test]
fn toml_string_escapes_quotes() {
assert_eq!(toml_string(r#"a"b"#), r#""a\"b""#);
@@ -198,4 +291,28 @@ url = "http://localhost:3002"
assert!(content.contains("transport = \"slack\""));
assert!(content.contains("slack_bot_token = \"xoxb-123\""));
}
#[test]
fn roundtrip_project_entry_with_auth_token() {
let entry = ProjectEntry {
url: Some("http://a:3001".into()),
auth_token: Some("mysecret".into()),
};
let mut projects = BTreeMap::new();
projects.insert("myproj".into(), entry);
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
parsed.projects["myproj"].url.as_deref(),
Some("http://a:3001")
);
assert_eq!(
parsed.projects["myproj"].auth_token.as_deref(),
Some("mysecret")
);
}
}