transport config plane (ConfigPlane + /config routes, manifest.config), hull network/legacy-usenet/wait_ready calls, SDK 1.5.0

This commit is contained in:
Worph
2026-10-04 09:57:38 +00:00
parent cd426de1e8
commit a39f728b41
9 changed files with 487 additions and 14 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "meta-feeder-sdk" name = "meta-feeder-sdk"
version = "1.4.0" version = "1.5.0"
edition = "2021" edition = "2021"
rust-version = "1.85" rust-version = "1.85"
license = "MIT" license = "MIT"
@@ -76,4 +76,5 @@ tokio-util = { version = "0.7", features = ["io"], optional = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
tower = { version = "0.4", features = ["util"] }
tokio = { version = "1.40", features = ["full", "test-util"] } tokio = { version = "1.40", features = ["full", "test-util"] }
+7 -7
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>feeder config</title> <title>plugin config</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -47,7 +47,7 @@
</head> </head>
<body> <body>
<h1 id="title">Plugin configuration</h1> <h1 id="title">Plugin configuration</h1>
<p class="sub">Secrets are write-only — set values are shown as “set”, never echoed back. Saving requires a feeder restart to take effect. Fields marked <span class="req">*</span> are required.</p> <p class="sub">Secrets are write-only — set values are shown as “set”, never echoed back. Saving restarts the plugin to apply. Fields marked <span class="req">*</span> are required.</p>
<div id="banner" class="banner"></div> <div id="banner" class="banner"></div>
<form id="form"></form> <form id="form"></form>
<div class="bar" id="bar" style="display:none"> <div class="bar" id="bar" style="display:none">
@@ -220,14 +220,14 @@ async function save() {
}); });
if (!res.ok) { if (!res.ok) {
const detail = (await res.text().catch(() => "")).trim(); const detail = (await res.text().catch(() => "")).trim();
throw new Error(detail ? `the feeder rejected it (HTTP ${res.status}): ${detail}` throw new Error(detail ? `the plugin rejected it (HTTP ${res.status}): ${detail}`
: `the feeder returned HTTP ${res.status}`); : `the plugin returned HTTP ${res.status}`);
} }
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
if (j.reloading) { if (j.reloading) {
// The feeder is restarting itself (~2-3s) to apply the change. Poll it // The feeder is restarting itself (~2-3s) to apply the change. Poll it
// back up, then refresh the form so secret pills reflect stored state. // back up, then refresh the form so secret pills reflect stored state.
banner("warn", "Saved — reloading the feeder to apply (~3 s)…"); banner("warn", "Saved — reloading the plugin to apply (~3 s)…");
let tries = 6; let tries = 6;
const poll = async () => { const poll = async () => {
try { await load(); banner("ok", "Reloaded — new config applied."); } try { await load(); banner("ok", "Reloaded — new config applied."); }
@@ -235,7 +235,7 @@ async function save() {
}; };
setTimeout(poll, 3000); setTimeout(poll, 3000);
} else { } else {
if (j.restart_required) banner("warn", "Saved. Restart the feeder container for changes to take effect."); if (j.restart_required) banner("warn", "Saved. Restart the plugin container for changes to take effect.");
else banner("ok", "Saved."); else banner("ok", "Saved.");
// Re-fetch so secret pills reflect the new stored state. // Re-fetch so secret pills reflect the new stored state.
await load(); await load();
@@ -243,7 +243,7 @@ async function save() {
} catch (e) { } catch (e) {
const msg = (e && e.message) ? e.message : String(e); const msg = (e && e.message) ? e.message : String(e);
const friendly = /Failed to fetch|NetworkError|load failed/i.test(msg) const friendly = /Failed to fetch|NetworkError|load failed/i.test(msg)
? "couldn't reach the feeder — it may be restarting; wait a moment and try again." ? "couldn't reach the plugin — it may be restarting; wait a moment and try again."
: msg; : msg;
banner("err", "Could not save: " + friendly); banner("err", "Could not save: " + friendly);
} finally { } finally {
+347
View File
@@ -0,0 +1,347 @@
//! A transport plugin's own settings — the "config plane".
//!
//! Same model as a feeder's (`crate::config`): the plugin declares a
//! [`ConfigSchema`], keeps its values in `<state_dir>/config.json`, and serves a
//! schema-driven page that meta-share's dashboard embeds through its plugin
//! proxy (`/api/plugins/:name/ui/config`). Keys stay on the plugin; the hull
//! carries no protocol-specific settings.
//!
//! Routes (mounted by [`serve`](super::serve) when
//! [`TransportPlugin::config`](super::plugin::TransportPlugin::config) is
//! `Some`):
//!
//! - `GET /config` → the page ([`CONFIG_PAGE_HTML`]); every call it makes
//! is relative to its own path, so the proxy prefix is irrelevant.
//! - `GET /config/schema` → [`ConfigSchema`].
//! - `GET /config/values` → effective values, secrets redacted to `<key>_set`.
//! - `PUT /config/values` → merge (blank secret keeps the stored one), persist
//! atomically, then exit so the container restarts on the new values
//! (`{"saved":true,"reloading":true}`). Plugins read their config once at
//! boot — a restart is the one apply path that is right for every one of them.
//!
//! **Effective values** = `config.json` if it exists and parses as an object,
//! else the `seed` the plugin derived from its env (its pre-config-plane
//! settings source). A plugin deserializes [`ConfigPlane::effective`] into its
//! own typed struct with serde defaults, so a missing key is never fatal.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::Value;
use tracing::{info, warn};
use crate::config::{merge, redact, ConfigSchema, CONFIG_PAGE_HTML};
/// Env var naming the plugin's own writable state dir.
pub const STATE_DIR_ENV: &str = "META_SHARE_PLUGIN_STATE_DIR";
/// Default state dir (the plugin app's own `AppData/<app>/state` mount).
pub const DEFAULT_STATE_DIR: &str = "/state";
/// File name of the stored values inside the state dir.
pub const CONFIG_FILE: &str = "config.json";
/// The plugin's state dir from [`STATE_DIR_ENV`], else [`DEFAULT_STATE_DIR`].
pub fn state_dir_from_env() -> PathBuf {
std::env::var(STATE_DIR_ENV)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_STATE_DIR.to_string())
.into()
}
/// One plugin's schema + stored values.
pub struct ConfigPlane {
schema: ConfigSchema,
path: PathBuf,
seed: Value,
restart_on_save: bool,
}
impl ConfigPlane {
/// `seed` is what the plugin would run with if no `config.json` existed
/// (normally its env-derived settings, serialized).
pub fn new(schema: ConfigSchema, state_dir: &Path, seed: Value) -> Self {
Self {
schema,
path: state_dir.join(CONFIG_FILE),
seed,
restart_on_save: true,
}
}
/// Don't exit after a save (tests, or a plugin that hot-applies).
pub fn without_restart(mut self) -> Self {
self.restart_on_save = false;
self
}
pub fn schema(&self) -> &ConfigSchema {
&self.schema
}
pub fn path(&self) -> &Path {
&self.path
}
/// The stored values, if `config.json` exists and is a JSON object.
pub fn stored(&self) -> Option<Value> {
let bytes = std::fs::read(&self.path).ok()?;
match serde_json::from_slice::<Value>(&bytes) {
Ok(v @ Value::Object(_)) => Some(v),
Ok(_) | Err(_) => {
warn!(path = %self.path.display(), "plugin config is not a JSON object; using the seed");
None
}
}
}
/// Stored values, else the seed.
pub fn effective(&self) -> Value {
self.stored().unwrap_or_else(|| self.seed.clone())
}
/// Persist `values` atomically (tmp + rename), creating the state dir.
pub fn save(&self, values: &Value) -> std::io::Result<()> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
let body = serde_json::to_vec_pretty(values)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &self.path)
}
/// Write `values` as the initial config **only** if no `config.json` exists
/// yet — the one-shot migration hook (e.g. the nzb plugin importing the
/// NNTP block meta-share used to own). Returns whether it wrote.
pub fn seed_file_if_absent(&self, values: &Value) -> std::io::Result<bool> {
if self.path.exists() {
return Ok(false);
}
self.save(values)?;
Ok(true)
}
/// The `/config*` routes.
pub fn routes(self: Arc<Self>) -> Router {
Router::new()
.route("/config", get(page))
.route("/config/schema", get(schema))
.route("/config/values", get(values_get).put(values_put))
.with_state(self)
}
}
/// Lenient readers for a config object as the page writes it: an empty number
/// is `null`, an empty text is `""`, and a hand-edited file may hold a number as
/// a string. Each returns `None` for "not set" so the caller falls back to its
/// default.
pub mod read {
use serde_json::Value;
/// A non-blank, trimmed string.
pub fn text(v: &Value, key: &str) -> Option<String> {
match v.get(key)? {
Value::String(s) => Some(s.trim().to_string()).filter(|s| !s.is_empty()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
pub fn number(v: &Value, key: &str) -> Option<f64> {
match v.get(key)? {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse().ok(),
_ => None,
}
}
/// A non-negative integer (fractions truncate, negatives are "not set").
pub fn uint(v: &Value, key: &str) -> Option<u64> {
number(v, key).filter(|n| n.is_finite() && *n >= 0.0).map(|n| n as u64)
}
pub fn flag(v: &Value, key: &str) -> Option<bool> {
match v.get(key)? {
Value::Bool(b) => Some(*b),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
},
_ => None,
}
}
/// A list of non-blank strings (a string is split on commas/newlines).
pub fn list(v: &Value, key: &str) -> Option<Vec<String>> {
let items: Vec<String> = match v.get(key)? {
Value::Array(a) => a.iter().filter_map(|x| x.as_str()).map(str::to_string).collect(),
Value::String(s) => s.split([',', '\n']).map(str::to_string).collect(),
_ => return None,
};
Some(items.into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
}
}
async fn page() -> Html<&'static str> {
Html(CONFIG_PAGE_HTML)
}
async fn schema(State(p): State<Arc<ConfigPlane>>) -> Json<ConfigSchema> {
Json(p.schema.clone())
}
async fn values_get(State(p): State<Arc<ConfigPlane>>) -> Json<Value> {
Json(redact(&p.effective(), &p.schema))
}
async fn values_put(
State(p): State<Arc<ConfigPlane>>,
Json(incoming): Json<Value>,
) -> Result<Json<Value>, Response> {
if !incoming.is_object() {
return Err((StatusCode::BAD_REQUEST, "expected a JSON object").into_response());
}
let merged = merge(&p.effective(), &incoming, &p.schema);
p.save(&merged).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("write config: {e}")).into_response()
})?;
if !p.restart_on_save {
info!(path = %p.path.display(), "plugin config saved");
return Ok(Json(serde_json::json!({ "saved": true, "reloading": false })));
}
info!(path = %p.path.display(), "plugin config saved; restarting to apply");
// Let this response flush, then exit; `restart: unless-stopped` brings the
// plugin back and it reads config.json at boot.
tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(800)).await;
std::process::exit(0);
});
Ok(Json(serde_json::json!({ "saved": true, "reloading": true })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigField;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn schema() -> ConfigSchema {
ConfigSchema {
fields: vec![
ConfigField::text("host", "Host"),
ConfigField::secret("pass", "Password"),
ConfigField::number("connections", "Connections"),
],
}
}
fn plane(dir: &Path) -> Arc<ConfigPlane> {
Arc::new(
ConfigPlane::new(schema(), dir, serde_json::json!({"host":"seed.example","connections":4}))
.without_restart(),
)
}
async fn call(app: Router, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(match body {
Some(b) => Body::from(serde_json::to_vec(&b).unwrap()),
None => Body::empty(),
})
.unwrap();
let r = app.oneshot(req).await.unwrap();
let status = r.status();
let bytes = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
#[tokio::test]
async fn seed_until_saved_then_file_wins_and_secret_is_kept() {
let dir = tempfile::tempdir().unwrap();
let p = plane(dir.path());
assert_eq!(p.effective()["host"], "seed.example");
let (st, v) = call(Arc::clone(&p).routes(), "GET", "/config/values", None).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(v["host"], "seed.example");
assert_eq!(v["pass_set"], false);
let (st, _) = call(
Arc::clone(&p).routes(),
"PUT",
"/config/values",
Some(serde_json::json!({"host":"news.example","pass":"s3cret"})),
)
.await;
assert_eq!(st, StatusCode::OK);
let stored = p.stored().unwrap();
assert_eq!(stored["host"], "news.example");
assert_eq!(stored["pass"], "s3cret");
assert_eq!(stored["connections"], 4, "absent field keeps the effective value");
// A blank secret keeps the stored one; the GET never leaks it.
call(Arc::clone(&p).routes(), "PUT", "/config/values", Some(serde_json::json!({"pass":""}))).await;
assert_eq!(p.stored().unwrap()["pass"], "s3cret");
let (_, v) = call(Arc::clone(&p).routes(), "GET", "/config/values", None).await;
assert!(v.get("pass").is_none());
assert_eq!(v["pass_set"], true);
}
#[tokio::test]
async fn schema_and_page_are_served() {
let dir = tempfile::tempdir().unwrap();
let p = plane(dir.path());
let (st, v) = call(Arc::clone(&p).routes(), "GET", "/config/schema", None).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(v["fields"].as_array().unwrap().len(), 3);
let r = p
.routes()
.oneshot(Request::get("/config").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
#[test]
fn seed_file_if_absent_writes_once() {
let dir = tempfile::tempdir().unwrap();
let p = plane(dir.path());
assert!(p.seed_file_if_absent(&serde_json::json!({"host":"a"})).unwrap());
assert!(!p.seed_file_if_absent(&serde_json::json!({"host":"b"})).unwrap());
assert_eq!(p.effective()["host"], "a");
}
#[test]
fn readers_treat_blank_as_unset() {
let v = serde_json::json!({"a":"", "b":" x ", "n":null, "m":"7", "f":"off", "l":["a"," ",""], "s":"p, q"});
assert_eq!(read::text(&v, "a"), None);
assert_eq!(read::text(&v, "b").as_deref(), Some("x"));
assert_eq!(read::uint(&v, "n"), None);
assert_eq!(read::uint(&v, "m"), Some(7));
assert_eq!(read::flag(&v, "f"), Some(false));
assert_eq!(read::list(&v, "l"), Some(vec!["a".to_string()]));
assert_eq!(read::list(&v, "s"), Some(vec!["p".to_string(), "q".to_string()]));
assert_eq!(read::text(&v, "missing"), None);
}
#[test]
fn a_non_object_file_falls_back_to_the_seed() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), b"[1,2]").unwrap();
assert_eq!(plane(dir.path()).effective()["host"], "seed.example");
}
}
+5
View File
@@ -82,6 +82,11 @@ pub struct Manifest {
pub version: String, pub version: String,
pub contract: u32, pub contract: u32,
pub capabilities: Capabilities, pub capabilities: Capabilities,
/// Serves a config plane (`/config`, `/config/schema`, `/config/values`).
/// Set by the harness from [`TransportPlugin::config`](super::plugin::TransportPlugin::config);
/// absent in older manifests.
#[serde(default)]
pub config: bool,
} }
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
+49
View File
@@ -17,6 +17,13 @@
//! [`HDR_MANIFEST_CID`]. `404` = not a Usenet cid; any other error carries //! [`HDR_MANIFEST_CID`]. `404` = not a Usenet cid; any other error carries
//! `{"error"}` — the manifest could not be redeemed or fetched. Parsing and //! `{"error"}` — the manifest could not be redeemed or fetched. Parsing and
//! the `nzb-posting` digest check stay in the plugin. //! the `nzb-posting` digest check stay in the plugin.
//! - `GET /internal/network` → [`NetworkInfo`]: the hull's own endpoints a
//! plugin needs to know (the ipfs plugin announces `peer_url` in identify).
//! - `GET /internal/legacy/usenet` → the NNTP block meta-share's `settings.json`
//! held before the nzb plugin owned its settings (`404` = none). One-shot
//! migration source for the nzb plugin's config plane.
//! - `GET /health` → `200` once the hull has finished its boot steps (the
//! ipfs plugin waits on it before opening `ipfs/blocks.redb`).
//! //!
//! Why callbacks and not plugin-side logic: these need what only the hull has — //! Why callbacks and not plugin-side logic: these need what only the hull has —
//! the record cache and meta-core, the gateway redeem, the IPFS tier. Plugins //! the record cache and meta-core, the gateway redeem, the IPFS tier. Plugins
@@ -61,6 +68,17 @@ pub struct RecordInfo {
pub title: Option<String>, pub title: Option<String>,
} }
/// The hull's endpoints (`GET /internal/network`).
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkInfo {
/// meta-core base URL the hull talks to.
#[serde(default)]
pub meta_core_url: Option<String>,
/// The URL other peers reach this meta-share at (announced in identify).
#[serde(default)]
pub peer_url: Option<String>,
}
/// Plugin-side client for the hull's internal API. A client with no base URL /// Plugin-side client for the hull's internal API. A client with no base URL
/// (`META_SHARE_HULL_URL=` empty, or [`HullClient::disabled`]) answers every call /// (`META_SHARE_HULL_URL=` empty, or [`HullClient::disabled`]) answers every call
/// with "not available", which is what unit tests and a standalone plugin get. /// with "not available", which is what unit tests and a standalone plugin get.
@@ -191,6 +209,37 @@ impl HullClient {
self.get_json(&format!("/internal/records/{cid}"), Duration::from_secs(15)).await self.get_json(&format!("/internal/records/{cid}"), Duration::from_secs(15)).await
} }
/// The hull's endpoints.
pub async fn network(&self) -> Result<Option<NetworkInfo>, String> {
self.get_json("/internal/network", Duration::from_secs(5)).await
}
/// The NNTP block meta-share's own settings held before the nzb plugin had
/// a config plane; `Ok(None)` = there is none.
pub async fn legacy_usenet(&self) -> Result<Option<super::nzb::UsenetSettings>, String> {
self.get_json("/internal/legacy/usenet", Duration::from_secs(5)).await
}
/// Wait until the hull's internal listener answers `GET /health` with 2xx,
/// or `max` elapses. Returns whether it came up.
pub async fn wait_ready(&self, max: Duration) -> bool {
let Ok(url) = self.url("/health") else {
return false;
};
let deadline = tokio::time::Instant::now() + max;
loop {
if let Ok(r) = self.http.get(&url).timeout(Duration::from_secs(3)).send().await {
if r.status().is_success() {
return true;
}
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
/// [`RecordInfo`] from the hull's record cache only. /// [`RecordInfo`] from the hull's record cache only.
pub async fn cached_record(&self, cid: &str) -> Result<Option<RecordInfo>, String> { pub async fn cached_record(&self, cid: &str) -> Result<Option<RecordInfo>, String> {
self.get_json(&format!("/internal/records/{cid}?cached=1"), Duration::from_secs(5)).await self.get_json(&format!("/internal/records/{cid}?cached=1"), Duration::from_secs(5)).await
+5 -1
View File
@@ -12,6 +12,8 @@
//! - [`plugin::TransportPlugin`] + [`serve::serve_transport`]: the plugin side. //! - [`plugin::TransportPlugin`] + [`serve::serve_transport`]: the plugin side.
//! - [`client::RemoteTransport`]: the hull side. //! - [`client::RemoteTransport`]: the hull side.
//! - [`hull::HullClient`]: plugin → hull callbacks (internal port only). //! - [`hull::HullClient`]: plugin → hull callbacks (internal port only).
//! - [`config::ConfigPlane`]: the plugin's own settings + the config page the
//! hull's dashboard embeds.
//! - [`focus::FocusView`]: the plugin's replica of the hull's playback focus. //! - [`focus::FocusView`]: the plugin's replica of the hull's playback focus.
//! - [`dto`]: common wire types; [`error::ApiError`] / [`range`]: shared so a //! - [`dto`]: common wire types; [`error::ApiError`] / [`range`]: shared so a
//! relayed plugin response is byte-identical to what the monolith answered. //! relayed plugin response is byte-identical to what the monolith answered.
@@ -29,6 +31,7 @@
pub mod cid; pub mod cid;
pub mod client; pub mod client;
pub mod config;
pub mod dto; pub mod dto;
pub mod error; pub mod error;
pub mod file; pub mod file;
@@ -46,12 +49,13 @@ pub mod torrent;
pub mod testkit; pub mod testkit;
pub use client::RemoteTransport; pub use client::RemoteTransport;
pub use config::ConfigPlane;
pub use dto::{ pub use dto::{
Capabilities, Deleted, Event, Health, Job, Jobs, Manifest, PromotedFile, ReconcileReport, Capabilities, Deleted, Event, Health, Job, Jobs, Manifest, PromotedFile, ReconcileReport,
ReconcileRequest, CONTRACT_VERSION, ReconcileRequest, CONTRACT_VERSION,
}; };
pub use error::ApiError; pub use error::ApiError;
pub use focus::{FocusSnapshot, FocusTitle, FocusView, Lane}; pub use focus::{FocusSnapshot, FocusTitle, FocusView, Lane};
pub use hull::{HullClient, RecordInfo}; pub use hull::{HullClient, NetworkInfo, RecordInfo};
pub use plugin::TransportPlugin; pub use plugin::TransportPlugin;
pub use serve::serve_transport; pub use serve::serve_transport;
+8
View File
@@ -7,6 +7,7 @@ use axum::http::HeaderMap;
use axum::response::Response; use axum::response::Response;
use axum::Router; use axum::Router;
use super::config::ConfigPlane;
use super::dto::{Deleted, Health, Job, Manifest, ReconcileReport, ReconcileRequest}; use super::dto::{Deleted, Health, Job, Manifest, ReconcileReport, ReconcileRequest};
use super::error::ApiError; use super::error::ApiError;
use super::focus::FocusView; use super::focus::FocusView;
@@ -42,6 +43,13 @@ pub trait TransportPlugin: Send + Sync + 'static {
/// The view `PUT /focus` writes into. /// The view `PUT /focus` writes into.
fn focus(&self) -> &Arc<FocusView>; fn focus(&self) -> &Arc<FocusView>;
/// The plugin's own settings, if it has any. `Some` mounts the `/config*`
/// routes and sets `manifest.config` — the hull's dashboard then offers a
/// "configure" panel that embeds `/config` through its proxy.
fn config(&self) -> Option<Arc<ConfigPlane>> {
None
}
/// Protocol-specific routes, merged into the common router. Paths must not /// Protocol-specific routes, merged into the common router. Paths must not
/// collide with the common ones. /// collide with the common ones.
fn extra_routes(self: Arc<Self>) -> Router { fn extra_routes(self: Arc<Self>) -> Router {
+13 -4
View File
@@ -11,7 +11,9 @@
//! - `PUT /focus` ← [`FocusSnapshot`](super::focus::FocusSnapshot), `204` //! - `PUT /focus` ← [`FocusSnapshot`](super::focus::FocusSnapshot), `204`
//! - `GET /focus` → the snapshot the plugin currently obeys //! - `GET /focus` → the snapshot the plugin currently obeys
//! //!
//! plus whatever [`TransportPlugin::extra_routes`] adds. //! plus the `/config*` routes when [`TransportPlugin::config`] is `Some` (see
//! [`ConfigPlane`](super::config::ConfigPlane)), plus whatever
//! [`TransportPlugin::extra_routes`] adds.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@@ -32,8 +34,9 @@ type Shared = Arc<dyn TransportPlugin>;
/// The common router for `plugin`, merged with its extra routes. /// The common router for `plugin`, merged with its extra routes.
pub fn router<P: TransportPlugin>(plugin: Arc<P>) -> Router { pub fn router<P: TransportPlugin>(plugin: Arc<P>) -> Router {
let extra = Arc::clone(&plugin).extra_routes(); let extra = Arc::clone(&plugin).extra_routes();
let config = plugin.config().map(|c| c.routes());
let shared: Shared = plugin; let shared: Shared = plugin;
Router::new() let r = Router::new()
.route("/manifest", get(manifest)) .route("/manifest", get(manifest))
.route("/health", get(health)) .route("/health", get(health))
.route("/raw/:cid", get(raw)) .route("/raw/:cid", get(raw))
@@ -42,7 +45,11 @@ pub fn router<P: TransportPlugin>(plugin: Arc<P>) -> Router {
.route("/reconcile", post(reconcile)) .route("/reconcile", post(reconcile))
.route("/focus", get(get_focus).put(put_focus)) .route("/focus", get(get_focus).put(put_focus))
.with_state(shared) .with_state(shared)
.merge(extra) .merge(extra);
match config {
Some(c) => r.merge(c),
None => r,
}
} }
/// Bind `listen` and serve `plugin` until SIGTERM / Ctrl-C. /// Bind `listen` and serve `plugin` until SIGTERM / Ctrl-C.
@@ -82,7 +89,9 @@ pub async fn shutdown_signal() {
} }
async fn manifest(State(p): State<Shared>) -> Response { async fn manifest(State(p): State<Shared>) -> Response {
Json(p.manifest()).into_response() let mut m = p.manifest();
m.config = p.config().is_some();
Json(m).into_response()
} }
async fn health(State(p): State<Shared>) -> Response { async fn health(State(p): State<Shared>) -> Response {
+51 -1
View File
@@ -39,8 +39,10 @@ pub async fn conformance(base: &str) -> Result<(), Vec<String>> {
.with_control_timeout(Duration::from_secs(10)); .with_control_timeout(Duration::from_secs(10));
let mut fails = Vec::new(); let mut fails = Vec::new();
let mut has_config = false;
match t.manifest().await { match t.manifest().await {
Ok(m) => { Ok(m) => {
has_config = m.config;
if m.id.is_empty() { if m.id.is_empty() {
fails.push("manifest.id is empty".into()); fails.push("manifest.id is empty".into());
} }
@@ -110,6 +112,31 @@ pub async fn conformance(base: &str) -> Result<(), Vec<String>> {
Err(e) => fails.push(format!("GET /raw/not-a-cid: {e}")), Err(e) => fails.push(format!("GET /raw/not-a-cid: {e}")),
} }
if has_config {
match http.get(t.url("/config/schema")).send().await {
Ok(r) if r.status().is_success() => {
if r.json::<crate::config::ConfigSchema>().await.is_err() {
fails.push("GET /config/schema body is not a ConfigSchema".into());
}
}
Ok(r) => fails.push(format!("GET /config/schema: status {}", r.status())),
Err(e) => fails.push(format!("GET /config/schema: {e}")),
}
match http.get(t.url("/config/values")).send().await {
Ok(r) if r.status().is_success() => match r.json::<serde_json::Value>().await {
Ok(v) if v.is_object() => {}
_ => fails.push("GET /config/values is not a JSON object".into()),
},
Ok(r) => fails.push(format!("GET /config/values: status {}", r.status())),
Err(e) => fails.push(format!("GET /config/values: {e}")),
}
match http.get(t.url("/config")).send().await {
Ok(r) if r.status().is_success() => {}
Ok(r) => fails.push(format!("GET /config: status {}", r.status())),
Err(e) => fails.push(format!("GET /config: {e}")),
}
}
if fails.is_empty() { if fails.is_empty() {
Ok(()) Ok(())
} else { } else {
@@ -130,6 +157,7 @@ mod tests {
/// The smallest plugin that should pass: holds nothing, fetches nothing. /// The smallest plugin that should pass: holds nothing, fetches nothing.
struct Empty { struct Empty {
focus: Arc<FocusView>, focus: Arc<FocusView>,
config: Option<Arc<crate::transport::config::ConfigPlane>>,
} }
#[async_trait] #[async_trait]
@@ -141,6 +169,7 @@ mod tests {
version: "0".into(), version: "0".into(),
contract: CONTRACT_VERSION, contract: CONTRACT_VERSION,
capabilities: Capabilities { fetch: true, share: false }, capabilities: Capabilities { fetch: true, share: false },
config: false,
} }
} }
async fn raw(&self, _cid: String, _h: HeaderMap) -> Response { async fn raw(&self, _cid: String, _h: HeaderMap) -> Response {
@@ -158,11 +187,32 @@ mod tests {
fn focus(&self) -> &Arc<FocusView> { fn focus(&self) -> &Arc<FocusView> {
&self.focus &self.focus
} }
fn config(&self) -> Option<Arc<crate::transport::config::ConfigPlane>> {
self.config.clone()
}
} }
#[tokio::test] #[tokio::test]
async fn the_empty_plugin_conforms() { async fn the_empty_plugin_conforms() {
let addr = spawn_local(Arc::new(Empty { focus: FocusView::new() })).await; let addr = spawn_local(Arc::new(Empty { focus: FocusView::new(), config: None })).await;
conformance(&format!("http://{addr}")).await.unwrap(); conformance(&format!("http://{addr}")).await.unwrap();
} }
#[tokio::test]
async fn a_plugin_with_a_config_plane_conforms_and_says_so() {
use crate::config::{ConfigField, ConfigSchema};
use crate::transport::config::ConfigPlane;
let dir = tempfile::tempdir().unwrap();
let plane = ConfigPlane::new(
ConfigSchema { fields: vec![ConfigField::secret("key", "Key")] },
dir.path(),
serde_json::json!({}),
)
.without_restart();
let addr = spawn_local(Arc::new(Empty { focus: FocusView::new(), config: Some(Arc::new(plane)) })).await;
let base = format!("http://{addr}");
conformance(&base).await.unwrap();
let m: Manifest = reqwest::get(format!("{base}/manifest")).await.unwrap().json().await.unwrap();
assert!(m.config, "the harness must advertise the config plane");
}
} }