clusterflux-public/crates/clusterflux-coordinator/src/service.rs
Clusterflux release d2e84229c5 Public release release-b8454b34d38c
Source commit: b8454b34d38cc2ba0bd278e842060db45d091e1c

Public tree identity: sha256:69d6c8143bf6227e1bb07852aa94e8af9c26793eca252bb46788894f728cb6d2
2026-07-19 09:43:54 +02:00

571 lines
21 KiB
Rust

// Request handlers intentionally spell out their deserialized protocol fields.
// Keeping those authority and payload values explicit at this boundary is safer
// than passing an unvalidated wire request deeper into the service.
#![allow(clippy::too_many_arguments)]
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::time::{SystemTime, UNIX_EPOCH};
use clusterflux_core::{
Actor, AgentId, ArtifactRegistry, CapabilityReportError, CredentialKind, Digest, LimitError,
NativeQuicTransport, NodeDescriptor, NodeId, PanelState, Placement, ProcessId, ProjectId,
RateLimit, TenantId, TransportError, UserId,
};
use thiserror::Error;
use crate::{Coordinator, CoordinatorError};
mod admin;
mod artifacts;
mod authenticated;
mod authorization;
mod debug;
mod debug_requests;
mod durable_runtime;
mod keys;
mod logs;
mod main_runtime;
mod nodes;
mod panels;
mod process_launch;
mod processes;
mod protocol;
mod quota;
mod relay;
mod routing;
mod signed_nodes;
mod tcp;
mod wire_protocol;
use authorization::authorize_authenticated_user_operation;
use durable_runtime::RuntimeDurableStore;
use keys::{
artifact_id_from_path, enrollment_grant_key, EnrollmentGrantKey, PanelStopKey,
ProcessControlKey, TaskAssignmentKey, TaskControlKey, TaskRestartKey,
};
pub use protocol::{
ArtifactTransferAssignment, AuthenticatedCoordinatorRequest, CoordinatorRequest,
CoordinatorResponse, DebugAcknowledgementState, DebugAuditEvent,
DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus,
TaskAssignment, TaskAttemptSnapshot, TaskAttemptState, TaskCancellationTarget,
TaskCompletionEvent, TaskExecutor, TaskFailureResolution, TaskReplacementBundle,
TaskTerminalState, VirtualProcessStatus, WorkflowActor,
};
pub use quota::CoordinatorQuotaConfiguration;
pub use relay::{
ArtifactRelayConfiguration, ArtifactRelayDurableState, ArtifactRelayError, ArtifactRelayUsage,
};
pub use tcp::{bind_listener, ClientAuthorityMode};
pub use wire_protocol::CoordinatorWireRequest;
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
const DEBUG_CONTROL_READ_BYTES: u64 = 1024;
const MAX_REPLAY_NONCES_PER_AUTHORITY: usize = 1_024;
const NODE_SIGNATURE_WINDOW_SECONDS: u64 = 30;
const MAX_NODE_REPLAY_NONCES_PER_AUTHORITY: usize = 4_096;
const MAX_ENROLLMENT_GRANTS_PER_PROJECT: usize = 64;
const MAX_TASK_EVENTS_PER_PROCESS: usize = 128;
const MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS: usize = 256;
const MAX_RESTART_CHECKPOINTS_PER_PROCESS: usize = 128;
const MAX_TASK_EVENTS_TOTAL: usize = 8_192;
const MAX_DEBUG_AUDIT_EVENTS_TOTAL: usize = 8_192;
const MAX_RESTART_CHECKPOINTS_TOTAL: usize = 4_096;
const MAX_TASK_ATTEMPT_HISTORIES: usize = 4_096;
const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256;
const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024;
const DEFAULT_NODE_STALE_AFTER_SECONDS: u64 = 30;
fn bounded_ttl(requested: u64, maximum: u64) -> u64 {
requested.clamp(1, maximum)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoordinatorMainRuntimeConfiguration {
pub fuel_units_per_second: u64,
pub fuel_burst_seconds: u64,
pub memory_bytes: usize,
pub nested_join_timeout_ms: u64,
pub max_active_mains: usize,
pub max_wakeups_per_minute: u64,
pub max_output_bytes: usize,
pub max_state_bytes: usize,
}
impl Default for CoordinatorMainRuntimeConfiguration {
fn default() -> Self {
Self {
fuel_units_per_second: 10_000_000,
fuel_burst_seconds: 60,
memory_bytes: 256 * 1024 * 1024,
nested_join_timeout_ms: 24 * 60 * 60 * 1_000,
max_active_mains: usize::MAX,
max_wakeups_per_minute: 6_000,
max_output_bytes: MAX_TASK_LOG_TAIL_BYTES,
max_state_bytes: clusterflux_core::MAX_WASM_TASK_ENVELOPE_BYTES,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CoordinatorAdmission {
pub workflow_placement_allowed: bool,
pub max_node_enrollment_ttl_seconds: u64,
pub max_artifact_download_ttl_seconds: u64,
}
impl Default for CoordinatorAdmission {
fn default() -> Self {
Self {
workflow_placement_allowed: true,
max_node_enrollment_ttl_seconds: 15 * 60,
max_artifact_download_ttl_seconds: 15 * 60,
}
}
}
#[derive(Debug, Error)]
pub enum CoordinatorServiceError {
#[error("coordinator protocol I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("coordinator protocol JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("coordinator protocol error: {0}")]
Protocol(String),
#[error("coordinator request failed: {0}")]
Coordinator(#[from] CoordinatorError),
#[error("artifact download request failed: {0}")]
Download(#[from] clusterflux_core::DownloadError),
#[error("scheduler placement failed: {0}")]
Scheduler(#[from] clusterflux_core::PlacementError),
#[error("transport request failed: {0}")]
Transport(#[from] TransportError),
#[error("resource limit failed: {0}")]
Resource(#[from] LimitError),
#[error("operator panel request failed: {0}")]
Panel(#[from] clusterflux_core::PanelError),
#[error("invalid node capability report: {0}")]
CapabilityReport(#[from] CapabilityReportError),
#[error("invalid VFS artifact path reported by node: {0}")]
InvalidArtifactPath(String),
#[error("invalid task log tail reported by node: {0}")]
InvalidTaskLogTail(String),
#[error("durable coordinator state failed: {0}")]
Durable(String),
}
pub struct CoordinatorService {
coordinator: Coordinator,
store: RuntimeDurableStore,
node_descriptors: BTreeMap<NodeId, NodeDescriptor>,
node_last_seen_epoch_seconds: BTreeMap<NodeId, u64>,
node_stale_after_seconds: u64,
debug_freeze_timeout: std::time::Duration,
enrollment_grants: BTreeMap<EnrollmentGrantKey, clusterflux_core::EnrollmentGrant>,
task_events: VecDeque<TaskCompletionEvent>,
process_scope_history: VecDeque<ProcessControlKey>,
debug_audit_events: VecDeque<DebugAuditEvent>,
debug_epochs: BTreeMap<ProcessControlKey, u64>,
debug_epoch_runtime: BTreeMap<ProcessControlKey, debug::DebugEpochRuntime>,
debug_breakpoints: BTreeMap<ProcessControlKey, debug::DebugBreakpointPlan>,
debug_commands: BTreeMap<TaskControlKey, debug::DebugPendingCommand>,
task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>,
task_restart_checkpoints: BTreeMap<TaskRestartKey, processes::TaskRestartCheckpoint>,
task_restart_checkpoint_order: VecDeque<TaskRestartKey>,
task_attempts: BTreeMap<TaskRestartKey, Vec<protocol::TaskAttemptSnapshot>>,
restart_launches: BTreeSet<TaskRestartKey>,
main_runtime: main_runtime::CoordinatorMainRuntime,
pending_task_launches: VecDeque<processes::PendingTaskLaunch>,
task_placements: BTreeMap<TaskControlKey, Placement>,
active_tasks: BTreeSet<TaskControlKey>,
task_cancellations: BTreeSet<TaskControlKey>,
task_aborts: BTreeSet<TaskControlKey>,
process_cancellations: BTreeSet<ProcessControlKey>,
process_aborts: BTreeSet<ProcessControlKey>,
agent_replay_nonces: BTreeMap<(TenantId, ProjectId, AgentId, String), u64>,
node_replay_nonces: BTreeMap<(NodeId, String), u64>,
panel_snapshots: BTreeMap<PanelStopKey, PanelState>,
stopped_panels: BTreeSet<PanelStopKey>,
panel_event_limits: BTreeMap<(TenantId, ProjectId, ProcessId, String), RateLimit>,
artifact_registry: ArtifactRegistry,
artifact_reverse_transfers: BTreeMap<String, artifacts::ArtifactReverseTransfer>,
artifact_transfer_by_token: BTreeMap<Digest, String>,
artifact_relay: relay::ArtifactRelayLedger,
transport: NativeQuicTransport,
quota: quota::CoordinatorQuota,
admission: CoordinatorAdmission,
#[cfg(test)]
server_time_override: Option<u64>,
admin_token_digest: Option<Digest>,
admin_replay_nonces: BTreeMap<String, u64>,
}
impl CoordinatorService {
pub fn configure_artifact_relay(
&mut self,
configuration: ArtifactRelayConfiguration,
) -> Result<(), CoordinatorServiceError> {
let now_epoch_seconds = self.current_epoch_seconds()?;
let mut candidate = self.artifact_relay.clone();
candidate
.configure(configuration)
.map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?;
candidate.reconcile_after_restart(now_epoch_seconds);
self.commit_artifact_relay(candidate)
}
pub fn artifact_relay_usage(&self) -> ArtifactRelayUsage {
self.artifact_relay.usage()
}
fn commit_artifact_relay(
&mut self,
candidate: relay::ArtifactRelayLedger,
) -> Result<(), CoordinatorServiceError> {
let previous = self.artifact_relay.clone();
let previous_state = previous.durable_state();
self.artifact_relay = candidate;
self.coordinator
.set_artifact_relay_state(self.artifact_relay.durable_state());
if let Err(error) = self.persist_durable_state() {
self.artifact_relay = previous;
self.coordinator.set_artifact_relay_state(previous_state);
return Err(error);
}
Ok(())
}
fn mutate_artifact_relay<T>(
&mut self,
mutation: impl FnOnce(&mut relay::ArtifactRelayLedger) -> Result<T, ArtifactRelayError>,
) -> Result<T, CoordinatorServiceError> {
let mut candidate = self.artifact_relay.clone();
let result = mutation(&mut candidate)
.map_err(|error| CoordinatorServiceError::Protocol(error.to_string()))?;
self.commit_artifact_relay(candidate)?;
Ok(result)
}
pub fn set_debug_freeze_timeout(&mut self, timeout: std::time::Duration) {
self.debug_freeze_timeout = timeout.max(std::time::Duration::from_millis(1));
}
pub fn configure_coordinator_main_runtime(
&mut self,
configuration: CoordinatorMainRuntimeConfiguration,
) -> Result<(), CoordinatorServiceError> {
self.main_runtime.configure(configuration)
}
pub fn record_service_policy(
&mut self,
tenant: TenantId,
name: impl Into<String>,
digest: Digest,
) -> Result<crate::ServicePolicyRecord, CoordinatorServiceError> {
let name = name.into();
self.coordinator
.upsert_service_policy_record(tenant.clone(), name.clone(), digest);
self.persist_durable_state()?;
Ok(self
.coordinator
.service_policy_record(&tenant, &name)
.expect("service policy record was persisted immediately after insertion")
.clone())
}
pub(super) fn authorize_node_for_process_or_termination(
&self,
node: &NodeId,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
) -> Result<(), CoordinatorServiceError> {
let process_key = keys::process_control_key(tenant, project, process);
if self.process_cancellations.contains(&process_key)
|| self.process_aborts.contains(&process_key)
{
let identity = self
.coordinator
.node_identity(node)
.ok_or(CoordinatorError::UnknownNode)?;
if &identity.tenant != tenant || &identity.project != project {
return Err(CoordinatorError::Unauthorized(
"node process-control request is outside its enrolled tenant/project scope"
.to_owned(),
)
.into());
}
return Ok(());
}
self.coordinator
.authorize_node_for_process(node, tenant, project, process)?;
Ok(())
}
pub fn new(coordinator_epoch: u64) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(),
CoordinatorAdmission::default(),
)
}
pub fn new_with_admin_token(coordinator_epoch: u64, admin_token: impl Into<String>) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
Some(admin_token.into()),
CoordinatorAdmission::default(),
)
}
pub fn new_with_admission(coordinator_epoch: u64, admission: CoordinatorAdmission) -> Self {
Self::new_with_optional_admin_token_and_admission(
coordinator_epoch,
std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(),
admission,
)
}
fn new_with_optional_admin_token_and_admission(
coordinator_epoch: u64,
admin_token: Option<String>,
admission: CoordinatorAdmission,
) -> Self {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
admin_token,
admission,
None,
CoordinatorQuotaConfiguration::default(),
)
.expect("in-memory durable coordinator store initialization cannot fail")
}
pub fn new_with_database_url(
coordinator_epoch: u64,
database_url: Option<&str>,
) -> Result<Self, CoordinatorServiceError> {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
std::env::var("CLUSTERFLUX_ADMIN_TOKEN").ok(),
CoordinatorAdmission::default(),
database_url,
CoordinatorQuotaConfiguration::default(),
)
}
pub fn new_with_admin_token_and_database_url(
coordinator_epoch: u64,
admin_token: impl Into<String>,
database_url: Option<&str>,
) -> Result<Self, CoordinatorServiceError> {
Self::new_with_admin_token_database_url_and_quota(
coordinator_epoch,
admin_token,
database_url,
CoordinatorQuotaConfiguration::default(),
)
}
pub fn new_with_admin_token_database_url_and_quota(
coordinator_epoch: u64,
admin_token: impl Into<String>,
database_url: Option<&str>,
quota_configuration: CoordinatorQuotaConfiguration,
) -> Result<Self, CoordinatorServiceError> {
Self::try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch,
Some(admin_token.into()),
CoordinatorAdmission::default(),
database_url,
quota_configuration,
)
}
fn try_new_with_optional_admin_token_admission_and_database_url(
coordinator_epoch: u64,
admin_token: Option<String>,
admission: CoordinatorAdmission,
database_url: Option<&str>,
quota_configuration: CoordinatorQuotaConfiguration,
) -> Result<Self, CoordinatorServiceError> {
let mut store = RuntimeDurableStore::from_database_url(database_url)
.map_err(CoordinatorServiceError::Durable)?;
let coordinator = Coordinator::try_boot(&mut store, coordinator_epoch)
.map_err(CoordinatorServiceError::Durable)?;
let artifact_relay_state = coordinator.artifact_relay_state().clone();
let admin_token_digest = admin_token
.filter(|token| !token.trim().is_empty())
.map(Digest::sha256);
Ok(Self {
coordinator,
store,
node_descriptors: BTreeMap::new(),
node_last_seen_epoch_seconds: BTreeMap::new(),
node_stale_after_seconds: std::env::var("CLUSTERFLUX_NODE_STALE_AFTER_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_NODE_STALE_AFTER_SECONDS),
debug_freeze_timeout: std::time::Duration::from_secs(5),
enrollment_grants: BTreeMap::new(),
task_events: VecDeque::new(),
process_scope_history: VecDeque::new(),
debug_audit_events: VecDeque::new(),
debug_epochs: BTreeMap::new(),
debug_epoch_runtime: BTreeMap::new(),
debug_breakpoints: BTreeMap::new(),
debug_commands: BTreeMap::new(),
task_assignments: BTreeMap::new(),
task_restart_checkpoints: BTreeMap::new(),
task_restart_checkpoint_order: VecDeque::new(),
task_attempts: BTreeMap::new(),
restart_launches: BTreeSet::new(),
main_runtime: main_runtime::CoordinatorMainRuntime::default(),
pending_task_launches: VecDeque::new(),
task_placements: BTreeMap::new(),
active_tasks: BTreeSet::new(),
task_cancellations: BTreeSet::new(),
task_aborts: BTreeSet::new(),
process_cancellations: BTreeSet::new(),
process_aborts: BTreeSet::new(),
agent_replay_nonces: BTreeMap::new(),
node_replay_nonces: BTreeMap::new(),
panel_snapshots: BTreeMap::new(),
stopped_panels: BTreeSet::new(),
panel_event_limits: BTreeMap::new(),
artifact_registry: ArtifactRegistry::default(),
artifact_reverse_transfers: BTreeMap::new(),
artifact_transfer_by_token: BTreeMap::new(),
artifact_relay: relay::ArtifactRelayLedger::from_durable(
ArtifactRelayConfiguration::default(),
artifact_relay_state,
),
transport: NativeQuicTransport,
quota: quota::CoordinatorQuota::new(quota_configuration),
admission,
#[cfg(test)]
server_time_override: None,
admin_token_digest,
admin_replay_nonces: BTreeMap::new(),
})
}
pub fn durable_store_kind(&self) -> &'static str {
self.store.kind()
}
fn persist_durable_state(&mut self) -> Result<(), CoordinatorServiceError> {
self.coordinator
.try_persist(&mut self.store)
.map_err(CoordinatorServiceError::Durable)
}
fn current_epoch_seconds(&self) -> Result<u64, CoordinatorServiceError> {
#[cfg(test)]
if let Some(now) = self.server_time_override {
return Ok(now);
}
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|err| CoordinatorServiceError::Protocol(format!("system clock error: {err}")))
}
fn handle_quota_status(
&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 durable_project = self.coordinator.project(&project).ok_or_else(|| {
CoordinatorError::Unauthorized("quota status requires an existing project".to_owned())
})?;
if durable_project.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"quota status project is outside the tenant scope".to_owned(),
)
.into());
}
let now_epoch_seconds = self.current_epoch_seconds()?;
let status = self
.quota
.project_status(&tenant, &project, now_epoch_seconds);
Ok(CoordinatorResponse::QuotaStatus {
tenant,
project,
actor,
policy_label: status.policy_label,
limits: status.limits,
window_seconds: status.window_seconds,
usage: status.usage,
window_started_epoch_seconds: status.window_started_epoch_seconds,
})
}
#[cfg(test)]
fn set_server_time(&mut self, now_epoch_seconds: u64) {
self.server_time_override = Some(now_epoch_seconds);
}
pub fn issue_cli_session(
&mut self,
tenant: TenantId,
project: ProjectId,
user: UserId,
session_secret: &str,
expires_at_epoch_seconds: Option<u64>,
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
if let Some(existing) = self.coordinator.project(&project) {
if existing.tenant != tenant {
return Err(CoordinatorError::Unauthorized(
"CLI session project belongs to a different tenant".to_owned(),
)
.into());
}
} else {
self.coordinator
.upsert_project(tenant.clone(), project.clone(), "Session project");
}
self.coordinator
.grant_project_debug(tenant.clone(), project.clone(), user.clone());
let record = self.coordinator.issue_cli_session(
tenant,
project,
user,
session_secret,
expires_at_epoch_seconds,
);
self.persist_durable_state()?;
Ok(record)
}
pub fn authenticate_cli_session_context(
&self,
session_secret: &str,
) -> Result<clusterflux_core::AuthContext, CoordinatorServiceError> {
Ok(self.coordinator.authenticate_cli_session(session_secret)?)
}
pub fn authenticate_cli_session_status_context(
&self,
session_secret: &str,
) -> Result<clusterflux_core::AuthContext, CoordinatorServiceError> {
Ok(self
.coordinator
.authenticate_cli_session_for_status(session_secret)?)
}
pub fn revoke_cli_session(
&mut self,
session_secret: &str,
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
let record = self.coordinator.revoke_cli_session(session_secret)?;
self.persist_durable_state()?;
Ok(record)
}
}
#[cfg(test)]
mod tests;