Merge pull request 'feat(sidecar): protocol 8 — three lease forwards (phase 12)' (#8) from feat/phase-12-leases into edge
Reviewed-on: #8
This commit is contained in:
@@ -129,10 +129,19 @@ use tracing_subscriber::EnvFilter;
|
||||
/// plugin that never sends it — against protocol 6 it would read every raid as a base with no
|
||||
/// cupboard, and alert nobody while looking healthy.
|
||||
///
|
||||
/// # Protocol 8 — the leases
|
||||
///
|
||||
/// `GET /lease`, `POST /lease` and `POST /lease/release`: an event borrowing a value and giving
|
||||
/// it back (the module's PLAN.md §27). Three correlated round trips and one event,
|
||||
/// `lease.expired`, which is filed like every other event. The allowlist, the bounds, the
|
||||
/// seven-day ceiling and the deadline timer all live in the plugin; the ledger lives on the
|
||||
/// website. This process moves lines between them and learns nothing about either, which is why
|
||||
/// the whole protocol is three thin forwards here.
|
||||
///
|
||||
/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the
|
||||
/// mirror, §11 configuration, §12 clans, §13 the raid frame; this constant is one of its four
|
||||
/// declaration sites.
|
||||
pub const PROTOCOL_VERSION: u32 = 7;
|
||||
/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases; this constant is one
|
||||
/// of its four declaration sites.
|
||||
pub const PROTOCOL_VERSION: u32 = 8;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = match cli::parse(std::env::args().skip(1)) {
|
||||
|
||||
@@ -106,6 +106,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
// whose reply can take most of the RPC budget: the plugin holds it open across a reload
|
||||
// and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`.
|
||||
.route("/config/write", post(config_write))
|
||||
// Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back.
|
||||
// Three correlated round trips, and the sidecar knows nothing about any of them — not
|
||||
// which keys exist, not their bounds, not what a deadline is. The plugin holds the
|
||||
// allowlist, the ceiling and the timer; the website holds the ledger. This moves lines.
|
||||
.route("/lease", get(lease_list).post(lease_apply))
|
||||
.route("/lease/release", post(lease_release))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||
|
||||
let app = Router::new()
|
||||
@@ -408,6 +414,92 @@ struct ConfigPathQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// What `GET /lease` may narrow to. Both optional, both forwarded as they are.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LeaseQuery {
|
||||
key: Option<String>,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything this server lends, what it holds now, and every hold in force (protocol 8).
|
||||
///
|
||||
/// A live round trip, never a cached board, for the reason `/permissions/catalogue` is one: the
|
||||
/// website asks this immediately before taking a lease, to record the baseline it will later give
|
||||
/// back, and a baseline that was a minute stale would be restored over whatever happened in that
|
||||
/// minute.
|
||||
async fn lease_list(State(st): State<AppState>, Query(q): Query<LeaseQuery>) -> Response {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let mut command = json!({ "cmd": "lease.list", "reqId": req_id });
|
||||
|
||||
if let Some(key) = q.key {
|
||||
command["key"] = Value::String(key);
|
||||
}
|
||||
if let Some(target) = q.target {
|
||||
command["target"] = Value::String(target);
|
||||
}
|
||||
|
||||
respond(st.rpc.call(&st.game, command, &req_id).await)
|
||||
}
|
||||
|
||||
/// Take a value for a while. The plugin answers `lease.ok` or `lease.error` with a reason.
|
||||
async fn lease_apply(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
|
||||
forward_object(&st, body, "lease.apply", "a lease").await
|
||||
}
|
||||
|
||||
/// Give a value back. **A drifted value is a `200` carrying `lease.drifted`**, not an error: the
|
||||
/// plugin did exactly what it was asked — it compared, and declined to overwrite somebody's
|
||||
/// deliberate change — and the website records that as its own outcome.
|
||||
async fn lease_release(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
|
||||
forward_object(&st, body, "lease.release", "a lease release").await
|
||||
}
|
||||
|
||||
/// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything
|
||||
/// the caller put there. `None` when the body is not an object.
|
||||
fn stamp(body: Value, cmd: &str, req_id: &str) -> Option<Value> {
|
||||
let mut map = match body {
|
||||
Value::Object(map) => map,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
map.insert("cmd".into(), Value::String(cmd.into()));
|
||||
map.insert("reqId".into(), Value::String(req_id.into()));
|
||||
Some(Value::Object(map))
|
||||
}
|
||||
|
||||
/// The opaque-object forward the lease routes share: stamp the envelope, refuse what the game
|
||||
/// link cannot carry, and hand back whatever the plugin answered.
|
||||
async fn forward_object(st: &AppState, body: Value, cmd: &str, what: &str) -> Response {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
|
||||
let command = match stamp(body, cmd, &req_id) {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": format!("{what} must be an object") })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = command.to_string();
|
||||
|
||||
if encoded.len() > MAX_COMMAND_BYTES {
|
||||
warn!(bytes = encoded.len(), cmd, "command refused: too large");
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({
|
||||
"error": format!("{what} is larger than the game link will carry"),
|
||||
"bytes": encoded.len(),
|
||||
"limit": MAX_COMMAND_BYTES,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
respond(st.rpc.call(&st.game, command, &req_id).await)
|
||||
}
|
||||
|
||||
/// Every settings file on the game host, and every plugin loaded to reload one.
|
||||
///
|
||||
/// The sidecar knows nothing about either: not where the tree is (the framework decides, and it is
|
||||
@@ -793,6 +885,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Protocol 8's routes cannot choose their own command or correlation id either, and they keep
|
||||
/// everything else the caller sent, unread.
|
||||
#[test]
|
||||
fn a_lease_cannot_choose_its_own_command() {
|
||||
let body =
|
||||
json!({ "cmd": "config.write", "reqId": "r-1", "key": "decay.scale", "value": "0" });
|
||||
|
||||
let command = stamp(body, "lease.apply", "r-9").expect("an object is stamped");
|
||||
|
||||
assert_eq!(command["cmd"], json!("lease.apply"));
|
||||
assert_eq!(command["reqId"], json!("r-9"));
|
||||
assert_eq!(command["key"], json!("decay.scale"));
|
||||
assert_eq!(command["value"], json!("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lease_that_is_not_an_object_is_not_stamped() {
|
||||
assert!(stamp(json!(["decay.scale"]), "lease.apply", "r-1").is_none());
|
||||
assert!(stamp(json!("decay.scale"), "lease.release", "r-1").is_none());
|
||||
}
|
||||
|
||||
/// The same envelope rule as `perm_sync`, on the route that writes to a filesystem.
|
||||
#[test]
|
||||
fn a_configuration_write_cannot_choose_its_own_command() {
|
||||
|
||||
Reference in New Issue
Block a user