huskies: merge 1169 story Gateway pulls signed artifacts from a release channel into its local store
This commit is contained in:
@@ -22,22 +22,30 @@ 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.
|
||||
/// Existing `[sled_tokens]` and `[release_channels]` entries are preserved so
|
||||
/// that adding or removing projects via the UI does not wipe the sled
|
||||
/// authentication tokens or configured release channels.
|
||||
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)
|
||||
let existing = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|data| toml::from_str::<GatewayConfig>(&data).ok())
|
||||
.map(|c| c.sled_tokens)
|
||||
.and_then(|data| toml::from_str::<GatewayConfig>(&data).ok());
|
||||
let sled_tokens = existing
|
||||
.as_ref()
|
||||
.map(|c| c.sled_tokens.clone())
|
||||
.unwrap_or_default();
|
||||
let release_channels = existing
|
||||
.as_ref()
|
||||
.map(|c| c.release_channels.clone())
|
||||
.unwrap_or_default();
|
||||
let config = GatewayConfig {
|
||||
projects: projects.clone(),
|
||||
sled_tokens,
|
||||
release_channels,
|
||||
};
|
||||
if let Ok(data) = toml::to_string_pretty(&config) {
|
||||
let _ = tokio::fs::write(&path, data).await;
|
||||
@@ -120,6 +128,115 @@ pub async fn probe_identity(
|
||||
.ok()
|
||||
}
|
||||
|
||||
// ── Release channel I/O (story 1169) ────────────────────────────────────────
|
||||
|
||||
/// Fetch `{base_url}/manifest.json` and parse it into a
|
||||
/// [`release_manifest::SignedManifest`].
|
||||
///
|
||||
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set. Returns
|
||||
/// `Err` on any network, HTTP-status, or parse failure — `pull` treats all of
|
||||
/// these the same as any other loud, install-nothing failure.
|
||||
pub async fn fetch_release_manifest(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
bearer_token: Option<&str>,
|
||||
) -> Result<release_manifest::SignedManifest, String> {
|
||||
let url = format!("{}/manifest.json", base_url.trim_end_matches('/'));
|
||||
let mut req = client.get(&url);
|
||||
if let Some(token) = bearer_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach {url}: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{url} returned HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("invalid manifest.json at {url}: {e}"))
|
||||
}
|
||||
|
||||
/// Download a named artifact from a release channel.
|
||||
///
|
||||
/// Sends `Authorization: Bearer <token>` when `bearer_token` is set.
|
||||
pub async fn download_channel_artifact(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
artifact_name: &str,
|
||||
bearer_token: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let url = format!("{}/{artifact_name}", base_url.trim_end_matches('/'));
|
||||
let mut req = client.get(&url);
|
||||
if let Some(token) = bearer_token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach {url}: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{url} returned HTTP {}", resp.status()));
|
||||
}
|
||||
resp.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| format!("failed reading artifact body from {url}: {e}"))
|
||||
}
|
||||
|
||||
/// Atomically install a verified artifact plus its `.hash` and
|
||||
/// `.manifest.json` sidecars into the gateway's artifact store.
|
||||
///
|
||||
/// Mirrors the write-tmp-then-rename pattern `release` uses for
|
||||
/// `SLED_ARTIFACT_NAME` so a concurrent `/api/artifacts` download never
|
||||
/// observes a half-written file. The `.hash` sidecar carries `manifest.version`
|
||||
/// so `upgrade all`'s existing convergence check (`sled_upgrade.rs`) keeps
|
||||
/// working unmodified. The `.manifest.json` sidecar records the full manifest
|
||||
/// so the next pull can run [`super::release_manifest::check_rollback`]
|
||||
/// against it.
|
||||
pub fn install_pulled_artifact(
|
||||
artifacts_dir: &Path,
|
||||
artifact_name: &str,
|
||||
bytes: &[u8],
|
||||
manifest: &release_manifest::ReleaseManifest,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(artifacts_dir)
|
||||
.map_err(|e| format!("cannot create {}: {e}", artifacts_dir.display()))?;
|
||||
|
||||
let artifact_path = artifacts_dir.join(artifact_name);
|
||||
let tmp_path = artifacts_dir.join(".pull.tmp");
|
||||
std::fs::write(&tmp_path, bytes).map_err(|e| format!("write tmp artifact failed: {e}"))?;
|
||||
std::fs::rename(&tmp_path, &artifact_path)
|
||||
.map_err(|e| format!("rename artifact failed: {e}"))?;
|
||||
|
||||
let hash_path = artifacts_dir.join(format!("{artifact_name}.hash"));
|
||||
std::fs::write(&hash_path, &manifest.version)
|
||||
.map_err(|e| format!("write hash sidecar failed: {e}"))?;
|
||||
|
||||
let manifest_path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
|
||||
let manifest_json = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|e| format!("serialise manifest sidecar: {e}"))?;
|
||||
std::fs::write(&manifest_path, manifest_json)
|
||||
.map_err(|e| format!("write manifest sidecar failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the `.manifest.json` sidecar for the currently installed artifact, if any.
|
||||
///
|
||||
/// Returns `None` when no artifact has ever been pulled from a channel (e.g.
|
||||
/// only `release` has published so far, or this is a fresh gateway) — callers
|
||||
/// treat that as "no rollback information available", not an error.
|
||||
pub fn read_installed_manifest(
|
||||
artifacts_dir: &Path,
|
||||
artifact_name: &str,
|
||||
) -> Option<release_manifest::ReleaseManifest> {
|
||||
let path = artifacts_dir.join(format!("{artifact_name}.manifest.json"));
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
// ── MCP proxy I/O ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Proxy a raw MCP request body to the given project URL.
|
||||
@@ -534,6 +651,9 @@ pub fn spawn_gateway_bot(
|
||||
port: u16,
|
||||
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
|
||||
permission_registry: std::sync::Arc<crate::service::permission_router::ResponderRegistry>,
|
||||
gateway_channels_store: std::sync::Arc<
|
||||
tokio::sync::RwLock<BTreeMap<String, super::config::ReleaseChannelConfig>>,
|
||||
>,
|
||||
) -> (
|
||||
Option<tokio::task::AbortHandle>,
|
||||
tokio::sync::watch::Sender<Option<crate::rebuild::ShutdownReason>>,
|
||||
@@ -600,6 +720,7 @@ pub fn spawn_gateway_bot(
|
||||
timer_store,
|
||||
gateway_event_rx,
|
||||
Some(port),
|
||||
Some(gateway_channels_store),
|
||||
);
|
||||
(handle, shutdown_tx)
|
||||
}
|
||||
@@ -622,6 +743,8 @@ mod tests {
|
||||
let permission_registry = crate::service::permission_router::ResponderRegistry::new();
|
||||
let projects_store =
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
|
||||
let channels_store =
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new()));
|
||||
let (handle, shutdown_tx) = spawn_gateway_bot(
|
||||
tmp.path(),
|
||||
active,
|
||||
@@ -629,6 +752,7 @@ mod tests {
|
||||
3001,
|
||||
Some(event_tx),
|
||||
permission_registry,
|
||||
channels_store,
|
||||
);
|
||||
|
||||
// No bot.toml in tmp → no abort handle spawned.
|
||||
@@ -647,4 +771,160 @@ mod tests {
|
||||
"shutdown receiver must see the Manual reason"
|
||||
);
|
||||
}
|
||||
|
||||
// ── fetch_release_manifest / download_channel_artifact ──────────────────
|
||||
|
||||
/// Spawn a one-shot TCP listener that captures the raw request text of a
|
||||
/// single connection and responds with `body`.
|
||||
fn spawn_capturing_responder(
|
||||
listener: tokio::net::TcpListener,
|
||||
body: Vec<u8>,
|
||||
) -> tokio::sync::oneshot::Receiver<String> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((mut stream, _)) = listener.accept().await {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = [0u8; 8192];
|
||||
let n = stream.read(&mut buf).await.unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(&body).await;
|
||||
let _ = tx.send(request);
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn sample_signed_manifest() -> release_manifest::SignedManifest {
|
||||
release_manifest::SignedManifest {
|
||||
manifest: release_manifest::ReleaseManifest {
|
||||
artifact: "huskies-linux-arm64".to_string(),
|
||||
sha256: "a".repeat(64),
|
||||
version: "abc1234".to_string(),
|
||||
channel: "stable".to_string(),
|
||||
timestamp: 1,
|
||||
},
|
||||
signature: "deadbeef".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_release_manifest_sends_bearer_auth_header() {
|
||||
let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let rx = spawn_capturing_responder(listener, body);
|
||||
|
||||
let client = Client::new();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let fetched = fetch_release_manifest(&client, &base_url, Some("secret-token"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fetched.manifest.version, "abc1234");
|
||||
|
||||
let request = rx.await.unwrap();
|
||||
assert!(
|
||||
request
|
||||
.to_lowercase()
|
||||
.contains("authorization: bearer secret-token"),
|
||||
"request must carry the bearer token: {request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_release_manifest_without_token_sends_no_auth_header() {
|
||||
let body = serde_json::to_vec(&sample_signed_manifest()).unwrap();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let rx = spawn_capturing_responder(listener, body);
|
||||
|
||||
let client = Client::new();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
fetch_release_manifest(&client, &base_url, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request = rx.await.unwrap();
|
||||
assert!(
|
||||
!request.to_lowercase().contains("authorization:"),
|
||||
"request must not carry an Authorization header when no token is set: {request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_release_manifest_unreachable_is_error() {
|
||||
let client = Client::new();
|
||||
let err = fetch_release_manifest(&client, "http://127.0.0.1:1", None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_channel_artifact_returns_bytes() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let rx = spawn_capturing_responder(listener, b"binary bytes".to_vec());
|
||||
|
||||
let client = Client::new();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let bytes = download_channel_artifact(&client, &base_url, "huskies-linux-arm64", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bytes, b"binary bytes");
|
||||
rx.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_channel_artifact_sends_bearer_auth_header() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let rx = spawn_capturing_responder(listener, b"bytes".to_vec());
|
||||
|
||||
let client = Client::new();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
download_channel_artifact(&client, &base_url, "art", Some("tok"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let request = rx.await.unwrap();
|
||||
assert!(request.to_lowercase().contains("authorization: bearer tok"));
|
||||
}
|
||||
|
||||
// ── install_pulled_artifact / read_installed_manifest ────────────────────
|
||||
|
||||
#[test]
|
||||
fn install_pulled_artifact_writes_atomically_with_sidecars() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest = sample_signed_manifest().manifest;
|
||||
install_pulled_artifact(
|
||||
dir.path(),
|
||||
"huskies-linux-arm64",
|
||||
b"binary content",
|
||||
&manifest,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let artifact_path = dir.path().join("huskies-linux-arm64");
|
||||
assert_eq!(std::fs::read(&artifact_path).unwrap(), b"binary content");
|
||||
assert!(
|
||||
!dir.path().join(".pull.tmp").exists(),
|
||||
"tmp file must be renamed away"
|
||||
);
|
||||
|
||||
let hash = std::fs::read_to_string(dir.path().join("huskies-linux-arm64.hash")).unwrap();
|
||||
assert_eq!(hash, "abc1234");
|
||||
|
||||
let read_back = read_installed_manifest(dir.path(), "huskies-linux-arm64").unwrap();
|
||||
assert_eq!(read_back, manifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_installed_manifest_missing_sidecar_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(read_installed_manifest(dir.path(), "huskies-linux-arm64").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user