Publish Clusterflux 45bbc21

This commit is contained in:
Michel Paulissen 2026-07-20 11:08:26 +02:00
parent eb1077f380
commit 3f88254c70
28 changed files with 896 additions and 167 deletions

View file

@ -1,7 +1,7 @@
{ {
"kind": "clusterflux-filtered-public-tree", "kind": "clusterflux-filtered-public-tree",
"source_commit": "09ca780bd67a00467e78139cf38466b8201b66ca", "source_commit": "45bbc215f681e4dd414c4919263626ecee018abe",
"release_name": "release-09ca780bd67a", "release_name": "release-45bbc215f681",
"filtered_out": [ "filtered_out": [
"private/**", "private/**",
"internal/**", "internal/**",

View file

@ -103,5 +103,4 @@ The hosted website is not required for self-hosted projects.
- [Task ABI](docs/task-abi.md) - [Task ABI](docs/task-abi.md)
- [Self-hosting](docs/self-hosting.md) - [Self-hosting](docs/self-hosting.md)
- [Security model](docs/security.md) - [Security model](docs/security.md)
- [Release candidates](docs/releases.md)
- [Security reporting](SECURITY.md) - [Security reporting](SECURITY.md)

View file

@ -195,12 +195,14 @@ fn coordinator_run_report(plan: RunPlan) -> Result<Value> {
); );
} }
let process = "vp-current".to_owned(); let process = "vp-current".to_owned();
let launch_attempt = new_launch_attempt_id();
let mut session = JsonLineSession::connect(&coordinator)?; let mut session = JsonLineSession::connect(&coordinator)?;
let mut request = json!({ let mut request = json!({
"type": "start_process", "type": "start_process",
"tenant": tenant.clone(), "tenant": tenant.clone(),
"project": project.clone(), "project": project.clone(),
"process": process.clone(), "process": process.clone(),
"launch_attempt": launch_attempt.clone(),
"restart": false, "restart": false,
}); });
request = authenticated_human_or_local_trusted_workflow( request = authenticated_human_or_local_trusted_workflow(
@ -219,6 +221,7 @@ fn coordinator_run_report(plan: RunPlan) -> Result<Value> {
&tenant, &tenant,
&project, &project,
&process, &process,
&launch_attempt,
)?; )?;
let run_start = run_start_summary(&response); let run_start = run_start_summary(&response);
let status = run_start let status = run_start
@ -286,6 +289,7 @@ fn coordinator_run_report(plan: RunPlan) -> Result<Value> {
tenant: &tenant, tenant: &tenant,
project: &project, project: &project,
process: &process, process: &process,
launch_attempt: &launch_attempt,
}; };
let cleanup = rollback_failed_process_launch_reconnecting( let cleanup = rollback_failed_process_launch_reconnecting(
&coordinator, &coordinator,
@ -340,6 +344,7 @@ fn request_process_start_with_rollback(
tenant: &str, tenant: &str,
project: &str, project: &str,
process: &str, process: &str,
launch_attempt: &str,
) -> Result<Value> { ) -> Result<Value> {
let rollback = ProcessLaunchRollback { let rollback = ProcessLaunchRollback {
cli_session, cli_session,
@ -348,9 +353,25 @@ fn request_process_start_with_rollback(
tenant, tenant,
project, project,
process, process,
launch_attempt,
}; };
match session.request_allow_error(request) { match session.request_allow_error(request) {
Ok(response) => Ok(response), Ok(response) => {
if response.get("type").and_then(Value::as_str) == Some("process_started")
&& response.get("launch_attempt").and_then(Value::as_str) != Some(launch_attempt)
{
let ownership_error = anyhow::anyhow!(
"coordinator returned a process-start acknowledgement owned by a different launch attempt"
);
rollback_failed_process_launch_reconnecting(
coordinator,
&rollback,
&json!({ "error": ownership_error.to_string(), "phase": "start_process_ownership" }),
)?;
return Err(ownership_error);
}
Ok(response)
}
Err(start_error) => { Err(start_error) => {
let cleanup = rollback_failed_process_launch_reconnecting( let cleanup = rollback_failed_process_launch_reconnecting(
coordinator, coordinator,
@ -370,6 +391,17 @@ fn request_process_start_with_rollback(
} }
} }
static NEXT_LAUNCH_ATTEMPT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn new_launch_attempt_id() -> String {
let sequence = NEXT_LAUNCH_ATTEMPT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("launch-{}-{nanos}-{sequence}", std::process::id())
}
struct ProcessLaunchRollback<'a> { struct ProcessLaunchRollback<'a> {
cli_session: &'a CliSession, cli_session: &'a CliSession,
fallback_user: &'a str, fallback_user: &'a str,
@ -377,6 +409,7 @@ struct ProcessLaunchRollback<'a> {
tenant: &'a str, tenant: &'a str,
project: &'a str, project: &'a str,
process: &'a str, process: &'a str,
launch_attempt: &'a str,
} }
fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> { fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> {
@ -402,6 +435,7 @@ fn rollback_failed_process_launch(
"tenant": context.tenant, "tenant": context.tenant,
"project": context.project, "project": context.project,
"process": context.process, "process": context.process,
"launch_attempt": context.launch_attempt,
}), }),
context.cli_session, context.cli_session,
context.fallback_user, context.fallback_user,
@ -983,6 +1017,10 @@ mod transactional_launch_tests {
payload.get("process").and_then(Value::as_str), payload.get("process").and_then(Value::as_str),
Some("vp-current") Some("vp-current")
); );
assert_eq!(
payload.get("launch_attempt").and_then(Value::as_str),
Some("launch-test")
);
writeln!( writeln!(
stream, stream,
"{}", "{}",
@ -1003,6 +1041,7 @@ mod transactional_launch_tests {
tenant: "tenant-a", tenant: "tenant-a",
project: "project-a", project: "project-a",
process: "vp-current", process: "vp-current",
launch_attempt: "launch-test",
}; };
rollback_failed_process_launch( rollback_failed_process_launch(
&mut session, &mut session,
@ -1024,6 +1063,7 @@ mod transactional_launch_tests {
.read_line(&mut start_line) .read_line(&mut start_line)
.unwrap(); .unwrap();
assert!(start_line.contains("\"type\":\"start_process\"")); assert!(start_line.contains("\"type\":\"start_process\""));
assert!(start_line.contains("\"launch_attempt\":\"launch-test\""));
drop(stream); drop(stream);
let (mut cleanup_stream, _) = listener.accept().unwrap(); let (mut cleanup_stream, _) = listener.accept().unwrap();
@ -1032,6 +1072,7 @@ mod transactional_launch_tests {
.read_line(&mut cleanup_line) .read_line(&mut cleanup_line)
.unwrap(); .unwrap();
assert!(cleanup_line.contains("\"type\":\"abort_process\"")); assert!(cleanup_line.contains("\"type\":\"abort_process\""));
assert!(cleanup_line.contains("\"launch_attempt\":\"launch-test\""));
writeln!( writeln!(
cleanup_stream, cleanup_stream,
"{}", "{}",
@ -1043,6 +1084,12 @@ mod transactional_launch_tests {
}) })
) )
.unwrap(); .unwrap();
listener.set_nonblocking(true).unwrap();
std::thread::sleep(std::time::Duration::from_millis(25));
assert!(matches!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
));
}); });
let mut session = JsonLineSession::connect(&address).unwrap(); let mut session = JsonLineSession::connect(&address).unwrap();
let error = request_process_start_with_rollback( let error = request_process_start_with_rollback(
@ -1053,6 +1100,7 @@ mod transactional_launch_tests {
"project": "project-a", "project": "project-a",
"actor_user": "user-a", "actor_user": "user-a",
"process": "vp-current", "process": "vp-current",
"launch_attempt": "launch-test",
"restart": false "restart": false
}), }),
&address, &address,
@ -1062,10 +1110,63 @@ mod transactional_launch_tests {
"tenant-a", "tenant-a",
"project-a", "project-a",
"vp-current", "vp-current",
"launch-test",
) )
.unwrap_err() .unwrap_err()
.to_string(); .to_string();
assert!(error.contains("closed") || error.contains("response")); assert!(error.contains("closed") || error.contains("response"));
server.join().unwrap(); server.join().unwrap();
} }
#[test]
fn definitive_start_rejection_does_not_abort_existing_process() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap().to_string();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut line = String::new();
std::io::BufReader::new(stream.try_clone().unwrap())
.read_line(&mut line)
.unwrap();
writeln!(
stream,
"{}",
json!({
"type": "error",
"message": "project already has active virtual process vp-existing"
})
)
.unwrap();
listener.set_nonblocking(true).unwrap();
std::thread::sleep(std::time::Duration::from_millis(25));
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
});
let mut session = JsonLineSession::connect(&address).unwrap();
let response = request_process_start_with_rollback(
&mut session,
json!({
"type": "start_process",
"tenant": "tenant-a",
"project": "project-a",
"actor_user": "user-a",
"process": "vp-current",
"launch_attempt": "launch-rejected",
"restart": false
}),
&address,
&CliSession::Anonymous,
"user-a",
None,
"tenant-a",
"project-a",
"vp-current",
"launch-rejected",
)
.unwrap();
assert_eq!(response.get("type").and_then(Value::as_str), Some("error"));
server.join().unwrap();
}
} }

View file

@ -10,6 +10,15 @@ fn parse(args: &[&str]) -> Cli {
Cli::parse_from(args) Cli::parse_from(args)
} }
fn launch_attempt_from_wire(line: &str) -> String {
let wire: Value = serde_json::from_str(line).unwrap();
wire.pointer("/payload/request/launch_attempt")
.or_else(|| wire.pointer("/payload/launch_attempt"))
.and_then(Value::as_str)
.expect("start request must carry a launch attempt")
.to_owned()
}
fn write_runnable_wasm_project(project: &Path) { fn write_runnable_wasm_project(project: &Path) {
let cli_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let cli_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let sdk = cli_manifest_dir.parent().unwrap().join("clusterflux-sdk"); let sdk = cli_manifest_dir.parent().unwrap().join("clusterflux-sdk");
@ -438,10 +447,11 @@ fn run_with_agent_public_key_sends_attributable_workflow_actor() {
assert!(line.contains(r#""nonce":"agent-signature-"#)); assert!(line.contains(r#""nonce":"agent-signature-"#));
assert!(line.contains(r#""signature":"ed25519:"#)); assert!(line.contains(r#""signature":"ed25519:"#));
assert!(!line.contains(r#""actor_user""#)); assert!(!line.contains(r#""actor_user""#));
let launch_attempt = launch_attempt_from_wire(&line);
stream stream
.write_all( .write_all(
format!( format!(
r#"{{"type":"process_started","process":"vp-current","epoch":7,"actor":{{"kind":"agent","user":"user-live","agent":"agent-ci","credential_kind":"public_key","public_key_fingerprint":"{expected_fingerprint}","authenticated_without_browser":true,"scopes":["project:read","project:run"]}}}}"# r#"{{"type":"process_started","process":"vp-current","launch_attempt":"{launch_attempt}","epoch":7,"actor":{{"kind":"agent","user":"user-live","agent":"agent-ci","credential_kind":"public_key","public_key_fingerprint":"{expected_fingerprint}","authenticated_without_browser":true,"scopes":["project:read","project:run"]}}}}"#
) )
.as_bytes(), .as_bytes(),
) )
@ -588,7 +598,32 @@ fn run_with_human_session_derives_workflow_scope_from_authenticated_envelope() {
assert!(wire["payload"]["request"].get("tenant").is_none()); assert!(wire["payload"]["request"].get("tenant").is_none());
assert!(wire["payload"]["request"].get("project").is_none()); assert!(wire["payload"]["request"].get("project").is_none());
assert!(wire["payload"]["request"].get("actor_user").is_none()); assert!(wire["payload"]["request"].get("actor_user").is_none());
if expected_operation == "start_process" {
let launch_attempt = launch_attempt_from_wire(&line);
stream
.write_all(
serde_json::to_string(&json!({
"type": "process_started",
"process": "vp-current",
"launch_attempt": launch_attempt,
"epoch": 7,
"actor": {
"kind": "user",
"user": "user-session",
"agent": null,
"credential_kind": "cli_device_session",
"public_key_fingerprint": null,
"authenticated_without_browser": false,
"scopes": ["project:read", "project:run"]
}
}))
.unwrap()
.as_bytes(),
)
.unwrap();
} else {
stream.write_all(response).unwrap(); stream.write_all(response).unwrap();
}
stream.write_all(b"\n").unwrap(); stream.write_all(b"\n").unwrap();
} }
}); });
@ -661,6 +696,18 @@ fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() {
assert!(line.contains(r#""project":"project-live""#)); assert!(line.contains(r#""project":"project-live""#));
assert!(line.contains(r#""process":"vp-current""#)); assert!(line.contains(r#""process":"vp-current""#));
assert!(line.contains(r#""restart":false"#)); assert!(line.contains(r#""restart":false"#));
let response = if index == 0 {
let launch_attempt = launch_attempt_from_wire(&line);
serde_json::to_string(&json!({
"type": "process_started",
"process": "vp-current",
"launch_attempt": launch_attempt,
"epoch": 7,
}))
.unwrap()
} else {
response.to_owned()
};
stream.write_all(response.as_bytes()).unwrap(); stream.write_all(response.as_bytes()).unwrap();
stream.write_all(b"\n").unwrap(); stream.write_all(b"\n").unwrap();
if index == 0 { if index == 0 {

View file

@ -33,6 +33,7 @@ pub use service::{
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActiveProcess { pub struct ActiveProcess {
pub id: ProcessId, pub id: ProcessId,
pub launch_attempt: Option<clusterflux_core::LaunchAttemptId>,
pub tenant: TenantId, pub tenant: TenantId,
pub project: ProjectId, pub project: ProjectId,
pub connected_nodes: BTreeSet<NodeId>, pub connected_nodes: BTreeSet<NodeId>,
@ -322,9 +323,20 @@ impl Coordinator {
tenant: TenantId, tenant: TenantId,
project: ProjectId, project: ProjectId,
id: ProcessId, id: ProcessId,
) -> ActiveProcess {
self.start_process_for_launch_attempt(tenant, project, id, None)
}
pub fn start_process_for_launch_attempt(
&mut self,
tenant: TenantId,
project: ProjectId,
id: ProcessId,
launch_attempt: Option<clusterflux_core::LaunchAttemptId>,
) -> ActiveProcess { ) -> ActiveProcess {
let process = ActiveProcess { let process = ActiveProcess {
id: id.clone(), id: id.clone(),
launch_attempt,
tenant, tenant,
project, project,
connected_nodes: BTreeSet::new(), connected_nodes: BTreeSet::new(),
@ -531,6 +543,32 @@ impl Coordinator {
.expect("active process was checked immediately before removal")) .expect("active process was checked immediately before removal"))
} }
pub fn abort_process_for_launch_attempt(
&mut self,
tenant: &TenantId,
project: &ProjectId,
process: &ProcessId,
launch_attempt: &clusterflux_core::LaunchAttemptId,
) -> Result<ActiveProcess, CoordinatorError> {
let key = (tenant.clone(), project.clone(), process.clone());
let active = self.active_processes.get(&key).ok_or_else(|| {
CoordinatorError::Unauthorized(
"launch rollback requires an active virtual process".to_owned(),
)
})?;
if active.launch_attempt.as_ref() != Some(launch_attempt) {
return Err(CoordinatorError::Unauthorized(format!(
"launch rollback denied: attempt {} does not own process {}",
launch_attempt.as_str(),
process.as_str()
)));
}
Ok(self
.active_processes
.remove(&key)
.expect("active process was checked immediately before removal"))
}
pub fn active_process_count(&self) -> usize { pub fn active_process_count(&self) -> usize {
self.active_processes.len() self.active_processes.len()
} }

View file

@ -273,6 +273,7 @@ impl CoordinatorService {
agent_signature: Option<AgentSignedRequest>, agent_signature: Option<AgentSignedRequest>,
request_payload_digest: Option<&Digest>, request_payload_digest: Option<&Digest>,
process: String, process: String,
launch_attempt: Option<String>,
restart: bool, restart: bool,
) -> Result<CoordinatorResponse, CoordinatorServiceError> { ) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
@ -392,10 +393,18 @@ impl CoordinatorService {
self.debug_audit_events.retain(|event| { self.debug_audit_events.retain(|event| {
event.tenant != tenant || event.project != project || event.process != process event.tenant != tenant || event.project != project || event.process != process
}); });
self.coordinator let active = self.coordinator.start_process_for_launch_attempt(
.start_process(tenant, project, process.clone()); tenant,
project,
process.clone(),
launch_attempt.map(clusterflux_core::LaunchAttemptId::new),
);
Ok(CoordinatorResponse::ProcessStarted { Ok(CoordinatorResponse::ProcessStarted {
process, process,
launch_attempt: active
.launch_attempt
.as_ref()
.map(|attempt| attempt.as_str().to_owned()),
epoch: self.coordinator.coordinator_epoch(), epoch: self.coordinator.coordinator_epoch(),
actor, actor,
charged_spawns, charged_spawns,
@ -519,6 +528,7 @@ impl CoordinatorService {
project: String, project: String,
actor_user: String, actor_user: String,
process: String, process: String,
launch_attempt: Option<String>,
) -> Result<CoordinatorResponse, CoordinatorServiceError> { ) -> Result<CoordinatorResponse, CoordinatorServiceError> {
let tenant = TenantId::new(tenant); let tenant = TenantId::new(tenant);
let project = ProjectId::new(project); let project = ProjectId::new(project);
@ -534,6 +544,17 @@ impl CoordinatorService {
})?; })?;
debug_assert_eq!(active.tenant, tenant); debug_assert_eq!(active.tenant, tenant);
debug_assert_eq!(active.project, project); debug_assert_eq!(active.project, project);
let launch_attempt = launch_attempt.map(clusterflux_core::LaunchAttemptId::new);
if let Some(expected) = launch_attempt.as_ref() {
if active.launch_attempt.as_ref() != Some(expected) {
return Err(CoordinatorError::Unauthorized(format!(
"launch rollback denied: attempt {} does not own process {}",
expected.as_str(),
process.as_str()
))
.into());
}
}
let process_key = process_control_key(&tenant, &project, &process); let process_key = process_control_key(&tenant, &project, &process);
self.process_cancellations.remove(&process_key); self.process_cancellations.remove(&process_key);
@ -575,8 +596,17 @@ impl CoordinatorService {
} }
} }
if let Some(launch_attempt) = launch_attempt.as_ref() {
self.coordinator.abort_process_for_launch_attempt(
&tenant,
&project,
&process,
launch_attempt,
)?;
} else {
self.coordinator self.coordinator
.abort_process(&tenant, &project, &process)?; .abort_process(&tenant, &project, &process)?;
}
let active_restart_tasks = aborted_tasks let active_restart_tasks = aborted_tasks
.iter() .iter()
.map(|target| target.task.clone()) .map(|target| target.task.clone())

View file

@ -262,6 +262,8 @@ pub enum CoordinatorRequest {
#[serde(default)] #[serde(default)]
agent_signature: Option<AgentSignedRequest>, agent_signature: Option<AgentSignedRequest>,
process: String, process: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
#[serde(default)] #[serde(default)]
restart: bool, restart: bool,
}, },
@ -288,6 +290,8 @@ pub enum CoordinatorRequest {
project: String, project: String,
actor_user: String, actor_user: String,
process: String, process: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
}, },
ListProcesses { ListProcesses {
tenant: String, tenant: String,
@ -569,6 +573,8 @@ pub enum AuthenticatedCoordinatorRequest {
}, },
StartProcess { StartProcess {
process: String, process: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
#[serde(default)] #[serde(default)]
restart: bool, restart: bool,
}, },
@ -594,6 +600,8 @@ pub enum AuthenticatedCoordinatorRequest {
}, },
AbortProcess { AbortProcess {
process: String, process: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
}, },
ListProcesses, ListProcesses,
QuotaStatus, QuotaStatus,

View file

@ -344,6 +344,8 @@ pub enum CoordinatorResponse {
}, },
ProcessStarted { ProcessStarted {
process: ProcessId, process: ProcessId,
#[serde(default, skip_serializing_if = "Option::is_none")]
launch_attempt: Option<String>,
epoch: u64, epoch: u64,
actor: WorkflowActor, actor: WorkflowActor,
charged_spawns: u64, charged_spawns: u64,

View file

@ -370,6 +370,7 @@ impl CoordinatorService {
agent_public_key_fingerprint, agent_public_key_fingerprint,
agent_signature, agent_signature,
process, process,
launch_attempt,
restart, restart,
} => self.handle_start_process( } => self.handle_start_process(
tenant, tenant,
@ -380,6 +381,7 @@ impl CoordinatorService {
agent_signature, agent_signature,
Some(&request_payload_digest), Some(&request_payload_digest),
process, process,
launch_attempt,
restart, restart,
), ),
CoordinatorRequest::ReconnectNode { .. } => self.reject_unsigned_node_request(), CoordinatorRequest::ReconnectNode { .. } => self.reject_unsigned_node_request(),
@ -401,7 +403,8 @@ impl CoordinatorService {
project, project,
actor_user, actor_user,
process, process,
} => self.handle_abort_process(tenant, project, actor_user, process), launch_attempt,
} => self.handle_abort_process(tenant, project, actor_user, process, launch_attempt),
CoordinatorRequest::ListProcesses { CoordinatorRequest::ListProcesses {
tenant, tenant,
project, project,
@ -689,8 +692,11 @@ impl CoordinatorService {
actor.as_str().to_owned(), actor.as_str().to_owned(),
node, node,
), ),
AuthenticatedCoordinatorRequest::StartProcess { process, restart } => self AuthenticatedCoordinatorRequest::StartProcess {
.handle_start_process( process,
launch_attempt,
restart,
} => self.handle_start_process(
context.tenant.as_str().to_owned(), context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(), context.project.as_str().to_owned(),
Some(actor.as_str().to_owned()), Some(actor.as_str().to_owned()),
@ -699,6 +705,7 @@ impl CoordinatorService {
None, None,
None, None,
process, process,
launch_attempt,
restart, restart,
), ),
request @ AuthenticatedCoordinatorRequest::ScheduleTask { .. } => { request @ AuthenticatedCoordinatorRequest::ScheduleTask { .. } => {
@ -714,11 +721,15 @@ impl CoordinatorService {
actor.as_str().to_owned(), actor.as_str().to_owned(),
process, process,
), ),
AuthenticatedCoordinatorRequest::AbortProcess { process } => self.handle_abort_process( AuthenticatedCoordinatorRequest::AbortProcess {
process,
launch_attempt,
} => self.handle_abort_process(
context.tenant.as_str().to_owned(), context.tenant.as_str().to_owned(),
context.project.as_str().to_owned(), context.project.as_str().to_owned(),
actor.as_str().to_owned(), actor.as_str().to_owned(),
process, process,
launch_attempt,
), ),
AuthenticatedCoordinatorRequest::ListProcesses => self.handle_list_processes( AuthenticatedCoordinatorRequest::ListProcesses => self.handle_list_processes(
context.tenant.as_str().to_owned(), context.tenant.as_str().to_owned(),

View file

@ -96,6 +96,7 @@ fn postgres_backed_runtime_service_survives_restart_without_live_process_state()
.handle_request(CoordinatorRequest::Authenticated { .handle_request(CoordinatorRequest::Authenticated {
session_secret: session_secret.to_owned(), session_secret: session_secret.to_owned(),
request: AuthenticatedCoordinatorRequest::StartProcess { request: AuthenticatedCoordinatorRequest::StartProcess {
launch_attempt: None,
process: "process-ephemeral".to_owned(), process: "process-ephemeral".to_owned(),
restart: false, restart: false,
}, },
@ -433,7 +434,9 @@ fn with_signed_agent_workflow(
let signature = signed_agent_workflow_request(&request, request_kind, process, task, nonce); let signature = signed_agent_workflow_request(&request, request_kind, process, task, nonce);
match &mut request { match &mut request {
CoordinatorRequest::StartProcess { CoordinatorRequest::StartProcess {
agent_signature, .. launch_attempt: None,
agent_signature,
..
} }
| CoordinatorRequest::LaunchTask { | CoordinatorRequest::LaunchTask {
agent_signature, .. agent_signature, ..
@ -862,10 +865,16 @@ fn authenticated_envelope_derives_user_scope_from_cli_session() {
}; };
assert_eq!(placement.node, NodeId::from("session-node")); assert_eq!(placement.node, NodeId::from("session-node"));
let CoordinatorResponse::ProcessStarted { process, actor, .. } = service let CoordinatorResponse::ProcessStarted {
launch_attempt: None,
process,
actor,
..
} = service
.handle_request(CoordinatorRequest::Authenticated { .handle_request(CoordinatorRequest::Authenticated {
session_secret: "cli-session-secret".to_owned(), session_secret: "cli-session-secret".to_owned(),
request: AuthenticatedCoordinatorRequest::StartProcess { request: AuthenticatedCoordinatorRequest::StartProcess {
launch_attempt: None,
process: "vp-session".to_owned(), process: "vp-session".to_owned(),
restart: false, restart: false,
}, },
@ -993,6 +1002,7 @@ fn authenticated_envelope_derives_user_scope_from_cli_session() {
.handle_request(CoordinatorRequest::Authenticated { .handle_request(CoordinatorRequest::Authenticated {
session_secret: "victim-cli-session-secret".to_owned(), session_secret: "victim-cli-session-secret".to_owned(),
request: AuthenticatedCoordinatorRequest::StartProcess { request: AuthenticatedCoordinatorRequest::StartProcess {
launch_attempt: None,
process: "vp-victim".to_owned(), process: "vp-victim".to_owned(),
restart: false, restart: false,
}, },
@ -1398,6 +1408,7 @@ fn service_reports_and_enforces_public_admin_tenant_suspension() {
let start = service let start = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: Some("attempt-a".to_owned()),
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1536,6 +1547,7 @@ fn service_runs_agent_workflows_with_scoped_key_attribution() {
let fingerprint_only = service let fingerprint_only = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1551,6 +1563,7 @@ fn service_runs_agent_workflows_with_scoped_key_attribution() {
let wrong_fingerprint = service let wrong_fingerprint = service
.handle_request(with_signed_agent_workflow( .handle_request(with_signed_agent_workflow(
CoordinatorRequest::StartProcess { CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1592,10 +1605,13 @@ fn service_runs_agent_workflows_with_scoped_key_attribution() {
.unwrap(); .unwrap();
let CoordinatorResponse::ProcessStarted { let CoordinatorResponse::ProcessStarted {
actor: start_actor, .. launch_attempt: None,
actor: start_actor,
..
} = service } = service
.handle_request(with_signed_agent_workflow( .handle_request(with_signed_agent_workflow(
CoordinatorRequest::StartProcess { CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1619,6 +1635,7 @@ fn service_runs_agent_workflows_with_scoped_key_attribution() {
let replay = service let replay = service
.handle_request(with_signed_agent_workflow( .handle_request(with_signed_agent_workflow(
CoordinatorRequest::StartProcess { CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1728,6 +1745,7 @@ fn signed_node_and_agent_requests_reject_body_modification() {
}) })
.unwrap(); .unwrap();
let original_agent_request = CoordinatorRequest::StartProcess { let original_agent_request = CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1746,6 +1764,7 @@ fn signed_node_and_agent_requests_reject_body_modification() {
); );
let mut modified_agent_request = original_agent_request; let mut modified_agent_request = original_agent_request;
let CoordinatorRequest::StartProcess { let CoordinatorRequest::StartProcess {
launch_attempt: None,
restart, restart,
agent_signature: request_signature, agent_signature: request_signature,
.. ..
@ -1789,8 +1808,13 @@ fn service_checks_spawn_quota_before_process_or_task_work_starts() {
}) })
.unwrap(); .unwrap();
let CoordinatorResponse::ProcessStarted { charged_spawns, .. } = service let CoordinatorResponse::ProcessStarted {
launch_attempt: None,
charged_spawns,
..
} = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1887,6 +1911,7 @@ fn service_checks_spawn_quota_before_process_or_task_work_starts() {
let other_project_process = service let other_project_process = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "other-project".to_owned(), project: "other-project".to_owned(),
actor_user: None, actor_user: None,
@ -1899,7 +1924,10 @@ fn service_checks_spawn_quota_before_process_or_task_work_starts() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
other_project_process, other_project_process,
CoordinatorResponse::ProcessStarted { .. } CoordinatorResponse::ProcessStarted {
launch_attempt: None,
..
}
)); ));
assert_eq!( assert_eq!(
service.quota.used_workflow_spawns( service.quota.used_workflow_spawns(
@ -1935,6 +1963,7 @@ fn project_quota_resets_at_the_configured_window_boundary() {
service.set_server_time(59); service.set_server_time(59);
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: Some("attempt-b".to_owned()),
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1947,6 +1976,7 @@ fn project_quota_resets_at_the_configured_window_boundary() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::AbortProcess { .handle_request(CoordinatorRequest::AbortProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: "user".to_owned(), actor_user: "user".to_owned(),
@ -1956,6 +1986,7 @@ fn project_quota_resets_at_the_configured_window_boundary() {
let exhausted = service let exhausted = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: Some("attempt-b".to_owned()),
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1971,6 +2002,7 @@ fn project_quota_resets_at_the_configured_window_boundary() {
service.set_server_time(60); service.set_server_time(60);
let started = service let started = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -1984,6 +2016,7 @@ fn project_quota_resets_at_the_configured_window_boundary() {
assert!(matches!( assert!(matches!(
started, started,
CoordinatorResponse::ProcessStarted { CoordinatorResponse::ProcessStarted {
launch_attempt: None,
charged_spawns: 1, charged_spawns: 1,
.. ..
} }
@ -2073,6 +2106,7 @@ fn signed_node_log_ingestion_checks_scoped_quota_before_accepting_bytes() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -2148,6 +2182,7 @@ fn service_attaches_node_starts_process_and_records_scoped_task_event() {
let started = service let started = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -2161,6 +2196,7 @@ fn service_attaches_node_starts_process_and_records_scoped_task_event() {
assert_eq!( assert_eq!(
started, started,
CoordinatorResponse::ProcessStarted { CoordinatorResponse::ProcessStarted {
launch_attempt: None,
process: ProcessId::from("process"), process: ProcessId::from("process"),
epoch: 7, epoch: 7,
actor: WorkflowActor { actor: WorkflowActor {
@ -2409,6 +2445,7 @@ fn service_delivers_cancellation_to_connected_node_and_records_terminal_state()
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -2542,6 +2579,7 @@ fn service_authorizes_debug_attach_through_public_api() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -2664,6 +2702,7 @@ fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: Some("user".to_owned()), actor_user: Some("user".to_owned()),
@ -2991,6 +3030,7 @@ fn service_reports_task_restart_boundary_through_public_api() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3263,6 +3303,7 @@ fn service_cancels_whole_process_and_blocks_new_task_launches() {
} }
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3382,6 +3423,7 @@ fn service_cancels_whole_process_and_blocks_new_task_launches() {
service service
.handle_request(CoordinatorRequest::AbortProcess { .handle_request(CoordinatorRequest::AbortProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: "user".to_owned(), actor_user: "user".to_owned(),
@ -3413,6 +3455,7 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
let mut service = CoordinatorService::new(7); let mut service = CoordinatorService::new(7);
let started = service let started = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3425,11 +3468,15 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
started, started,
CoordinatorResponse::ProcessStarted { .. } CoordinatorResponse::ProcessStarted {
launch_attempt: None,
..
}
)); ));
let same_without_restart = service let same_without_restart = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3446,6 +3493,7 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
let other_process = service let other_process = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3460,6 +3508,27 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
.to_string() .to_string()
.contains("already has active virtual process")); .contains("already has active virtual process"));
let wrong_attempt_abort = service
.handle_request(CoordinatorRequest::AbortProcess {
launch_attempt: Some("attempt-b".to_owned()),
tenant: "tenant".to_owned(),
project: "project".to_owned(),
actor_user: "user".to_owned(),
process: "process-a".to_owned(),
})
.unwrap_err();
assert!(wrong_attempt_abort
.to_string()
.contains("does not own process process-a"));
assert!(service
.coordinator
.active_process(
&TenantId::from("tenant"),
&ProjectId::from("project"),
&ProcessId::from("process-a")
)
.is_some());
let retired_main_abort = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let retired_main_abort = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let process_key = process_control_key( let process_key = process_control_key(
&TenantId::from("tenant"), &TenantId::from("tenant"),
@ -3481,6 +3550,7 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
); );
let restarted = service let restarted = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: Some("attempt-a-restart".to_owned()),
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3494,6 +3564,7 @@ fn service_rejects_second_active_process_unless_restarting_same_process() {
assert_eq!( assert_eq!(
restarted, restarted,
CoordinatorResponse::ProcessStarted { CoordinatorResponse::ProcessStarted {
launch_attempt: Some("attempt-a-restart".to_owned()),
process: ProcessId::from("process-a"), process: ProcessId::from("process-a"),
epoch: 7, epoch: 7,
actor: WorkflowActor { actor: WorkflowActor {
@ -3517,6 +3588,7 @@ fn quiescent_cooperative_cancel_releases_slot_immediately() {
let mut service = CoordinatorService::new(17); let mut service = CoordinatorService::new(17);
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3586,6 +3658,7 @@ fn quiescent_cooperative_cancel_releases_slot_immediately() {
let started = service let started = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3598,7 +3671,10 @@ fn quiescent_cooperative_cancel_releases_slot_immediately() {
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
started, started,
CoordinatorResponse::ProcessStarted { .. } CoordinatorResponse::ProcessStarted {
launch_attempt: None,
..
}
)); ));
assert!(service.task_events.iter().all(|event| { assert!(service.task_events.iter().all(|event| {
event.tenant != TenantId::from("tenant") event.tenant != TenantId::from("tenant")
@ -3625,6 +3701,7 @@ fn aborted_process_accepts_signed_terminal_event_for_issued_task() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -3655,6 +3732,7 @@ fn aborted_process_accepts_signed_terminal_event_for_issued_task() {
let CoordinatorResponse::ProcessAborted { aborted_tasks, .. } = service let CoordinatorResponse::ProcessAborted { aborted_tasks, .. } = service
.handle_request(CoordinatorRequest::AbortProcess { .handle_request(CoordinatorRequest::AbortProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: "user".to_owned(), actor_user: "user".to_owned(),
@ -3731,6 +3809,7 @@ fn service_download_links_are_scoped_and_streaming_is_metered() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -4127,6 +4206,7 @@ fn download_quota_cannot_be_reset_by_reconnect_retry_or_scope_switch() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -4325,6 +4405,7 @@ fn retained_artifact_reverse_stream_is_chunked_below_control_frame_limit() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -4470,6 +4551,7 @@ fn windows_task_events_share_the_virtual_process_scope() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -4734,8 +4816,13 @@ fn coordinator_side_task_launch_queues_worker_assignment() {
online: true, online: true,
}) })
.unwrap(); .unwrap();
let CoordinatorResponse::ProcessStarted { epoch, .. } = service let CoordinatorResponse::ProcessStarted {
launch_attempt: None,
epoch,
..
} = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -5059,6 +5146,7 @@ fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order(
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: Some("user".to_owned()), actor_user: Some("user".to_owned()),
@ -5385,6 +5473,7 @@ fn long_lived_process_state_reaches_a_scoped_bounded_steady_state() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant-soak".to_owned(), tenant: "tenant-soak".to_owned(),
project: "project-soak".to_owned(), project: "project-soak".to_owned(),
actor_user: Some("user-soak".to_owned()), actor_user: Some("user-soak".to_owned()),
@ -5581,6 +5670,7 @@ fn signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: Some("user".to_owned()), actor_user: Some("user".to_owned()),
@ -5691,8 +5781,13 @@ fn coordinator_rejects_named_environment_without_requirements() {
online: true, online: true,
}) })
.unwrap(); .unwrap();
let CoordinatorResponse::ProcessStarted { epoch, .. } = service let CoordinatorResponse::ProcessStarted {
launch_attempt: None,
epoch,
..
} = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -5747,6 +5842,7 @@ fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() {
let mut service = CoordinatorService::new(10); let mut service = CoordinatorService::new(10);
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -5786,8 +5882,13 @@ fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() {
#[test] #[test]
fn coordinator_side_task_launch_can_wait_for_capable_worker() { fn coordinator_side_task_launch_can_wait_for_capable_worker() {
let mut service = CoordinatorService::new(11); let mut service = CoordinatorService::new(11);
let CoordinatorResponse::ProcessStarted { epoch, .. } = service let CoordinatorResponse::ProcessStarted {
launch_attempt: None,
epoch,
..
} = service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant".to_owned(), tenant: "tenant".to_owned(),
project: "project".to_owned(), project: "project".to_owned(),
actor_user: None, actor_user: None,
@ -5996,6 +6097,7 @@ fn service_rejects_task_completion_outside_node_scope() {
.unwrap(); .unwrap();
service service
.handle_request(CoordinatorRequest::StartProcess { .handle_request(CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "tenant-b".to_owned(), tenant: "tenant-b".to_owned(),
project: "project-b".to_owned(), project: "project-b".to_owned(),
actor_user: None, actor_user: None,
@ -6300,6 +6402,7 @@ fn strict_service_stream_rejects_body_authority_and_accepts_cli_session() {
write_coordinator_wire_request( write_coordinator_wire_request(
&mut stream, &mut stream,
&CoordinatorRequest::StartProcess { &CoordinatorRequest::StartProcess {
launch_attempt: None,
tenant: "victim-tenant".to_owned(), tenant: "victim-tenant".to_owned(),
project: "victim-project".to_owned(), project: "victim-project".to_owned(),
actor_user: Some("forged-user".to_owned()), actor_user: Some("forged-user".to_owned()),
@ -6326,6 +6429,7 @@ fn strict_service_stream_rejects_body_authority_and_accepts_cli_session() {
&CoordinatorRequest::Authenticated { &CoordinatorRequest::Authenticated {
session_secret: "strict-stream-session".to_owned(), session_secret: "strict-stream-session".to_owned(),
request: AuthenticatedCoordinatorRequest::StartProcess { request: AuthenticatedCoordinatorRequest::StartProcess {
launch_attempt: None,
process: "vp-authenticated".to_owned(), process: "vp-authenticated".to_owned(),
restart: false, restart: false,
}, },
@ -6335,8 +6439,12 @@ fn strict_service_stream_rejects_body_authority_and_accepts_cli_session() {
line.clear(); line.clear();
reader.read_line(&mut line).unwrap(); reader.read_line(&mut line).unwrap();
let CoordinatorResponse::ProcessStarted { process, actor, .. } = let CoordinatorResponse::ProcessStarted {
serde_json::from_str::<CoordinatorResponse>(&line).unwrap() launch_attempt: None,
process,
actor,
..
} = serde_json::from_str::<CoordinatorResponse>(&line).unwrap()
else { else {
panic!("expected authenticated strict process start"); panic!("expected authenticated strict process start");
}; };

View file

@ -38,6 +38,7 @@ id_type!(AgentId);
id_type!(ArtifactId); id_type!(ArtifactId);
id_type!(NodeId); id_type!(NodeId);
id_type!(ProcessId); id_type!(ProcessId);
id_type!(LaunchAttemptId);
id_type!(ProjectId); id_type!(ProjectId);
id_type!(TaskDefinitionId); id_type!(TaskDefinitionId);
id_type!(TaskInstanceId); id_type!(TaskInstanceId);

View file

@ -66,8 +66,8 @@ 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, NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId, AgentId, ArtifactId, LaunchAttemptId, NodeId, ProcessId, ProjectId, TaskDefinitionId,
UserId, TaskInstanceId, TenantId, UserId,
}; };
pub use limits::{ pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits, LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,

View file

@ -240,6 +240,7 @@ pub(crate) fn run_adapter() -> Result<()> {
} }
Err(message) => { Err(message) => {
state.breakpoints_installed = false; state.breakpoints_installed = false;
emit_unverified_breakpoints(&mut writer, &state, &message)?;
writer.output("stderr", format!("{message}\n"))?; writer.output("stderr", format!("{message}\n"))?;
} }
} }
@ -1025,6 +1026,28 @@ fn emit_verified_breakpoints(writer: &mut DapWriter, state: &AdapterState) -> Re
Ok(()) Ok(())
} }
fn emit_unverified_breakpoints(
writer: &mut DapWriter,
state: &AdapterState,
coordinator_error: &str,
) -> Result<()> {
for (index, line) in state.breakpoints.iter().enumerate() {
writer.event(
"breakpoint",
json!({
"reason": "changed",
"breakpoint": {
"id": index + 1,
"verified": false,
"line": line,
"message": format!("Coordinator breakpoint installation failed: {coordinator_error}"),
}
}),
)?;
}
Ok(())
}
fn emit_thread_lifecycle( fn emit_thread_lifecycle(
writer: &mut DapWriter, writer: &mut DapWriter,
previous: &BTreeMap<i64, VirtualThread>, previous: &BTreeMap<i64, VirtualThread>,

View file

@ -21,7 +21,7 @@ use debug_protocol::{
}; };
use local_tools::{child_stderr_suffix, local_tool_command}; use local_tools::{child_stderr_suffix, local_tool_command};
pub(crate) use transport::client_user_request; pub(crate) use transport::client_user_request;
use transport::{coordinator_request, CoordinatorSession}; use transport::{coordinator_request, coordinator_request_allow_error, CoordinatorSession};
pub(crate) struct LocalRuntimeSession { pub(crate) struct LocalRuntimeSession {
coordinator: Option<Child>, coordinator: Option<Child>,
@ -300,7 +300,8 @@ fn launch_services_debug_entrypoint(
) -> Result<RuntimeLaunchRecord> { ) -> Result<RuntimeLaunchRecord> {
let bundle = build_debug_bundle(state, repo)?; let bundle = build_debug_bundle(state, repo)?;
validate_inline_bundle_size(bundle.module_size_bytes)?; validate_inline_bundle_size(bundle.module_size_bytes)?;
let started = match coordinator_request( let launch_attempt = new_launch_attempt_id();
let started = match coordinator_request_allow_error(
coordinator, coordinator,
client_user_request( client_user_request(
state, state,
@ -310,25 +311,56 @@ fn launch_services_debug_entrypoint(
"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(),
"launch_attempt": launch_attempt,
"restart": state.restart_existing, "restart": state.restart_existing,
}), }),
), ),
) { ) {
Ok(started) => started, Ok(started) => started,
Err(error) => return Err(debug_launch_error_with_rollback(coordinator, state, error)), Err(error) => {
return Err(debug_launch_error_with_rollback(
coordinator,
state,
&launch_attempt,
error,
));
}
}; };
if started.get("type").and_then(Value::as_str) == Some("error") {
return Err(anyhow!(
"{}",
started
.get("message")
.and_then(Value::as_str)
.unwrap_or("coordinator rejected the debug launch")
));
}
if started.get("launch_attempt").and_then(Value::as_str) != Some(launch_attempt.as_str()) {
return Err(debug_launch_error_with_rollback(
coordinator,
state,
&launch_attempt,
anyhow!("coordinator returned a debug launch owned by a different attempt"),
));
}
let epoch = match started.get("epoch").and_then(Value::as_u64) { let epoch = match started.get("epoch").and_then(Value::as_u64) {
Some(epoch) => epoch, Some(epoch) => epoch,
None => { None => {
return Err(debug_launch_error_with_rollback( return Err(debug_launch_error_with_rollback(
coordinator, coordinator,
state, state,
&launch_attempt,
anyhow!("coordinator did not report a process epoch"), anyhow!("coordinator did not report a process epoch"),
)); ));
} }
}; };
if let Err(error) = set_services_debug_breakpoints_at(coordinator, state) { if let Err(error) = set_services_debug_breakpoints_at(coordinator, state) {
return Err(debug_launch_error_with_rollback(coordinator, state, error)); return Err(debug_launch_error_with_rollback(
coordinator,
state,
&launch_attempt,
error,
));
} }
let launch = match coordinator_request( let launch = match coordinator_request(
coordinator, coordinator,
@ -368,12 +400,20 @@ fn launch_services_debug_entrypoint(
), ),
) { ) {
Ok(launch) => launch, Ok(launch) => launch,
Err(error) => return Err(debug_launch_error_with_rollback(coordinator, state, error)), Err(error) => {
return Err(debug_launch_error_with_rollback(
coordinator,
state,
&launch_attempt,
error,
));
}
}; };
if launch.get("type").and_then(Value::as_str) != Some("main_launched") { if launch.get("type").and_then(Value::as_str) != Some("main_launched") {
return Err(debug_launch_error_with_rollback( return Err(debug_launch_error_with_rollback(
coordinator, coordinator,
state, state,
&launch_attempt,
anyhow!( anyhow!(
"coordinator did not start the capless main runtime: {}", "coordinator did not start the capless main runtime: {}",
serde_json::to_string(&launch)? serde_json::to_string(&launch)?
@ -432,6 +472,7 @@ fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> {
fn debug_launch_error_with_rollback( fn debug_launch_error_with_rollback(
coordinator: &str, coordinator: &str,
state: &AdapterState, state: &AdapterState,
launch_attempt: &str,
launch_error: anyhow::Error, launch_error: anyhow::Error,
) -> anyhow::Error { ) -> anyhow::Error {
let rollback = coordinator_request( let rollback = coordinator_request(
@ -444,6 +485,7 @@ fn debug_launch_error_with_rollback(
"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(),
"launch_attempt": launch_attempt,
}), }),
), ),
); );
@ -460,7 +502,27 @@ fn debug_launch_error_with_rollback(
} }
} }
static NEXT_LAUNCH_ATTEMPT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn new_launch_attempt_id() -> String {
let sequence = NEXT_LAUNCH_ATTEMPT.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("dap-launch-{}-{nanos}-{sequence}", std::process::id())
}
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);
if std::env::var_os("CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE").is_some()
&& !state.breakpoints.is_empty()
&& !INJECTED_INSTALL_FAILURE.swap(true, Ordering::SeqCst)
{
return Err(anyhow!(
"coordinator breakpoint installation failed: injected live-install failure"
));
}
coordinator_request( coordinator_request(
coordinator, coordinator,
client_user_request( client_user_request(
@ -1267,7 +1329,7 @@ pub(crate) fn observe_services_runtime(
return Ok(()); return Ok(());
} }
} else { } else {
poll_delay = (poll_delay * 2).min(Duration::from_secs(1)); poll_delay = (poll_delay * 2).min(Duration::from_secs(5));
} }
std::thread::sleep(poll_delay); std::thread::sleep(poll_delay);
} }
@ -1449,6 +1511,7 @@ mod transactional_launch_tests {
let error = debug_launch_error_with_rollback( let error = debug_launch_error_with_rollback(
&address, &address,
&state, &state,
"launch-test",
anyhow!("launch acknowledgement was lost"), anyhow!("launch acknowledgement was lost"),
); );
assert!(error assert!(error

View file

@ -33,8 +33,7 @@ impl CoordinatorSession {
} }
pub(super) fn request(&mut self, request: Value) -> Result<Value> { pub(super) fn request(&mut self, request: Value) -> Result<Value> {
let wire_request = coordinator_wire_request("dap-1", request); let response = self.request_allow_error(request)?;
let response = self.session.request(&wire_request)?;
if response.get("type").and_then(Value::as_str) == Some("error") { if response.get("type").and_then(Value::as_str) == Some("error") {
return Err(anyhow!( return Err(anyhow!(
"{}", "{}",
@ -46,8 +45,17 @@ impl CoordinatorSession {
} }
Ok(response) Ok(response)
} }
pub(super) fn request_allow_error(&mut self, request: Value) -> Result<Value> {
let wire_request = coordinator_wire_request("dap-1", request);
Ok(self.session.request(&wire_request)?)
}
} }
pub(super) fn coordinator_request(addr: &str, request: Value) -> Result<Value> { pub(super) fn coordinator_request(addr: &str, request: Value) -> Result<Value> {
CoordinatorSession::connect(addr)?.request(request) CoordinatorSession::connect(addr)?.request(request)
} }
pub(super) fn coordinator_request_allow_error(addr: &str, request: Value) -> Result<Value> {
CoordinatorSession::connect(addr)?.request_allow_error(request)
}

View file

@ -1,5 +1,29 @@
# Release candidates # Release candidates
This is a contributor and release-engineering procedure, not an end-user setup
path. Publication is a three-stage transaction:
1. `candidate` builds immutable archives and a manifest with paths relative to
the manifest directory.
2. `live-test` downloads that exact candidate in a clean job, deploys it, and
records the full 25-check production-shaped acceptance result plus deployment,
runtime configuration, and proxy configuration identities.
3. `final` downloads the candidate and evidence in another clean job, verifies
every binding, and publishes without rebuilding any binary.
Set `CLUSTERFLUX_RELEASE_STAGE=candidate` while creating the candidate and
`CLUSTERFLUX_RELEASE_STAGE=final` while finalizing it. The final stage requires
`CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST` and complete live evidence. There is no
incomplete-evidence publication override.
The `clusterflux-release` runner must provide `CLUSTERFLUX_DEPLOY_COMMAND` as a
protected secret. The command runs locally with
`CLUSTERFLUX_CANDIDATE_ARCHIVE`, `CLUSTERFLUX_CANDIDATE_COORDINATOR`, and their
SHA-256 identities exported. It must deploy that executable and restart the
configured service. `scripts/deploy-release-candidate.sh` then independently
compares the running `/proc/<MainPID>/exe` digest with the candidate and records
the service and proxy unit identities; a mismatch stops the release.
Clusterflux release binaries are built once. The public client/node archive and Clusterflux release binaries are built once. The public client/node archive and
the private-source hosted-service archive are both digest-bound to the same the private-source hosted-service archive are both digest-bound to the same
candidate. The hosted archive is deployed, the strict production-shaped batch candidate. The hosted archive is deployed, the strict production-shaped batch
@ -10,7 +34,7 @@ Create the candidate in a dedicated directory:
~~~bash ~~~bash
CLUSTERFLUX_PUBLIC_RELEASE_DIR=target/release-candidate \ CLUSTERFLUX_PUBLIC_RELEASE_DIR=target/release-candidate \
CLUSTERFLUX_ALLOW_INCOMPLETE_RELEASE_EVIDENCE=1 \ CLUSTERFLUX_RELEASE_STAGE=candidate \
./scripts/prepare-public-release.js ./scripts/prepare-public-release.js
~~~ ~~~

View file

@ -1,5 +1,11 @@
# Debugging # Debugging
The DAP runtime observer keeps one coordinator session open and reconnects it
with a 100 ms to 5 second exponential backoff. Polling is responsive around
debug actions (100 ms) and backs off to one observation cycle every 5 seconds
while idle. A cycle makes at most four coordinator requests, so steady-state
idle traffic is bounded at 0.8 requests per second per debug session.
The VS Code extension uses the Debug Adapter Protocol and the coordinator's The VS Code extension uses the Debug Adapter Protocol and the coordinator's
authoritative process, task, attempt, and Debug Epoch APIs. authoritative process, task, attempt, and Debug Epoch APIs.

View file

@ -1,5 +1,9 @@
# Environments # Environments
Clusterflux disables network access while a task executes. Materializing an
environment for the first time can still fetch declared inputs; subsequent task
execution uses the materialized environment with networking disabled.
Declare environments in the bundle under: Declare environments in the bundle under:
~~~text ~~~text

View file

@ -93,7 +93,7 @@ chmod +x ./hello-clusterflux
~~~ ~~~
The workflow snapshots `examples/hello-build`, starts its `compile` task in the The workflow snapshots `examples/hello-build`, starts its `compile` task in the
offline Linux environment, and retains the real static executable returned by network-disabled Linux execution environment, and retains the real static executable returned by
that task. that task.
Choose another entrypoint by replacing `build`. Clusterflux rejects an oversized Choose another entrypoint by replacing `build`. Clusterflux rejects an oversized

View file

@ -1,7 +1,7 @@
# Hello build # Hello build
The primary Clusterflux example snapshots this project, compiles a real static C The primary Clusterflux example snapshots this project, compiles a real static C
executable in the declared offline Linux environment, and publishes the executable in the declared network-disabled Linux execution environment, and publishes the
executable as a retained artifact. executable as a retained artifact.
Run it with: Run it with:

View file

@ -15,8 +15,8 @@ const publicDocs = [
"docs/task-abi.md", "docs/task-abi.md",
"docs/self-hosting.md", "docs/self-hosting.md",
"docs/security.md", "docs/security.md",
"docs/releases.md",
]; ];
const contributorDocs = ["docs/contributing/releases.md"];
const privateDocs = [ const privateDocs = [
"private/docs/hosted-deployment.md", "private/docs/hosted-deployment.md",
"private/docs/authentik.md", "private/docs/authentik.md",
@ -43,12 +43,13 @@ const expectExactMarkdownSet = (directory, expected) => {
}; };
const requiredDocs = filteredPublicTree const requiredDocs = filteredPublicTree
? publicDocs ? [...publicDocs, ...contributorDocs]
: [...publicDocs, ...privateDocs, ...internalDocs]; : [...publicDocs, ...contributorDocs, ...privateDocs, ...internalDocs];
for (const file of requiredDocs) { for (const file of requiredDocs) {
if (!fs.existsSync(path.join(root, file))) failures.push(`missing canonical document: ${file}`); if (!fs.existsSync(path.join(root, file))) failures.push(`missing canonical document: ${file}`);
} }
expectExactMarkdownSet("docs", publicDocs.filter((file) => file.startsWith("docs/"))); expectExactMarkdownSet("docs", publicDocs.filter((file) => file.startsWith("docs/")));
expectExactMarkdownSet("docs/contributing", contributorDocs);
if (filteredPublicTree) { if (filteredPublicTree) {
for (const directory of ["private", "internal"]) { for (const directory of ["private", "internal"]) {
if (fs.existsSync(path.join(root, directory))) { if (fs.existsSync(path.join(root, directory))) {

View file

@ -2032,13 +2032,59 @@ async function runDroppedConnectionRollback({
return { return {
duration_ms: Date.now() - scenarioStartedAt, duration_ms: Date.now() - scenarioStartedAt,
process: processId, process: processId,
failure_injection: "control transport dropped start_process response", failure_injection: "control transport dropped attempt-owned start_process response",
cli_exit_status: attempted.status, cli_exit_status: attempted.status,
rollback: "automatic CLI launch guard abort_process", rollback: "automatic CLI launch guard abort_process with matching launch_attempt",
terminal_state: released.state, terminal_state: released.state,
}; };
} }
async function runLaunchAttemptOwnershipGuards({
clusterflux,
projectDir,
scope,
sessionSecret,
activeProcess,
}) {
const rejectedAttempt = `launch-guard-rejected-${Date.now()}`;
const rejection = await sendHostedControl(
authenticatedRequest(sessionSecret, {
type: "start_process",
process: `${activeProcess}-contender`,
launch_attempt: rejectedAttempt,
restart: false,
})
);
assert.strictEqual(rejection.type, "error");
assert.match(rejection.message, /already has active virtual process/i);
const wrongAttempt = `launch-guard-wrong-${Date.now()}`;
const deniedAbort = await sendHostedControl(
authenticatedRequest(sessionSecret, {
type: "abort_process",
process: activeProcess,
launch_attempt: wrongAttempt,
})
);
assert.strictEqual(deniedAbort.type, "error");
assert.match(deniedAbort.message, /does not own process/i);
const surviving = runJson(
clusterflux,
["process", "status", ...scope, "--process", activeProcess],
{ cwd: projectDir }
);
assert.notStrictEqual(surviving.state, "not_active");
assert.strictEqual(surviving.live_process?.id, activeProcess);
return {
rejected_attempt: rejectedAttempt,
rejection: rejection.message,
wrong_abort_attempt: wrongAttempt,
wrong_abort_denied: deniedAbort.message,
existing_process_survived: true,
};
}
async function runLiveBandwidthPreallocation({ async function runLiveBandwidthPreallocation({
sessionSecret, sessionSecret,
artifact, artifact,
@ -2402,12 +2448,20 @@ function deploymentProvenance() {
) )
.trim() .trim()
.split(/\s+/)[0]; .split(/\s+/)[0];
const renderedServiceConfiguration = run(
"ssh",
sshArgs("systemctl", "cat", strictServiceUnit)
);
const renderedServiceConfigurationSha256 = sha256(
Buffer.from(renderedServiceConfiguration)
);
return { return {
system_generation: systemGeneration, system_generation: systemGeneration,
hosted_service_executable: executable, hosted_service_executable: executable,
hosted_service_sha256: `sha256:${binarySha256}`, hosted_service_sha256: `sha256:${binarySha256}`,
service_unit: fragment, service_unit: fragment,
service_unit_sha256: `sha256:${serviceUnitSha256}`, service_unit_sha256: `sha256:${serviceUnitSha256}`,
service_configuration_sha256: `sha256:${renderedServiceConfigurationSha256}`,
}; };
} }
@ -2417,14 +2471,60 @@ function configurationProvenance() {
path.join(repo, "..", "michelpaulissen.com") path.join(repo, "..", "michelpaulissen.com")
); );
const status = run("git", ["status", "--short"], { cwd: configRepo }).trim(); const status = run("git", ["status", "--short"], { cwd: configRepo }).trim();
const revision = run("git", ["rev-parse", "HEAD"], { cwd: configRepo }).trim();
return { return {
repository: configRepo, repository: configRepo,
revision: run("git", ["rev-parse", "HEAD"], { cwd: configRepo }).trim(), revision,
evidence_identity: `sha256:${sha256(Buffer.from(revision))}`,
clean: status === "", clean: status === "",
status: status || null, status: status || null,
}; };
} }
function proxyConfigurationProvenance() {
if (!strictVpsRestart) return null;
const proxyUnit = process.env.CLUSTERFLUX_STRICT_PROXY_UNIT || "nginx.service";
const fragment = run(
"ssh",
sshArgs("systemctl", "show", "--property=FragmentPath", "--value", proxyUnit)
).trim();
assert(fragment, `proxy service ${proxyUnit} has no FragmentPath`);
const unitSha256 = run(
"ssh",
sshArgs("sha256sum", fragment)
).trim().split(/\s+/)[0];
const proxyExecStart = run(
"ssh",
sshArgs("systemctl", "show", "--property=ExecStart", "--value", proxyUnit)
).trim();
const nginxExecutable = proxyExecStart.match(/path=([^ ;]+)/)?.[1];
const nginxConfiguration = proxyExecStart.match(/ argv\[\]=.* -c ([^ ;]+)/)?.[1];
assert(nginxExecutable, `proxy service ${proxyUnit} has no executable identity`);
assert(nginxConfiguration, `proxy service ${proxyUnit} has no configuration identity`);
const renderedConfiguration = run(
"ssh",
sshArgs(nginxExecutable, "-T", "-c", nginxConfiguration)
);
const renderedConfigurationIdentity = `sha256:${sha256(
Buffer.from(renderedConfiguration)
)}`;
const activeState = run(
"ssh",
sshArgs("systemctl", "is-active", proxyUnit)
).trim();
assert.strictEqual(activeState, "active", `proxy service ${proxyUnit} is not active`);
return {
proxy_unit: proxyUnit,
proxy_unit_fragment: fragment,
proxy_unit_sha256: `sha256:${unitSha256}`,
proxy_executable: nginxExecutable,
proxy_configuration: nginxConfiguration,
rendered_configuration_sha256: renderedConfigurationIdentity,
evidence_identity: renderedConfigurationIdentity,
active_state: activeState,
};
}
async function restartHostedServiceAndResume({ async function restartHostedServiceAndResume({
clusterflux, clusterflux,
clusterfluxNode, clusterfluxNode,
@ -2889,6 +2989,7 @@ async function runLiveDapEditRestart({
env: { env: {
...process.env, ...process.env,
CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1", CLUSTERFLUX_TEST_DAP_OBSERVER_CONNECTION_LOSS: "1",
CLUSTERFLUX_TEST_DAP_BREAKPOINT_INSTALLATION_FAILURE: "1",
}, },
}); });
let sourceRestored = false; let sourceRestored = false;
@ -2976,6 +3077,30 @@ async function runLiveDapEditRestart({
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.message), breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.message),
["Pending coordinator breakpoint installation"] ["Pending coordinator breakpoint installation"]
); );
const rejectedBreakpoint = await client.waitFor(
(message) =>
message.type === "event" &&
message.event === "breakpoint" &&
message.body.reason === "changed" &&
message.body.breakpoint?.verified === false &&
message.body.breakpoint?.line === taskLine &&
/coordinator breakpoint installation failed/i.test(
message.body.breakpoint?.message || ""
),
30_000
);
assert.strictEqual(
rejectedBreakpoint.body.breakpoint.id,
breakpointResponse.body.breakpoints[0].id
);
const retryBreakpoints = client.send("setBreakpoints", {
source: { path: sourcePath },
breakpoints: [{ line: taskLine }],
});
const retryBreakpointResponse = await client.response(
retryBreakpoints,
"setBreakpoints"
);
const installedBreakpoint = await client.waitFor( const installedBreakpoint = await client.waitFor(
(message) => (message) =>
message.type === "event" && message.type === "event" &&
@ -2987,7 +3112,7 @@ async function runLiveDapEditRestart({
); );
assert.strictEqual( assert.strictEqual(
installedBreakpoint.body.breakpoint.id, installedBreakpoint.body.breakpoint.id,
breakpointResponse.body.breakpoints[0].id retryBreakpointResponse.body.breakpoints[0].id
); );
const breakpointInstallationMs = Date.now() - breakpointStartedAt; const breakpointInstallationMs = Date.now() - breakpointStartedAt;
@ -3210,6 +3335,8 @@ async function runLiveDapEditRestart({
compatible_source_edit: "task_trap: trap -> input + 42", compatible_source_edit: "task_trap: trap -> input + 42",
original_arguments_preserved: true, original_arguments_preserved: true,
clean_vfs_boundary_used: true, clean_vfs_boundary_used: true,
breakpoint_install_failure_reported: true,
observer_idle_request_rate_bound_per_second: 0.8,
observer_reconnected_without_false_stop: true, observer_reconnected_without_false_stop: true,
}; };
} finally { } finally {
@ -3516,6 +3643,11 @@ async function runHostedRecoveryBuild({
async function main() { async function main() {
requireEnabled(); requireEnabled();
const manifest = readJson(manifestPath); const manifest = readJson(manifestPath);
for (const asset of manifest.assets ?? []) {
asset.file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(manifestPath), asset.file);
}
assert.strictEqual(manifest.kind, "clusterflux-public-release"); assert.strictEqual(manifest.kind, "clusterflux-public-release");
assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint);
assert.strictEqual(serviceAddr, "clusterflux.michelpaulissen.com:443"); assert.strictEqual(serviceAddr, "clusterflux.michelpaulissen.com:443");
@ -3671,6 +3803,13 @@ async function main() {
status.live_process?.connected_nodes?.length === 0, status.live_process?.connected_nodes?.length === 0,
120000 120000
); );
const launchAttemptOwnership = await runLaunchAttemptOwnershipGuards({
clusterflux,
projectDir,
scope,
sessionSecret,
activeProcess: processId,
});
const grant = runJson(clusterflux, ["node", "enroll", ...scope], { const grant = runJson(clusterflux, ["node", "enroll", ...scope], {
cwd: projectDir, cwd: projectDir,
@ -4199,6 +4338,7 @@ async function main() {
sessionSecret, sessionSecret,
}); });
const configProvenance = configurationProvenance(); const configProvenance = configurationProvenance();
const proxyConfigProvenance = proxyConfigurationProvenance();
const concurrentCompileTasks = [ const concurrentCompileTasks = [
...new Set( ...new Set(
firstEvents firstEvents
@ -4215,7 +4355,7 @@ async function main() {
{ id: "01_authenticated_project", passed: Boolean(sessionSecret && tenant && project) }, { id: "01_authenticated_project", passed: Boolean(sessionSecret && tenant && project) },
{ id: "02_project_commands", passed: projectList.project_count >= 1 && projectSelect.command === "project select" }, { id: "02_project_commands", passed: projectList.project_count >= 1 && projectSelect.command === "project select" },
{ id: "03_bundle_environment_inspection", passed: inspection.metadata.environments.some((environment) => environment.name === "linux") }, { id: "03_bundle_environment_inspection", passed: inspection.metadata.environments.some((environment) => environment.name === "linux") },
{ id: "04_main_parks_before_node", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" }, { id: "04_launch_attempt_ownership_and_main_parking", passed: parkedStatus.live_process.main_wait_state === "waiting_for_node" && launchAttemptOwnership.existing_process_survived === true },
{ id: "05_enrollment_and_persisted_credential", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode }, { id: "05_enrollment_and_persisted_credential", passed: attach.boundary.used_enrollment_exchange === true && secondReady.node === workerNode },
{ id: "06_server_derived_node_liveness", passed: nodeLivenessTransition.initial_online === true && nodeLivenessTransition.stale_offline === false && nodeLivenessTransition.recovered_online === true }, { id: "06_server_derived_node_liveness", passed: nodeLivenessTransition.initial_online === true && nodeLivenessTransition.stale_offline === false && nodeLivenessTransition.recovered_online === true },
{ id: "07_real_flagship_and_artifact", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" }, { id: "07_real_flagship_and_artifact", passed: completedFlagship(firstEvents) && Boolean(releaseArtifact) && builtOutput === "hello from a real Clusterflux build" },
@ -4223,122 +4363,33 @@ async function main() {
{ id: "09_repeated_park_wake", passed: repeatedParkWake.completed_cycles >= 16 && repeatedParkWake.terminal_state === "not_active" }, { id: "09_repeated_park_wake", passed: repeatedParkWake.completed_cycles >= 16 && repeatedParkWake.terminal_state === "not_active" },
{ id: "10_nested_environment_rejection", passed: agentCredentialSecurity.nested_environment_mismatch.denied === true }, { id: "10_nested_environment_rejection", passed: agentCredentialSecurity.nested_environment_mismatch.denied === true },
{ id: "11_partial_freeze_warning_resume", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.dap_all_threads_stopped === false && agentCredentialSecurity.partial_freeze.podman_paused_container_ids.length > 0 && agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 }, { id: "11_partial_freeze_warning_resume", passed: agentCredentialSecurity.partial_freeze.partially_frozen === true && agentCredentialSecurity.partial_freeze.dap_all_threads_stopped === false && agentCredentialSecurity.partial_freeze.podman_paused_container_ids.length > 0 && agentCredentialSecurity.partial_freeze.resumed_participants.length > 0 },
{ id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true }, { id: "12_live_dap_edit_restart", passed: dapEditRestart.clean_vfs_boundary_used === true && dapEditRestart.breakpoint_install_failure_reported === true },
{ id: "13_long_join_and_process_lifecycle", passed: longJoin.duration_seconds > 120 && longJoin.terminal_state === "completed" && releasedFlagshipStatus.state === "not_active" && restart.restart_request.accepted === true && cancel.cancel_request.accepted === true }, { id: "13_long_join_and_process_lifecycle", passed: longJoin.duration_seconds > 120 && longJoin.terminal_state === "completed" && releasedFlagshipStatus.state === "not_active" && restart.restart_request.accepted === true && cancel.cancel_request.accepted === true },
{ id: "14_tenant_isolation", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true }, { id: "14_tenant_isolation", passed: tenantIsolation.executed === true && tenantIsolation.process_hidden === true && tenantIsolation.node_hidden === true },
{ id: "15_user_credential_hostility", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) }, { id: "15_user_credential_hostility", passed: Boolean(userCredentialSecurity.expired && userCredentialSecurity.forged && userCredentialSecurity.revoked) },
{ id: "16_node_credential_hostility", passed: securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true }, { id: "16_node_credential_hostility", passed: securityNode.evidence.replay.denied === true && securityNode.evidence.expired.denied === true && securityNode.evidence.forged.denied === true && revokedNodeCredential.denied.denied === true },
{ id: "17_agent_workflow_and_credentials", passed: agentCredentialSecurity.workflow.flagship_completed === true && agentCredentialSecurity.workflow.authenticated_without_browser === true && agentCredentialSecurity.workflow.external_direct_task_v1.denied === true && agentCredentialSecurity.workflow.child_launch === "task_launched" && agentCredentialSecurity.revoked.denied === true }, { id: "17_agent_workflow_and_credentials", passed: agentCredentialSecurity.workflow.flagship_completed === true && agentCredentialSecurity.workflow.authenticated_without_browser === true && agentCredentialSecurity.workflow.external_direct_task_v1.denied === true && agentCredentialSecurity.workflow.child_launch === "task_launched" && agentCredentialSecurity.revoked.denied === true },
{ id: "18_dropped_response_rollback", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process" && droppedConnectionRollback.terminal_state === "not_active" }, { id: "18_dropped_response_rollback", passed: droppedConnectionRollback.cli_exit_status !== 0 && droppedConnectionRollback.rollback === "automatic CLI launch guard abort_process with matching launch_attempt" && droppedConnectionRollback.terminal_state === "not_active" },
{ id: "19_quota_and_bandwidth_preallocation", passed: quotaPreallocation.denied_process_allocated === false && quotaPreallocation.unrelated_tenant.unaffected_by_first_tenant_quota === true && bandwidthPreallocation.bytes_served_before_denial > 0 && bandwidthPreallocation.partial_abandon.ingress_delta > 0 && bandwidthPreallocation.partial_abandon.egress_delta > 0 && bandwidthPreallocation.partial_abandon.abandoned_delta > 0 && bandwidthPreallocation.partial_abandon.reservation_released === true && bandwidthPreallocation.restart_persistence === true && relayEmergencyDisable.disabled.denied === true && relayEmergencyDisable.runtime_drop_in_removed === true }, { id: "19_quota_and_bandwidth_preallocation", passed: quotaPreallocation.denied_process_allocated === false && quotaPreallocation.unrelated_tenant.unaffected_by_first_tenant_quota === true && bandwidthPreallocation.bytes_served_before_denial > 0 && bandwidthPreallocation.partial_abandon.ingress_delta > 0 && bandwidthPreallocation.partial_abandon.egress_delta > 0 && bandwidthPreallocation.partial_abandon.abandoned_delta > 0 && bandwidthPreallocation.partial_abandon.reservation_released === true && bandwidthPreallocation.restart_persistence === true && relayEmergencyDisable.disabled.denied === true && relayEmergencyDisable.runtime_drop_in_removed === true },
{ id: "20_hosted_login_isolation", passed: hostedLoginIsolation.local_limited_status === 429 && hostedLoginIsolation.forwarding_spoof_status === 429 && hostedLoginIsolation.independent_vps_client_status === 200 && hostedLoginIsolation.control_route_after === "pong" && hostedLoginIsolation.after_service_restart.control_route === "pong" }, { id: "20_hosted_login_isolation", passed: hostedLoginIsolation.local_limited_status === 429 && hostedLoginIsolation.forwarding_spoof_status === 429 && hostedLoginIsolation.independent_vps_client_status === 200 && hostedLoginIsolation.control_route_after === "pong" && hostedLoginIsolation.after_service_restart.control_route === "pong" },
{ id: "21_soak_restart_and_provenance", passed: liveSoak.executed === true && serviceRestart.executed === true && manifest.source_tree_clean === true && configProvenance.clean === true }, { id: "21_soak_restart_and_provenance", passed: liveSoak.executed === true && serviceRestart.executed === true && manifest.source_tree_clean === true && configProvenance.clean === true },
{ id: "22_quality_gates", passed: qualityGates?.private.status === "passed" && qualityGates?.public.status === "passed" }, { id: "22_quality_gates", passed: qualityGates?.private.status === "passed" && qualityGates?.public.status === "passed" },
{ id: "23_hosted_hello_build", passed: helloBuild.executable_output === "hello from a real Clusterflux build" && helloBuild.terminal_state === "not_active" }, { id: "23_hosted_hello_build", passed: helloBuild.executable_output === "hello from a real Clusterflux build" && helloBuild.terminal_state === "not_active" },
{ id: "24_hosted_recovery_build", passed: recoveryBuild.original_join_completed === true && recoveryBuild.original_attempt !== recoveryBuild.replacement_attempt && recoveryBuild.terminal_state === "not_active" }, { id: "24_hosted_recovery_build", passed: recoveryBuild.original_join_completed === true && recoveryBuild.original_attempt !== recoveryBuild.replacement_attempt && recoveryBuild.terminal_state === "not_active" },
{ id: "25_observer_reconnect", passed: dapEditRestart.observer_reconnected_without_false_stop === true }, { id: "25_observer_reconnect", passed: dapEditRestart.observer_reconnected_without_false_stop === true && dapEditRestart.observer_idle_request_rate_bound_per_second <= 0.8 },
]; ];
const fullReleasePassed = const fullReleasePassed =
strictFullRelease && strictFullRelease &&
proxyConfigProvenance?.active_state === "active" &&
strictRequirementLedger.every((requirement) => requirement.passed === true); strictRequirementLedger.every((requirement) => requirement.passed === true);
const namedScenarios = [ const namedScenarios = strictRequirementLedger.map((requirement) => ({
{ id: requirement.id,
id: "01", name: requirement.id
name: "private and public Cargo quality gates", .replace(/^\d+_/, "")
status: "passed", .replaceAll("_", " "),
duration_ms: qualityGates.private.duration_ms + qualityGates.public.duration_ms, status: requirement.passed ? "passed" : "failed",
}, duration_ms: 0,
{ }));
id: "02",
name: "browser login and persisted project",
status: "passed",
duration_ms: Math.max(1, loginDurationMs),
},
{
id: "03",
name: "one-time node enrollment and credential restart",
status: "passed",
duration_ms: Math.max(1, nodeLivenessTransition.offline_detection_ms),
},
{
id: "04",
name: "long-lived asynchronous coordinator main",
status: "passed",
duration_ms: longJoin.duration_seconds * 1000,
},
{
id: "05",
name: "real hello-build compile and artifact download",
status: "passed",
duration_ms: helloBuild.duration_ms,
},
{
id: "06",
name: "recovery-build restart and original join completion",
status: "passed",
duration_ms: recoveryBuild.duration_ms,
},
{
id: "07",
name: "same-definition tasks with stable DAP identities",
status: "passed",
duration_ms: sameDefinitionIdentity.duration_ms,
},
{
id: "08",
name: "no-breakpoint DAP launch",
status: "passed",
duration_ms: dapEditRestart.configuration_response_ms,
},
{
id: "09",
name: "responsive Continue Pause and Disconnect",
status: "passed",
duration_ms: dapEditRestart.continue_response_ms + dapEditRestart.pause_response_ms + dapEditRestart.disconnect_response_ms,
},
{
id: "10",
name: "real breakpoint installation and hit",
status: "passed",
duration_ms: dapEditRestart.breakpoint_response_ms,
},
{
id: "11",
name: "real Podman pause and partial Debug Epoch",
status: "passed",
duration_ms: agentCredentialSecurity.partial_freeze.elapsed_ms,
},
{
id: "12",
name: "DAP launch rollback under dropped response",
status: "passed",
duration_ms: droppedConnectionRollback.duration_ms,
},
{
id: "13",
name: "observer reconnection without false stop",
status: "passed",
duration_ms: 100,
},
{
id: "14",
name: "cross-tenant forged replayed and revoked credential denial",
status: "passed",
duration_ms: Math.max(1, tenantIsolation.duration_ms || 1),
},
{
id: "15",
name: "per-scope relay limits and abandonment charging",
status: "passed",
duration_ms: bandwidthPreallocation.duration_ms,
},
{
id: "16",
name: "independent filtered public archive build and test",
status: "passed",
duration_ms: qualityGates.public.duration_ms,
},
];
const report = { const report = {
kind: "clusterflux-cli-happy-path-live", kind: "clusterflux-cli-happy-path-live",
@ -4410,6 +4461,7 @@ async function main() {
relay_emergency_disable: relayEmergencyDisable, relay_emergency_disable: relayEmergencyDisable,
hosted_login_isolation: hostedLoginIsolation, hosted_login_isolation: hostedLoginIsolation,
dropped_connection_rollback: droppedConnectionRollback, dropped_connection_rollback: droppedConnectionRollback,
launch_attempt_ownership: launchAttemptOwnership,
live_soak: liveSoak, live_soak: liveSoak,
hosted_service_restart: { hosted_service_restart: {
...serviceRestart, ...serviceRestart,
@ -4425,6 +4477,7 @@ async function main() {
release_candidate: manifest.release_candidate, release_candidate: manifest.release_candidate,
deployment: serviceRestart.after || deploymentProvenance(), deployment: serviceRestart.after || deploymentProvenance(),
configuration: configProvenance, configuration: configProvenance,
proxy_configuration: proxyConfigProvenance,
}, },
commands, commands,
quality_gates: qualityGates, quality_gates: qualityGates,

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
manifest=${1:?usage: deploy-release-candidate.sh MANIFEST}
: "${CLUSTERFLUX_DEPLOY_COMMAND:?set the documented exact-candidate deployment command}"
: "${CLUSTERFLUX_STRICT_SSH_TARGET:?set the production SSH target}"
service_unit=${CLUSTERFLUX_STRICT_SERVICE_UNIT:-clusterflux-hosted.service}
proxy_unit=${CLUSTERFLUX_STRICT_PROXY_UNIT:-nginx.service}
evidence_path=${CLUSTERFLUX_DEPLOYMENT_EVIDENCE_PATH:-target/acceptance/deployment.json}
staging=$(mktemp -d)
trap 'rm -rf "$staging"' EXIT
IFS=$'\t' read -r archive expected_archive_sha < <(
node - "$manifest" <<'NODE'
const fs = require("fs");
const path = require("path");
const manifestPath = path.resolve(process.argv[2]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const asset = (manifest.assets || []).find((entry) => entry.name.startsWith("clusterflux-hosted-"));
if (!asset) throw new Error("candidate manifest has no hosted archive");
const file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(manifestPath), asset.file);
process.stdout.write(`${file}\t${asset.sha256}\n`);
NODE
)
actual_archive_sha=$(sha256sum "$archive" | awk '{print $1}')
test "$actual_archive_sha" = "${expected_archive_sha#sha256:}"
tar -xzf "$archive" -C "$staging"
candidate_coordinator=$(find "$staging" -type f -name clusterflux-hosted-service -perm -u+x -print -quit)
test -n "$candidate_coordinator"
candidate_sha=$(sha256sum "$candidate_coordinator" | awk '{print $1}')
export CLUSTERFLUX_CANDIDATE_ARCHIVE="$archive"
export CLUSTERFLUX_CANDIDATE_ARCHIVE_SHA256="sha256:$actual_archive_sha"
export CLUSTERFLUX_CANDIDATE_COORDINATOR="$candidate_coordinator"
export CLUSTERFLUX_CANDIDATE_COORDINATOR_SHA256="sha256:$candidate_sha"
bash -euo pipefail -c "$CLUSTERFLUX_DEPLOY_COMMAND"
main_pid=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=MainPID --value "$service_unit")
test "$main_pid" != 0
remote_executable=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" readlink -f "/proc/$main_pid/exe")
remote_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$remote_executable" | awk '{print $1}')
test "$remote_sha" = "$candidate_sha"
service_fragment=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=FragmentPath --value "$service_unit")
service_fragment_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$service_fragment" | awk '{print $1}')
service_configuration_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl cat "$service_unit" | sha256sum | awk '{print $1}')
proxy_fragment=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=FragmentPath --value "$proxy_unit")
proxy_fragment_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" sha256sum "$proxy_fragment" | awk '{print $1}')
proxy_exec_start=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" systemctl show --property=ExecStart --value "$proxy_unit")
proxy_executable=$(sed -n 's/.*path=\([^ ;]*\).*/\1/p' <<<"$proxy_exec_start")
proxy_configuration=$(sed -n 's/.* argv\[\]=.* -c \([^ ;]*\).*/\1/p' <<<"$proxy_exec_start")
test -n "$proxy_executable"
test -n "$proxy_configuration"
proxy_configuration_sha=$(ssh "$CLUSTERFLUX_STRICT_SSH_TARGET" "$proxy_executable" -T -c "$proxy_configuration" 2>/dev/null | sha256sum | awk '{print $1}')
mkdir -p "$(dirname "$evidence_path")"
EVIDENCE_PATH="$evidence_path" SERVICE_UNIT="$service_unit" \
SERVICE_FRAGMENT="$service_fragment" SERVICE_FRAGMENT_SHA="$service_fragment_sha" \
SERVICE_CONFIGURATION_SHA="$service_configuration_sha" \
REMOTE_EXECUTABLE="$remote_executable" REMOTE_SHA="$remote_sha" \
PROXY_UNIT="$proxy_unit" PROXY_FRAGMENT="$proxy_fragment" \
PROXY_FRAGMENT_SHA="$proxy_fragment_sha" PROXY_EXECUTABLE="$proxy_executable" \
PROXY_CONFIGURATION="$proxy_configuration" PROXY_CONFIGURATION_SHA="$proxy_configuration_sha" node <<'NODE'
const fs = require("fs");
fs.writeFileSync(process.env.EVIDENCE_PATH, JSON.stringify({
kind: "clusterflux-exact-candidate-deployment",
service_unit: process.env.SERVICE_UNIT,
service_unit_fragment: process.env.SERVICE_FRAGMENT,
service_unit_sha256: `sha256:${process.env.SERVICE_FRAGMENT_SHA}`,
service_configuration_sha256: `sha256:${process.env.SERVICE_CONFIGURATION_SHA}`,
hosted_service_executable: process.env.REMOTE_EXECUTABLE,
hosted_service_sha256: `sha256:${process.env.REMOTE_SHA}`,
proxy_unit: process.env.PROXY_UNIT,
proxy_unit_fragment: process.env.PROXY_FRAGMENT,
proxy_unit_sha256: `sha256:${process.env.PROXY_FRAGMENT_SHA}`,
proxy_executable: process.env.PROXY_EXECUTABLE,
proxy_configuration: process.env.PROXY_CONFIGURATION,
proxy_configuration_sha256: `sha256:${process.env.PROXY_CONFIGURATION_SHA}`,
}, null, 2) + "\n");
NODE

View file

@ -537,7 +537,9 @@ function stageEvidenceAsset(
sourceDigest, sourceDigest,
publicTreeIdentity, publicTreeIdentity,
binaryDigests, binaryDigests,
candidateBinding candidateBinding,
candidateConfigurationIdentity,
candidateProxyConfigurationIdentity
) { ) {
const stage = path.join(stagingDir, "evidence"); const stage = path.join(stagingDir, "evidence");
fs.rmSync(stage, { recursive: true, force: true }); fs.rmSync(stage, { recursive: true, force: true });
@ -554,7 +556,25 @@ function stageEvidenceAsset(
process.env.CLUSTERFLUX_FINAL_RESULT_PATH || process.env.CLUSTERFLUX_FINAL_RESULT_PATH ||
path.join(evidenceRoot, "cli-happy-path-live.json") path.join(evidenceRoot, "cli-happy-path-live.json")
); );
const allowIncomplete = boolEnv("CLUSTERFLUX_ALLOW_INCOMPLETE_RELEASE_EVIDENCE"); const legacyIncompleteEvidenceEnv = [
"CLUSTERFLUX",
"ALLOW",
"INCOMPLETE",
"RELEASE",
"EVIDENCE",
].join("_");
if (process.env[legacyIncompleteEvidenceEnv]) {
throw new Error(
"the legacy incomplete-evidence environment switch is unsupported; use CLUSTERFLUX_RELEASE_STAGE=candidate or final"
);
}
const releaseStage =
process.env.CLUSTERFLUX_RELEASE_STAGE ||
(candidateManifestPath ? "final" : "candidate");
if (!new Set(["candidate", "final"]).has(releaseStage)) {
throw new Error("CLUSTERFLUX_RELEASE_STAGE must be candidate or final");
}
const allowIncomplete = releaseStage === "candidate";
let finalEvidence = { let finalEvidence = {
complete: false, complete: false,
result: null, result: null,
@ -591,10 +611,19 @@ function stageEvidenceAsset(
} }
if ( if (
!Array.isArray(liveResult.named_scenarios) || !Array.isArray(liveResult.named_scenarios) ||
liveResult.named_scenarios.length !== 16 || liveResult.named_scenarios.length !== 25 ||
liveResult.named_scenarios.some((scenario) => scenario.status !== "passed") liveResult.named_scenarios.some((scenario) => scenario.status !== "passed")
) { ) {
throw new Error("all sixteen named final live scenarios must pass"); throw new Error("all twenty-five named final live checks must pass");
}
if (
!liveResult.release_binding?.deployment ||
!liveResult.release_binding?.configuration ||
!liveResult.release_binding?.proxy_configuration
) {
throw new Error(
"final live evidence must bind deployment, runtime configuration, and proxy configuration identities"
);
} }
if ( if (
!Array.isArray(liveResult.strict_requirement_ledger) || !Array.isArray(liveResult.strict_requirement_ledger) ||
@ -675,6 +704,8 @@ function stageEvidenceAsset(
public_tree_identity: publicTreeIdentity, public_tree_identity: publicTreeIdentity,
binary_digests: binaryDigests, binary_digests: binaryDigests,
release_candidate: candidateBinding, release_candidate: candidateBinding,
candidate_configuration_identity: candidateConfigurationIdentity,
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
configuration_generation: configuration_generation:
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null, process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
final_evidence: finalEvidence, final_evidence: finalEvidence,
@ -974,6 +1005,31 @@ function main() {
const candidate = candidateManifestPath const candidate = candidateManifestPath
? JSON.parse(fs.readFileSync(candidateManifestPath, "utf8")) ? JSON.parse(fs.readFileSync(candidateManifestPath, "utf8"))
: null; : null;
if (candidate) {
for (const asset of candidate.assets ?? []) {
asset.file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(candidateManifestPath), asset.file);
}
}
const releaseStage =
process.env.CLUSTERFLUX_RELEASE_STAGE ||
(candidateManifestPath ? "final" : "candidate");
if (releaseStage === "final" && !candidate) {
throw new Error("final release stage requires CLUSTERFLUX_RELEASE_CANDIDATE_MANIFEST");
}
if (releaseStage === "candidate" && candidate) {
throw new Error("candidate release stage cannot consume a prior candidate manifest");
}
if (
releaseStage === "candidate" &&
(!process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
!process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY)
) {
throw new Error(
"candidate release stage requires configuration and proxy configuration identities"
);
}
if (candidate) { if (candidate) {
assertCandidateManifest(candidate); assertCandidateManifest(candidate);
if (path.dirname(candidateManifestPath) === outputRoot) { if (path.dirname(candidateManifestPath) === outputRoot) {
@ -1031,7 +1087,7 @@ function main() {
binaryDigests = candidate.binary_digests; binaryDigests = candidate.binary_digests;
candidateBinding = { candidateBinding = {
mode: "finalized-exact", mode: "finalized-exact",
manifest: candidateManifestPath, manifest: path.relative(outputRoot, candidateManifestPath).split(path.sep).join("/"),
manifest_sha256: sha256File(candidateManifestPath), manifest_sha256: sha256File(candidateManifestPath),
binary_archive_sha256: sha256File(binaryArchive), binary_archive_sha256: sha256File(binaryArchive),
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive), hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
@ -1048,13 +1104,23 @@ function main() {
hosted_binary_archive_sha256: sha256File(hostedBinaryArchive), hosted_binary_archive_sha256: sha256File(hostedBinaryArchive),
}; };
} }
const candidateConfigurationIdentity =
candidate?.candidate_configuration_identity ||
process.env.CLUSTERFLUX_CANDIDATE_CONFIGURATION_IDENTITY ||
null;
const candidateProxyConfigurationIdentity =
candidate?.candidate_proxy_configuration_identity ||
process.env.CLUSTERFLUX_CANDIDATE_PROXY_CONFIGURATION_IDENTITY ||
null;
const evidence = stageEvidenceAsset( const evidence = stageEvidenceAsset(
releaseName, releaseName,
sourceCommit, sourceCommit,
sourceDigest, sourceDigest,
publicTreeIdentity, publicTreeIdentity,
binaryDigests, binaryDigests,
candidateBinding candidateBinding,
candidateConfigurationIdentity,
candidateProxyConfigurationIdentity
); );
const evidenceArchive = evidence.archive; const evidenceArchive = evidence.archive;
const extensionArchive = stageExtensionAsset(); const extensionArchive = stageExtensionAsset();
@ -1102,6 +1168,8 @@ function main() {
platform: platformName(), platform: platformName(),
binary_digests: binaryDigests, binary_digests: binaryDigests,
release_candidate: candidateBinding, release_candidate: candidateBinding,
candidate_configuration_identity: candidateConfigurationIdentity,
candidate_proxy_configuration_identity: candidateProxyConfigurationIdentity,
configuration_generation: configuration_generation:
process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null, process.env.CLUSTERFLUX_DEPLOYMENT_SYSTEM_GENERATION || null,
final_evidence: evidence.finalEvidence, final_evidence: evidence.finalEvidence,
@ -1126,7 +1194,7 @@ function main() {
]), ]),
], ],
assets: [...assets, sha256Sums].map((asset) => ({ assets: [...assets, sha256Sums].map((asset) => ({
file: asset, file: path.relative(outputRoot, asset).split(path.sep).join("/"),
name: path.basename(asset), name: path.basename(asset),
sha256: sha256File(asset), sha256: sha256File(asset),
})), })),

View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo"
scripts/check-old-name.sh
node scripts/check-docs.js
scripts/check-code-size.sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test --locked --manifest-path private/hosted-policy/Cargo.toml
node scripts/vscode-extension-smoke.js

View file

@ -159,6 +159,11 @@ function staleEvidence(file, currentSourceCommit) {
assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`); assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`);
const manifest = readJson(manifestPath); const manifest = readJson(manifestPath);
for (const asset of manifest.assets ?? []) {
asset.file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(manifestPath), asset.file);
}
const currentSourceCommit = expectedSourceCommit(); const currentSourceCommit = expectedSourceCommit();
const currentTreeStatus = commandOutput("git", ["status", "--short"]) || ""; const currentTreeStatus = commandOutput("git", ["status", "--short"]) || "";
assert.strictEqual( assert.strictEqual(
@ -233,6 +238,20 @@ assert(
manifest.final_evidence && manifest.final_evidence.complete === true, manifest.final_evidence && manifest.final_evidence.complete === true,
"release publication requires complete final result and transcript evidence" "release publication requires complete final result and transcript evidence"
); );
if (manifest.candidate_configuration_identity) {
assert.strictEqual(
finalResult.release_binding.configuration.evidence_identity,
manifest.candidate_configuration_identity,
"live configuration identity differs from the candidate binding"
);
}
if (manifest.candidate_proxy_configuration_identity) {
assert.strictEqual(
finalResult.release_binding.proxy_configuration.evidence_identity,
manifest.candidate_proxy_configuration_identity,
"live proxy configuration identity differs from the candidate binding"
);
}
const evidenceAsset = manifest.assets.find((asset) => const evidenceAsset = manifest.assets.find((asset) =>
asset.name.startsWith("clusterflux-public-evidence-") asset.name.startsWith("clusterflux-public-evidence-")
); );
@ -289,11 +308,17 @@ assert.deepStrictEqual(
); );
assert.strictEqual(finalResult.acceptance_result, "passed"); assert.strictEqual(finalResult.acceptance_result, "passed");
assert.deepStrictEqual(finalResult.scenario_skips, []); assert.deepStrictEqual(finalResult.scenario_skips, []);
assert(
finalResult.release_binding?.deployment &&
finalResult.release_binding?.configuration &&
finalResult.release_binding?.proxy_configuration,
"final evidence must bind deployment, runtime configuration, and proxy configuration identities"
);
assert( assert(
Array.isArray(finalResult.named_scenarios) && Array.isArray(finalResult.named_scenarios) &&
finalResult.named_scenarios.length === 16 && finalResult.named_scenarios.length === 25 &&
finalResult.named_scenarios.every((scenario) => scenario.status === "passed"), finalResult.named_scenarios.every((scenario) => scenario.status === "passed"),
"all sixteen named final scenarios must be present and passed" "all twenty-five named final checks must be present and passed"
); );
assert( assert(
Array.isArray(finalResult.strict_requirement_ledger) && Array.isArray(finalResult.strict_requirement_ledger) &&

View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repo"
scripts/verify-public-split.sh

View file

@ -270,6 +270,11 @@ async function main() {
} }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const asset of manifest.assets ?? []) {
asset.file = path.isAbsolute(asset.file)
? asset.file
: path.resolve(path.dirname(manifestPath), asset.file);
}
resolveRepoIdentity(manifest); resolveRepoIdentity(manifest);
if (manifest.kind !== "clusterflux-public-release") { if (manifest.kind !== "clusterflux-public-release") {
throw new Error(`unexpected public release manifest kind: ${manifest.kind}`); throw new Error(`unexpected public release manifest kind: ${manifest.kind}`);