Public release release-e47f9c27bbeb

Source commit: e47f9c27bbebe6759f6b6fe7b12fd00bb20d116d

Public tree identity: sha256:4c1af1dfd67d3f2b5088531ea8727b11124b013d2251a4d5088f51a6424c0196
This commit is contained in:
Clusterflux release 2026-07-24 15:52:30 +02:00
parent 3a4d4fa7ae
commit 9223c54939
30 changed files with 4195 additions and 434 deletions

View file

@ -1,7 +1,7 @@
{ {
"kind": "clusterflux-filtered-public-tree", "kind": "clusterflux-filtered-public-tree",
"source_commit": "6d8c5c5b2a8b1da23eb69123e478237bd22883bd", "source_commit": "e47f9c27bbebe6759f6b6fe7b12fd00bb20d116d",
"release_name": "release-6d8c5c5b2a8b", "release_name": "release-e47f9c27bbeb",
"filtered_out": [ "filtered_out": [
"private/**", "private/**",
"internal/**", "internal/**",

View file

@ -61,12 +61,20 @@ impl ControlSession {
endpoint: &str, endpoint: &str,
api_path: &str, api_path: &str,
) -> Result<Self, ControlTransportError> { ) -> Result<Self, ControlTransportError> {
let mut session = Self::connect(endpoint)?; let session = Self::connect(endpoint)?;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
if let ControlTransport::Https { url, .. } = &mut session.transport { {
*url = endpoint_api_url(endpoint, api_path)?; let mut session = session;
if let ControlTransport::Https { url, .. } = &mut session.transport {
*url = endpoint_api_url(endpoint, api_path)?;
}
Ok(session)
}
#[cfg(target_arch = "wasm32")]
{
let _ = api_path;
Ok(session)
} }
Ok(session)
} }
pub fn connect_with_timeouts( pub fn connect_with_timeouts(

View file

@ -35,6 +35,7 @@ pub(super) struct DebugEpochRuntime {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct DebugBreakpointPlan { pub(super) struct DebugBreakpointPlan {
pub(super) actor: UserId, pub(super) actor: UserId,
pub(super) revision: u64,
pub(super) probe_symbols: BTreeSet<String>, pub(super) probe_symbols: BTreeSet<String>,
pub(super) hit_epoch: Option<u64>, pub(super) hit_epoch: Option<u64>,
pub(super) hit_task: Option<TaskInstanceId>, pub(super) hit_task: Option<TaskInstanceId>,
@ -85,6 +86,7 @@ impl CoordinatorService {
project: String, project: String,
actor_user: String, actor_user: String,
process: String, process: String,
revision: u64,
probe_symbols: Vec<String>, probe_symbols: Vec<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> { ) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let probe_symbols = validate_probe_symbols(probe_symbols)?; let probe_symbols = validate_probe_symbols(probe_symbols)?;
@ -115,10 +117,28 @@ impl CoordinatorService {
)) ))
.into()); .into());
} }
let key = process_control_key(&tenant, &project, &process);
if let Some(current) = self.debug_breakpoints.get(&key) {
if current.actor == actor && revision < current.revision {
return Ok(CoordinatorResponse::DebugBreakpoints {
process,
actor,
revision: current.revision,
probe_symbols: current.probe_symbols.iter().cloned().collect(),
hit_epoch: current.hit_epoch,
hit_task: current.hit_task.clone(),
hit_probe_symbol: current.hit_probe_symbol.clone(),
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
used_debug_read_bytes: audit_event.used_debug_read_bytes,
audit_event,
});
}
}
self.debug_breakpoints.insert( self.debug_breakpoints.insert(
process_control_key(&tenant, &project, &process), key,
DebugBreakpointPlan { DebugBreakpointPlan {
actor: actor.clone(), actor: actor.clone(),
revision,
probe_symbols: probe_symbols.iter().cloned().collect(), probe_symbols: probe_symbols.iter().cloned().collect(),
hit_epoch: None, hit_epoch: None,
hit_task: None, hit_task: None,
@ -128,6 +148,7 @@ impl CoordinatorService {
Ok(CoordinatorResponse::DebugBreakpoints { Ok(CoordinatorResponse::DebugBreakpoints {
process, process,
actor, actor,
revision,
probe_symbols, probe_symbols,
hit_epoch: None, hit_epoch: None,
hit_task: None, hit_task: None,
@ -190,6 +211,7 @@ impl CoordinatorService {
Ok(CoordinatorResponse::DebugBreakpoints { Ok(CoordinatorResponse::DebugBreakpoints {
process, process,
actor, actor,
revision: plan.revision,
probe_symbols: plan.probe_symbols.into_iter().collect(), probe_symbols: plan.probe_symbols.into_iter().collect(),
hit_epoch: plan.hit_epoch, hit_epoch: plan.hit_epoch,
hit_task: plan.hit_task, hit_task: plan.hit_task,

View file

@ -33,12 +33,14 @@ impl CoordinatorService {
project, project,
actor_user, actor_user,
process, process,
revision,
probe_symbols, probe_symbols,
} => self.handle_set_debug_breakpoints( } => self.handle_set_debug_breakpoints(
tenant, tenant,
project, project,
actor_user, actor_user,
process, process,
revision,
probe_symbols, probe_symbols,
), ),
CoordinatorRequest::InspectDebugBreakpoints { CoordinatorRequest::InspectDebugBreakpoints {
@ -96,12 +98,14 @@ impl CoordinatorService {
), ),
AuthenticatedCoordinatorRequest::SetDebugBreakpoints { AuthenticatedCoordinatorRequest::SetDebugBreakpoints {
process, process,
revision,
probe_symbols, probe_symbols,
} => self.handle_set_debug_breakpoints( } => self.handle_set_debug_breakpoints(
tenant.as_str().to_owned(), tenant.as_str().to_owned(),
project.as_str().to_owned(), project.as_str().to_owned(),
actor.as_str().to_owned(), actor.as_str().to_owned(),
process, process,
revision,
probe_symbols, probe_symbols,
), ),
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self

View file

@ -30,14 +30,14 @@ use super::{
}; };
#[derive(Clone)] #[derive(Clone)]
struct MainScope { pub(super) struct MainScope {
tenant: TenantId, pub(super) tenant: TenantId,
project: ProjectId, pub(super) project: ProjectId,
process: ProcessId, pub(super) process: ProcessId,
task_definition: TaskDefinitionId, pub(super) task_definition: TaskDefinitionId,
task_instance: TaskInstanceId, pub(super) task_instance: TaskInstanceId,
epoch: u64, pub(super) epoch: u64,
launch_id: u64, pub(super) launch_id: u64,
} }
enum MainCommand { enum MainCommand {
@ -889,7 +889,7 @@ impl CoordinatorService {
} }
} }
fn record_coordinator_main_completion( pub(super) fn record_coordinator_main_completion(
&mut self, &mut self,
scope: MainScope, scope: MainScope,
result: Result<WasmTaskResult, String>, result: Result<WasmTaskResult, String>,
@ -1229,4 +1229,60 @@ mod tests {
assert!(service.active_tasks.contains(&child_key)); assert!(service.active_tasks.contains(&child_key));
assert!(!service.task_aborts.contains(&child_key)); assert!(!service.task_aborts.contains(&child_key));
} }
#[test]
fn failed_main_aborts_unfinished_children_and_clears_process_debug_state() {
let mut service = CoordinatorService::new(7);
let tenant = TenantId::from("tenant");
let project = ProjectId::from("project");
let process = ProcessId::from("vp-failed-main");
let main_task = TaskInstanceId::from("ti:vp-failed-main:main");
let child_task = TaskInstanceId::from("ti:vp-failed-main:child:1");
let child_node = NodeId::from("worker");
let scope = MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: TaskDefinitionId::from("build"),
task_instance: main_task.clone(),
epoch: 7,
launch_id: 1,
};
service
.coordinator
.start_process(tenant.clone(), project.clone(), process.clone());
let process_key = process_control_key(&tenant, &project, &process);
service.main_runtime.controls.insert(
process_key.clone(),
CoordinatorMainControl {
task_definition: scope.task_definition.clone(),
task_instance: main_task,
abort: Arc::new(AtomicBool::new(false)),
debug: Arc::new(WasmDebugControl::default()),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: Arc::new(Mutex::new(HashMap::new())),
launch_id: 1,
},
);
let child_key = task_control_key(&tenant, &project, &process, &child_node, &child_task);
service.active_tasks.insert(child_key.clone());
service.debug_epochs.insert(process_key.clone(), 2);
service.record_coordinator_main_completion(scope, Err("main crashed".to_owned()));
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_none());
assert!(service.active_tasks.contains(&child_key));
assert!(service.task_aborts.contains(&child_key));
assert!(service.process_aborts.contains(&process_key));
assert!(!service.debug_epochs.contains_key(&process_key));
assert!(service.task_events.iter().any(|event| {
event.process == process
&& event.executor == TaskExecutor::CoordinatorMain
&& event.terminal_state == TaskTerminalState::Failed
}));
}
} }

File diff suppressed because it is too large Load diff

View file

@ -430,6 +430,7 @@ pub enum CoordinatorResponse {
DebugBreakpoints { DebugBreakpoints {
process: ProcessId, process: ProcessId,
actor: UserId, actor: UserId,
revision: u64,
probe_symbols: Vec<String>, probe_symbols: Vec<String>,
hit_epoch: Option<u64>, hit_epoch: Option<u64>,
hit_task: Option<TaskInstanceId>, hit_task: Option<TaskInstanceId>,

View file

@ -19,7 +19,9 @@ impl CoordinatorService {
)) ))
})?; })?;
let payload_digest = clusterflux_core::signed_request_payload_digest(&request_payload); let payload_digest = clusterflux_core::signed_request_payload_digest(&request_payload);
let signed_node = NodeId::new(signed_node); let signed_node = NodeId::try_new(signed_node).map_err(|error| {
CoordinatorServiceError::Protocol(format!("invalid signed node identifier: {error}"))
})?;
if request_node != signed_node { if request_node != signed_node {
return Err(CoordinatorError::Unauthorized( return Err(CoordinatorError::Unauthorized(
"signed node request node does not match the wrapped request node".to_owned(), "signed node request node does not match the wrapped request node".to_owned(),
@ -144,6 +146,19 @@ impl CoordinatorService {
provider, provider,
source_snapshot, source_snapshot,
), ),
CoordinatorRequest::RequestRendezvous {
scope,
source,
destination,
direct_connectivity,
failure_reason,
} => self.handle_request_rendezvous(
scope,
source,
destination,
direct_connectivity,
failure_reason,
),
CoordinatorRequest::ReconnectNode { CoordinatorRequest::ReconnectNode {
node, node,
process, process,
@ -322,6 +337,7 @@ fn signed_node_request_kind(
CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"), CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"),
CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"), CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"),
CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"), CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"),
CoordinatorRequest::RequestRendezvous { .. } => Ok("request_rendezvous"),
CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"), CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"),
CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"), CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"),
CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"), CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"),
@ -356,7 +372,14 @@ fn signed_node_request_node(
| CoordinatorRequest::ReportDebugProbeHit { node, .. } | CoordinatorRequest::ReportDebugProbeHit { node, .. }
| CoordinatorRequest::ReportTaskLog { node, .. } | CoordinatorRequest::ReportTaskLog { node, .. }
| CoordinatorRequest::ReportVfsMetadata { node, .. } | CoordinatorRequest::ReportVfsMetadata { node, .. }
| CoordinatorRequest::TaskCompleted { node, .. } => Ok(NodeId::new(node.clone())), | CoordinatorRequest::TaskCompleted { node, .. } => {
NodeId::try_new(node.clone()).map_err(|error| {
CoordinatorServiceError::Protocol(format!(
"invalid wrapped node identifier: {error}"
))
})
}
CoordinatorRequest::RequestRendezvous { source, .. } => Ok(source.node.clone()),
_ => Err(CoordinatorError::Unauthorized( _ => Err(CoordinatorError::Unauthorized(
"signed_node envelope only accepts node-originated coordinator requests".to_owned(), "signed_node envelope only accepts node-originated coordinator requests".to_owned(),
) )

View file

@ -189,6 +189,11 @@ fn authorize_client_request(
#[cfg(test)] #[cfg(test)]
mod transport_boundary_tests { mod transport_boundary_tests {
use std::io::{BufRead as _, BufReader};
use clusterflux_core::{coordinator_wire_request, ProjectId, TenantId, UserId};
use serde_json::json;
use super::*; use super::*;
#[test] #[test]
@ -197,4 +202,96 @@ mod transport_boundary_tests {
let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err(); let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err();
assert!(error.to_string().contains("restricted to loopback")); assert!(error.to_string().contains("restricted to loopback"));
} }
#[test]
fn malformed_identifiers_are_rejected_before_the_shared_service_lock_and_do_not_poison_it() {
let (listener, addr) = bind_listener("127.0.0.1:0").unwrap();
let mut coordinator = CoordinatorService::new(11);
coordinator
.issue_cli_session(
TenantId::from("tenant"),
ProjectId::from("project"),
UserId::from("user"),
"healthy-session",
None,
)
.unwrap();
let shared = Arc::new(Mutex::new(coordinator));
let server_shared = Arc::clone(&shared);
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
handle_shared_stream(server_shared, stream, ClientAuthorityMode::Strict).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
for (index, malformed_process) in [
String::new(),
" ".to_owned(),
"bad\0process".to_owned(),
"bad process!".to_owned(),
"x".repeat(clusterflux_core::MAX_EXTERNAL_ID_BYTES + 1),
]
.into_iter()
.enumerate()
{
let malformed = coordinator_wire_request(
format!("malformed-{index}"),
json!({
"type": "authenticated",
"session_secret": "healthy-session",
"request": {
"type": "abort_process",
"process": malformed_process,
"launch_attempt": "valid-attempt"
}
}),
);
serde_json::to_writer(&mut stream, &malformed).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let CoordinatorResponse::Error { message } =
serde_json::from_str::<CoordinatorResponse>(&line).unwrap()
else {
panic!("malformed identifier request unexpectedly succeeded");
};
assert!(
message.contains("malformed external identifier")
&& message.contains("request.request.process"),
"unexpected malformed identifier response: {message}"
);
let valid = coordinator_wire_request(
format!("healthy-{index}"),
json!({
"type": "authenticated",
"session_secret": "healthy-session",
"request": { "type": "auth_status" }
}),
);
serde_json::to_writer(&mut stream, &valid).unwrap();
stream.write_all(b"\n").unwrap();
stream.flush().unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert!(
matches!(
serde_json::from_str::<CoordinatorResponse>(&line).unwrap(),
CoordinatorResponse::AuthStatus {
authenticated: true,
..
}
),
"valid authenticated traffic failed after malformed request {index}"
);
}
stream.shutdown(std::net::Shutdown::Both).unwrap();
server.join().unwrap();
assert!(!shared.is_poisoned());
}
} }

View file

@ -12,8 +12,8 @@ use clusterflux_core::{
AgentSignedRequest, AgentWorkflowScope, ArtifactFlush, ArtifactHandle, ArtifactId, Capability, AgentSignedRequest, AgentWorkflowScope, ArtifactFlush, ArtifactHandle, ArtifactId, Capability,
DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements, DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements,
LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits, LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits,
SourceProviderKind, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec, SourceProviderKind, TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId,
VfsPath, WasmExportAbi, TaskJoinState, TaskSpec, VfsPath, WasmExportAbi, WasmTaskResult,
}; };
use serde_json::json; use serde_json::json;
@ -521,6 +521,9 @@ fn signed_node_request_auto(request: CoordinatorRequest) -> CoordinatorRequest {
CoordinatorRequest::CompleteSourcePreparation { node, .. } => { CoordinatorRequest::CompleteSourcePreparation { node, .. } => {
(node.clone(), "complete_source_preparation") (node.clone(), "complete_source_preparation")
} }
CoordinatorRequest::RequestRendezvous { source, .. } => {
(source.node.to_string(), "request_rendezvous")
}
CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"), CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"),
CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"), CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"),
CoordinatorRequest::PollDebugCommand { node, .. } => (node.clone(), "poll_debug_command"), CoordinatorRequest::PollDebugCommand { node, .. } => (node.clone(), "poll_debug_command"),
@ -2752,6 +2755,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
}, },
); );
let CoordinatorResponse::DebugBreakpoints { let CoordinatorResponse::DebugBreakpoints {
revision,
probe_symbols, probe_symbols,
hit_epoch, hit_epoch,
.. ..
@ -2761,15 +2765,37 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
project: "project".to_owned(), project: "project".to_owned(),
actor_user: "user".to_owned(), actor_user: "user".to_owned(),
process: "process".to_owned(), process: "process".to_owned(),
revision: 1,
probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()], probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()],
}) })
.unwrap() .unwrap()
else { else {
panic!("expected debug breakpoints response"); panic!("expected debug breakpoints response");
}; };
assert_eq!(revision, 1);
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]); assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
assert_eq!(hit_epoch, None); assert_eq!(hit_epoch, None);
let CoordinatorResponse::DebugBreakpoints {
revision,
probe_symbols,
..
} = service
.handle_request(CoordinatorRequest::SetDebugBreakpoints {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: "process".to_owned(),
revision: 0,
probe_symbols: vec!["clusterflux.probe.stale".to_owned()],
})
.unwrap()
else {
panic!("expected stale breakpoint response");
};
assert_eq!(revision, 1);
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
let CoordinatorResponse::DebugProbeHit { let CoordinatorResponse::DebugProbeHit {
breakpoint_matched, breakpoint_matched,
debug_epoch, debug_epoch,
@ -3583,6 +3609,184 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
assert!(!service.main_runtime.controls.contains_key(&process_key)); assert!(!service.main_runtime.controls.contains_key(&process_key));
} }
#[test]
fn completed_main_waits_for_final_child_then_preserves_history_and_releases_the_slot() {
let mut service = CoordinatorService::new(31);
let tenant = TenantId::from("tenant");
let project = ProjectId::from("project");
let process = ProcessId::from("process-main-before-child");
let child = TaskInstanceId::from("child-active");
let process_key = process_control_key(&tenant, &project, &process);
service
.handle_request(CoordinatorRequest::AttachNode {
tenant: tenant.to_string(),
project: project.to_string(),
node: "worker".to_owned(),
public_key: test_node_public_key("worker"),
})
.unwrap();
service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: process.to_string(),
restart: false,
})
.unwrap();
service
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
node: "worker".to_owned(),
process: process.to_string(),
epoch: 31,
})
.unwrap();
register_test_task_assignment(
&mut service,
tenant.as_str(),
project.as_str(),
process.as_str(),
"worker",
"child-definition",
child.as_str(),
31,
);
let main = TaskInstanceId::from("main-instance");
service.main_runtime.controls.insert(
process_key.clone(),
super::main_runtime::CoordinatorMainControl {
task_definition: TaskDefinitionId::from("build"),
task_instance: main.clone(),
abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()),
state: "running".to_owned(),
stopped_probe_symbol: None,
handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
launch_id: 1,
},
);
service.debug_epochs.insert(process_key.clone(), 9);
service.record_coordinator_main_completion(
super::main_runtime::MainScope {
tenant: tenant.clone(),
project: project.clone(),
process: process.clone(),
task_definition: TaskDefinitionId::from("build"),
task_instance: main,
epoch: 31,
launch_id: 1,
},
Ok(WasmTaskResult::completed(
TaskInstanceId::from("main-instance"),
TaskBoundaryValue::SmallJson(json!("main completed")),
)),
);
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_some());
assert!(service.debug_epochs.contains_key(&process_key));
assert!(service
.active_tasks
.iter()
.any(|(_, _, retained_process, _, task)| {
retained_process == &process && task == &child
}));
let blocked_next = service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: "process-too-early".to_owned(),
restart: false,
})
.unwrap_err();
assert!(blocked_next
.to_string()
.contains("already has active virtual process"));
let artifact_bytes = b"child artifact survives terminal cleanup";
service
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
tenant: tenant.to_string(),
project: project.to_string(),
process: process.to_string(),
node: "worker".to_owned(),
task: child.to_string(),
terminal_state: Some(TaskTerminalState::Completed),
status_code: Some(0),
stdout_bytes: artifact_bytes.len() as u64,
stderr_bytes: 0,
stdout_tail: "child completed".to_owned(),
stderr_tail: String::new(),
stdout_truncated: false,
stderr_truncated: false,
artifact_path: Some("/vfs/artifacts/child-output".to_owned()),
artifact_digest: Some(Digest::sha256(artifact_bytes)),
artifact_size_bytes: Some(artifact_bytes.len() as u64),
result: Some(TaskBoundaryValue::SmallJson(json!("child completed"))),
})
.unwrap();
assert!(service
.coordinator
.active_process(&tenant, &project, &process)
.is_none());
assert!(!service.debug_epochs.contains_key(&process_key));
let CoordinatorResponse::TaskEvents { events } = service
.handle_request(CoordinatorRequest::ListTaskEvents {
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: "user".to_owned(),
process: Some(process.to_string()),
})
.unwrap()
else {
panic!("expected retained task events");
};
assert_eq!(events.len(), 2);
assert!(events.iter().any(|event| {
event.executor == TaskExecutor::CoordinatorMain
&& event.terminal_state == TaskTerminalState::Completed
}));
assert!(events.iter().any(|event| {
event.task == child && event.artifact_digest == Some(Digest::sha256(artifact_bytes))
}));
let metadata = service
.artifact_registry
.metadata(&ArtifactId::from("child-output"))
.expect("artifact metadata must survive terminal cleanup");
assert_eq!(metadata.process, process);
assert_eq!(metadata.digest, Digest::sha256(artifact_bytes));
let next = service
.handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: tenant.to_string(),
project: project.to_string(),
actor_user: None,
actor_agent: None,
agent_public_key_fingerprint: None,
agent_signature: None,
process: "process-after-cleanup".to_owned(),
restart: false,
})
.unwrap();
assert!(matches!(next, CoordinatorResponse::ProcessStarted { .. }));
}
#[test] #[test]
fn quiescent_cooperative_cancel_releases_slot_immediately() { fn quiescent_cooperative_cancel_releases_slot_immediately() {
let mut service = CoordinatorService::new(17); let mut service = CoordinatorService::new(17);
@ -6084,6 +6288,35 @@ fn service_meters_rendezvous_before_direct_transfer_plan() {
assert!(quota.to_string().contains("RendezvousAttempt")); assert!(quota.to_string().contains("RendezvousAttempt"));
} }
#[test]
fn enrolled_node_can_request_a_structurally_scoped_rendezvous_with_signed_authority() {
let mut service = CoordinatorService::new(7);
service
.handle_request(CoordinatorRequest::AttachNode {
tenant: "tenant".to_owned(),
project: "project".to_owned(),
node: "node-a".to_owned(),
public_key: test_node_public_key("node-a"),
})
.unwrap();
let response = service
.handle_signed_node_request_auto(CoordinatorRequest::RequestRendezvous {
scope: data_plane_scope("project"),
source: endpoint("node-a"),
destination: endpoint("node-b"),
direct_connectivity: true,
failure_reason: String::new(),
})
.unwrap();
let CoordinatorResponse::RendezvousPlan { plan, .. } = response else {
panic!("expected signed rendezvous plan");
};
assert_eq!(plan.source.node, NodeId::from("node-a"));
assert_eq!(plan.destination.node, NodeId::from("node-b"));
}
#[test] #[test]
fn service_rejects_task_completion_outside_node_scope() { fn service_rejects_task_completion_outside_node_scope() {
let mut service = CoordinatorService::new(1); let mut service = CoordinatorService::new(1);

View file

@ -364,7 +364,13 @@ pub fn agent_workflow_request_scope_from_payload(
.get("task_instance") .get("task_instance")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| "launch_task agent scope is missing task_instance".to_owned())?; .ok_or_else(|| "launch_task agent scope is missing task_instance".to_owned())?;
(process, Some(TaskInstanceId::from(task))) (
process,
Some(
TaskInstanceId::try_new(task)
.map_err(|error| format!("malformed launch_task task instance: {error}"))?,
),
)
} }
_ => { _ => {
return Err(format!( return Err(format!(
@ -373,10 +379,13 @@ pub fn agent_workflow_request_scope_from_payload(
} }
}; };
AgentWorkflowRequestScope::new( AgentWorkflowRequestScope::new(
TenantId::from(tenant), TenantId::try_new(tenant)
ProjectId::from(project), .map_err(|error| format!("malformed agent workflow tenant: {error}"))?,
ProjectId::try_new(project)
.map_err(|error| format!("malformed agent workflow project: {error}"))?,
request_kind, request_kind,
ProcessId::from(process), ProcessId::try_new(process)
.map_err(|error| format!("malformed agent workflow process: {error}"))?,
task, task,
) )
} }

View file

@ -1,7 +1,35 @@
#[cfg(not(target_arch = "wasm32"))]
use serde::{de::Error as _, Deserializer};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const MAX_EXTERNAL_ID_BYTES: usize = 255; pub const MAX_EXTERNAL_ID_BYTES: usize = 255;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpaqueTokenError {
reason: String,
}
impl std::fmt::Display for OpaqueTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason)
}
}
impl std::error::Error for OpaqueTokenError {}
pub fn validate_opaque_token(value: &str, max_bytes: usize) -> Result<(), OpaqueTokenError> {
let reason = if value.trim().is_empty() {
Some("value must not be empty or whitespace-only".to_owned())
} else if value.len() > max_bytes {
Some(format!("value exceeds the {max_bytes}-byte limit"))
} else if value.chars().any(char::is_control) {
Some("control characters are forbidden".to_owned())
} else {
None
};
reason.map_or(Ok(()), |reason| Err(OpaqueTokenError { reason }))
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdParseError { pub struct IdParseError {
id_type: &'static str, id_type: &'static str,
@ -55,7 +83,8 @@ fn validate_id(value: &str, id_type: &'static str) -> Result<(), IdParseError> {
macro_rules! id_type { macro_rules! id_type {
($name:ident) => { ($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct $name(String); pub struct $name(String);
impl $name { impl $name {
@ -81,6 +110,17 @@ macro_rules! id_type {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::try_new(value).map_err(D::Error::custom)
}
}
impl std::fmt::Display for $name { impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0) f.write_str(&self.0)
@ -134,4 +174,38 @@ mod tests {
assert_hostile_values(TenantId::try_new); assert_hostile_values(TenantId::try_new);
assert_hostile_values(UserId::try_new); assert_hostile_values(UserId::try_new);
} }
#[test]
fn every_identifier_type_validates_during_deserialization() {
macro_rules! assert_deserialization {
($id:ty) => {
assert!(serde_json::from_str::<$id>(r#""valid-id""#).is_ok());
let error = serde_json::from_str::<$id>(r#""hostile id!""#).unwrap_err();
assert!(
error.to_string().contains("is invalid"),
"{} produced an unexpected error: {error}",
stringify!($id)
);
};
}
assert_deserialization!(AgentId);
assert_deserialization!(ArtifactId);
assert_deserialization!(NodeId);
assert_deserialization!(ProcessId);
assert_deserialization!(LaunchAttemptId);
assert_deserialization!(ProjectId);
assert_deserialization!(TaskDefinitionId);
assert_deserialization!(TaskInstanceId);
assert_deserialization!(TenantId);
assert_deserialization!(UserId);
}
#[test]
fn opaque_tokens_are_bounded_without_using_identifier_syntax() {
validate_opaque_token("opaque secret/+==", 64).unwrap();
assert!(validate_opaque_token("", 64).is_err());
assert!(validate_opaque_token("bad\0token", 64).is_err());
assert!(validate_opaque_token(&"x".repeat(65), 64).is_err());
}
} }

View file

@ -66,9 +66,9 @@ pub use execution::{
WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION, WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, WASM_TASK_ABI_VERSION,
}; };
pub use ids::{ pub use ids::{
AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId, NodeId, ProcessId, validate_opaque_token, AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId,
ProjectId, RequestId, TaskDefinitionId, TaskInstanceId, TenantId, UserId, NodeId, OpaqueTokenError, ProcessId, ProjectId, RequestId, TaskDefinitionId, TaskInstanceId,
MAX_EXTERNAL_ID_BYTES, TenantId, UserId, MAX_EXTERNAL_ID_BYTES,
}; };
pub use limits::{ pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits, LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,

View file

@ -64,6 +64,7 @@ struct LaunchCompletion {
state: AdapterState, state: AdapterState,
local_runtime_session: Option<LocalRuntimeSession>, local_runtime_session: Option<LocalRuntimeSession>,
breakpoint_revision: u64, breakpoint_revision: u64,
observation_diagnostics: Vec<String>,
} }
pub(crate) fn run_adapter() -> Result<()> { pub(crate) fn run_adapter() -> Result<()> {
@ -108,6 +109,9 @@ pub(crate) fn run_adapter() -> Result<()> {
if let Some(session) = completion.local_runtime_session { if let Some(session) = completion.local_runtime_session {
_local_runtime_session = Some(session); _local_runtime_session = Some(session);
} }
for diagnostic in completion.observation_diagnostics {
writer.output("stderr", format!("{diagnostic}\n"))?;
}
runtime_started = true; runtime_started = true;
if state.breakpoints_installed { if state.breakpoints_installed {
emit_verified_breakpoints(&mut writer, &state)?; emit_verified_breakpoints(&mut writer, &state)?;
@ -252,7 +256,12 @@ pub(crate) fn run_adapter() -> Result<()> {
revision, revision,
result, result,
} => { } => {
if generation == runtime_generation && revision == state.breakpoint_revision { if breakpoint_update_is_current(
generation,
runtime_generation,
revision,
state.breakpoint_revision,
) {
match result { match result {
Ok(()) => { Ok(()) => {
state.breakpoints_installed = true; state.breakpoints_installed = true;
@ -478,6 +487,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state, state: completed_state,
local_runtime_session: None, local_runtime_session: None,
breakpoint_revision, breakpoint_revision,
observation_diagnostics: Vec::new(),
} }
}) })
.map_err(|error| format!("services runtime attach failed: {error:#}")) .map_err(|error| format!("services runtime attach failed: {error:#}"))
@ -486,6 +496,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LocalServices => { RuntimeBackend::LocalServices => {
run_local_services_runtime(&completed_state) run_local_services_runtime(&completed_state)
.map(|(record, session)| { .map(|(record, session)| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record); completed_state.apply_runtime_record(record);
let breakpoint_revision = let breakpoint_revision =
completed_state.breakpoint_revision; completed_state.breakpoint_revision;
@ -493,6 +505,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state, state: completed_state,
local_runtime_session: Some(session), local_runtime_session: Some(session),
breakpoint_revision, breakpoint_revision,
observation_diagnostics,
} }
}) })
.map_err(|error| { .map_err(|error| {
@ -502,6 +515,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LiveServices => { RuntimeBackend::LiveServices => {
run_live_services_runtime(&completed_state) run_live_services_runtime(&completed_state)
.map(|record| { .map(|record| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record); completed_state.apply_runtime_record(record);
let breakpoint_revision = let breakpoint_revision =
completed_state.breakpoint_revision; completed_state.breakpoint_revision;
@ -509,6 +524,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state, state: completed_state,
local_runtime_session: None, local_runtime_session: None,
breakpoint_revision, breakpoint_revision,
observation_diagnostics,
} }
}) })
.map_err(|error| { .map_err(|error| {
@ -1051,6 +1067,29 @@ fn spawn_runtime_observer(
}); });
} }
pub(crate) fn breakpoint_update_is_current(
update_generation: u64,
runtime_generation: u64,
update_revision: u64,
current_revision: u64,
) -> bool {
update_generation == runtime_generation && update_revision == current_revision
}
fn runtime_observation_diagnostics(
record: &crate::virtual_model::RuntimeLaunchRecord,
) -> Vec<String> {
record
.node_report
.get("observation_diagnostics")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
}
fn emit_verified_breakpoints(writer: &mut DapWriter, state: &AdapterState) -> Result<()> { fn emit_verified_breakpoints(writer: &mut DapWriter, state: &AdapterState) -> Result<()> {
if !state.breakpoints_installed { if !state.breakpoints_installed {
return Ok(()); return Ok(());

View file

@ -539,7 +539,24 @@ fn new_launch_attempt_id() -> String {
fn set_services_debug_breakpoints_at(coordinator: &str, state: &AdapterState) -> Result<()> { fn set_services_debug_breakpoints_at(coordinator: &str, state: &AdapterState) -> Result<()> {
static INJECTED_INSTALL_FAILURE: AtomicBool = AtomicBool::new(false); static INJECTED_INSTALL_FAILURE: AtomicBool = AtomicBool::new(false);
if std::env::var_os("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE").is_some() if std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_DELAY_REVISION")
.ok()
.and_then(|value| value.parse::<u64>().ok())
== Some(state.breakpoint_revision)
{
let delay_ms = std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_DELAY_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(750);
std::thread::sleep(Duration::from_millis(delay_ms));
}
let revision_failure =
std::env::var("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE_REVISION")
.ok()
.and_then(|value| value.parse::<u64>().ok())
== Some(state.breakpoint_revision);
if (revision_failure
|| std::env::var_os("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE").is_some())
&& !state.breakpoints.is_empty() && !state.breakpoints.is_empty()
&& !INJECTED_INSTALL_FAILURE.swap(true, Ordering::SeqCst) && !INJECTED_INSTALL_FAILURE.swap(true, Ordering::SeqCst)
{ {
@ -557,6 +574,7 @@ fn set_services_debug_breakpoints_at(coordinator: &str, state: &AdapterState) ->
"project": state.project_id, "project": state.project_id,
"actor_user": state.actor_user, "actor_user": state.actor_user,
"process": state.process.as_str(), "process": state.process.as_str(),
"revision": state.breakpoint_revision,
"probe_symbols": state.requested_probe_symbols(), "probe_symbols": state.requested_probe_symbols(),
}), }),
), ),
@ -1026,6 +1044,12 @@ pub(crate) fn observe_services_runtime(
let inject_connection_loss = let inject_connection_loss =
std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1"); std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1");
let mut connection_loss_injected = false; let mut connection_loss_injected = false;
let inject_snapshot_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_SNAPSHOT_FAILURE").is_some();
let mut snapshot_failure_injected = false;
let inject_process_status_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_PROCESS_STATUS_FAILURE").is_some();
let mut process_status_failure_injected = false;
let inject_fallback_failure = let inject_fallback_failure =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE").is_some(); std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE").is_some();
let mut fallback_failure_stage = 0_u8; let mut fallback_failure_stage = 0_u8;
@ -1095,11 +1119,49 @@ pub(crate) fn observe_services_runtime(
continue; continue;
} }
if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) { if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
let task_snapshots = fetch_task_snapshots_in(current_session, state) let snapshot_request = if inject_snapshot_failure && !snapshot_failure_injected {
.unwrap_or_else(|_| json!({ "snapshots": [] })); snapshot_failure_injected = true;
let (process_statuses, process_status) = Err(anyhow!("injected task snapshot transport failure"))
fetch_current_process_status_in(current_session, state) } else {
.unwrap_or_else(|_| (json!({ "processes": [] }), None)); fetch_task_snapshots_in(current_session, state)
};
let task_snapshots = match snapshot_request {
Ok(snapshots) => snapshots,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime terminal snapshot observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
let process_status_request =
if inject_process_status_failure && !process_status_failure_injected {
process_status_failure_injected = true;
Err(anyhow!("injected process-status transport failure"))
} else {
fetch_current_process_status_in(current_session, state)
};
let (process_statuses, process_status) = match process_status_request {
Ok(status) => status,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime terminal process observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
if !has_current_runtime_task(&task_snapshots) { if !has_current_runtime_task(&task_snapshots) {
if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome { if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
record.node_report = json!({ record.node_report = json!({
@ -1113,7 +1175,13 @@ pub(crate) fn observe_services_runtime(
return Ok(()); return Ok(());
} }
} }
let task_snapshots = match fetch_task_snapshots_in(current_session, state) { let snapshot_request = if inject_snapshot_failure && !snapshot_failure_injected {
snapshot_failure_injected = true;
Err(anyhow!("injected task snapshot transport failure"))
} else {
fetch_task_snapshots_in(current_session, state)
};
let task_snapshots = match snapshot_request {
Ok(snapshots) => snapshots, Ok(snapshots) => snapshots,
Err(error) => { Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!( if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
@ -1128,22 +1196,28 @@ pub(crate) fn observe_services_runtime(
continue; continue;
} }
}; };
let (process_statuses, process_status) = let process_status_request =
match fetch_current_process_status_in(current_session, state) { if inject_process_status_failure && !process_status_failure_injected {
Ok(status) => status, process_status_failure_injected = true;
Err(error) => { Err(anyhow!("injected process-status transport failure"))
if !emit(RuntimeContinuationOutcome::Diagnostic(format!( } else {
"runtime process observation failed: {error:#}; reconnecting in {} ms", fetch_current_process_status_in(current_session, state)
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
}; };
let (process_statuses, process_status) = match process_status_request {
Ok(status) => status,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime process observation failed: {error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
reconnect_delay = Duration::from_millis(100); reconnect_delay = Duration::from_millis(100);
if let Some((failed_task, _attempt_id)) = failed_awaiting_action_snapshot(&task_snapshots) { if let Some((failed_task, _attempt_id)) = failed_awaiting_action_snapshot(&task_snapshots) {
let failed_task = failed_task.to_owned(); let failed_task = failed_task.to_owned();
@ -1277,8 +1351,21 @@ pub(crate) fn observe_services_runtime(
} }
}; };
if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) { if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
let task_snapshots = fetch_task_snapshots_in(current_session, state) let task_snapshots = match fetch_task_snapshots_in(current_session, state) {
.unwrap_or_else(|_| json!({ "snapshots": [] })); Ok(snapshots) => snapshots,
Err(snapshot_error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
"runtime fallback terminal snapshot observation failed: {snapshot_error:#}; reconnecting in {} ms",
reconnect_delay.as_millis()
))) {
return Ok(());
}
session = None;
std::thread::sleep(reconnect_delay);
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(5));
continue;
}
};
if !has_current_runtime_task(&task_snapshots) { if !has_current_runtime_task(&task_snapshots) {
if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome { if let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
record.node_report = json!({ record.node_report = json!({

View file

@ -173,7 +173,9 @@ pub(crate) fn parse_task_restart_response(response: Value) -> Result<TaskRestart
restarted_task_instance: response restarted_task_instance: response
.get("restarted_task_instance") .get("restarted_task_instance")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(TaskInstanceId::new), .map(TaskInstanceId::try_new)
.transpose()
.map_err(|error| anyhow!("malformed restarted task instance: {error}"))?,
restarted_attempt_id: response restarted_attempt_id: response
.get("restarted_attempt_id") .get("restarted_attempt_id")
.and_then(Value::as_str) .and_then(Value::as_str)

View file

@ -45,6 +45,13 @@ fn initialize_capabilities_use_standard_dap_flags() {
.all(|key| key.starts_with("supports") && !key.contains("clusterflux"))); .all(|key| key.starts_with("supports") && !key.contains("clusterflux")));
} }
#[test]
fn stale_breakpoint_success_and_failure_completions_are_not_current() {
assert!(crate::adapter::breakpoint_update_is_current(4, 4, 9, 9));
assert!(!crate::adapter::breakpoint_update_is_current(4, 4, 8, 9));
assert!(!crate::adapter::breakpoint_update_is_current(3, 4, 9, 9));
}
#[test] #[test]
fn runtime_backend_defaults_to_local_services_and_requires_explicit_demo() { fn runtime_backend_defaults_to_local_services_and_requires_explicit_demo() {
assert_eq!( assert_eq!(

View file

@ -313,7 +313,10 @@ impl AdapterState {
self.debug_all_threads_stopped = record.all_participants_frozen; self.debug_all_threads_stopped = record.all_participants_frozen;
self.epoch = record.debug_epoch.unwrap_or(self.epoch); self.epoch = record.debug_epoch.unwrap_or(self.epoch);
self.coordinator_debug_epoch = record.debug_epoch; self.coordinator_debug_epoch = record.debug_epoch;
self.stopped_task = record.stopped_task.as_deref().map(TaskInstanceId::new); self.stopped_task = record
.stopped_task
.as_deref()
.and_then(|task| TaskInstanceId::try_new(task).ok());
self.stopped_probe_symbol = record.stopped_probe_symbol.clone(); self.stopped_probe_symbol = record.stopped_probe_symbol.clone();
if let Some(acknowledgements) = record if let Some(acknowledgements) = record
.node_report .node_report
@ -330,6 +333,12 @@ impl AdapterState {
else { else {
continue; continue;
}; };
let Ok(task_id) = TaskInstanceId::try_new(task) else {
continue;
};
let Ok(task_definition_id) = TaskDefinitionId::try_new(task_definition) else {
continue;
};
let coordinator_main = acknowledgement.get("node").and_then(Value::as_str) let coordinator_main = acknowledgement.get("node").and_then(Value::as_str)
== Some("coordinator-main"); == Some("coordinator-main");
let thread_id = if coordinator_main { let thread_id = if coordinator_main {
@ -339,14 +348,19 @@ impl AdapterState {
.values() .values()
.find(|thread| thread.task.as_str() == task) .find(|thread| thread.task.as_str() == task)
.map(|thread| thread.id) .map(|thread| thread.id)
.unwrap_or_else(|| self.insert_runtime_thread(task, task_definition)) .unwrap_or_else(|| {
self.insert_runtime_thread(
task_id.clone(),
task_definition_id.clone(),
)
})
}; };
let thread = self let thread = self
.threads .threads
.get_mut(&thread_id) .get_mut(&thread_id)
.expect("runtime thread was found or inserted"); .expect("runtime thread was found or inserted");
thread.task = TaskInstanceId::new(task); thread.task = task_id;
thread.task_definition = TaskDefinitionId::new(task_definition); thread.task_definition = task_definition_id;
thread.runtime_stack_frames = acknowledgement thread.runtime_stack_frames = acknowledgement
.get("stack_frames") .get("stack_frames")
.and_then(Value::as_array) .and_then(Value::as_array)
@ -428,18 +442,22 @@ impl AdapterState {
let _ = crate::view_state::write_view_state(self, &record); let _ = crate::view_state::write_view_state(self, &record);
} }
fn insert_runtime_thread(&mut self, task: &str, task_definition: &str) -> i64 { fn insert_runtime_thread(
&mut self,
task: TaskInstanceId,
task_definition: TaskDefinitionId,
) -> i64 {
let id = self.allocate_runtime_thread_id(); let id = self.allocate_runtime_thread_id();
let line = self let line = self
.debug_probes .debug_probes
.iter() .iter()
.find(|probe| { .find(|probe| {
probe.task.as_str() == task_definition || probe.function == task_definition probe.task == task_definition || probe.function == task_definition.as_str()
}) })
.map(|probe| i64::from(probe.line_start)) .map(|probe| i64::from(probe.line_start))
.unwrap_or(1); .unwrap_or(1);
let definition_name = task_definition.replace(['_', '-'], " "); let definition_name = task_definition.as_str().replace(['_', '-'], " ");
let name = if task == task_definition { let name = if task.as_str() == task_definition.as_str() {
definition_name definition_name
} else { } else {
format!("{definition_name} ({task})") format!("{definition_name} ({task})")
@ -457,9 +475,9 @@ impl AdapterState {
target_ref: 6000 + id, target_ref: 6000 + id,
vfs_ref: 7000 + id, vfs_ref: 7000 + id,
command_ref: 8000 + id, command_ref: 8000 + id,
task: TaskInstanceId::new(task), task,
attempt_id: "unknown-attempt".to_owned(), attempt_id: "unknown-attempt".to_owned(),
task_definition: TaskDefinitionId::new(task_definition), task_definition,
name, name,
line, line,
state: DebugRuntimeState::Running, state: DebugRuntimeState::Running,
@ -725,7 +743,12 @@ pub(crate) fn process_id(project: &str, entry: &str) -> ProcessId {
ProcessId::new(format!("vp-{suffix}")) ProcessId::new(format!("vp-{suffix}"))
} }
fn coordinator_main_thread(entry: &str, task: &str, task_definition: &str) -> VirtualThread { fn coordinator_main_thread(
entry: &str,
task: TaskInstanceId,
task_definition: TaskDefinitionId,
) -> VirtualThread {
let name = format!("{entry} coordinator main ({task})");
VirtualThread { VirtualThread {
id: MAIN_THREAD, id: MAIN_THREAD,
frame_id: 1_001, frame_id: 1_001,
@ -737,10 +760,10 @@ fn coordinator_main_thread(entry: &str, task: &str, task_definition: &str) -> Vi
target_ref: 4_501, target_ref: 4_501,
vfs_ref: 5_001, vfs_ref: 5_001,
command_ref: 5_501, command_ref: 5_501,
task: TaskInstanceId::from(task), task,
attempt_id: "main:unknown".to_owned(), attempt_id: "main:unknown".to_owned(),
task_definition: TaskDefinitionId::from(task_definition), task_definition,
name: format!("{entry} coordinator main ({task})"), name,
line: 0, line: 0,
state: DebugRuntimeState::Running, state: DebugRuntimeState::Running,
freeze_supported: true, freeze_supported: true,
@ -776,10 +799,14 @@ fn coordinator_threads_from_events(
.expect("launch thread model should include main"); .expect("launch thread model should include main");
if let Some(status) = process_status { if let Some(status) = process_status {
if let Some(task) = status.get("main_task_instance").and_then(Value::as_str) { if let Some(task) = status.get("main_task_instance").and_then(Value::as_str) {
main.task = TaskInstanceId::from(task); if let Ok(task) = TaskInstanceId::try_new(task) {
main.task = task;
}
} }
if let Some(definition) = status.get("main_task_definition").and_then(Value::as_str) { if let Some(definition) = status.get("main_task_definition").and_then(Value::as_str) {
main.task_definition = TaskDefinitionId::from(definition); if let Ok(definition) = TaskDefinitionId::try_new(definition) {
main.task_definition = definition;
}
} }
main.name = format!("{entry} coordinator main ({})", main.task); main.name = format!("{entry} coordinator main ({})", main.task);
main.state = if status main.state = if status
@ -841,6 +868,12 @@ fn coordinator_threads_from_snapshots(
.get("main_task_definition") .get("main_task_definition")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or(entry); .unwrap_or(entry);
let (Ok(task), Ok(task_definition)) = (
TaskInstanceId::try_new(task),
TaskDefinitionId::try_new(task_definition),
) else {
return threads;
};
let mut main = coordinator_main_thread(entry, task, task_definition); let mut main = coordinator_main_thread(entry, task, task_definition);
main.attempt_id = format!( main.attempt_id = format!(
"main:{}", "main:{}",
@ -895,6 +928,12 @@ fn coordinator_threads_from_snapshots(
let Some(task_definition) = snapshot.get("task_definition").and_then(Value::as_str) else { let Some(task_definition) = snapshot.get("task_definition").and_then(Value::as_str) else {
continue; continue;
}; };
let Ok(task_id) = TaskInstanceId::try_new(task) else {
continue;
};
let Ok(task_definition_id) = TaskDefinitionId::try_new(task_definition) else {
continue;
};
let attempt_id = snapshot let attempt_id = snapshot
.get("attempt_id") .get("attempt_id")
.and_then(Value::as_str) .and_then(Value::as_str)
@ -945,9 +984,9 @@ fn coordinator_threads_from_snapshots(
target_ref: 6000 + id, target_ref: 6000 + id,
vfs_ref: 7000 + id, vfs_ref: 7000 + id,
command_ref: 8000 + id, command_ref: 8000 + id,
task: TaskInstanceId::from(task), task: task_id,
attempt_id, attempt_id,
task_definition: TaskDefinitionId::from(task_definition), task_definition: task_definition_id,
name: format!("{display_name} ({task}, attempt {short_attempt})"), name: format!("{display_name} ({task}, attempt {short_attempt})"),
line: snapshot line: snapshot
.get("source_line") .get("source_line")
@ -994,6 +1033,8 @@ fn coordinator_threads_from_snapshots(
fn coordinator_event_thread(id: i64, _event_index: usize, event: &Value) -> Option<VirtualThread> { fn coordinator_event_thread(id: i64, _event_index: usize, event: &Value) -> Option<VirtualThread> {
let task = event.get("task").and_then(Value::as_str)?; let task = event.get("task").and_then(Value::as_str)?;
let task_definition = event.get("task_definition").and_then(Value::as_str)?; let task_definition = event.get("task_definition").and_then(Value::as_str)?;
let task_id = TaskInstanceId::try_new(task).ok()?;
let task_definition_id = TaskDefinitionId::try_new(task_definition).ok()?;
let definition_name = task_definition.replace(['_', '-'], " "); let definition_name = task_definition.replace(['_', '-'], " ");
let name = if task == task_definition { let name = if task == task_definition {
definition_name definition_name
@ -1039,13 +1080,13 @@ fn coordinator_event_thread(id: i64, _event_index: usize, event: &Value) -> Opti
target_ref: 6000 + id, target_ref: 6000 + id,
vfs_ref: 7000 + id, vfs_ref: 7000 + id,
command_ref: 8000 + id, command_ref: 8000 + id,
task: TaskInstanceId::from(task), task: task_id,
attempt_id: event attempt_id: event
.get("attempt_id") .get("attempt_id")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("unknown-attempt") .unwrap_or("unknown-attempt")
.to_owned(), .to_owned(),
task_definition: TaskDefinitionId::from(task_definition), task_definition: task_definition_id,
name, name,
line: 0, line: 0,
state, state,

View file

@ -7,8 +7,7 @@ use std::time::{Duration, Instant};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clusterflux_core::{ use clusterflux_core::{
sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId, sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId,
ProcessId, ProjectId, RequestId, TaskInstanceId, TaskSpec, TenantId, ProcessId, ProjectId, TaskInstanceId, TaskSpec, TenantId, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
MIN_SIGNED_NODE_POLL_INTERVAL_MS,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -577,7 +576,7 @@ fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
ProjectId::try_new(project.clone())?; ProjectId::try_new(project.clone())?;
NodeId::try_new(node.clone())?; NodeId::try_new(node.clone())?;
if let Some(grant) = enrollment_grant.as_ref() { if let Some(grant) = enrollment_grant.as_ref() {
RequestId::try_new(grant.clone())?; clusterflux_core::validate_opaque_token(grant, 512)?;
} }
Ok(Args { Ok(Args {
coordinator: coordinator.ok_or("--coordinator is required")?, coordinator: coordinator.ok_or("--coordinator is required")?,

View file

@ -6,7 +6,7 @@ path. Publication is a three-stage transaction:
1. `candidate` builds immutable archives and a manifest with paths relative to 1. `candidate` builds immutable archives and a manifest with paths relative to
the manifest directory. the manifest directory.
2. `live-test` downloads that exact candidate in a clean job, deploys it, and 2. `live-test` downloads that exact candidate in a clean job, deploys it, and
records the full 25-check production-shaped acceptance result plus deployment, records the full named production-shaped acceptance result plus deployment,
runtime configuration, and proxy configuration identities. runtime configuration, and proxy configuration identities.
3. `final` downloads the candidate and evidence in another clean job, verifies 3. `final` downloads the candidate and evidence in another clean job, verifies
every binding, and publishes without rebuilding any binary. every binding, and publishes without rebuilding any binary.

View file

@ -47,12 +47,12 @@ function agentWorkflowSignatureMessage({
function signedAgentWorkflowProof(identity, request, options = {}) { function signedAgentWorkflowProof(identity, request, options = {}) {
const nonce = const nonce =
options.nonce || options.nonce ??
`${request.type}-${process.pid}-${Date.now()}-${crypto `${request.type}-${process.pid}-${Date.now()}-${crypto
.randomBytes(8) .randomBytes(8)
.toString("hex")}`; .toString("hex")}`;
const issuedAtEpochSeconds = const issuedAtEpochSeconds =
options.issuedAtEpochSeconds || Math.floor(Date.now() / 1000); options.issuedAtEpochSeconds ?? Math.floor(Date.now() / 1000);
const processId = const processId =
request.type === "launch_task" ? request.task_spec?.process : request.process; request.type === "launch_task" ? request.task_spec?.process : request.process;
const task = const task =

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,55 @@ const { DapClient } = require("./dap-client");
sourceLines.findIndex((line) => line.includes("pub async fn build_main()")) + 1; sourceLines.findIndex((line) => line.includes("pub async fn build_main()")) + 1;
assert(buildMainLine > 0, "flagship source must contain build_main"); assert(buildMainLine > 0, "flagship source must contain build_main");
const hostileDapClient = new DapClient();
try {
const initialize = hostileDapClient.send("initialize", {
adapterID: "clusterflux",
linesStartAt1: true,
columnsStartAt1: true,
});
await hostileDapClient.response(initialize, "initialize");
for (const processId of [
"",
" ",
"bad\u0000process",
"bad process!",
"x".repeat(256),
]) {
const malformedLaunch = hostileDapClient.send("launch", {
entry: "build",
project,
runtimeBackend: "local-services",
processId,
});
const rejection = await hostileDapClient.failure(
malformedLaunch,
"launch"
);
assert.match(
rejection.message,
/invalid DAP processId: ProcessId is invalid/,
`unexpected malformed DAP identifier response: ${JSON.stringify(rejection)}`
);
}
const validLaunch = hostileDapClient.send("launch", {
entry: "build",
project,
runtimeBackend: "local-services",
processId: "vp-valid-after-malformed",
});
await hostileDapClient.response(validLaunch, "launch");
await hostileDapClient.waitFor(
(message) => message.type === "event" && message.event === "initialized"
);
await hostileDapClient.close();
} catch (error) {
if (hostileDapClient.child.exitCode === null) {
hostileDapClient.child.kill("SIGKILL");
}
throw error;
}
const asynchronousClient = new DapClient(); const asynchronousClient = new DapClient();
try { try {
const initialize = asynchronousClient.send("initialize", { const initialize = asynchronousClient.send("initialize", {

View file

@ -3,9 +3,53 @@
const assert = require("assert"); const assert = require("assert");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const {
agentIdentity,
signedAgentWorkflowRequest,
} = require("./agent-signing");
const {
nodeIdentity,
signedNodeHeartbeat,
} = require("./node-signing");
const repo = path.resolve(__dirname, ".."); const repo = path.resolve(__dirname, "..");
const signingInstrumentAgent = agentIdentity(
"hostile-input-contract-agent",
"agent-hostile-input-contract"
);
const explicitlyEmptyAgentNonce = signedAgentWorkflowRequest(
signingInstrumentAgent,
{
type: "start_process",
tenant: "tenant",
project: "project",
actor_agent: "agent-hostile-input-contract",
process: "process",
launch_attempt: "attempt",
restart: false,
},
{ nonce: "" }
);
assert.strictEqual(
explicitlyEmptyAgentNonce.agent_signature.nonce,
"",
"Agent signing instrument replaced an explicitly empty hostile nonce"
);
const signingInstrumentNode = nodeIdentity(
"hostile-input-contract-node",
"node-hostile-input-contract"
);
assert.strictEqual(
signedNodeHeartbeat(
"node-hostile-input-contract",
signingInstrumentNode,
{ nonce: "" }
).nonce,
"",
"Node signing instrument replaced an explicitly empty hostile nonce"
);
function read(relativePath) { function read(relativePath) {
return fs.readFileSync(path.join(repo, relativePath), "utf8"); return fs.readFileSync(path.join(repo, relativePath), "utf8");
} }

View file

@ -127,7 +127,7 @@ function nodeSignatureMessage(
function signedNodeProof(node, identity, requestKind, request, options = {}) { function signedNodeProof(node, identity, requestKind, request, options = {}) {
const nonce = const nonce =
options.nonce || options.nonce ??
`${requestKind}-${process.pid}-${Date.now()}-${crypto `${requestKind}-${process.pid}-${Date.now()}-${crypto
.randomBytes(8) .randomBytes(8)
.toString("hex")}`; .toString("hex")}`;

View file

@ -51,7 +51,7 @@ function copyVerifiedAsset(asset, destinationRoot) {
return destination; return destination;
} }
function extractBinaries(archive, destinationRoot) { function extractBinaries(archive, destinationRoot, expectedDigests) {
const staging = path.join(destinationRoot, ".binary-extract"); const staging = path.join(destinationRoot, ".binary-extract");
fs.mkdirSync(staging, { recursive: true }); fs.mkdirSync(staging, { recursive: true });
const extracted = spawnSync("tar", ["-xzf", archive, "-C", staging], { const extracted = spawnSync("tar", ["-xzf", archive, "-C", staging], {
@ -69,6 +69,11 @@ function extractBinaries(archive, destinationRoot) {
const destination = path.join(destinationRoot, "binaries", name); const destination = path.join(destinationRoot, "binaries", name);
fs.copyFileSync(source, destination); fs.copyFileSync(source, destination);
fs.chmodSync(destination, 0o755); fs.chmodSync(destination, 0o755);
assert.strictEqual(
`sha256:${sha256(destination)}`,
expectedDigests[name],
`tested binary digest changed: ${name}`
);
copied.push(destination); copied.push(destination);
} }
fs.rmSync(staging, { recursive: true, force: true }); fs.rmSync(staging, { recursive: true, force: true });
@ -87,11 +92,21 @@ function main() {
assert.strictEqual(result.source_tree_digest, manifest.source_tree_digest); assert.strictEqual(result.source_tree_digest, manifest.source_tree_digest);
assert.deepStrictEqual(result.binary_digests, manifest.binary_digests); assert.deepStrictEqual(result.binary_digests, manifest.binary_digests);
assert.strictEqual(result.scenario_skips.length, 0); assert.strictEqual(result.scenario_skips.length, 0);
assert.strictEqual(result.strict_requirement_ledger.length, 25); assert(result.strict_requirement_ledger.length >= 29);
assert( assert(
result.strict_requirement_ledger.every((requirement) => requirement.passed === true), result.strict_requirement_ledger.every(
(requirement) =>
requirement.passed === true &&
Number.isFinite(requirement.duration_ms) &&
requirement.duration_ms > 0 &&
requirement.evidence
),
"final validation contains a failed launch requirement" "final validation contains a failed launch requirement"
); );
assert.strictEqual(
result.named_scenarios.length,
result.strict_requirement_ledger.length
);
const destinationRoot = path.resolve( const destinationRoot = path.resolve(
process.env.CLUSTERFLUX_GITHUB_RELEASE_DIR || process.env.CLUSTERFLUX_GITHUB_RELEASE_DIR ||
@ -104,11 +119,26 @@ function main() {
const copiedAssets = manifest.assets.map((asset) => const copiedAssets = manifest.assets.map((asset) =>
copyVerifiedAsset(asset, destinationRoot) copyVerifiedAsset(asset, destinationRoot)
); );
const publicationGuidance = [
JSON.stringify(manifest.notes || []),
...copiedAssets
.filter((file) => file.endsWith(".md"))
.map((file) => fs.readFileSync(file, "utf8")),
].join("\n");
assert.doesNotMatch(
publicationGuidance,
/(?:download|upload|release downloads?)[^\n]*Forgejo/i,
"manual GitHub package contains Forgejo release-publication instructions"
);
const binaryArchive = copiedAssets.find((file) => const binaryArchive = copiedAssets.find((file) =>
path.basename(file).startsWith("clusterflux-public-binaries-") path.basename(file).startsWith("clusterflux-public-binaries-")
); );
assert(binaryArchive, "final manifest omitted the public binary archive"); assert(binaryArchive, "final manifest omitted the public binary archive");
const binaries = extractBinaries(binaryArchive, destinationRoot); const binaries = extractBinaries(
binaryArchive,
destinationRoot,
manifest.binary_digests
);
const vsix = copiedAssets.find((file) => file.endsWith(".vsix")); const vsix = copiedAssets.find((file) => file.endsWith(".vsix"));
assert(vsix, "final manifest omitted the tested VSIX"); assert(vsix, "final manifest omitted the tested VSIX");
assert.strictEqual( assert.strictEqual(
@ -124,7 +154,6 @@ function main() {
); );
write(path.join(destinationRoot, "VSIX_SHA256"), `${sha256(vsix)} ${path.basename(vsix)}\n`); write(path.join(destinationRoot, "VSIX_SHA256"), `${sha256(vsix)} ${path.basename(vsix)}\n`);
fs.copyFileSync(manifestPath, path.join(destinationRoot, "public-release-manifest.json")); fs.copyFileSync(manifestPath, path.join(destinationRoot, "public-release-manifest.json"));
fs.copyFileSync(resultPath, path.join(destinationRoot, "final-result.json"));
fs.copyFileSync(transcriptPath, path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log")); fs.copyFileSync(transcriptPath, path.join(destinationRoot, "VALIDATION_TRANSCRIPT.log"));
write( write(
path.join(destinationRoot, "RELEASE_NOTES.md"), path.join(destinationRoot, "RELEASE_NOTES.md"),
@ -138,6 +167,31 @@ function main() {
"- Full Windows sandbox validation is deferred.\n" + "- Full Windows sandbox validation is deferred.\n" +
"- Providers, billing, teams, managed compute, and operator-panel UI are not part of this release.\n" "- Providers, billing, teams, managed compute, and operator-panel UI are not part of this release.\n"
); );
const testedUploadAssets = [
...copiedAssets,
...binaries,
]
.sort()
.map((file) => ({
file: path.relative(destinationRoot, file),
sha256: `sha256:${sha256(file)}`,
}));
write(
path.join(destinationRoot, "final-result.json"),
`${JSON.stringify(
{
...result,
manual_release: {
source_revision: manifest.source_commit,
source_tree_digest: manifest.source_tree_digest,
tested_upload_assets: testedUploadAssets,
publication: "manual GitHub upload from this directory",
},
},
null,
2
)}\n`
);
const checksummed = [ const checksummed = [
...copiedAssets, ...copiedAssets,

View file

@ -632,10 +632,22 @@ function stageEvidenceAsset(
} }
if ( if (
!Array.isArray(liveResult.named_scenarios) || !Array.isArray(liveResult.named_scenarios) ||
liveResult.named_scenarios.length !== 25 || !Array.isArray(liveResult.strict_requirement_ledger) ||
liveResult.named_scenarios.some((scenario) => scenario.status !== "passed") liveResult.named_scenarios.length !==
liveResult.strict_requirement_ledger.length ||
liveResult.named_scenarios.length < 29 ||
liveResult.named_scenarios.some(
(scenario) =>
scenario.status !== "passed" ||
!Number.isFinite(scenario.duration_ms) ||
scenario.duration_ms <= 0
) ||
new Set(liveResult.named_scenarios.map((scenario) => scenario.id))
.size !== liveResult.named_scenarios.length
) { ) {
throw new Error("all twenty-five named final live checks must pass"); throw new Error(
"every named final live check must be unique, measured, and passed"
);
} }
if ( if (
!liveResult.release_binding?.deployment || !liveResult.release_binding?.deployment ||
@ -649,7 +661,17 @@ function stageEvidenceAsset(
if ( if (
!Array.isArray(liveResult.strict_requirement_ledger) || !Array.isArray(liveResult.strict_requirement_ledger) ||
!liveResult.strict_requirement_ledger.length || !liveResult.strict_requirement_ledger.length ||
liveResult.strict_requirement_ledger.some((requirement) => requirement.passed !== true) liveResult.strict_requirement_ledger.some(
(requirement) =>
requirement.passed !== true ||
!Number.isFinite(requirement.duration_ms) ||
requirement.duration_ms <= 0 ||
!requirement.evidence
) ||
liveResult.strict_requirement_ledger.some(
(requirement, index) =>
requirement.id !== liveResult.named_scenarios[index].id
)
) { ) {
throw new Error("the strict final requirement ledger must be complete and passed"); throw new Error("the strict final requirement ledger must be complete and passed");
} }
@ -685,8 +707,7 @@ function stageEvidenceAsset(
evidence_packaged_at: packagedAt, evidence_packaged_at: packagedAt,
}, },
release_commands: [ release_commands: [
"nix develop -c ./scripts/acceptance-private.sh", "nix develop -c node scripts/release-quality-gates.js",
"nix develop -c ./scripts/acceptance-public.sh",
"node scripts/cli-happy-path-live-smoke.js (strict production-shaped environment)", "node scripts/cli-happy-path-live-smoke.js (strict production-shaped environment)",
], ],
}; };
@ -827,7 +848,7 @@ ${resolution}
## Install ## Install
1. Download the binary archive for your platform from the Forgejo release. 1. Download the binary archive for your platform from the manually published GitHub release.
2. Check the archive against \`SHA256SUMS\`. 2. Check the archive against \`SHA256SUMS\`.
3. Extract it and put \`bin/\` on your \`PATH\`. 3. Extract it and put \`bin/\` on your \`PATH\`.
4. Install \`clusterflux-vscode-*.vsix\` when you want the VS Code debugger: 4. Install \`clusterflux-vscode-*.vsix\` when you want the VS Code debugger:
@ -880,8 +901,8 @@ function writeReleaseNotesAsset(releaseName, publicTreeIdentity, resolution) {
process.env.CLUSTERFLUX_PUBLIC_REPO_URL || process.env.CLUSTERFLUX_PUBLIC_REPO_URL ||
"https://git.michelpaulissen.com/michel/clusterflux-public"; "https://git.michelpaulissen.com/michel/clusterflux-public";
const releaseUrl = const releaseUrl =
process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL || process.env.CLUSTERFLUX_GITHUB_RELEASE_URL ||
"the Forgejo release attached to the public repository"; "the manually published GitHub release";
fs.writeFileSync( fs.writeFileSync(
file, file,
`# Clusterflux Release Notes `# Clusterflux Release Notes
@ -1200,7 +1221,8 @@ function main() {
public_repo_remote: public_repo_remote:
process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || candidate?.public_repo_remote || null, process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || candidate?.public_repo_remote || null,
public_tree_publish: publicTreePublish, public_tree_publish: publicTreePublish,
forgejo_release_url: process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL || null, github_release_url: process.env.CLUSTERFLUX_GITHUB_RELEASE_URL || null,
forgejo_release_url: null,
default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint, default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint,
dns_publication_state: dns_publication_state:
process.env.CLUSTERFLUX_DNS_PUBLICATION_STATE || "published", process.env.CLUSTERFLUX_DNS_PUBLICATION_STATE || "published",
@ -1239,10 +1261,18 @@ function main() {
name: path.basename(asset), name: path.basename(asset),
sha256: sha256File(asset), sha256: sha256File(asset),
})), })),
notes: [ notes:
"Upload the assets to the Forgejo Release for the filtered public repository.", releaseStage === "final"
"The real service deployment and full e2e release remain separate acceptance evidence.", ? [
], "Publish manually to GitHub from the prepared manual release directory.",
"The manifest embeds the exact production-shaped validation evidence and candidate binding.",
"Forgejo release publication is not launch authority.",
]
: [
"This is a validation candidate, not a published release.",
"GitHub publication remains manual after final production-shaped validation.",
"Forgejo release publication is not launch authority.",
],
}; };
const manifestPath = path.join(outputRoot, "public-release-manifest.json"); const manifestPath = path.join(outputRoot, "public-release-manifest.json");

View file

@ -316,14 +316,27 @@ if (manifest.candidate_proxy_configuration_identity) {
} }
assert( assert(
Array.isArray(finalResult.named_scenarios) && Array.isArray(finalResult.named_scenarios) &&
finalResult.named_scenarios.length === 25 && finalResult.named_scenarios.length >= 29 &&
finalResult.named_scenarios.every((scenario) => scenario.status === "passed"), finalResult.named_scenarios.length ===
"all twenty-five named final checks must be present and passed" finalResult.strict_requirement_ledger.length &&
finalResult.named_scenarios.every(
(scenario) =>
scenario.status === "passed" &&
Number.isFinite(scenario.duration_ms) &&
scenario.duration_ms > 0
),
"all named final checks must be present, measured, and passed"
); );
assert( assert(
Array.isArray(finalResult.strict_requirement_ledger) && Array.isArray(finalResult.strict_requirement_ledger) &&
finalResult.strict_requirement_ledger.length > 0 && finalResult.strict_requirement_ledger.length > 0 &&
finalResult.strict_requirement_ledger.every((requirement) => requirement.passed === true), finalResult.strict_requirement_ledger.every(
(requirement) =>
requirement.passed === true &&
Number.isFinite(requirement.duration_ms) &&
requirement.duration_ms > 0 &&
requirement.evidence
),
"the strict final requirement ledger must be present and passed" "the strict final requirement ledger must be present and passed"
); );
const finalTranscript = readArchiveMember( const finalTranscript = readArchiveMember(

322
scripts/release-quality-gates.js Executable file
View file

@ -0,0 +1,322 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const manifestPath = path.resolve(
process.env.CLUSTERFLUX_PUBLIC_RELEASE_MANIFEST ||
path.join(
process.env.CLUSTERFLUX_PUBLIC_RELEASE_DIR ||
path.join(repo, "target/release-candidate"),
"public-release-manifest.json"
)
);
const evidencePath = path.resolve(
process.env.CLUSTERFLUX_QUALITY_GATE_EVIDENCE_PATH ||
path.join(repo, "target/acceptance/quality-gates.json")
);
const transcriptPath = path.resolve(
process.env.CLUSTERFLUX_QUALITY_GATE_TRANSCRIPT_PATH ||
path.join(repo, "target/acceptance/quality-gates-transcript.txt")
);
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function sha256(file) {
return crypto
.createHash("sha256")
.update(fs.readFileSync(file))
.digest("hex");
}
function commandText(command, args) {
return [command, ...args]
.map((value) =>
/^[A-Za-z0-9_./:=+-]+$/.test(value)
? value
: JSON.stringify(value)
)
.join(" ");
}
function main() {
assert(fs.existsSync(manifestPath), `missing release manifest ${manifestPath}`);
const manifest = readJson(manifestPath);
fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
const transcript = fs.openSync(transcriptPath, "w");
const evidence = {
kind: "clusterflux-release-quality-gates",
source_commit: manifest.source_commit,
source_tree_digest: manifest.source_tree_digest,
public_tree_identity: manifest.public_tree_identity,
checks: {},
};
const runGroup = (id, commands, extra = {}) => {
const startedAt = Date.now();
const commandEvidence = [];
let status = "passed";
for (const command of commands) {
const args = command.args || [];
const cwd = command.cwd || repo;
const text = commandText(command.command, args);
fs.writeSync(transcript, `\n[${id}] ${text}\n`);
const commandStartedAt = Date.now();
const result = cp.spawnSync(command.command, args, {
cwd,
env: { ...process.env, ...(command.env || {}) },
stdio: ["ignore", transcript, transcript],
});
const durationMs = Math.max(1, Date.now() - commandStartedAt);
commandEvidence.push({
command: text,
cwd,
exit_status: result.status,
duration_ms: durationMs,
});
if (result.error || result.status !== 0) {
status = "failed";
evidence.checks[id] = {
status,
duration_ms: Math.max(1, Date.now() - startedAt),
commands: commandEvidence,
error: result.error?.message || `command exited ${result.status}`,
...extra,
};
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
throw new Error(`${id} failed: ${text}`);
}
}
evidence.checks[id] = {
status,
duration_ms: Math.max(1, Date.now() - startedAt),
commands: commandEvidence,
...extra,
};
};
try {
runGroup("repository_structure", [
{ command: path.join(repo, "scripts/check-old-name.sh") },
{ command: "node", args: [path.join(repo, "scripts/check-docs.js")] },
{ command: path.join(repo, "scripts/check-code-size.sh") },
]);
runGroup("formatting", [
{ command: "cargo", args: ["fmt", "--all", "--check"] },
{
command: "cargo",
args: [
"fmt",
"--all",
"--manifest-path",
"private/hosted-policy/Cargo.toml",
"--check",
],
},
]);
runGroup("clippy_warnings_denied", [
{
command: "cargo",
args: ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"],
},
{
command: "cargo",
args: [
"clippy",
"--manifest-path",
"private/hosted-policy/Cargo.toml",
"--all-targets",
"--",
"-D",
"warnings",
],
},
]);
runGroup("public_workspace_tests", [
{
command: "cargo",
args: ["test", "--workspace", "--all-targets"],
},
{
command: "cargo",
args: ["build", "--workspace", "--all-targets"],
},
]);
runGroup("private_hosted_policy_locked_tests", [
{
command: "cargo",
args: [
"test",
"--locked",
"--manifest-path",
"private/hosted-policy/Cargo.toml",
"--all-targets",
],
},
]);
runGroup("process_lifecycle_regressions", [
{
command: "cargo",
args: [
"test",
"-p",
"clusterflux-coordinator",
"completed_main_waits_for_final_child_then_preserves_history_and_releases_the_slot",
],
},
{
command: "cargo",
args: [
"test",
"-p",
"clusterflux-coordinator",
"failed_main_aborts_unfinished_children_and_clears_process_debug_state",
],
},
{
command: "cargo",
args: [
"test",
"-p",
"clusterflux-coordinator",
"service_cancels_whole_process_and_blocks_new_task_launches",
],
},
]);
runGroup("wasm_example_builds", [
{
command: "cargo",
args: [
"build",
"-p",
"hello-build",
"--target",
"wasm32-unknown-unknown",
],
},
{
command: "cargo",
args: [
"build",
"-p",
"recovery-build",
"--target",
"wasm32-unknown-unknown",
],
},
{
command: "cargo",
args: [
"build",
"-p",
"runtime-conformance",
"--target",
"wasm32-unknown-unknown",
],
},
]);
runGroup("filtered_public_tree_build_and_tests", [
{ command: path.join(repo, "scripts/verify-public-split.sh") },
]);
const extensionAsset = manifest.assets.find((asset) =>
asset.name.endsWith(".vsix")
);
assert(extensionAsset, "candidate manifest omitted its VSIX");
const extensionFile = path.resolve(
path.dirname(manifestPath),
extensionAsset.file
);
assert(fs.existsSync(extensionFile), `missing candidate VSIX ${extensionFile}`);
const extensionDigest = sha256(extensionFile);
assert.strictEqual(extensionDigest, extensionAsset.sha256);
assert.strictEqual(
extensionDigest,
manifest.release_candidate.extension_sha256
);
const extractedVsix = fs.mkdtempSync(
path.join(os.tmpdir(), "clusterflux-tested-vsix-")
);
try {
runGroup(
"vscode_extension_candidate",
[
{
command: "npm",
args: ["ci", "--ignore-scripts"],
cwd: path.join(repo, "vscode-extension"),
},
{
command: "unzip",
args: ["-q", extensionFile, "-d", extractedVsix],
},
{
command: "node",
args: [path.join(repo, "scripts/vscode-extension-smoke.js")],
env: {
CLUSTERFLUX_VSCODE_EXTENSION_ROOT: path.join(
extractedVsix,
"extension"
),
},
},
],
{
candidate_vsix: {
file: extensionFile,
sha256: extensionDigest,
},
}
);
} finally {
fs.rmSync(extractedVsix, { recursive: true, force: true });
}
const publicCheckIds = [
"repository_structure",
"formatting",
"clippy_warnings_denied",
"public_workspace_tests",
"process_lifecycle_regressions",
"wasm_example_builds",
"filtered_public_tree_build_and_tests",
"vscode_extension_candidate",
];
evidence.public = {
status: "passed",
duration_ms: publicCheckIds.reduce(
(total, id) => total + evidence.checks[id].duration_ms,
0
),
independent_filtered_tree: true,
};
evidence.private = {
status: "passed",
duration_ms:
evidence.checks.formatting.duration_ms +
evidence.checks.clippy_warnings_denied.duration_ms +
evidence.checks.private_hosted_policy_locked_tests.duration_ms,
};
evidence.status = "passed";
evidence.transcript = transcriptPath;
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
} finally {
fs.closeSync(transcript);
}
console.log(`Release quality gates passed: ${evidencePath}`);
}
try {
main();
} catch (error) {
console.error(error.stack || error.message);
process.exit(1);
}

View file

@ -5,16 +5,20 @@ const os = require("os");
const path = require("path"); const path = require("path");
const assert = require("assert"); const assert = require("assert");
const extension = require("../vscode-extension/extension"); const extensionRoot = path.resolve(
const packageJson = require("../vscode-extension/package.json"); process.env.CLUSTERFLUX_VSCODE_EXTENSION_ROOT ||
path.join(__dirname, "../vscode-extension")
);
const extension = require(path.join(extensionRoot, "extension.js"));
const packageJson = require(path.join(extensionRoot, "package.json"));
const extensionSource = fs.readFileSync( const extensionSource = fs.readFileSync(
path.join(__dirname, "../vscode-extension/extension.js"), path.join(extensionRoot, "extension.js"),
"utf8" "utf8"
); );
const repo = path.resolve(__dirname, ".."); const repo = path.resolve(__dirname, "..");
assert.strictEqual(packageJson.main, "./extension.js"); assert.strictEqual(packageJson.main, "./extension.js");
assert(fs.existsSync(path.join(__dirname, "../vscode-extension", packageJson.main))); assert(fs.existsSync(path.join(extensionRoot, packageJson.main)));
assert.deepStrictEqual(packageJson.dependencies || {}, {}); assert.deepStrictEqual(packageJson.dependencies || {}, {});
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-vscode-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-vscode-"));
fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true }); fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true });
@ -157,7 +161,7 @@ assert(
"package.json must contribute a Clusterflux activity-bar container" "package.json must contribute a Clusterflux activity-bar container"
); );
assert( assert(
fs.existsSync(path.join(__dirname, "../vscode-extension/resources/clusterflux.svg")), fs.existsSync(path.join(extensionRoot, "resources/clusterflux.svg")),
"Clusterflux activity-bar icon must exist" "Clusterflux activity-bar icon must exist"
); );
const packageViewIds = packageJson.contributes.views.clusterflux.map((view) => view.id).sort(); const packageViewIds = packageJson.contributes.views.clusterflux.map((view) => view.id).sort();