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")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `None` for any other single-byte value (which is a valid reply,
|
||||
/// 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) ──────────────────────────
|
||||
@@ -724,6 +752,217 @@ async fn fanout_delete_tag(s: &V2State, name: &str) -> Vec<PeerResult> {
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
/// 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/renew", post(handle_renew_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))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user