Public release release-f70e47af061d
Source commit: f70e47af061d8f7ba27cd769c8cd2e2f8707dc72 Public tree identity: sha256:03a43db60d295811688bedaa5ad6460df0dbde27fbf37033997f4d8100f83ab2
This commit is contained in:
commit
b23e4e5bff
210 changed files with 78295 additions and 0 deletions
452
crates/clusterflux-coordinator/src/service/nodes.rs
Normal file
452
crates/clusterflux-coordinator/src/service/nodes.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use clusterflux_core::{
|
||||
generate_opaque_token, verify_node_request_signature, Actor, ArtifactId, CredentialKind,
|
||||
Digest, NodeCapabilities, NodeDescriptor, NodeId, NodeSignedRequest, ProjectId,
|
||||
SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{
|
||||
bounded_ttl, enrollment_grant_key, CoordinatorResponse, CoordinatorService,
|
||||
CoordinatorServiceError,
|
||||
};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub fn set_node_stale_after_seconds(&mut self, seconds: u64) {
|
||||
self.node_stale_after_seconds = seconds.max(1);
|
||||
}
|
||||
|
||||
pub(super) fn liveness_now_epoch_seconds(&self) -> u64 {
|
||||
#[cfg(test)]
|
||||
if let Some(now) = self.server_time_override {
|
||||
return now;
|
||||
}
|
||||
unix_timestamp_seconds()
|
||||
}
|
||||
|
||||
pub(super) fn node_is_live(&self, node: &NodeId) -> bool {
|
||||
self.node_last_seen_epoch_seconds
|
||||
.get(node)
|
||||
.is_some_and(|last_seen| {
|
||||
self.liveness_now_epoch_seconds().saturating_sub(*last_seen)
|
||||
<= self.node_stale_after_seconds
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn live_node_descriptors(&self) -> Vec<NodeDescriptor> {
|
||||
self.node_descriptors
|
||||
.values()
|
||||
.cloned()
|
||||
.map(|mut descriptor| {
|
||||
descriptor.online = self.node_is_live(&descriptor.id);
|
||||
descriptor
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn handle_attach_node(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator.upsert_user(
|
||||
tenant.clone(),
|
||||
UserId::from("local-user"),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||
self.coordinator.upsert_source_provider_config(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
SourceProviderKind::Filesystem,
|
||||
Digest::sha256("local-filesystem"),
|
||||
);
|
||||
self.coordinator.enroll_node(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
node.clone(),
|
||||
public_key,
|
||||
"node:attach",
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeAttached {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_create_node_enrollment_grant(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator
|
||||
.upsert_user(tenant.clone(), actor, CredentialKind::CliDeviceSession);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||
self.coordinator.upsert_source_provider_config(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
SourceProviderKind::Filesystem,
|
||||
Digest::sha256("local-filesystem"),
|
||||
);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.enrollment_grants.retain(|_, grant| {
|
||||
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
|
||||
});
|
||||
if self
|
||||
.enrollment_grants
|
||||
.values()
|
||||
.filter(|grant| grant.tenant == tenant && grant.project == project)
|
||||
.count()
|
||||
>= super::MAX_ENROLLMENT_GRANTS_PER_PROJECT
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node enrollment grant limit reached for this project; consume a grant or wait for one to expire"
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
let grant =
|
||||
generate_opaque_token("node_grant").map_err(CoordinatorServiceError::Protocol)?;
|
||||
let ttl_seconds = bounded_ttl(ttl_seconds, self.admission.max_node_enrollment_ttl_seconds);
|
||||
let scope = "node:attach".to_owned();
|
||||
let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
|
||||
let enrollment = self.coordinator.create_node_enrollment_grant(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
grant.clone(),
|
||||
scope.clone(),
|
||||
expires_at_epoch_seconds,
|
||||
);
|
||||
self.enrollment_grants
|
||||
.insert(enrollment_grant_key(&tenant, &project, &grant), enrollment);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeEnrollmentGrantCreated {
|
||||
tenant,
|
||||
project,
|
||||
grant,
|
||||
scope,
|
||||
expires_at_epoch_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_exchange_node_enrollment_grant(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
enrollment_grant: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.enrollment_grants.retain(|_, grant| {
|
||||
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
|
||||
});
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
if self.coordinator.node_identity(&node).is_none() {
|
||||
self.quota.ensure_node_admission(
|
||||
&tenant,
|
||||
self.coordinator.node_identity_count_for_tenant(&tenant),
|
||||
)?;
|
||||
}
|
||||
let grant_key = enrollment_grant_key(&tenant, &project, &enrollment_grant);
|
||||
let grant =
|
||||
self.enrollment_grants
|
||||
.get_mut(&grant_key)
|
||||
.ok_or(CoordinatorError::Enrollment(
|
||||
clusterflux_core::EnrollmentError::Expired,
|
||||
))?;
|
||||
let credential = self.coordinator.exchange_node_enrollment_grant(
|
||||
grant,
|
||||
node.clone(),
|
||||
&public_key,
|
||||
"node:attach",
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
self.enrollment_grants.remove(&grant_key);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeEnrollmentExchanged {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
credential,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_node_heartbeat(
|
||||
&mut self,
|
||||
node: String,
|
||||
node_signature: Option<NodeSignedRequest>,
|
||||
payload_digest: &Digest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let node = NodeId::new(node);
|
||||
self.authenticate_node_request(&node, node_signature, "node_heartbeat", payload_digest)?;
|
||||
Ok(CoordinatorResponse::NodeHeartbeat {
|
||||
node,
|
||||
epoch: self.coordinator.coordinator_epoch(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_report_node_capabilities(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
capabilities: NodeCapabilities,
|
||||
cached_environment_digests: Vec<Digest>,
|
||||
dependency_cache_digests: Vec<Digest>,
|
||||
source_snapshots: Vec<Digest>,
|
||||
artifact_locations: Vec<String>,
|
||||
direct_connectivity: bool,
|
||||
_online: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(&node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if identity.tenant != tenant || identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node capability report is outside the enrolled tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
capabilities.validate_public_report()?;
|
||||
for (kind, count) in [
|
||||
("cached environments", cached_environment_digests.len()),
|
||||
("dependency caches", dependency_cache_digests.len()),
|
||||
("source snapshots", source_snapshots.len()),
|
||||
("artifact locations", artifact_locations.len()),
|
||||
] {
|
||||
if count > super::MAX_NODE_REPORTED_OBJECTS_PER_KIND {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"node capability report contains {count} {kind}; limit is {}",
|
||||
super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
|
||||
)));
|
||||
}
|
||||
}
|
||||
if cached_environment_digests
|
||||
.iter()
|
||||
.chain(&dependency_cache_digests)
|
||||
.chain(&source_snapshots)
|
||||
.any(|digest| !digest.is_valid_sha256())
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node capability report contains an invalid digest".to_owned(),
|
||||
));
|
||||
}
|
||||
if artifact_locations.iter().any(|artifact| {
|
||||
artifact.trim().is_empty()
|
||||
|| artifact.len() > 256
|
||||
|| artifact
|
||||
.chars()
|
||||
.any(|character| matches!(character, '/' | '\\' | '\0'))
|
||||
}) {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node capability report contains an invalid artifact id".to_owned(),
|
||||
));
|
||||
}
|
||||
let artifact_locations = artifact_locations
|
||||
.into_iter()
|
||||
.map(ArtifactId::new)
|
||||
.collect::<BTreeSet<_>>();
|
||||
self.artifact_registry
|
||||
.reconcile_node_retention(&node, &artifact_locations);
|
||||
|
||||
let online = self.node_is_live(&node);
|
||||
self.node_descriptors.insert(
|
||||
node.clone(),
|
||||
NodeDescriptor {
|
||||
id: node.clone(),
|
||||
tenant,
|
||||
project,
|
||||
capabilities,
|
||||
cached_environments: cached_environment_digests.into_iter().collect(),
|
||||
dependency_caches: dependency_cache_digests.into_iter().collect(),
|
||||
source_snapshots: source_snapshots.into_iter().collect(),
|
||||
artifact_locations,
|
||||
direct_connectivity,
|
||||
online,
|
||||
},
|
||||
);
|
||||
Ok(CoordinatorResponse::NodeCapabilitiesRecorded {
|
||||
node,
|
||||
node_descriptors: self.node_descriptors.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_list_node_descriptors(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let descriptors = self
|
||||
.live_node_descriptors()
|
||||
.into_iter()
|
||||
.filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project)
|
||||
.collect();
|
||||
Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor })
|
||||
}
|
||||
|
||||
pub(super) fn handle_revoke_node_credential(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
node: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let node = NodeId::new(node);
|
||||
let context = clusterflux_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
self.coordinator.revoke_node_credential(&context, &node)?;
|
||||
let descriptor_removed = self.node_descriptors.remove(&node).is_some();
|
||||
self.node_last_seen_epoch_seconds.remove(&node);
|
||||
self.artifact_registry.garbage_collect_node(&node);
|
||||
let queued_assignments_removed = self
|
||||
.task_assignments
|
||||
.remove(&(tenant.clone(), project.clone(), node.clone()))
|
||||
.map_or(0, |assignments| assignments.len());
|
||||
self.active_tasks
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_cancellations
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_aborts
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_placements
|
||||
.retain(|(task_tenant, task_project, _, task_node, _), _| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeCredentialRevoked {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
descriptor_removed,
|
||||
queued_assignments_removed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn authenticate_node_request(
|
||||
&mut self,
|
||||
node: &NodeId,
|
||||
node_signature: Option<NodeSignedRequest>,
|
||||
request_kind: &str,
|
||||
payload_digest: &Digest,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
let signature = node_signature.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"node request requires a signed proof of enrolled private-key possession"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request nonce is missing or invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now_epoch_seconds = unix_timestamp_seconds();
|
||||
if signature
|
||||
.issued_at_epoch_seconds
|
||||
.abs_diff(now_epoch_seconds)
|
||||
> super::NODE_SIGNATURE_WINDOW_SECONDS
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request is expired or outside the allowed clock skew".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let replay_key = (node.clone(), signature.nonce.clone());
|
||||
self.node_replay_nonces.retain(|_, accepted_at| {
|
||||
now_epoch_seconds <= accepted_at.saturating_add(super::NODE_SIGNATURE_WINDOW_SECONDS)
|
||||
});
|
||||
if self.node_replay_nonces.contains_key(&replay_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request nonce has already been used".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
verify_node_request_signature(
|
||||
&identity.public_key,
|
||||
node,
|
||||
request_kind,
|
||||
payload_digest,
|
||||
&signature,
|
||||
)
|
||||
.map_err(CoordinatorError::Unauthorized)?;
|
||||
if self
|
||||
.node_replay_nonces
|
||||
.keys()
|
||||
.filter(|(retained_node, _)| retained_node == node)
|
||||
.count()
|
||||
>= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request replay window is full; retry after the bounded signature window advances"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.node_replay_nonces
|
||||
.insert(replay_key, now_epoch_seconds);
|
||||
let seen_at = self.liveness_now_epoch_seconds();
|
||||
self.node_last_seen_epoch_seconds
|
||||
.insert(node.clone(), seen_at);
|
||||
if let Some(descriptor) = self.node_descriptors.get_mut(node) {
|
||||
descriptor.online = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue