Public release release-e47f9c27bbeb
Source commit: e47f9c27bbebe6759f6b6fe7b12fd00bb20d116d Public tree identity: sha256:4c1af1dfd67d3f2b5088531ea8727b11124b013d2251a4d5088f51a6424c0196
This commit is contained in:
parent
3a4d4fa7ae
commit
9223c54939
30 changed files with 4195 additions and 434 deletions
|
|
@ -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/**",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(());
|
||||||
|
|
|
||||||
|
|
@ -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!({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")?,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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", {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")}`;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
322
scripts/release-quality-gates.js
Executable 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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue