Phase 9 R1: RepoEnsure — peer RPC + aggregator fan-out + daemon wiring #106
@@ -192,6 +192,43 @@ pub async fn call_dashboard_storage(conn: &Connection) -> Result<DashboardStorag
|
|||||||
serde_json::from_slice(&reply).context("decoding DashboardStorageReply JSON")
|
serde_json::from_slice(&reply).context("decoding DashboardStorageReply JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 9 R1b: convenience wrapper for [`Method::RepoEnsure`].
|
||||||
|
/// Materializes `(url, git_ref)` on the connected peer under the
|
||||||
|
/// caller-provided workspace namespace and returns the resulting
|
||||||
|
/// on-disk path + head sha. Cached reply = the checkout was already
|
||||||
|
/// present with a valid `.git`.
|
||||||
|
pub async fn call_repo_ensure(
|
||||||
|
conn: &Connection,
|
||||||
|
req: &crate::cluster::repo_ensure::RepoEnsureRequest,
|
||||||
|
) -> Result<crate::cluster::repo_ensure::RepoEnsureReply> {
|
||||||
|
let payload = serde_json::to_vec(req).context("encoding RepoEnsureRequest")?;
|
||||||
|
let reply = rpc_call(conn, Method::RepoEnsure, &payload).await?;
|
||||||
|
if reply.len() == 1 {
|
||||||
|
if let Some(code) = decode_error(reply[0]) {
|
||||||
|
bail!("peer replied with error: {}", code.describe());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&reply).context("decoding RepoEnsureReply JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 9 R1b: convenience wrapper for [`Method::RepoRelease`].
|
||||||
|
/// Removes the on-disk checkout for `(url, git_ref)` under the
|
||||||
|
/// caller's workspace. `removed=false` when nothing was on disk to
|
||||||
|
/// begin with (still `Ok`).
|
||||||
|
pub async fn call_repo_release(
|
||||||
|
conn: &Connection,
|
||||||
|
req: &crate::cluster::repo_ensure::RepoReleaseRequest,
|
||||||
|
) -> Result<crate::cluster::repo_ensure::RepoReleaseReply> {
|
||||||
|
let payload = serde_json::to_vec(req).context("encoding RepoReleaseRequest")?;
|
||||||
|
let reply = rpc_call(conn, Method::RepoRelease, &payload).await?;
|
||||||
|
if reply.len() == 1 {
|
||||||
|
if let Some(code) = decode_error(reply[0]) {
|
||||||
|
bail!("peer replied with error: {}", code.describe());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&reply).context("decoding RepoReleaseReply JSON")
|
||||||
|
}
|
||||||
|
|
||||||
/// Recognise a single-byte reply as one of our error codes. Returns
|
/// Recognise a single-byte reply as one of our error codes. Returns
|
||||||
/// `None` for any other single-byte value (which is a valid reply,
|
/// `None` for any other single-byte value (which is a valid reply,
|
||||||
/// just an unusually short one).
|
/// just an unusually short one).
|
||||||
|
|||||||
@@ -542,6 +542,34 @@ impl AuthedCaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 9 R1b: returns the workspace this caller may act on for
|
||||||
|
/// repo-ensure operations. Namespaced callers are pinned to their
|
||||||
|
/// own namespace; admin/open callers must provide one explicitly
|
||||||
|
/// in the request body. Explicit user-supplied workspace is only
|
||||||
|
/// honored for admin/open — a namespaced caller supplying a
|
||||||
|
/// mismatched workspace is a forbidden write.
|
||||||
|
pub fn resolve_workspace(&self, requested: Option<&str>) -> Result<String, (StatusCode, String)> {
|
||||||
|
match self {
|
||||||
|
AuthedCaller::Admin | AuthedCaller::Open => match requested {
|
||||||
|
Some(w) if !w.is_empty() => Ok(w.to_string()),
|
||||||
|
_ => Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"workspace is required for unnamespaced callers".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
AuthedCaller::Namespaced { namespace } => match requested {
|
||||||
|
None => Ok(namespace.clone()),
|
||||||
|
Some(w) if w == namespace => Ok(namespace.clone()),
|
||||||
|
Some(w) => Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
format!(
|
||||||
|
"workspace \"{w}\" is outside your namespace \"{namespace}\""
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── write-through fan-out (Phase 9 F1) ──────────────────────────
|
// ── write-through fan-out (Phase 9 F1) ──────────────────────────
|
||||||
@@ -724,6 +752,217 @@ async fn fanout_delete_tag(s: &V2State, name: &str) -> Vec<PeerResult> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 9 R1b: repo-ensure fan-out ────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RepoEnsureBody {
|
||||||
|
pub url: String,
|
||||||
|
pub git_ref: String,
|
||||||
|
/// Optional for namespaced tokens (server pins to the caller's
|
||||||
|
/// namespace); required for admin/open callers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub workspace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RepoPeerResult {
|
||||||
|
pub peer: String,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub head_sha: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cached: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub removed: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RepoFanoutReply {
|
||||||
|
pub url: String,
|
||||||
|
pub git_ref: String,
|
||||||
|
pub workspace: String,
|
||||||
|
pub peers: Vec<RepoPeerResult>,
|
||||||
|
pub all_ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_repos_ensure(
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
Json(body): Json<RepoEnsureBody>,
|
||||||
|
) -> Result<Json<RepoFanoutReply>, (StatusCode, String)> {
|
||||||
|
if body.url.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "url cannot be empty".into()));
|
||||||
|
}
|
||||||
|
if body.git_ref.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "git_ref cannot be empty".into()));
|
||||||
|
}
|
||||||
|
let workspace = caller.resolve_workspace(body.workspace.as_deref())?;
|
||||||
|
let req = crate::cluster::repo_ensure::RepoEnsureRequest {
|
||||||
|
url: body.url.clone(),
|
||||||
|
git_ref: body.git_ref.clone(),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
};
|
||||||
|
let results = fanout_repo_ensure(&s, &req).await;
|
||||||
|
let all_ok = results.iter().all(|r| r.ok);
|
||||||
|
Ok(Json(RepoFanoutReply {
|
||||||
|
url: body.url,
|
||||||
|
git_ref: body.git_ref,
|
||||||
|
workspace,
|
||||||
|
peers: results,
|
||||||
|
all_ok,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_repos_release(
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
Json(body): Json<RepoEnsureBody>,
|
||||||
|
) -> Result<Json<RepoFanoutReply>, (StatusCode, String)> {
|
||||||
|
if body.url.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "url cannot be empty".into()));
|
||||||
|
}
|
||||||
|
if body.git_ref.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "git_ref cannot be empty".into()));
|
||||||
|
}
|
||||||
|
let workspace = caller.resolve_workspace(body.workspace.as_deref())?;
|
||||||
|
let req = crate::cluster::repo_ensure::RepoReleaseRequest {
|
||||||
|
url: body.url.clone(),
|
||||||
|
git_ref: body.git_ref.clone(),
|
||||||
|
workspace: workspace.clone(),
|
||||||
|
};
|
||||||
|
let results = fanout_repo_release(&s, &req).await;
|
||||||
|
let all_ok = results.iter().all(|r| r.ok);
|
||||||
|
Ok(Json(RepoFanoutReply {
|
||||||
|
url: body.url,
|
||||||
|
git_ref: body.git_ref,
|
||||||
|
workspace,
|
||||||
|
peers: results,
|
||||||
|
all_ok,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fanout_repo_ensure(
|
||||||
|
s: &V2State,
|
||||||
|
req: &crate::cluster::repo_ensure::RepoEnsureRequest,
|
||||||
|
) -> Vec<RepoPeerResult> {
|
||||||
|
let mut set = JoinSet::new();
|
||||||
|
for peer in &s.peers {
|
||||||
|
let peer = peer.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
let s = s.clone_shallow();
|
||||||
|
set.spawn(async move {
|
||||||
|
let peer_name = peer.name.clone();
|
||||||
|
let res = async {
|
||||||
|
let conn = s.dial(&peer).await?;
|
||||||
|
let r = crate::cluster::rpc::call_repo_ensure(&conn, &req).await;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
r
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(reply) => RepoPeerResult {
|
||||||
|
peer: peer_name,
|
||||||
|
ok: true,
|
||||||
|
path: Some(reply.path),
|
||||||
|
head_sha: Some(reply.head_sha),
|
||||||
|
cached: Some(reply.cached),
|
||||||
|
removed: None,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => RepoPeerResult {
|
||||||
|
peer: peer_name,
|
||||||
|
ok: false,
|
||||||
|
path: None,
|
||||||
|
head_sha: None,
|
||||||
|
cached: None,
|
||||||
|
removed: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
match joined {
|
||||||
|
Ok(r) => out.push(r),
|
||||||
|
Err(e) => out.push(RepoPeerResult {
|
||||||
|
peer: "<join-error>".into(),
|
||||||
|
ok: false,
|
||||||
|
path: None,
|
||||||
|
head_sha: None,
|
||||||
|
cached: None,
|
||||||
|
removed: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.peer.cmp(&b.peer));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fanout_repo_release(
|
||||||
|
s: &V2State,
|
||||||
|
req: &crate::cluster::repo_ensure::RepoReleaseRequest,
|
||||||
|
) -> Vec<RepoPeerResult> {
|
||||||
|
let mut set = JoinSet::new();
|
||||||
|
for peer in &s.peers {
|
||||||
|
let peer = peer.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
let s = s.clone_shallow();
|
||||||
|
set.spawn(async move {
|
||||||
|
let peer_name = peer.name.clone();
|
||||||
|
let res = async {
|
||||||
|
let conn = s.dial(&peer).await?;
|
||||||
|
let r = crate::cluster::rpc::call_repo_release(&conn, &req).await;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
r
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(reply) => RepoPeerResult {
|
||||||
|
peer: peer_name,
|
||||||
|
ok: true,
|
||||||
|
path: None,
|
||||||
|
head_sha: None,
|
||||||
|
cached: None,
|
||||||
|
removed: Some(reply.removed),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => RepoPeerResult {
|
||||||
|
peer: peer_name,
|
||||||
|
ok: false,
|
||||||
|
path: None,
|
||||||
|
head_sha: None,
|
||||||
|
cached: None,
|
||||||
|
removed: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
match joined {
|
||||||
|
Ok(r) => out.push(r),
|
||||||
|
Err(e) => out.push(RepoPeerResult {
|
||||||
|
peer: "<join-error>".into(),
|
||||||
|
ok: false,
|
||||||
|
path: None,
|
||||||
|
head_sha: None,
|
||||||
|
cached: None,
|
||||||
|
removed: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.peer.cmp(&b.peer));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ── auth middleware ─────────────────────────────────────────────
|
// ── auth middleware ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Resolve the request's bearer against the aggregator's known
|
/// Resolve the request's bearer against the aggregator's known
|
||||||
@@ -1085,6 +1324,8 @@ pub fn build(state: Arc<V2State>) -> Router {
|
|||||||
.route("/api/v2/sessions/:id/pin", post(handle_session_pin))
|
.route("/api/v2/sessions/:id/pin", post(handle_session_pin))
|
||||||
.route("/api/v2/sessions/:id/renew", post(handle_renew_session))
|
.route("/api/v2/sessions/:id/renew", post(handle_renew_session))
|
||||||
.route("/api/v2/sessions/:id/commit", post(handle_commit_session))
|
.route("/api/v2/sessions/:id/commit", post(handle_commit_session))
|
||||||
|
.route("/api/v2/repos/ensure", post(handle_repos_ensure))
|
||||||
|
.route("/api/v2/repos/release", post(handle_repos_release))
|
||||||
.route_layer(axum::middleware::from_fn_with_state(state.clone(), v2_auth))
|
.route_layer(axum::middleware::from_fn_with_state(state.clone(), v2_auth))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user