clusterflux-public/crates/clusterflux-node/src/daemon.rs
Clusterflux release eb1077f380 Public release release-09ca780bd67a
Source commit: 09ca780bd67a00467e78139cf38466b8201b66ca

Public tree identity: sha256:2f744c260b5fc2cf074ad9129f97f87f03714902e5b9fe174128afdc342ec2d0
2026-07-19 18:38:40 +02:00

749 lines
26 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{
sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId,
TaskSpec, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
};
use serde_json::{json, Value};
use crate::assignment_runner::run_verified_wasmtime_assignment;
#[cfg(test)]
use crate::coordinator_session::control_endpoint_identity;
use crate::coordinator_session::CoordinatorSession;
use crate::debug_agent::poll_task_cancellation;
use crate::node_identity::{
establish_node_identity, node_nonce, node_private_key_for_runtime, signed_node_request_json,
unix_timestamp_seconds,
};
#[cfg(test)]
use crate::node_identity::{load_or_create_local_node_credential, unix_timestamp_nanos};
use crate::source_snapshot::snapshot_project;
use crate::task_artifacts::{
clean_stale_task_output_roots, current_epoch_seconds, NodeArtifactRetentionLimits,
NodeArtifactStore,
};
use crate::task_reports::{record_cancelled_task, record_completed_task, record_failed_task};
static WORKER_SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
pub(crate) fn worker_shutdown_requested() -> bool {
WORKER_SHUTDOWN_REQUESTED.load(Ordering::Acquire)
}
#[cfg(unix)]
extern "C" fn request_worker_shutdown(_signal: libc::c_int) {
WORKER_SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}
fn install_worker_shutdown_handler() {
WORKER_SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGINT, request_worker_shutdown as libc::sighandler_t);
libc::signal(libc::SIGTERM, request_worker_shutdown as libc::sighandler_t);
}
}
#[derive(Clone, Debug)]
pub(crate) struct Args {
pub(crate) coordinator: String,
pub(crate) tenant: String,
pub(crate) project: String,
pub(crate) project_root: Option<PathBuf>,
pub(crate) node: String,
pub(crate) enrollment_grant: Option<String>,
pub(crate) public_key: Option<String>,
pub(crate) control_poll_ms: u64,
pub(crate) assignment_poll_ms: u64,
pub(crate) emit_ready: bool,
pub(crate) worker: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeTask {
pub(crate) process: String,
pub(crate) task: String,
pub(crate) epoch: Option<u64>,
pub(crate) task_spec: Option<TaskSpec>,
pub(crate) bundle_digest: Option<Digest>,
pub(crate) wasm_module_base64: Option<String>,
pub(crate) task_assignment_response: Value,
}
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = parse_args()?;
let mut session = CoordinatorSession::connect(&args.coordinator)?;
let node_private_key = node_private_key_for_runtime(&args.node)?;
let registration = establish_node_identity(&mut session, &args, &node_private_key)?;
let heartbeat_request = json!({
"type": "node_heartbeat",
"node": &args.node,
});
let heartbeat_signature = sign_node_request(
&node_private_key,
&NodeId::from(args.node.as_str()),
"node_heartbeat",
&signed_request_payload_digest(&heartbeat_request),
node_nonce("node-heartbeat"),
unix_timestamp_seconds(),
)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
let mut heartbeat_request = heartbeat_request;
heartbeat_request["node_signature"] = json!(heartbeat_signature);
let heartbeat = session.request(heartbeat_request)?;
clean_stale_task_output_roots(args.project_root.as_deref(), &args.node)?;
let artifact_store = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node)?;
let retention_limits = NodeArtifactRetentionLimits::from_environment()?;
artifact_store.garbage_collect(retention_limits, &BTreeSet::new(), current_epoch_seconds())?;
let capability_report = report_node_capabilities(
&args,
&mut session,
&node_private_key,
&artifact_store,
true,
)?;
if !args.worker {
return Err(
"one-shot native command mode was removed; run `clusterflux-node --worker` and launch a bundled Wasm task through the coordinator"
.into(),
);
}
install_worker_shutdown_handler();
worker_loop(
&args,
&mut session,
registration,
heartbeat,
capability_report,
&node_private_key,
)
}
fn worker_loop(
args: &Args,
session: &mut CoordinatorSession,
registration: Value,
heartbeat: Value,
capability_report: Value,
node_private_key: &str,
) -> Result<(), Box<dyn std::error::Error>> {
const ARTIFACT_GC_INTERVAL: Duration = Duration::from_secs(30);
let artifact_store = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node)?;
let retention_limits = NodeArtifactRetentionLimits::from_environment()?;
let mut last_artifact_gc = Instant::now();
let mut restart_pins = BTreeMap::<ArtifactId, Instant>::new();
if args.emit_ready {
println!(
"{}",
serde_json::to_string(&json!({
"node_status": "ready",
"mode": "worker",
"node": &args.node,
}))?
);
std::io::stdout().flush()?;
}
loop {
if worker_shutdown_requested() {
report_node_capabilities(args, session, node_private_key, &artifact_store, false)?;
return Ok(());
}
if service_pending_artifact_transfer(args, session, node_private_key)? {
continue;
}
if last_artifact_gc.elapsed() >= ARTIFACT_GC_INTERVAL {
let now = Instant::now();
restart_pins.retain(|_, expiry| *expiry > now);
let pinned = restart_pins.keys().cloned().collect::<BTreeSet<_>>();
artifact_store.garbage_collect(retention_limits, &pinned, current_epoch_seconds())?;
report_node_capabilities(args, session, node_private_key, &artifact_store, true)?;
last_artifact_gc = Instant::now();
}
let response = session.request(signed_node_request_json(
args,
node_private_key,
"poll_task_assignment",
json!({
"type": "poll_task_assignment",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
}),
)?)?;
let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else {
std::thread::sleep(Duration::from_millis(args.assignment_poll_ms));
continue;
};
let runtime_task = runtime_task_from_assignment(assignment)?;
if args.emit_ready {
println!(
"{}",
serde_json::to_string(&json!({
"node_status": "assignment_started",
"node": &args.node,
"process": &runtime_task.process,
"virtual_thread": &runtime_task.task,
"task_assignment_response": &runtime_task.task_assignment_response,
}))?
);
std::io::stdout().flush()?;
}
if let Some(task_spec) = &runtime_task.task_spec {
let expiry = Instant::now()
.checked_add(Duration::from_secs(retention_limits.restart_pin_seconds))
.unwrap_or_else(Instant::now);
for artifact in &task_spec.required_artifacts {
restart_pins.insert(artifact.clone(), expiry);
}
}
let report = run_runtime_task(
args,
session,
runtime_task,
registration.clone(),
heartbeat.clone(),
capability_report.clone(),
node_private_key,
)?;
if let Some(artifact) = report
.pointer("/vfs_metadata_response/artifact_path")
.and_then(Value::as_str)
.and_then(|path| path.strip_prefix("/vfs/artifacts/"))
{
let expiry = Instant::now()
.checked_add(Duration::from_secs(retention_limits.restart_pin_seconds))
.unwrap_or_else(Instant::now);
restart_pins.insert(ArtifactId::new(artifact), expiry);
}
println!("{}", serde_json::to_string(&report)?);
std::io::stdout().flush()?;
}
}
fn report_node_capabilities(
args: &Args,
session: &mut CoordinatorSession,
node_private_key: &str,
artifact_store: &NodeArtifactStore,
online: bool,
) -> Result<Value, Box<dyn std::error::Error>> {
let artifact_locations = artifact_store
.artifact_ids()?
.into_iter()
.map(|artifact| artifact.as_str().to_owned())
.collect::<Vec<_>>();
let source_snapshots = args
.project_root
.as_deref()
.map(snapshot_project)
.transpose()?
.into_iter()
.map(|snapshot| snapshot.digest)
.collect::<Vec<_>>();
session.request(signed_node_request_json(
args,
node_private_key,
"report_node_capabilities",
json!({
"type": "report_node_capabilities",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
"capabilities": NodeCapabilities::detect_current(),
"cached_environment_digests": [],
"dependency_cache_digests": [],
"source_snapshots": source_snapshots,
"artifact_locations": artifact_locations,
"direct_connectivity": false,
"online": online,
}),
)?)
}
fn service_pending_artifact_transfer(
args: &Args,
session: &mut CoordinatorSession,
node_private_key: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let response = session.request(signed_node_request_json(
args,
node_private_key,
"poll_artifact_transfer",
json!({
"type": "poll_artifact_transfer",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
}),
)?)?;
let Some(transfer) = response.get("transfer").filter(|value| !value.is_null()) else {
return Ok(false);
};
let transfer_id = required_string(transfer, "transfer_id")?;
let artifact = ArtifactId::new(required_string(transfer, "artifact")?);
let expected_digest: Digest = serde_json::from_value(
transfer
.get("expected_digest")
.cloned()
.ok_or("artifact transfer omitted expected_digest")?,
)?;
let expected_size_bytes = transfer
.get("expected_size_bytes")
.and_then(Value::as_u64)
.ok_or("artifact transfer omitted expected_size_bytes")?;
let offset = transfer
.get("offset")
.and_then(Value::as_u64)
.ok_or("artifact transfer omitted offset")?;
let max_chunk_bytes = transfer
.get("max_chunk_bytes")
.and_then(Value::as_u64)
.ok_or("artifact transfer omitted max_chunk_bytes")?;
let read = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node).and_then(
|store| {
store.read_verified_chunk(
&artifact,
&expected_digest,
expected_size_bytes,
offset,
max_chunk_bytes,
)
},
);
let content = match read {
Ok(content) => content,
Err(message) => {
session.request(signed_node_request_json(
args,
node_private_key,
"fail_artifact_transfer",
json!({
"type": "fail_artifact_transfer",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
"transfer_id": transfer_id,
"artifact": artifact,
"message": message,
}),
)?)?;
return Ok(true);
}
};
let eof = offset.saturating_add(content.len() as u64) == expected_size_bytes;
session.request(signed_node_request_json(
args,
node_private_key,
"upload_artifact_transfer_chunk",
json!({
"type": "upload_artifact_transfer_chunk",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
"transfer_id": transfer_id,
"artifact": artifact,
"offset": offset,
"content_base64": BASE64_STANDARD.encode(&content),
"chunk_digest": Digest::sha256(&content),
"eof": eof,
}),
)?)?;
Ok(true)
}
pub(crate) fn runtime_task_from_assignment(
value: &Value,
) -> Result<RuntimeTask, Box<dyn std::error::Error>> {
let task_spec: TaskSpec = serde_json::from_value(
value
.get("task_spec")
.cloned()
.ok_or("task assignment missing task_spec")?,
)?;
Ok(RuntimeTask {
process: required_string(value, "process")?,
task: required_string(value, "task")?,
epoch: value.get("epoch").and_then(Value::as_u64),
bundle_digest: task_spec.bundle_digest.clone(),
task_spec: Some(task_spec),
wasm_module_base64: Some(required_string(value, "wasm_module_base64")?),
task_assignment_response: value.clone(),
})
}
fn required_string(value: &Value, field: &str) -> Result<String, Box<dyn std::error::Error>> {
value
.get(field)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| format!("task assignment missing string field `{field}`").into())
}
fn run_runtime_task(
args: &Args,
session: &mut CoordinatorSession,
task: RuntimeTask,
registration: Value,
heartbeat: Value,
capability_report: Value,
node_private_key: &str,
) -> Result<Value, Box<dyn std::error::Error>> {
let epoch = match task.epoch {
Some(epoch) => epoch,
None => {
let started = session.request(json!({
"type": "start_process",
"tenant": &args.tenant,
"project": &args.project,
"process": &task.process,
}))?;
started
.get("epoch")
.and_then(Value::as_u64)
.ok_or("coordinator start_process response missing epoch")?
}
};
session.request(signed_node_request_json(
args,
node_private_key,
"reconnect_node",
json!({
"type": "reconnect_node",
"node": &args.node,
"process": &task.process,
"epoch": epoch,
}),
)?)?;
let debug_command = session.request(signed_node_request_json(
args,
node_private_key,
"poll_debug_command",
json!({
"type": "poll_debug_command",
"tenant": &args.tenant,
"project": &args.project,
"process": &task.process,
"node": &args.node,
"task": &task.task,
}),
)?)?;
if args.emit_ready && !args.worker {
println!(
"{}",
serde_json::to_string(&json!({
"node_status": "ready",
"node": &args.node,
"process": &task.process,
"task": &task.task,
}))?
);
std::io::stdout().flush()?;
}
if args.control_poll_ms > 0 && poll_task_cancellation(session, args, &task, node_private_key)? {
return record_cancelled_task(
args,
session,
&task,
registration,
heartbeat,
capability_report,
debug_command,
node_private_key,
);
}
let execution = run_verified_wasmtime_assignment(args, &task, node_private_key);
match execution {
Ok((output, manifest, result)) => match crate::task_artifacts::retained_result_artifact(
args.project_root.as_deref(),
&args.node,
result.as_ref(),
) {
Ok(retained) => record_completed_task(
args,
session,
task,
output,
manifest,
result,
retained,
registration,
heartbeat,
capability_report,
debug_command,
node_private_key,
),
Err(error) => record_failed_task(
args,
session,
&task,
registration,
heartbeat,
capability_report,
debug_command,
node_private_key,
&error,
),
},
Err(error) => {
let error = error.to_string();
if error.contains("task execution cancelled:") {
record_cancelled_task(
args,
session,
&task,
registration,
heartbeat,
capability_report,
debug_command,
node_private_key,
)
} else {
record_failed_task(
args,
session,
&task,
registration,
heartbeat,
capability_report,
debug_command,
node_private_key,
&error,
)
}
}
}
}
fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
let mut coordinator = None;
let mut tenant = "tenant".to_owned();
let mut project = "project".to_owned();
let mut project_root = None;
let mut node = "node".to_owned();
let mut enrollment_grant = None;
let mut public_key = None;
let mut control_poll_ms = 0;
let mut assignment_poll_ms = 500;
let mut emit_ready = false;
let mut worker = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--coordinator" => coordinator = args.next(),
"--tenant" => tenant = args.next().ok_or("--tenant requires a value")?,
"--project-id" => project = args.next().ok_or("--project-id requires a value")?,
"--node" => node = args.next().ok_or("--node requires a value")?,
"--enrollment-grant" => enrollment_grant = args.next(),
"--public-key" => public_key = args.next(),
"--control-poll-ms" => {
control_poll_ms = args
.next()
.ok_or("--control-poll-ms requires a value")?
.parse()?
}
"--assignment-poll-ms" => {
assignment_poll_ms = validate_assignment_poll_ms(
args.next()
.ok_or("--assignment-poll-ms requires a value")?
.parse()?,
)?
}
"--emit-ready" => emit_ready = true,
"--worker" => worker = true,
"--project-root" => {
project_root = Some(PathBuf::from(
args.next().ok_or("--project-root requires a path")?,
));
}
other => return Err(format!("unknown argument: {other}").into()),
}
}
Ok(Args {
coordinator: coordinator.ok_or("--coordinator is required")?,
tenant,
project,
project_root,
node,
enrollment_grant,
public_key,
control_poll_ms,
assignment_poll_ms,
emit_ready,
worker,
})
}
fn validate_assignment_poll_ms(value: u64) -> Result<u64, String> {
if value < MIN_SIGNED_NODE_POLL_INTERVAL_MS {
return Err(format!(
"--assignment-poll-ms must be at least {MIN_SIGNED_NODE_POLL_INTERVAL_MS} ms so signed polling remains within the coordinator's bounded replay window"
));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{
Digest, ProcessId, ProjectId, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskSpec,
TenantId, WasmExportAbi, WasmTaskResult,
};
use crate::assignment_runner::run_verified_wasmtime_assignment;
use clusterflux_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS;
use super::{
control_endpoint_identity, load_or_create_local_node_credential,
validate_assignment_poll_ms, Args, RuntimeTask,
};
#[test]
fn node_poll_interval_cannot_exhaust_the_bounded_replay_window() {
assert!(validate_assignment_poll_ms(MIN_SIGNED_NODE_POLL_INTERVAL_MS).is_ok());
let error = validate_assignment_poll_ms(MIN_SIGNED_NODE_POLL_INTERVAL_MS - 1).unwrap_err();
assert!(error.contains("bounded replay window"));
}
#[test]
fn hosted_url_remains_an_https_control_endpoint() {
assert_eq!(
control_endpoint_identity("https://clusterflux.michelpaulissen.com").unwrap(),
"https://clusterflux.michelpaulissen.com/api/v1/control"
);
assert_eq!(
control_endpoint_identity("https://clusterflux.michelpaulissen.com/api/v1/control")
.unwrap(),
"https://clusterflux.michelpaulissen.com/api/v1/control"
);
assert_eq!(
control_endpoint_identity("127.0.0.1:7999").unwrap(),
"clusterflux+tcp://127.0.0.1:7999"
);
}
#[test]
fn daemon_local_node_credential_is_durable_between_runs() {
let temp = std::env::temp_dir().join(format!(
"clusterflux-node-credential-test-{}-{}",
std::process::id(),
super::unix_timestamp_nanos()
));
std::fs::create_dir_all(&temp).unwrap();
let first = load_or_create_local_node_credential(&temp, "daemon-node").unwrap();
let second = load_or_create_local_node_credential(&temp, "daemon-node").unwrap();
assert_eq!(first, second);
assert!(temp.join(".clusterflux").join("nodes").exists());
std::fs::remove_dir_all(&temp).unwrap();
}
#[test]
fn daemon_wasm_task_assignment_uses_abi_version_and_verifies_bundle_digest() {
let args = test_args();
let task_instance = TaskInstanceId::from("task_add_one-1");
let boundary = TaskBoundaryValue::SmallJson(serde_json::json!(2));
let result = serde_json::to_string(&WasmTaskResult::completed(
task_instance.clone(),
boundary.clone(),
))
.unwrap();
let wat_result = result.replace('\\', "\\\\").replace('"', "\\\"");
let result_length = result.len();
let packed = ((result_length as u64) << 32) | 2048;
let wasm = wat::parse_str(format!(
r#"(module
(memory (export "memory") 1)
(data (i32.const 2048) "{wat_result}")
(func (export "clusterflux_alloc_v1") (param i32) (result i32)
i32.const 1024)
(func (export "task_add_one") (param i32 i32) (result i64)
i64.const {packed}))"#
))
.unwrap();
let task = RuntimeTask {
process: "vp".to_owned(),
task: task_instance.as_str().to_owned(),
epoch: Some(7),
task_spec: Some(TaskSpec {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: ProcessId::from("vp"),
task_definition: clusterflux_core::TaskDefinitionId::from("task_add_one"),
task_instance,
dispatch: TaskDispatch::CoordinatorNodeWasm {
export: Some("task_add_one".to_owned()),
abi: WasmExportAbi::TaskV1,
},
environment_id: None,
environment: None,
environment_digest: None,
required_capabilities: BTreeSet::new(),
dependency_cache: None,
source_snapshot: None,
required_artifacts: Vec::new(),
args: Vec::new(),
vfs_epoch: 7,
failure_policy: Default::default(),
bundle_digest: Some(Digest::sha256(&wasm)),
}),
bundle_digest: Some(Digest::sha256(&wasm)),
wasm_module_base64: Some(BASE64_STANDARD.encode(&wasm)),
task_assignment_response: serde_json::json!({}),
};
let (output, manifest, result) =
run_verified_wasmtime_assignment(&args, &task, "test-node-private-key").unwrap();
assert_eq!(output.status_code, Some(0));
assert_eq!(
output.stdout,
format!("{}\n", serde_json::to_string(&boundary).unwrap())
);
assert!(output.staged_artifact.is_none());
assert!(manifest.objects.is_empty());
assert!(!manifest.large_bytes_uploaded);
assert_eq!(result, Some(boundary));
let mismatch = RuntimeTask {
bundle_digest: Some(Digest::sha256("different bundle bytes")),
wasm_module_base64: Some(BASE64_STANDARD.encode("not valid wasm")),
..task
};
let error = run_verified_wasmtime_assignment(&args, &mismatch, "test-node-private-key")
.unwrap_err();
assert!(error.to_string().contains("bundle digest mismatch"));
assert!(!error.to_string().contains("failed to parse"));
}
fn test_args() -> Args {
Args {
coordinator: "127.0.0.1:1".to_owned(),
tenant: "tenant".to_owned(),
project: "project".to_owned(),
project_root: None,
node: "node".to_owned(),
enrollment_grant: None,
public_key: None,
control_poll_ms: 0,
assignment_poll_ms: 1,
emit_ready: false,
worker: false,
}
}
}