Phase 9 S1-S3: TTL-scoped sessions with tag leases #105
Generated
+12
@@ -399,6 +399,7 @@ dependencies = [
|
|||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2184,6 +2185,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.23.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ axum = { version = "0.7", features = ["macros"] }
|
|||||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# v1 — session IDs (Phase 9 S1). v4 random hex; no persistence
|
||||||
|
# concerns beyond "opaque URL-safe id".
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
# v0.2.0 — flock(2) wrapper for atomic+locked manifest writes
|
# v0.2.0 — flock(2) wrapper for atomic+locked manifest writes
|
||||||
# (manifest.rs). Already a transitive dep; declaring it directly
|
# (manifest.rs). Already a transitive dep; declaring it directly
|
||||||
# makes the call site obvious.
|
# makes the call site obvious.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub mod manifest;
|
|||||||
pub mod restore;
|
pub mod restore;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod serve_v2;
|
pub mod serve_v2;
|
||||||
|
pub mod sessions;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod zfs;
|
pub mod zfs;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod manifest;
|
|||||||
mod restore;
|
mod restore;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod serve_v2;
|
mod serve_v2;
|
||||||
|
mod sessions;
|
||||||
mod snapshot;
|
mod snapshot;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod zfs;
|
mod zfs;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use crate::cluster::rpc::{
|
|||||||
};
|
};
|
||||||
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
||||||
use crate::config::{Config, PeerEntry, TokenEntry};
|
use crate::config::{Config, PeerEntry, TokenEntry};
|
||||||
|
use crate::sessions::{LeasedTag, Session, SessionStore};
|
||||||
|
|
||||||
/// Aggregator runtime: one QuicClient, one peer list, one identity.
|
/// Aggregator runtime: one QuicClient, one peer list, one identity.
|
||||||
///
|
///
|
||||||
@@ -63,6 +64,10 @@ pub struct V2State {
|
|||||||
/// entries here can be admin (no `namespace`) or scoped (must
|
/// entries here can be admin (no `namespace`) or scoped (must
|
||||||
/// match `<namespace>:*` on tag names). First match wins.
|
/// match `<namespace>:*` on tag names). First match wins.
|
||||||
pub token_entries: Vec<TokenEntry>,
|
pub token_entries: Vec<TokenEntry>,
|
||||||
|
/// TTL-scoped session store (Phase 9 S1-S3). Sessions own tag
|
||||||
|
/// leases and are reaped by a background sweeper when their
|
||||||
|
/// `expires_at_unix` passes without a `renew` or `commit`.
|
||||||
|
pub sessions: SessionStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl V2State {
|
impl V2State {
|
||||||
@@ -79,6 +84,13 @@ impl V2State {
|
|||||||
.map_err(|e| anyhow::anyhow!("loading fleet-CA identity: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("loading fleet-CA identity: {e}"))?;
|
||||||
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)
|
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)
|
||||||
.map_err(|e| anyhow::anyhow!("building QUIC client: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("building QUIC client: {e}"))?;
|
||||||
|
// Session store persists next to warm.projects_path — the
|
||||||
|
// aggregator already has a writable directory there. Fall
|
||||||
|
// back to /tmp so tests don't need config.warm set.
|
||||||
|
let sessions_path = std::path::PathBuf::from(&cfg.warm.projects_path)
|
||||||
|
.join("aggregator-sessions.json");
|
||||||
|
let sessions = SessionStore::load(sessions_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("loading session store: {e}"))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
aggregator_name: cfg.node.name.clone(),
|
aggregator_name: cfg.node.name.clone(),
|
||||||
peers: cluster.peers.clone(),
|
peers: cluster.peers.clone(),
|
||||||
@@ -90,6 +102,7 @@ impl V2State {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.tokens.clone())
|
.map(|a| a.tokens.clone())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
sessions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +508,7 @@ impl V2State {
|
|||||||
default_rpc_port_offset: self.default_rpc_port_offset,
|
default_rpc_port_offset: self.default_rpc_port_offset,
|
||||||
api_token: self.api_token.clone(),
|
api_token: self.api_token.clone(),
|
||||||
token_entries: self.token_entries.clone(),
|
token_entries: self.token_entries.clone(),
|
||||||
|
sessions: self.sessions.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,13 +792,275 @@ async fn v2_auth(
|
|||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── session endpoints (Phase 9 S1-S3) ───────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateSessionBody {
|
||||||
|
/// Absolute-from-now TTL in seconds. Callers should send the
|
||||||
|
/// same value on `renew` to reset the clock; there's no additive
|
||||||
|
/// mode. Cap enforced at 24h to protect the sweeper from a
|
||||||
|
/// caller pinning tags forever.
|
||||||
|
pub ttl_secs: u64,
|
||||||
|
/// Optional human note for operator/debugging visibility.
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PinInSessionBody {
|
||||||
|
pub tag: String,
|
||||||
|
pub blob_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenewBody {
|
||||||
|
pub ttl_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max TTL a session may live before requiring renewal or commit.
|
||||||
|
/// Prevents a caller from creating an effectively-immortal session
|
||||||
|
/// with, say, ttl_secs = u64::MAX.
|
||||||
|
const MAX_SESSION_TTL_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Namespace filter that pairs a caller with the sessions they may
|
||||||
|
/// see or modify. Admin/Open see everything; namespaced callers only
|
||||||
|
/// see their own namespace's sessions.
|
||||||
|
fn caller_ns(caller: &AuthedCaller) -> Option<String> {
|
||||||
|
match caller {
|
||||||
|
AuthedCaller::Admin | AuthedCaller::Open => None,
|
||||||
|
AuthedCaller::Namespaced { namespace } => Some(namespace.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `caller` may inspect / mutate `session`.
|
||||||
|
fn caller_may_access(caller: &AuthedCaller, session: &Session) -> bool {
|
||||||
|
match caller {
|
||||||
|
AuthedCaller::Admin | AuthedCaller::Open => true,
|
||||||
|
AuthedCaller::Namespaced { namespace } => {
|
||||||
|
session.namespace.as_deref() == Some(namespace.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_create_session(
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
Json(body): Json<CreateSessionBody>,
|
||||||
|
) -> Result<Json<Session>, (StatusCode, String)> {
|
||||||
|
if body.ttl_secs == 0 || body.ttl_secs > MAX_SESSION_TTL_SECS {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("ttl_secs must be 1..={MAX_SESSION_TTL_SECS}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ns = caller_ns(&caller);
|
||||||
|
let sess = s
|
||||||
|
.sessions
|
||||||
|
.create(ns, body.ttl_secs, body.note)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
Ok(Json(sess))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_list_sessions(
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
) -> Json<Vec<Session>> {
|
||||||
|
// caller_ns == None means admin/open → no filter → see all.
|
||||||
|
let filter = caller_ns(&caller);
|
||||||
|
let list = s.sessions.list(filter.as_deref()).await;
|
||||||
|
Json(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_get_session(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
) -> Result<Json<Session>, StatusCode> {
|
||||||
|
let sess = s.sessions.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
if !caller_may_access(&caller, &sess) {
|
||||||
|
// Preserve the "does it exist" secret from other tenants —
|
||||||
|
// NOT_FOUND, not FORBIDDEN. Same trick the wizard-side
|
||||||
|
// publish-approval endpoint uses.
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
Ok(Json(sess))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin a tag *within* a session: fan the pin out to peers, then
|
||||||
|
/// record it in the session so a later commit/cancel/expiry can
|
||||||
|
/// reverse it. Same tag scope rule as bare `POST /tags/:name` —
|
||||||
|
/// namespaced callers must stay in their namespace.
|
||||||
|
async fn handle_session_pin(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
Json(body): Json<PinInSessionBody>,
|
||||||
|
) -> Result<Json<Session>, (StatusCode, String)> {
|
||||||
|
check_tag_scope(&caller, &body.tag)?;
|
||||||
|
let value = decode_blob_id(&body.blob_id)
|
||||||
|
.ok_or_else(|| (StatusCode::BAD_REQUEST, "blob_id must be 64-char hex".into()))?;
|
||||||
|
let sess = s
|
||||||
|
.sessions
|
||||||
|
.get(&id)
|
||||||
|
.await
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "session not found".into()))?;
|
||||||
|
if !caller_may_access(&caller, &sess) {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "session not found".into()));
|
||||||
|
}
|
||||||
|
if sess.committed {
|
||||||
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"cannot attach leases to a committed session".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Fan out the pin BEFORE attaching to the session — if the fleet
|
||||||
|
// rejects it, we don't record a phantom lease that can never be
|
||||||
|
// unpinned. Same shape as the standalone put-tag path.
|
||||||
|
let peer_results = fanout_put_tag(&s, &body.tag, &value).await;
|
||||||
|
if !peer_results.iter().all(|r| r.ok) {
|
||||||
|
// Return the per-peer error detail so the caller can act.
|
||||||
|
let err = serde_json::to_string(&peer_results).unwrap_or_default();
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, err));
|
||||||
|
}
|
||||||
|
let lease = LeasedTag {
|
||||||
|
tag: body.tag,
|
||||||
|
blob_id_hex: body.blob_id,
|
||||||
|
pinned_at_unix: now_unix_u64(),
|
||||||
|
};
|
||||||
|
let updated = s
|
||||||
|
.sessions
|
||||||
|
.attach_lease(&id, lease)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
Ok(Json(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_renew_session(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
Json(body): Json<RenewBody>,
|
||||||
|
) -> Result<Json<Session>, (StatusCode, String)> {
|
||||||
|
if body.ttl_secs == 0 || body.ttl_secs > MAX_SESSION_TTL_SECS {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("ttl_secs must be 1..={MAX_SESSION_TTL_SECS}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let sess = s
|
||||||
|
.sessions
|
||||||
|
.get(&id)
|
||||||
|
.await
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "session not found".into()))?;
|
||||||
|
if !caller_may_access(&caller, &sess) {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "session not found".into()));
|
||||||
|
}
|
||||||
|
let updated = s
|
||||||
|
.sessions
|
||||||
|
.renew(&id, body.ttl_secs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
Ok(Json(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_commit_session(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
) -> Result<Json<Session>, (StatusCode, String)> {
|
||||||
|
let sess = s
|
||||||
|
.sessions
|
||||||
|
.get(&id)
|
||||||
|
.await
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "session not found".into()))?;
|
||||||
|
if !caller_may_access(&caller, &sess) {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "session not found".into()));
|
||||||
|
}
|
||||||
|
let updated = s
|
||||||
|
.sessions
|
||||||
|
.commit(&id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
Ok(Json(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_delete_session(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(s): State<Arc<V2State>>,
|
||||||
|
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
|
||||||
|
) -> Result<Json<Vec<PeerResult>>, StatusCode> {
|
||||||
|
let sess = s.sessions.get(&id).await.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
if !caller_may_access(&caller, &sess) {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
// Unpin every tag on every peer FIRST — if we removed the session
|
||||||
|
// first, a mid-deletion crash would strand tags on the fleet.
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
for lease in &sess.leases {
|
||||||
|
let r = fanout_delete_tag(&s, &lease.tag).await;
|
||||||
|
all_results.extend(r);
|
||||||
|
}
|
||||||
|
let _ = s.sessions.remove(&id).await;
|
||||||
|
Ok(Json(all_results))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_u64() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reap callback for the sessions sweeper. Owns the actual fan-out.
|
||||||
|
/// Kept a free function so `sessions::spawn_sweeper` doesn't have
|
||||||
|
/// to know about `V2State` or its RPC client.
|
||||||
|
async fn reap_expired(state: Arc<V2State>, sess: Session) {
|
||||||
|
for lease in &sess.leases {
|
||||||
|
let results = fanout_delete_tag(&state, &lease.tag).await;
|
||||||
|
// Log per-peer failures; the sweeper is best-effort. A
|
||||||
|
// failing peer will be retried on the next lease that
|
||||||
|
// touches it because the tag stays in the store until
|
||||||
|
// successfully removed everywhere (well, the sweeper drops
|
||||||
|
// the session either way — this is a known tradeoff:
|
||||||
|
// durability of unpin vs. never-ending sweeper retries).
|
||||||
|
for r in results {
|
||||||
|
if !r.ok {
|
||||||
|
eprintln!(
|
||||||
|
"sweeper: session {} tag {} peer {} unpin failed: {}",
|
||||||
|
sess.id, lease.tag, r.peer,
|
||||||
|
r.error.as_deref().unwrap_or("<no error message>")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── route registration ──────────────────────────────────────────
|
// ── route registration ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Assemble the aggregator router with state and middleware baked in.
|
/// Assemble the aggregator router with state and middleware baked in.
|
||||||
/// The auth middleware needs the concrete `Arc<V2State>` at layer time
|
/// The auth middleware needs the concrete `Arc<V2State>` at layer time
|
||||||
/// (so it can read `api_token`), which is why this returns a fully-
|
/// (so it can read `api_token`), which is why this returns a fully-
|
||||||
/// stated `Router<()>` instead of a state-generic router.
|
/// stated `Router<()>` instead of a state-generic router.
|
||||||
|
///
|
||||||
|
/// Also spawns the background TTL sweeper (Phase 9 S1). The task is
|
||||||
|
/// detached — its lifetime is the process lifetime.
|
||||||
pub fn build(state: Arc<V2State>) -> Router {
|
pub fn build(state: Arc<V2State>) -> Router {
|
||||||
|
// Spawn the sweeper. 15s tick is a reasonable balance: quick
|
||||||
|
// enough that a mid-wizard-close cleanup feels prompt, slow
|
||||||
|
// enough that idle aggregators aren't burning cycles.
|
||||||
|
{
|
||||||
|
let state_for_sweeper = state.clone();
|
||||||
|
let store = state.sessions.clone();
|
||||||
|
crate::sessions::spawn_sweeper(
|
||||||
|
store,
|
||||||
|
Duration::from_secs(15),
|
||||||
|
move |sess| {
|
||||||
|
let state = state_for_sweeper.clone();
|
||||||
|
async move { reap_expired(state, sess).await }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v2/fleet", get(handle_fleet))
|
.route("/api/v2/fleet", get(handle_fleet))
|
||||||
.route("/api/v2/node/:name/status", get(handle_node_status))
|
.route("/api/v2/node/:name/status", get(handle_node_status))
|
||||||
@@ -798,6 +1074,17 @@ pub fn build(state: Arc<V2State>) -> Router {
|
|||||||
"/api/v2/tags/:name",
|
"/api/v2/tags/:name",
|
||||||
post(handle_put_tag).delete(handle_delete_tag),
|
post(handle_put_tag).delete(handle_delete_tag),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v2/sessions",
|
||||||
|
post(handle_create_session).get(handle_list_sessions),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v2/sessions/:id",
|
||||||
|
get(handle_get_session).delete(handle_delete_session),
|
||||||
|
)
|
||||||
|
.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_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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! Aggregator-side session + lease store (Phase 9 S1-S3).
|
||||||
|
//!
|
||||||
|
//! A **session** is a TTL-bounded container that owns one or more
|
||||||
|
//! **tag leases**. Callers create a session, attach pins to it, and
|
||||||
|
//! either `commit` (make the tags permanent, drop tracking) or let
|
||||||
|
//! the session `expire` / `DELETE` it (unpin every tag on every peer).
|
||||||
|
//!
|
||||||
|
//! This solves the wizard-cancel problem: the client picks a repo,
|
||||||
|
//! we mint a session + pin under it, and if the browser closes we
|
||||||
|
//! reap automatically. No orphaned fleet-wide state.
|
||||||
|
//!
|
||||||
|
//! ## Persistence
|
||||||
|
//!
|
||||||
|
//! One JSON file at `path`. Rewritten on every mutation (small store,
|
||||||
|
//! aggregator only, correctness > throughput). Survives aggregator
|
||||||
|
//! restart so mid-flight leases don't leak.
|
||||||
|
//!
|
||||||
|
//! ## Concurrency
|
||||||
|
//!
|
||||||
|
//! One `RwLock<HashMap<SessionId, Session>>`. Handlers take the write
|
||||||
|
//! lock briefly (mutation + `save()`), the sweeper takes it for the
|
||||||
|
//! per-tick scan + reap. Peer fan-out RPCs happen **outside** the
|
||||||
|
//! lock so a slow peer never blocks other API calls.
|
||||||
|
//!
|
||||||
|
//! ## Auth
|
||||||
|
//!
|
||||||
|
//! Every session carries the caller's `namespace` at creation. The
|
||||||
|
//! aggregator enforces "same-namespace only" access on every session
|
||||||
|
//! endpoint. Admin callers see all sessions.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Opaque session id — UUID v4 hex, stable in the URL. Public so
|
||||||
|
/// HTTP handlers can parse from path params.
|
||||||
|
pub type SessionId = String;
|
||||||
|
|
||||||
|
/// A single tag that a session owns. On reap, the aggregator issues
|
||||||
|
/// a fleet-wide `DELETE /api/v2/tags/:name` for each of these.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LeasedTag {
|
||||||
|
pub tag: String,
|
||||||
|
/// 64-char lowercase hex — the blob the tag was pinned to. Kept
|
||||||
|
/// so listing endpoints can show what the session is holding.
|
||||||
|
pub blob_id_hex: String,
|
||||||
|
/// When the pin actually landed on the fleet, unix seconds. For
|
||||||
|
/// operator inspection; not consulted for reap decisions.
|
||||||
|
pub pinned_at_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: SessionId,
|
||||||
|
/// Namespace the session belongs to. `None` means an admin-owned
|
||||||
|
/// session (created via the root `api_token`). Enforced by the
|
||||||
|
/// HTTP layer, not this module.
|
||||||
|
pub namespace: Option<String>,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
/// When the sweeper will reap this session (unix seconds).
|
||||||
|
/// Extended by `renew`; frozen by `commit`.
|
||||||
|
pub expires_at_unix: u64,
|
||||||
|
pub leases: Vec<LeasedTag>,
|
||||||
|
/// Optional operator note, e.g. `"research-wizard draft"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<String>,
|
||||||
|
/// After `commit`, the session sticks around for a short grace
|
||||||
|
/// window so the UI can still show "committed" state, but the
|
||||||
|
/// sweeper stops treating expiry as a reap trigger. Reapable
|
||||||
|
/// only via explicit `DELETE`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub committed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to the on-disk + in-memory session store.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
inner: Arc<RwLock<HashMap<SessionId, Session>>>,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
/// Load from disk (empty map if the file doesn't exist). Called
|
||||||
|
/// once at aggregator boot; concurrent handlers share the returned
|
||||||
|
/// clone via `Arc`.
|
||||||
|
pub fn load(path: PathBuf) -> Result<Self> {
|
||||||
|
let map: HashMap<SessionId, Session> = if path.exists() {
|
||||||
|
let bytes = std::fs::read(&path)
|
||||||
|
.with_context(|| format!("read sessions store at {}", path.display()))?;
|
||||||
|
serde_json::from_slice(&bytes).context("parse sessions store")?
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(RwLock::new(map)),
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the whole store to disk. Called under the write lock.
|
||||||
|
/// Small store, correctness > throughput.
|
||||||
|
fn save_locked(&self, map: &HashMap<SessionId, Session>) -> Result<()> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, serde_json::to_vec_pretty(map)?)?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
&self,
|
||||||
|
namespace: Option<String>,
|
||||||
|
ttl_secs: u64,
|
||||||
|
note: Option<String>,
|
||||||
|
) -> Result<Session> {
|
||||||
|
let now = now_unix();
|
||||||
|
let sess = Session {
|
||||||
|
id: Uuid::new_v4().simple().to_string(),
|
||||||
|
namespace,
|
||||||
|
created_at_unix: now,
|
||||||
|
expires_at_unix: now.saturating_add(ttl_secs),
|
||||||
|
leases: Vec::new(),
|
||||||
|
note,
|
||||||
|
committed: false,
|
||||||
|
};
|
||||||
|
let mut w = self.inner.write().await;
|
||||||
|
w.insert(sess.id.clone(), sess.clone());
|
||||||
|
self.save_locked(&w)?;
|
||||||
|
Ok(sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, id: &str) -> Option<Session> {
|
||||||
|
self.inner.read().await.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all sessions visible to a caller. `None` namespace ==
|
||||||
|
/// admin (sees everything); `Some(ns)` filters to sessions owned
|
||||||
|
/// by that namespace.
|
||||||
|
pub async fn list(&self, namespace_filter: Option<&str>) -> Vec<Session> {
|
||||||
|
let r = self.inner.read().await;
|
||||||
|
let mut out: Vec<Session> = r
|
||||||
|
.values()
|
||||||
|
.filter(|s| match namespace_filter {
|
||||||
|
None => true,
|
||||||
|
Some(ns) => s.namespace.as_deref() == Some(ns),
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
// Newest first — mirrors the pattern in dashboards.
|
||||||
|
out.sort_by(|a, b| b.created_at_unix.cmp(&a.created_at_unix));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a tag lease to an existing session. Caller must have
|
||||||
|
/// already fanned the actual pin out to peers. This just records
|
||||||
|
/// the tag so the sweeper can reap it on expiry.
|
||||||
|
pub async fn attach_lease(&self, id: &str, lease: LeasedTag) -> Result<Session> {
|
||||||
|
let mut w = self.inner.write().await;
|
||||||
|
let s = w
|
||||||
|
.get_mut(id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session {id} not found"))?;
|
||||||
|
// Idempotent: dedupe by tag name so retries don't double-record.
|
||||||
|
if !s.leases.iter().any(|l| l.tag == lease.tag) {
|
||||||
|
s.leases.push(lease);
|
||||||
|
}
|
||||||
|
let out = s.clone();
|
||||||
|
self.save_locked(&w)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extend expiry. `ttl_secs` is absolute-from-now, not additive,
|
||||||
|
/// so heartbeats stay idempotent — sending "1 hour" repeatedly
|
||||||
|
/// pins expiry to `now + 1h` no matter how many arrive.
|
||||||
|
pub async fn renew(&self, id: &str, ttl_secs: u64) -> Result<Session> {
|
||||||
|
let mut w = self.inner.write().await;
|
||||||
|
let s = w
|
||||||
|
.get_mut(id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session {id} not found"))?;
|
||||||
|
s.expires_at_unix = now_unix().saturating_add(ttl_secs);
|
||||||
|
let out = s.clone();
|
||||||
|
self.save_locked(&w)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freeze the session's tags: mark committed, stop reaping on
|
||||||
|
/// expiry, but keep the record for a while so the UI can show
|
||||||
|
/// history. Explicit `DELETE` still works.
|
||||||
|
pub async fn commit(&self, id: &str) -> Result<Session> {
|
||||||
|
let mut w = self.inner.write().await;
|
||||||
|
let s = w
|
||||||
|
.get_mut(id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session {id} not found"))?;
|
||||||
|
s.committed = true;
|
||||||
|
let out = s.clone();
|
||||||
|
self.save_locked(&w)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a session from the store and return the tags that
|
||||||
|
/// need to be unpinned across the fleet. Callers must actually
|
||||||
|
/// issue the unpin fan-out — this module doesn't dial peers.
|
||||||
|
pub async fn remove(&self, id: &str) -> Result<Option<Session>> {
|
||||||
|
let mut w = self.inner.write().await;
|
||||||
|
let removed = w.remove(id);
|
||||||
|
if removed.is_some() {
|
||||||
|
self.save_locked(&w)?;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the currently-expired sessions (used by the sweeper).
|
||||||
|
/// Split into snapshot-then-reap so the peer RPCs happen outside
|
||||||
|
/// the store's write lock.
|
||||||
|
pub async fn snapshot_expired(&self) -> Vec<Session> {
|
||||||
|
let now = now_unix();
|
||||||
|
let r = self.inner.read().await;
|
||||||
|
r.values()
|
||||||
|
.filter(|s| !s.committed && s.expires_at_unix <= now)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background task that scans for expired sessions every
|
||||||
|
/// `tick`, calls `reap` for each expired one (which is responsible
|
||||||
|
/// for the actual per-peer unpin), then removes them from the store.
|
||||||
|
/// The reap callback owns fan-out so this module stays free of
|
||||||
|
/// clawstor RPC + config types.
|
||||||
|
pub fn spawn_sweeper<F, Fut>(
|
||||||
|
store: SessionStore,
|
||||||
|
tick: Duration,
|
||||||
|
reap: F,
|
||||||
|
) -> tokio::task::JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: Fn(Session) -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(tick);
|
||||||
|
// Skip the immediate first tick — nothing to reap at boot.
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let expired = store.snapshot_expired().await;
|
||||||
|
for sess in expired {
|
||||||
|
let id = sess.id.clone();
|
||||||
|
reap(sess).await;
|
||||||
|
let _ = store.remove(&id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user