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",
"source_commit": "6d8c5c5b2a8b1da23eb69123e478237bd22883bd",
"release_name": "release-6d8c5c5b2a8b",
"source_commit": "e47f9c27bbebe6759f6b6fe7b12fd00bb20d116d",
"release_name": "release-e47f9c27bbeb",
"filtered_out": [
"private/**",
"internal/**",

View file

@ -61,12 +61,20 @@ impl ControlSession {
endpoint: &str,
api_path: &str,
) -> Result<Self, ControlTransportError> {
let mut session = Self::connect(endpoint)?;
let session = Self::connect(endpoint)?;
#[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(

View file

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

View file

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

View file

@ -30,14 +30,14 @@ use super::{
};
#[derive(Clone)]
struct MainScope {
tenant: TenantId,
project: ProjectId,
process: ProcessId,
task_definition: TaskDefinitionId,
task_instance: TaskInstanceId,
epoch: u64,
launch_id: u64,
pub(super) struct MainScope {
pub(super) tenant: TenantId,
pub(super) project: ProjectId,
pub(super) process: ProcessId,
pub(super) task_definition: TaskDefinitionId,
pub(super) task_instance: TaskInstanceId,
pub(super) epoch: u64,
pub(super) launch_id: u64,
}
enum MainCommand {
@ -889,7 +889,7 @@ impl CoordinatorService {
}
}
fn record_coordinator_main_completion(
pub(super) fn record_coordinator_main_completion(
&mut self,
scope: MainScope,
result: Result<WasmTaskResult, String>,
@ -1229,4 +1229,60 @@ mod tests {
assert!(service.active_tasks.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 {
process: ProcessId,
actor: UserId,
revision: u64,
probe_symbols: Vec<String>,
hit_epoch: Option<u64>,
hit_task: Option<TaskInstanceId>,

View file

@ -19,7 +19,9 @@ impl CoordinatorService {
))
})?;
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 {
return Err(CoordinatorError::Unauthorized(
"signed node request node does not match the wrapped request node".to_owned(),
@ -144,6 +146,19 @@ impl CoordinatorService {
provider,
source_snapshot,
),
CoordinatorRequest::RequestRendezvous {
scope,
source,
destination,
direct_connectivity,
failure_reason,
} => self.handle_request_rendezvous(
scope,
source,
destination,
direct_connectivity,
failure_reason,
),
CoordinatorRequest::ReconnectNode {
node,
process,
@ -322,6 +337,7 @@ fn signed_node_request_kind(
CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"),
CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"),
CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"),
CoordinatorRequest::RequestRendezvous { .. } => Ok("request_rendezvous"),
CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"),
CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"),
CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"),
@ -356,7 +372,14 @@ fn signed_node_request_node(
| CoordinatorRequest::ReportDebugProbeHit { node, .. }
| CoordinatorRequest::ReportTaskLog { 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(
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
)

View file

@ -189,6 +189,11 @@ fn authorize_client_request(
#[cfg(test)]
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::*;
#[test]
@ -197,4 +202,96 @@ mod transport_boundary_tests {
let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err();
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,
DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements,
LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits,
SourceProviderKind, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec,
VfsPath, WasmExportAbi,
SourceProviderKind, TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId,
TaskJoinState, TaskSpec, VfsPath, WasmExportAbi, WasmTaskResult,
};
use serde_json::json;
@ -521,6 +521,9 @@ fn signed_node_request_auto(request: CoordinatorRequest) -> CoordinatorRequest {
CoordinatorRequest::CompleteSourcePreparation { node, .. } => {
(node.clone(), "complete_source_preparation")
}
CoordinatorRequest::RequestRendezvous { source, .. } => {
(source.node.to_string(), "request_rendezvous")
}
CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"),
CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"),
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 {
revision,
probe_symbols,
hit_epoch,
..
@ -2761,15 +2765,37 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: "process".to_owned(),
revision: 1,
probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()],
})
.unwrap()
else {
panic!("expected debug breakpoints response");
};
assert_eq!(revision, 1);
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
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 {
breakpoint_matched,
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));
}
#[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]
fn quiescent_cooperative_cancel_releases_slot_immediately() {
let mut service = CoordinatorService::new(17);
@ -6084,6 +6288,35 @@ fn service_meters_rendezvous_before_direct_transfer_plan() {
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]
fn service_rejects_task_completion_outside_node_scope() {
let mut service = CoordinatorService::new(1);

View file

@ -364,7 +364,13 @@ pub fn agent_workflow_request_scope_from_payload(
.get("task_instance")
.and_then(Value::as_str)
.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!(
@ -373,10 +379,13 @@ pub fn agent_workflow_request_scope_from_payload(
}
};
AgentWorkflowRequestScope::new(
TenantId::from(tenant),
ProjectId::from(project),
TenantId::try_new(tenant)
.map_err(|error| format!("malformed agent workflow tenant: {error}"))?,
ProjectId::try_new(project)
.map_err(|error| format!("malformed agent workflow project: {error}"))?,
request_kind,
ProcessId::from(process),
ProcessId::try_new(process)
.map_err(|error| format!("malformed agent workflow process: {error}"))?,
task,
)
}

View file

@ -1,7 +1,35 @@
#[cfg(not(target_arch = "wasm32"))]
use serde::{de::Error as _, Deserializer};
use serde::{Deserialize, Serialize};
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)]
pub struct IdParseError {
id_type: &'static str,
@ -55,7 +83,8 @@ fn validate_id(value: &str, id_type: &'static str) -> Result<(), IdParseError> {
macro_rules! id_type {
($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);
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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
@ -134,4 +174,38 @@ mod tests {
assert_hostile_values(TenantId::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,
};
pub use ids::{
AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId, NodeId, ProcessId,
ProjectId, RequestId, TaskDefinitionId, TaskInstanceId, TenantId, UserId,
MAX_EXTERNAL_ID_BYTES,
validate_opaque_token, AgentId, ArtifactId, DebugSessionId, IdParseError, LaunchAttemptId,
NodeId, OpaqueTokenError, ProcessId, ProjectId, RequestId, TaskDefinitionId, TaskInstanceId,
TenantId, UserId, MAX_EXTERNAL_ID_BYTES,
};
pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,

View file

@ -64,6 +64,7 @@ struct LaunchCompletion {
state: AdapterState,
local_runtime_session: Option<LocalRuntimeSession>,
breakpoint_revision: u64,
observation_diagnostics: Vec<String>,
}
pub(crate) fn run_adapter() -> Result<()> {
@ -108,6 +109,9 @@ pub(crate) fn run_adapter() -> Result<()> {
if let Some(session) = completion.local_runtime_session {
_local_runtime_session = Some(session);
}
for diagnostic in completion.observation_diagnostics {
writer.output("stderr", format!("{diagnostic}\n"))?;
}
runtime_started = true;
if state.breakpoints_installed {
emit_verified_breakpoints(&mut writer, &state)?;
@ -252,7 +256,12 @@ pub(crate) fn run_adapter() -> Result<()> {
revision,
result,
} => {
if generation == runtime_generation && revision == state.breakpoint_revision {
if breakpoint_update_is_current(
generation,
runtime_generation,
revision,
state.breakpoint_revision,
) {
match result {
Ok(()) => {
state.breakpoints_installed = true;
@ -478,6 +487,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: None,
breakpoint_revision,
observation_diagnostics: Vec::new(),
}
})
.map_err(|error| format!("services runtime attach failed: {error:#}"))
@ -486,6 +496,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LocalServices => {
run_local_services_runtime(&completed_state)
.map(|(record, session)| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record);
let breakpoint_revision =
completed_state.breakpoint_revision;
@ -493,6 +505,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: Some(session),
breakpoint_revision,
observation_diagnostics,
}
})
.map_err(|error| {
@ -502,6 +515,8 @@ pub(crate) fn run_adapter() -> Result<()> {
RuntimeBackend::LiveServices => {
run_live_services_runtime(&completed_state)
.map(|record| {
let observation_diagnostics =
runtime_observation_diagnostics(&record);
completed_state.apply_runtime_record(record);
let breakpoint_revision =
completed_state.breakpoint_revision;
@ -509,6 +524,7 @@ pub(crate) fn run_adapter() -> Result<()> {
state: completed_state,
local_runtime_session: None,
breakpoint_revision,
observation_diagnostics,
}
})
.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<()> {
if !state.breakpoints_installed {
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<()> {
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()
&& !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,
"actor_user": state.actor_user,
"process": state.process.as_str(),
"revision": state.breakpoint_revision,
"probe_symbols": state.requested_probe_symbols(),
}),
),
@ -1026,6 +1044,12 @@ pub(crate) fn observe_services_runtime(
let inject_connection_loss =
std::env::var("CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS").as_deref() == Ok("1");
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 =
std::env::var_os("CLUSTERFLUX_TEST_DAP_OBSERVER_FALLBACK_FAILURE").is_some();
let mut fallback_failure_stage = 0_u8;
@ -1095,11 +1119,49 @@ pub(crate) fn observe_services_runtime(
continue;
}
if let Some(mut outcome) = terminal_runtime_outcome(&coordinator, state, &events) {
let task_snapshots = fetch_task_snapshots_in(current_session, state)
.unwrap_or_else(|_| json!({ "snapshots": [] }));
let (process_statuses, process_status) =
fetch_current_process_status_in(current_session, state)
.unwrap_or_else(|_| (json!({ "processes": [] }), None));
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,
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 let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
record.node_report = json!({
@ -1113,7 +1175,13 @@ pub(crate) fn observe_services_runtime(
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,
Err(error) => {
if !emit(RuntimeContinuationOutcome::Diagnostic(format!(
@ -1128,22 +1196,28 @@ pub(crate) fn observe_services_runtime(
continue;
}
};
let (process_statuses, process_status) =
match fetch_current_process_status_in(current_session, state) {
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;
}
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 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);
if let Some((failed_task, _attempt_id)) = failed_awaiting_action_snapshot(&task_snapshots) {
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) {
let task_snapshots = fetch_task_snapshots_in(current_session, state)
.unwrap_or_else(|_| json!({ "snapshots": [] }));
let task_snapshots = match fetch_task_snapshots_in(current_session, state) {
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 let RuntimeContinuationOutcome::Terminal(record) = &mut outcome {
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
.get("restarted_task_instance")
.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
.get("restarted_attempt_id")
.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")));
}
#[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]
fn runtime_backend_defaults_to_local_services_and_requires_explicit_demo() {
assert_eq!(

View file

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

View file

@ -7,8 +7,7 @@ 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,
ProcessId, ProjectId, RequestId, TaskInstanceId, TaskSpec, TenantId,
MIN_SIGNED_NODE_POLL_INTERVAL_MS,
ProcessId, ProjectId, TaskInstanceId, TaskSpec, TenantId, MIN_SIGNED_NODE_POLL_INTERVAL_MS,
};
use serde_json::{json, Value};
@ -577,7 +576,7 @@ fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
ProjectId::try_new(project.clone())?;
NodeId::try_new(node.clone())?;
if let Some(grant) = enrollment_grant.as_ref() {
RequestId::try_new(grant.clone())?;
clusterflux_core::validate_opaque_token(grant, 512)?;
}
Ok(Args {
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
the manifest directory.
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.
3. `final` downloads the candidate and evidence in another clean job, verifies
every binding, and publishes without rebuilding any binary.

View file

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

View file

@ -3,9 +3,53 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const {
agentIdentity,
signedAgentWorkflowRequest,
} = require("./agent-signing");
const {
nodeIdentity,
signedNodeHeartbeat,
} = require("./node-signing");
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) {
return fs.readFileSync(path.join(repo, relativePath), "utf8");
}

View file

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

View file

@ -51,7 +51,7 @@ function copyVerifiedAsset(asset, destinationRoot) {
return destination;
}
function extractBinaries(archive, destinationRoot) {
function extractBinaries(archive, destinationRoot, expectedDigests) {
const staging = path.join(destinationRoot, ".binary-extract");
fs.mkdirSync(staging, { recursive: true });
const extracted = spawnSync("tar", ["-xzf", archive, "-C", staging], {
@ -69,6 +69,11 @@ function extractBinaries(archive, destinationRoot) {
const destination = path.join(destinationRoot, "binaries", name);
fs.copyFileSync(source, destination);
fs.chmodSync(destination, 0o755);
assert.strictEqual(
`sha256:${sha256(destination)}`,
expectedDigests[name],
`tested binary digest changed: ${name}`
);
copied.push(destination);
}
fs.rmSync(staging, { recursive: true, force: true });
@ -87,11 +92,21 @@ function main() {
assert.strictEqual(result.source_tree_digest, manifest.source_tree_digest);
assert.deepStrictEqual(result.binary_digests, manifest.binary_digests);
assert.strictEqual(result.scenario_skips.length, 0);
assert.strictEqual(result.strict_requirement_ledger.length, 25);
assert(result.strict_requirement_ledger.length >= 29);
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"
);
assert.strictEqual(
result.named_scenarios.length,
result.strict_requirement_ledger.length
);
const destinationRoot = path.resolve(
process.env.CLUSTERFLUX_GITHUB_RELEASE_DIR ||
@ -104,11 +119,26 @@ function main() {
const copiedAssets = manifest.assets.map((asset) =>
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) =>
path.basename(file).startsWith("clusterflux-public-binaries-")
);
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"));
assert(vsix, "final manifest omitted the tested VSIX");
assert.strictEqual(
@ -124,7 +154,6 @@ function main() {
);
write(path.join(destinationRoot, "VSIX_SHA256"), `${sha256(vsix)} ${path.basename(vsix)}\n`);
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"));
write(
path.join(destinationRoot, "RELEASE_NOTES.md"),
@ -138,6 +167,31 @@ function main() {
"- Full Windows sandbox validation is deferred.\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 = [
...copiedAssets,

View file

@ -632,10 +632,22 @@ function stageEvidenceAsset(
}
if (
!Array.isArray(liveResult.named_scenarios) ||
liveResult.named_scenarios.length !== 25 ||
liveResult.named_scenarios.some((scenario) => scenario.status !== "passed")
!Array.isArray(liveResult.strict_requirement_ledger) ||
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 (
!liveResult.release_binding?.deployment ||
@ -649,7 +661,17 @@ function stageEvidenceAsset(
if (
!Array.isArray(liveResult.strict_requirement_ledger) ||
!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");
}
@ -685,8 +707,7 @@ function stageEvidenceAsset(
evidence_packaged_at: packagedAt,
},
release_commands: [
"nix develop -c ./scripts/acceptance-private.sh",
"nix develop -c ./scripts/acceptance-public.sh",
"nix develop -c node scripts/release-quality-gates.js",
"node scripts/cli-happy-path-live-smoke.js (strict production-shaped environment)",
],
};
@ -827,7 +848,7 @@ ${resolution}
## 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\`.
3. Extract it and put \`bin/\` on your \`PATH\`.
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 ||
"https://git.michelpaulissen.com/michel/clusterflux-public";
const releaseUrl =
process.env.CLUSTERFLUX_FORGEJO_RELEASE_URL ||
"the Forgejo release attached to the public repository";
process.env.CLUSTERFLUX_GITHUB_RELEASE_URL ||
"the manually published GitHub release";
fs.writeFileSync(
file,
`# Clusterflux Release Notes
@ -1200,7 +1221,8 @@ function main() {
public_repo_remote:
process.env.CLUSTERFLUX_PUBLIC_REPO_REMOTE || candidate?.public_repo_remote || null,
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,
dns_publication_state:
process.env.CLUSTERFLUX_DNS_PUBLICATION_STATE || "published",
@ -1239,10 +1261,18 @@ function main() {
name: path.basename(asset),
sha256: sha256File(asset),
})),
notes: [
"Upload the assets to the Forgejo Release for the filtered public repository.",
"The real service deployment and full e2e release remain separate acceptance evidence.",
],
notes:
releaseStage === "final"
? [
"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");

View file

@ -316,14 +316,27 @@ if (manifest.candidate_proxy_configuration_identity) {
}
assert(
Array.isArray(finalResult.named_scenarios) &&
finalResult.named_scenarios.length === 25 &&
finalResult.named_scenarios.every((scenario) => scenario.status === "passed"),
"all twenty-five named final checks must be present and passed"
finalResult.named_scenarios.length >= 29 &&
finalResult.named_scenarios.length ===
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(
Array.isArray(finalResult.strict_requirement_ledger) &&
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"
);
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 assert = require("assert");
const extension = require("../vscode-extension/extension");
const packageJson = require("../vscode-extension/package.json");
const extensionRoot = path.resolve(
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(
path.join(__dirname, "../vscode-extension/extension.js"),
path.join(extensionRoot, "extension.js"),
"utf8"
);
const repo = path.resolve(__dirname, "..");
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 || {}, {});
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clusterflux-vscode-"));
fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true });
@ -157,7 +161,7 @@ assert(
"package.json must contribute a Clusterflux activity-bar container"
);
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"
);
const packageViewIds = packageJson.contributes.views.clusterflux.map((view) => view.id).sort();