Source commit: 5ee549dd5fddc98e4ee5054679bb0ac405c82529 Public tree identity: sha256:61b170dbdd26d350b8aff072e1792d38afd76bac7881bd42f271864d9662a6d4
6470 lines
221 KiB
Rust
6470 lines
221 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::TcpStream;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
|
use clusterflux_core::{
|
|
admin_request_proof, agent_ed25519_public_key_from_private_key, coordinator_wire_request,
|
|
derive_ed25519_private_key_from_seed, node_ed25519_public_key_from_private_key,
|
|
sign_agent_workflow_request, sign_node_request, signed_request_payload_digest,
|
|
AgentSignedRequest, AgentWorkflowScope, ArtifactFlush, ArtifactHandle, ArtifactId, Capability,
|
|
DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements,
|
|
LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits,
|
|
SourceProviderKind, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec,
|
|
VfsPath, WasmExportAbi,
|
|
};
|
|
use serde_json::json;
|
|
|
|
use crate::FallibleDurableStore;
|
|
|
|
use super::keys::{process_control_key, task_control_key};
|
|
use super::*;
|
|
|
|
fn test_admin_request(
|
|
token: &str,
|
|
operation: &str,
|
|
tenant: &str,
|
|
actor_user: &str,
|
|
target_tenant: &str,
|
|
nonce: &str,
|
|
) -> (Digest, String, u64) {
|
|
let issued_at_epoch_seconds = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.unwrap_or_default();
|
|
(
|
|
admin_request_proof(
|
|
token,
|
|
operation,
|
|
tenant,
|
|
actor_user,
|
|
target_tenant,
|
|
nonce,
|
|
issued_at_epoch_seconds,
|
|
),
|
|
nonce.to_owned(),
|
|
issued_at_epoch_seconds,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_service_uses_memory_only_when_database_url_is_absent() {
|
|
let service = CoordinatorService::new_with_database_url(1, None).unwrap();
|
|
assert_eq!(service.durable_store_kind(), "in_memory");
|
|
|
|
let error = CoordinatorService::new_with_database_url(1, Some("not-a-postgres-url"))
|
|
.err()
|
|
.expect("an invalid configured DATABASE_URL must fail closed");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("durable coordinator state failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_backed_runtime_service_survives_restart_without_live_process_state() {
|
|
let Ok(database_url) = std::env::var("CLUSTERFLUX_TEST_POSTGRES_SERVICE") else {
|
|
return;
|
|
};
|
|
let mut clean_store = crate::PostgresDurableStore::connect(&database_url).unwrap();
|
|
clean_store
|
|
.save_state(&crate::DurableState::default())
|
|
.unwrap();
|
|
|
|
let session_secret = "postgres-runtime-service-session";
|
|
let mut first = CoordinatorService::new_with_database_url(41, Some(&database_url)).unwrap();
|
|
assert_eq!(first.durable_store_kind(), "postgres");
|
|
first
|
|
.issue_cli_session(
|
|
TenantId::from("tenant-pg"),
|
|
ProjectId::from("project-pg"),
|
|
UserId::from("user-pg"),
|
|
session_secret,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
first
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-pg".to_owned(),
|
|
project: "project-pg".to_owned(),
|
|
node: "node-pg".to_owned(),
|
|
public_key: test_node_public_key("node-pg"),
|
|
})
|
|
.unwrap();
|
|
first
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: session_secret.to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::StartProcess {
|
|
process: "process-ephemeral".to_owned(),
|
|
restart: false,
|
|
},
|
|
})
|
|
.unwrap();
|
|
drop(first);
|
|
|
|
let mut restarted = CoordinatorService::new_with_database_url(42, Some(&database_url)).unwrap();
|
|
assert_eq!(restarted.durable_store_kind(), "postgres");
|
|
let auth = restarted
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: session_secret.to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
auth,
|
|
CoordinatorResponse::AuthStatus {
|
|
authenticated: true,
|
|
..
|
|
}
|
|
));
|
|
let heartbeat = restarted
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node-pg".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat(
|
|
"node-pg",
|
|
"postgres-restart-heartbeat",
|
|
)),
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
heartbeat,
|
|
CoordinatorResponse::NodeHeartbeat { epoch: 42, .. }
|
|
));
|
|
let CoordinatorResponse::ProcessStatuses { processes, .. } = restarted
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: session_secret.to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListProcesses,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process list");
|
|
};
|
|
assert!(processes.is_empty());
|
|
}
|
|
|
|
fn linux_capabilities() -> NodeCapabilities {
|
|
NodeCapabilities {
|
|
os: Os::Linux,
|
|
arch: "x86_64".to_owned(),
|
|
capabilities: BTreeSet::from([
|
|
Capability::Command,
|
|
Capability::Containers,
|
|
Capability::RootlessPodman,
|
|
Capability::VfsArtifacts,
|
|
]),
|
|
environment_backends: BTreeSet::from([EnvironmentBackend::Container]),
|
|
source_providers: BTreeSet::from(["filesystem".to_owned()]),
|
|
}
|
|
}
|
|
|
|
fn windows_capabilities() -> NodeCapabilities {
|
|
NodeCapabilities {
|
|
os: Os::Windows,
|
|
arch: "x86_64".to_owned(),
|
|
capabilities: BTreeSet::from([
|
|
Capability::Command,
|
|
Capability::WindowsCommandDev,
|
|
Capability::VfsArtifacts,
|
|
]),
|
|
environment_backends: BTreeSet::from([EnvironmentBackend::WindowsCommandDev]),
|
|
source_providers: BTreeSet::from(["filesystem".to_owned()]),
|
|
}
|
|
}
|
|
|
|
fn endpoint(name: &str) -> NodeEndpoint {
|
|
NodeEndpoint {
|
|
node: NodeId::from(name),
|
|
advertised_addr: format!("{name}.mesh.invalid:4433"),
|
|
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
|
|
}
|
|
}
|
|
|
|
fn data_plane_scope(project: &str) -> DataPlaneScope {
|
|
DataPlaneScope {
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from(project),
|
|
process: ProcessId::from("process"),
|
|
object: DataPlaneObject::Artifact(ArtifactId::from("artifact")),
|
|
authorization_subject: "node-a-to-node-b".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn assert_agent_workflow_actor(actor: &WorkflowActor, fingerprint: &Digest) {
|
|
assert_eq!(actor.kind, "agent");
|
|
assert_eq!(actor.user, Some(UserId::from("user")));
|
|
assert_eq!(actor.agent, Some(AgentId::from("agent-ci")));
|
|
assert_eq!(actor.credential_kind, CredentialKind::PublicKey);
|
|
assert_eq!(actor.public_key_fingerprint.as_ref(), Some(fingerprint));
|
|
assert!(actor.authenticated_without_browser);
|
|
assert!(actor.scopes.iter().any(|scope| scope == "project:run"));
|
|
}
|
|
|
|
const TEST_AGENT_PRIVATE_KEY: &str = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=";
|
|
static TEST_NODE_REQUEST_NONCE: AtomicU64 = AtomicU64::new(1);
|
|
const TEST_WASM_MODULE: &[u8] = b"clusterflux-test-wasm-module";
|
|
|
|
fn test_wasm_module_base64() -> String {
|
|
BASE64_STANDARD.encode(TEST_WASM_MODULE)
|
|
}
|
|
|
|
fn append_test_custom_section(module: &mut Vec<u8>, name: &str, data: &[u8]) {
|
|
fn leb(mut value: usize, output: &mut Vec<u8>) {
|
|
loop {
|
|
let mut byte = (value & 0x7f) as u8;
|
|
value >>= 7;
|
|
if value != 0 {
|
|
byte |= 0x80;
|
|
}
|
|
output.push(byte);
|
|
if value == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut payload = Vec::new();
|
|
leb(name.len(), &mut payload);
|
|
payload.extend_from_slice(name.as_bytes());
|
|
payload.extend_from_slice(data);
|
|
module.push(0);
|
|
leb(payload.len(), module);
|
|
module.extend_from_slice(&payload);
|
|
}
|
|
|
|
fn test_edited_task_bundle(compatibility: &Digest, edit_marker: &str) -> (String, Digest) {
|
|
let descriptor = serde_json::to_vec(&serde_json::json!({
|
|
"kind": "task",
|
|
"name": "compile",
|
|
"function": "compile",
|
|
"export": "compile_export",
|
|
"stable_id": Digest::sha256("compile-stable"),
|
|
"argument_schema": "u32",
|
|
"result_schema": "u32",
|
|
"required_capabilities": [],
|
|
"restart_compatibility_hash": compatibility,
|
|
"abi_version": clusterflux_core::WASM_TASK_ABI_VERSION,
|
|
"probe_symbol": "clusterflux.probe.compile",
|
|
}))
|
|
.unwrap();
|
|
let mut module = b"\0asm\x01\0\0\0".to_vec();
|
|
append_test_custom_section(&mut module, "clusterflux.tasks", &descriptor);
|
|
append_test_custom_section(&mut module, "clusterflux.edit", edit_marker.as_bytes());
|
|
let digest = Digest::sha256(&module);
|
|
(BASE64_STANDARD.encode(module), digest)
|
|
}
|
|
|
|
fn test_task_spec(
|
|
tenant: &str,
|
|
project: &str,
|
|
process: &str,
|
|
task: &str,
|
|
epoch: u64,
|
|
required_capabilities: impl IntoIterator<Item = Capability>,
|
|
) -> TaskSpec {
|
|
test_task_spec_instance(
|
|
tenant,
|
|
project,
|
|
process,
|
|
task,
|
|
task,
|
|
epoch,
|
|
required_capabilities,
|
|
)
|
|
}
|
|
|
|
fn test_task_spec_instance(
|
|
tenant: &str,
|
|
project: &str,
|
|
process: &str,
|
|
task_definition: &str,
|
|
task_instance: &str,
|
|
epoch: u64,
|
|
required_capabilities: impl IntoIterator<Item = Capability>,
|
|
) -> TaskSpec {
|
|
TaskSpec {
|
|
tenant: TenantId::from(tenant),
|
|
project: ProjectId::from(project),
|
|
process: ProcessId::from(process),
|
|
task_definition: clusterflux_core::TaskDefinitionId::from(task_definition),
|
|
task_instance: clusterflux_core::TaskInstanceId::from(task_instance),
|
|
dispatch: TaskDispatch::CoordinatorNodeWasm {
|
|
export: Some(task_definition.to_owned()),
|
|
abi: WasmExportAbi::TaskV1,
|
|
},
|
|
environment_id: None,
|
|
environment: None,
|
|
environment_digest: None,
|
|
required_capabilities: required_capabilities.into_iter().collect(),
|
|
dependency_cache: None,
|
|
source_snapshot: None,
|
|
required_artifacts: Vec::new(),
|
|
args: Vec::new(),
|
|
vfs_epoch: epoch,
|
|
failure_policy: Default::default(),
|
|
bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)),
|
|
}
|
|
}
|
|
|
|
trait AuthorizedTestTaskLaunch {
|
|
fn handle_authorized_test_task_launch(
|
|
&mut self,
|
|
request: CoordinatorRequest,
|
|
) -> Result<CoordinatorResponse, CoordinatorServiceError>;
|
|
}
|
|
|
|
impl AuthorizedTestTaskLaunch for CoordinatorService {
|
|
fn handle_authorized_test_task_launch(
|
|
&mut self,
|
|
request: CoordinatorRequest,
|
|
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
|
let CoordinatorRequest::LaunchTask {
|
|
task_spec,
|
|
wait_for_node,
|
|
artifact_path,
|
|
wasm_module_base64,
|
|
..
|
|
} = request
|
|
else {
|
|
panic!("authorized test task launch requires LaunchTask");
|
|
};
|
|
let tenant = task_spec.tenant.clone();
|
|
let project = task_spec.project.clone();
|
|
self.handle_launch_task_with_actor(
|
|
tenant,
|
|
project,
|
|
WorkflowActor {
|
|
kind: "task".to_owned(),
|
|
user: None,
|
|
agent: None,
|
|
credential_kind: CredentialKind::TaskCredential,
|
|
public_key_fingerprint: None,
|
|
authenticated_without_browser: true,
|
|
scopes: vec!["process:spawn-child".to_owned()],
|
|
},
|
|
task_spec,
|
|
wait_for_node,
|
|
artifact_path,
|
|
wasm_module_base64,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn register_test_task_assignment(
|
|
service: &mut CoordinatorService,
|
|
tenant: &str,
|
|
project: &str,
|
|
process: &str,
|
|
node: &str,
|
|
task_definition: &str,
|
|
task_instance: &str,
|
|
epoch: u64,
|
|
) {
|
|
let task_spec = test_task_spec_instance(
|
|
tenant,
|
|
project,
|
|
process,
|
|
task_definition,
|
|
task_instance,
|
|
epoch,
|
|
[],
|
|
);
|
|
let assignment = TaskAssignment {
|
|
tenant: TenantId::from(tenant),
|
|
project: ProjectId::from(project),
|
|
process: ProcessId::from(process),
|
|
task: TaskInstanceId::from(task_instance),
|
|
node: NodeId::from(node),
|
|
epoch,
|
|
artifact_path: format!("/vfs/artifacts/{task_instance}.bin"),
|
|
task_spec,
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
};
|
|
service
|
|
.capture_task_restart_checkpoint(&assignment)
|
|
.unwrap();
|
|
service.active_tasks.insert(super::keys::task_control_key(
|
|
&assignment.tenant,
|
|
&assignment.project,
|
|
&assignment.process,
|
|
&assignment.node,
|
|
&assignment.task,
|
|
));
|
|
}
|
|
|
|
fn test_agent_public_key() -> String {
|
|
agent_ed25519_public_key_from_private_key(TEST_AGENT_PRIVATE_KEY).unwrap()
|
|
}
|
|
|
|
fn signed_agent_workflow_request(
|
|
request: &CoordinatorRequest,
|
|
request_kind: &str,
|
|
process: &str,
|
|
task: Option<&str>,
|
|
nonce: &str,
|
|
) -> AgentSignedRequest {
|
|
let task = task.map(TaskInstanceId::from);
|
|
let payload = serde_json::to_value(request).unwrap();
|
|
let payload_digest = signed_request_payload_digest(&payload);
|
|
sign_agent_workflow_request(
|
|
TEST_AGENT_PRIVATE_KEY,
|
|
AgentWorkflowScope {
|
|
tenant: &TenantId::from("tenant"),
|
|
project: &ProjectId::from("project"),
|
|
agent: &AgentId::from("agent-ci"),
|
|
request_kind,
|
|
process: &ProcessId::from(process),
|
|
task: task.as_ref(),
|
|
},
|
|
&payload_digest,
|
|
nonce.to_owned(),
|
|
unix_timestamp_seconds_for_tests(),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn with_signed_agent_workflow(
|
|
mut request: CoordinatorRequest,
|
|
request_kind: &str,
|
|
process: &str,
|
|
task: Option<&str>,
|
|
nonce: &str,
|
|
) -> CoordinatorRequest {
|
|
let signature = signed_agent_workflow_request(&request, request_kind, process, task, nonce);
|
|
match &mut request {
|
|
CoordinatorRequest::StartProcess {
|
|
agent_signature, ..
|
|
}
|
|
| CoordinatorRequest::LaunchTask {
|
|
agent_signature, ..
|
|
} => *agent_signature = Some(signature),
|
|
_ => panic!("agent signing helper only accepts agent workflow requests"),
|
|
}
|
|
request
|
|
}
|
|
|
|
fn test_node_private_key(node: &str) -> String {
|
|
derive_ed25519_private_key_from_seed(&format!("test-node-key:{node}"))
|
|
}
|
|
|
|
fn test_node_public_key(node: &str) -> String {
|
|
node_ed25519_public_key_from_private_key(&test_node_private_key(node)).unwrap()
|
|
}
|
|
|
|
fn signed_node_heartbeat(node: &str, nonce: &str) -> NodeSignedRequest {
|
|
let payload = json!({"type": "node_heartbeat", "node": node});
|
|
signed_node_request_with_private_key(
|
|
node,
|
|
&test_node_private_key(node),
|
|
"node_heartbeat",
|
|
&signed_request_payload_digest(&payload),
|
|
nonce,
|
|
)
|
|
}
|
|
|
|
fn signed_node_heartbeat_with_private_key(
|
|
node: &str,
|
|
private_key: &str,
|
|
nonce: &str,
|
|
) -> NodeSignedRequest {
|
|
let payload = json!({"type": "node_heartbeat", "node": node});
|
|
signed_node_request_with_private_key(
|
|
node,
|
|
private_key,
|
|
"node_heartbeat",
|
|
&signed_request_payload_digest(&payload),
|
|
nonce,
|
|
)
|
|
}
|
|
|
|
fn signed_node_request_with_private_key(
|
|
node: &str,
|
|
private_key: &str,
|
|
request_kind: &str,
|
|
payload_digest: &Digest,
|
|
nonce: &str,
|
|
) -> NodeSignedRequest {
|
|
sign_node_request(
|
|
private_key,
|
|
&NodeId::from(node),
|
|
request_kind,
|
|
payload_digest,
|
|
nonce.to_owned(),
|
|
unix_timestamp_seconds_for_tests(),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn signed_node_request_auto(request: CoordinatorRequest) -> CoordinatorRequest {
|
|
let payload_digest = signed_request_payload_digest(&serde_json::to_value(&request).unwrap());
|
|
let (node, request_kind) = match &request {
|
|
CoordinatorRequest::ReportNodeCapabilities { node, .. } => {
|
|
(node.clone(), "report_node_capabilities")
|
|
}
|
|
CoordinatorRequest::PollTaskAssignment { node, .. } => {
|
|
(node.clone(), "poll_task_assignment")
|
|
}
|
|
CoordinatorRequest::PollArtifactTransfer { node, .. } => {
|
|
(node.clone(), "poll_artifact_transfer")
|
|
}
|
|
CoordinatorRequest::UploadArtifactTransferChunk { node, .. } => {
|
|
(node.clone(), "upload_artifact_transfer_chunk")
|
|
}
|
|
CoordinatorRequest::FailArtifactTransfer { node, .. } => {
|
|
(node.clone(), "fail_artifact_transfer")
|
|
}
|
|
CoordinatorRequest::LaunchChildTask { node, .. } => (node.clone(), "launch_child_task"),
|
|
CoordinatorRequest::JoinChildTask { node, .. } => (node.clone(), "join_child_task"),
|
|
CoordinatorRequest::CompleteSourcePreparation { node, .. } => {
|
|
(node.clone(), "complete_source_preparation")
|
|
}
|
|
CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"),
|
|
CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"),
|
|
CoordinatorRequest::PollDebugCommand { node, .. } => (node.clone(), "poll_debug_command"),
|
|
CoordinatorRequest::ReportDebugState { node, .. } => (node.clone(), "report_debug_state"),
|
|
CoordinatorRequest::ReportDebugProbeHit { node, .. } => {
|
|
(node.clone(), "report_debug_probe_hit")
|
|
}
|
|
CoordinatorRequest::ReportTaskLog { node, .. } => (node.clone(), "report_task_log"),
|
|
CoordinatorRequest::ReportVfsMetadata { node, .. } => (node.clone(), "report_vfs_metadata"),
|
|
CoordinatorRequest::TaskCompleted { node, .. } => (node.clone(), "task_completed"),
|
|
_ => panic!("test helper only signs node-originated requests"),
|
|
};
|
|
let nonce = TEST_NODE_REQUEST_NONCE
|
|
.fetch_add(1, Ordering::Relaxed)
|
|
.to_string();
|
|
CoordinatorRequest::SignedNode {
|
|
node: node.clone(),
|
|
node_signature: signed_node_request_with_private_key(
|
|
&node,
|
|
&test_node_private_key(&node),
|
|
request_kind,
|
|
&payload_digest,
|
|
&format!("node-request-{nonce}"),
|
|
),
|
|
request: Box::new(request),
|
|
}
|
|
}
|
|
|
|
trait SignedNodeRequestTestExt {
|
|
fn handle_signed_node_request_auto(
|
|
&mut self,
|
|
request: CoordinatorRequest,
|
|
) -> Result<CoordinatorResponse, CoordinatorServiceError>;
|
|
}
|
|
|
|
impl SignedNodeRequestTestExt for CoordinatorService {
|
|
fn handle_signed_node_request_auto(
|
|
&mut self,
|
|
request: CoordinatorRequest,
|
|
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
|
self.handle_request(signed_node_request_auto(request))
|
|
}
|
|
}
|
|
|
|
fn upload_pending_artifact_transfer(
|
|
service: &mut CoordinatorService,
|
|
node: &str,
|
|
bytes: &[u8],
|
|
) -> ArtifactTransferAssignment {
|
|
let mut first = None;
|
|
loop {
|
|
let CoordinatorResponse::ArtifactTransferAssignment {
|
|
transfer: Some(transfer),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollArtifactTransfer {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: node.to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected pending artifact reverse transfer");
|
|
};
|
|
assert_eq!(transfer.expected_digest, Digest::sha256(bytes));
|
|
assert_eq!(transfer.expected_size_bytes, bytes.len() as u64);
|
|
let start = transfer.offset as usize;
|
|
let end = start
|
|
.saturating_add(transfer.max_chunk_bytes as usize)
|
|
.min(bytes.len());
|
|
let content = &bytes[start..end];
|
|
let response = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::UploadArtifactTransferChunk {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: node.to_owned(),
|
|
transfer_id: transfer.transfer_id.clone(),
|
|
artifact: transfer.artifact.as_str().to_owned(),
|
|
offset: transfer.offset,
|
|
content_base64: BASE64_STANDARD.encode(content),
|
|
chunk_digest: Digest::sha256(content),
|
|
eof: end == bytes.len(),
|
|
})
|
|
.unwrap();
|
|
let complete = matches!(
|
|
response,
|
|
CoordinatorResponse::ArtifactTransferChunkAccepted { complete: true, .. }
|
|
);
|
|
first.get_or_insert_with(|| transfer.clone());
|
|
if complete {
|
|
return first.unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unix_timestamp_seconds_for_tests() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn write_coordinator_wire_request(
|
|
stream: &mut TcpStream,
|
|
request: &CoordinatorRequest,
|
|
request_id: &str,
|
|
) {
|
|
let payload = serde_json::to_value(request).unwrap();
|
|
let wire_request = coordinator_wire_request(request_id, payload);
|
|
serde_json::to_writer(&mut *stream, &wire_request).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
stream.flush().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn service_creates_selects_and_lists_signed_in_user_projects() {
|
|
let mut service = CoordinatorService::new(7);
|
|
|
|
let CoordinatorResponse::ProjectCreated { project, actor } = service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant-a".to_owned(),
|
|
actor_user: "user-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
name: "Demo".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected project creation");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(project.tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(project.id, ProjectId::from("project-a"));
|
|
assert_eq!(project.name, "Demo");
|
|
|
|
let CoordinatorResponse::ProjectSelected { project, actor } = service
|
|
.handle_request(CoordinatorRequest::SelectProject {
|
|
tenant: "tenant-a".to_owned(),
|
|
actor_user: "user-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected project selection");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(project.id, ProjectId::from("project-a"));
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant-b".to_owned(),
|
|
actor_user: "user-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
name: "Other".to_owned(),
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::Projects { projects, actor } = service
|
|
.handle_request(CoordinatorRequest::ListProjects {
|
|
tenant: "tenant-a".to_owned(),
|
|
actor_user: "user-a".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected project list");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(projects.len(), 1);
|
|
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
|
|
|
let cross_tenant = service
|
|
.handle_request(CoordinatorRequest::SelectProject {
|
|
tenant: "tenant-a".to_owned(),
|
|
actor_user: "user-a".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant.to_string().contains("tenant scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn authenticated_envelope_derives_user_scope_from_cli_session() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.issue_cli_session(
|
|
TenantId::from("tenant-a"),
|
|
ProjectId::from("project-a"),
|
|
UserId::from("user-a"),
|
|
"cli-session-secret",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::AuthStatus {
|
|
tenant,
|
|
project,
|
|
actor,
|
|
authenticated,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated auth status");
|
|
};
|
|
assert_eq!(tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(project, ProjectId::from("project-a"));
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert!(authenticated);
|
|
|
|
let CoordinatorResponse::ProjectCreated { project, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::CreateProject {
|
|
project: "project-b".to_owned(),
|
|
name: "From Session".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated project creation");
|
|
};
|
|
assert_eq!(project.tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(project.id, ProjectId::from("project-b"));
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
|
|
let CoordinatorResponse::Projects { projects, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListProjects,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated project list");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(projects.len(), 2);
|
|
assert!(projects
|
|
.iter()
|
|
.any(|project| project.id == ProjectId::from("project-a")));
|
|
assert!(projects
|
|
.iter()
|
|
.any(|project| project.id == ProjectId::from("project-b")));
|
|
|
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::RegisterAgentPublicKey {
|
|
agent: "agent-session".to_owned(),
|
|
public_key: "agent-session-key-v1".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated agent key registration");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(record.tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(record.project, ProjectId::from("project-a"));
|
|
assert_eq!(record.user, UserId::from("user-a"));
|
|
assert_eq!(record.agent, AgentId::from("agent-session"));
|
|
|
|
let CoordinatorResponse::AgentPublicKeys { records, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListAgentPublicKeys,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated agent key list");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].project, ProjectId::from("project-a"));
|
|
|
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::RevokeAgentPublicKey {
|
|
agent: "agent-session".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated agent key revocation");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert!(record.revoked);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
node: "session-node".to_owned(),
|
|
public_key: test_node_public_key("session-node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
node: "session-node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: vec![],
|
|
dependency_cache_digests: vec![],
|
|
source_snapshots: vec![],
|
|
artifact_locations: vec![],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::NodeDescriptors { descriptors, actor } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListNodeDescriptors,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated node descriptor list");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert_eq!(descriptors.len(), 1);
|
|
assert_eq!(descriptors[0].id, NodeId::from("session-node"));
|
|
|
|
let CoordinatorResponse::TaskPlacement { placement } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ScheduleTask {
|
|
environment: None,
|
|
environment_digest: None,
|
|
required_capabilities: vec![Capability::Command],
|
|
dependency_cache: None,
|
|
source_snapshot: None,
|
|
required_artifacts: vec![],
|
|
prefer_node: Some("session-node".to_owned()),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated task placement");
|
|
};
|
|
assert_eq!(placement.node, NodeId::from("session-node"));
|
|
|
|
let CoordinatorResponse::ProcessStarted { process, actor, .. } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::StartProcess {
|
|
process: "vp-session".to_owned(),
|
|
restart: false,
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated process start");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-session"));
|
|
assert_eq!(actor.user, Some(UserId::from("user-a")));
|
|
|
|
let denied_external_task = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::LaunchTask {
|
|
task_spec: Box::new(test_task_spec(
|
|
"tenant-a",
|
|
"project-a",
|
|
"vp-session",
|
|
"task-session",
|
|
7,
|
|
[Capability::Command],
|
|
)),
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/session.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
},
|
|
})
|
|
.unwrap_err();
|
|
assert!(denied_external_task
|
|
.to_string()
|
|
.contains("external callers may launch only EntrypointV1"));
|
|
|
|
let CoordinatorResponse::TaskLaunched {
|
|
process,
|
|
task,
|
|
actor,
|
|
assignment,
|
|
..
|
|
} = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant-a",
|
|
"project-a",
|
|
"vp-session",
|
|
"task-session",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/session.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated task launch");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-session"));
|
|
assert_eq!(task, TaskInstanceId::from("task-session"));
|
|
assert_eq!(actor.kind, "task");
|
|
assert_eq!(assignment.tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(assignment.project, ProjectId::from("project-a"));
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "session-node".to_owned(),
|
|
process: "vp-session".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
let artifact_bytes = "session artifact";
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
process: "vp-session".to_owned(),
|
|
node: "session-node".to_owned(),
|
|
task: "task-session".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: artifact_bytes.len() as u64,
|
|
stderr_bytes: 0,
|
|
stdout_tail: artifact_bytes.to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/session.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256(artifact_bytes)),
|
|
artifact_size_bytes: Some(artifact_bytes.len() as u64),
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListTaskEvents {
|
|
process: Some("vp-session".to_owned()),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated task event list");
|
|
};
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(events[0].project, ProjectId::from("project-a"));
|
|
|
|
service
|
|
.issue_cli_session(
|
|
TenantId::from("tenant-b"),
|
|
ProjectId::from("project-victim"),
|
|
UserId::from("user-b"),
|
|
"victim-cli-session-secret",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "victim-cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::StartProcess {
|
|
process: "vp-victim".to_owned(),
|
|
restart: false,
|
|
},
|
|
})
|
|
.unwrap();
|
|
let cross_tenant_events = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::ListTaskEvents {
|
|
process: Some("vp-victim".to_owned()),
|
|
},
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant_events
|
|
.to_string()
|
|
.contains("outside the virtual process tenant/project scope"));
|
|
|
|
let CoordinatorResponse::ArtifactDownloadLink { link } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink {
|
|
artifact: "session.txt".to_owned(),
|
|
max_bytes: 1024,
|
|
ttl_seconds: 60,
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated artifact link");
|
|
};
|
|
assert_eq!(link.tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(link.project, ProjectId::from("project-a"));
|
|
assert_eq!(link.actor, Actor::User(UserId::from("user-a")));
|
|
|
|
let CoordinatorResponse::ProcessCancellationRequested { process, .. } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::CancelProcess {
|
|
process: "vp-session".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated process cancellation");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-session"));
|
|
|
|
let CoordinatorResponse::DebugAttach { actor, .. } = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::DebugAttach {
|
|
process: "vp-session".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated debug attach");
|
|
};
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
|
|
let CoordinatorResponse::NodeCredentialRevoked {
|
|
node,
|
|
tenant,
|
|
project,
|
|
actor,
|
|
descriptor_removed,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::RevokeNodeCredential {
|
|
node: "session-node".to_owned(),
|
|
},
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected authenticated node credential revocation");
|
|
};
|
|
assert_eq!(node, NodeId::from("session-node"));
|
|
assert_eq!(tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(project, ProjectId::from("project-a"));
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
assert!(descriptor_removed);
|
|
|
|
let rejected = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "wrong-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap_err();
|
|
assert!(rejected.to_string().contains("not recognized"));
|
|
|
|
let expired = service
|
|
.issue_cli_session(
|
|
TenantId::from("tenant-a"),
|
|
ProjectId::from("project-b"),
|
|
UserId::from("user-a"),
|
|
"expired-cli-session-secret",
|
|
Some(1),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(expired.expires_at_epoch_seconds, Some(1));
|
|
let expired_rejected = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "expired-cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap_err();
|
|
assert!(expired_rejected.to_string().contains("expired"));
|
|
|
|
let CoordinatorResponse::CliSessionRevoked {
|
|
tenant,
|
|
project,
|
|
actor,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::RevokeCliSession,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected CLI session revocation");
|
|
};
|
|
assert_eq!(tenant, TenantId::from("tenant-a"));
|
|
assert_eq!(project, ProjectId::from("project-a"));
|
|
assert_eq!(actor, UserId::from("user-a"));
|
|
|
|
let revoked_rejected = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "cli-session-secret".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap_err();
|
|
assert!(revoked_rejected.to_string().contains("revoked"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_reports_and_enforces_public_admin_tenant_suspension() {
|
|
let mut service = CoordinatorService::new_with_admin_token(7, "admin-token");
|
|
|
|
let CoordinatorResponse::AuthStatus {
|
|
tenant,
|
|
project,
|
|
actor,
|
|
authenticated,
|
|
account_status,
|
|
suspended,
|
|
disabled,
|
|
sanitized_reason,
|
|
private_moderation_details_exposed,
|
|
signup_failure_details_exposed,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::AuthStatus {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected auth status");
|
|
};
|
|
assert_eq!(tenant, TenantId::from("tenant"));
|
|
assert_eq!(project, ProjectId::from("project"));
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert!(authenticated);
|
|
assert_eq!(account_status, "active");
|
|
assert!(!suspended);
|
|
assert!(!disabled);
|
|
assert!(sanitized_reason.is_none());
|
|
assert!(!private_moderation_details_exposed);
|
|
assert!(!signup_failure_details_exposed);
|
|
|
|
for (tenant, policy_name, expected_status, expected_reason) in [
|
|
(
|
|
"manual-tenant",
|
|
"tenant:manual_review",
|
|
"manual_review",
|
|
"account or tenant is pending hosted review",
|
|
),
|
|
(
|
|
"disabled-tenant",
|
|
"tenant:disabled",
|
|
"disabled",
|
|
"account or tenant is disabled by hosted policy",
|
|
),
|
|
(
|
|
"deleted-tenant",
|
|
"tenant:deleted",
|
|
"deleted",
|
|
"account or tenant is no longer active",
|
|
),
|
|
] {
|
|
service.coordinator.upsert_service_policy_record(
|
|
TenantId::from(tenant),
|
|
policy_name,
|
|
Digest::from_parts([tenant.as_bytes(), policy_name.as_bytes()]),
|
|
);
|
|
let CoordinatorResponse::AuthStatus {
|
|
account_status,
|
|
suspended,
|
|
disabled,
|
|
deleted,
|
|
manual_review,
|
|
sanitized_reason,
|
|
next_actions,
|
|
private_moderation_details_exposed,
|
|
signup_failure_details_exposed,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::AuthStatus {
|
|
tenant: tenant.to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected inactive auth status");
|
|
};
|
|
assert_eq!(account_status, expected_status);
|
|
assert_eq!(suspended, expected_status == "suspended");
|
|
assert_eq!(disabled, expected_status == "disabled");
|
|
assert_eq!(deleted, expected_status == "deleted");
|
|
assert_eq!(manual_review, expected_status == "manual_review");
|
|
assert_eq!(sanitized_reason.as_deref(), Some(expected_reason));
|
|
assert!(next_actions
|
|
.iter()
|
|
.any(|action| action.contains("hosted operator")));
|
|
assert!(!private_moderation_details_exposed);
|
|
assert!(!signup_failure_details_exposed);
|
|
}
|
|
|
|
let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request(
|
|
"admin-token",
|
|
"admin_status",
|
|
"tenant",
|
|
"admin",
|
|
"tenant",
|
|
"admin-status-1",
|
|
);
|
|
let admin_status_request = CoordinatorRequest::AdminStatus {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "admin".to_owned(),
|
|
admin_proof,
|
|
admin_nonce,
|
|
issued_at_epoch_seconds,
|
|
};
|
|
let CoordinatorResponse::AdminStatus {
|
|
tenant,
|
|
actor,
|
|
suspended,
|
|
safe_default,
|
|
} = service
|
|
.handle_request(admin_status_request.clone())
|
|
.unwrap()
|
|
else {
|
|
panic!("expected admin status");
|
|
};
|
|
assert_eq!(tenant, TenantId::from("tenant"));
|
|
assert_eq!(actor, UserId::from("admin"));
|
|
assert!(!suspended);
|
|
assert_eq!(safe_default, "read_only");
|
|
let replayed_admin_status = service.handle_request(admin_status_request).unwrap_err();
|
|
assert!(replayed_admin_status
|
|
.to_string()
|
|
.contains("nonce was already used"));
|
|
|
|
let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request(
|
|
"admin-token",
|
|
"suspend_tenant",
|
|
"admin-tenant",
|
|
"admin",
|
|
"tenant",
|
|
"admin-suspend-1",
|
|
);
|
|
let CoordinatorResponse::TenantSuspended {
|
|
tenant,
|
|
actor,
|
|
policy,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::SuspendTenant {
|
|
tenant: "admin-tenant".to_owned(),
|
|
actor_user: "admin".to_owned(),
|
|
target_tenant: "tenant".to_owned(),
|
|
admin_proof,
|
|
admin_nonce,
|
|
issued_at_epoch_seconds,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected tenant suspension");
|
|
};
|
|
assert_eq!(tenant, TenantId::from("tenant"));
|
|
assert_eq!(actor, UserId::from("admin"));
|
|
assert_eq!(policy.name, "tenant:suspended");
|
|
|
|
let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request(
|
|
"admin-token",
|
|
"admin_status",
|
|
"tenant",
|
|
"admin",
|
|
"tenant",
|
|
"admin-status-2",
|
|
);
|
|
let CoordinatorResponse::AdminStatus { suspended, .. } = service
|
|
.handle_request(CoordinatorRequest::AdminStatus {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "admin".to_owned(),
|
|
admin_proof,
|
|
admin_nonce,
|
|
issued_at_epoch_seconds,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected suspended admin status");
|
|
};
|
|
assert!(suspended);
|
|
|
|
let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request(
|
|
"admin-token",
|
|
"admin_status",
|
|
"tenant",
|
|
"admin",
|
|
"tenant",
|
|
"admin-status-unconfigured",
|
|
);
|
|
let missing_admin_credential = CoordinatorService::new(7)
|
|
.handle_request(CoordinatorRequest::AdminStatus {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "admin".to_owned(),
|
|
admin_proof,
|
|
admin_nonce,
|
|
issued_at_epoch_seconds,
|
|
})
|
|
.unwrap_err();
|
|
assert!(missing_admin_credential
|
|
.to_string()
|
|
.contains("admin credential is not configured"));
|
|
|
|
let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request(
|
|
"wrong-token",
|
|
"suspend_tenant",
|
|
"admin-tenant",
|
|
"admin",
|
|
"other-tenant",
|
|
"admin-suspend-wrong",
|
|
);
|
|
let invalid_admin_credential = service
|
|
.handle_request(CoordinatorRequest::SuspendTenant {
|
|
tenant: "admin-tenant".to_owned(),
|
|
actor_user: "admin".to_owned(),
|
|
target_tenant: "other-tenant".to_owned(),
|
|
admin_proof,
|
|
admin_nonce,
|
|
issued_at_epoch_seconds,
|
|
})
|
|
.unwrap_err();
|
|
assert!(invalid_admin_credential
|
|
.to_string()
|
|
.contains("admin request proof is invalid"));
|
|
|
|
let CoordinatorResponse::AuthStatus {
|
|
account_status,
|
|
suspended,
|
|
disabled,
|
|
sanitized_reason,
|
|
next_actions,
|
|
private_moderation_details_exposed,
|
|
signup_failure_details_exposed,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::AuthStatus {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected suspended auth status");
|
|
};
|
|
assert_eq!(account_status, "suspended");
|
|
assert!(suspended);
|
|
assert!(!disabled);
|
|
assert_eq!(
|
|
sanitized_reason.as_deref(),
|
|
Some("account or tenant is suspended by hosted policy")
|
|
);
|
|
assert!(next_actions
|
|
.iter()
|
|
.any(|action| action.contains("hosted operator")));
|
|
assert!(!private_moderation_details_exposed);
|
|
assert!(!signup_failure_details_exposed);
|
|
|
|
let create = service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
project: "project".to_owned(),
|
|
name: "Demo".to_owned(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(create.to_string().contains("tenant is suspended"));
|
|
|
|
let start = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap_err();
|
|
assert!(start.to_string().contains("tenant is suspended"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_manages_project_scoped_agent_public_keys() {
|
|
let mut service = CoordinatorService::new(7);
|
|
|
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
public_key: "agent-key-v1".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected agent public key registration");
|
|
};
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert_eq!(record.tenant, TenantId::from("tenant"));
|
|
assert_eq!(record.project, ProjectId::from("project"));
|
|
assert_eq!(record.user, UserId::from("user"));
|
|
assert_eq!(record.agent, AgentId::from("agent-ci"));
|
|
assert_eq!(record.version, 1);
|
|
assert!(!record.revoked);
|
|
assert_eq!(record.scopes, vec!["project:read", "project:run"]);
|
|
assert!(!record.human_account_creation_privilege);
|
|
assert!(!record.browser_interaction_required_each_run);
|
|
|
|
let CoordinatorResponse::AgentPublicKey { record, .. } = service
|
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
public_key: "agent-key-v2".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected agent public key rotation by re-add");
|
|
};
|
|
assert_eq!(record.version, 2);
|
|
assert_eq!(record.public_key, "agent-key-v2");
|
|
|
|
let CoordinatorResponse::AgentPublicKeys { records, actor } = service
|
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected agent public key list");
|
|
};
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert_eq!(records.len(), 1);
|
|
assert_eq!(records[0].agent, AgentId::from("agent-ci"));
|
|
|
|
let CoordinatorResponse::AgentPublicKeys { records, .. } = service
|
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "other-user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected foreign user agent public key list");
|
|
};
|
|
assert!(records.is_empty());
|
|
|
|
let CoordinatorResponse::AgentPublicKeys { records, .. } = service
|
|
.handle_request(CoordinatorRequest::ListAgentPublicKeys {
|
|
tenant: "other-tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected foreign tenant agent public key list");
|
|
};
|
|
assert!(records.is_empty());
|
|
|
|
let foreign_user_revoke = service
|
|
.handle_request(CoordinatorRequest::RevokeAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "other-user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(foreign_user_revoke
|
|
.to_string()
|
|
.contains("signed-in user scope"));
|
|
|
|
let CoordinatorResponse::AgentPublicKey { record, actor } = service
|
|
.handle_request(CoordinatorRequest::RevokeAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected agent public key revocation");
|
|
};
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert!(record.revoked);
|
|
}
|
|
|
|
#[test]
|
|
fn service_runs_agent_workflows_with_scoped_key_attribution() {
|
|
let mut service = CoordinatorService::new(7);
|
|
let agent_public_key = test_agent_public_key();
|
|
let agent_fingerprint = Digest::sha256(&agent_public_key);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
public_key: agent_public_key,
|
|
})
|
|
.unwrap();
|
|
|
|
let fingerprint_only = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(agent_fingerprint.clone()),
|
|
agent_signature: None,
|
|
process: "vp-agent-fingerprint-only".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap_err();
|
|
assert!(fingerprint_only.to_string().contains("signed request"));
|
|
|
|
let wrong_fingerprint = service
|
|
.handle_request(with_signed_agent_workflow(
|
|
CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(Digest::sha256("other-key")),
|
|
agent_signature: None,
|
|
process: "vp-agent-bad-key".to_owned(),
|
|
restart: false,
|
|
},
|
|
"start_process",
|
|
"vp-agent-bad-key",
|
|
None,
|
|
"bad-fingerprint-nonce",
|
|
))
|
|
.unwrap_err();
|
|
assert!(wrong_fingerprint.to_string().contains("fingerprint"));
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
public_key: test_node_public_key("worker-linux"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::ProcessStarted {
|
|
actor: start_actor, ..
|
|
} = service
|
|
.handle_request(with_signed_agent_workflow(
|
|
CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(agent_fingerprint.clone()),
|
|
agent_signature: None,
|
|
process: "vp-agent".to_owned(),
|
|
restart: false,
|
|
},
|
|
"start_process",
|
|
"vp-agent",
|
|
None,
|
|
"start-nonce",
|
|
))
|
|
.unwrap()
|
|
else {
|
|
panic!("expected agent-authenticated process start");
|
|
};
|
|
assert_agent_workflow_actor(&start_actor, &agent_fingerprint);
|
|
|
|
let replay = service
|
|
.handle_request(with_signed_agent_workflow(
|
|
CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(agent_fingerprint.clone()),
|
|
agent_signature: None,
|
|
process: "vp-agent".to_owned(),
|
|
restart: true,
|
|
},
|
|
"start_process",
|
|
"vp-agent",
|
|
None,
|
|
"start-nonce",
|
|
))
|
|
.unwrap_err();
|
|
assert!(replay.to_string().contains("nonce"));
|
|
|
|
let denied_external_task = service
|
|
.handle_request(with_signed_agent_workflow(
|
|
CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-agent",
|
|
"compile-linux",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(agent_fingerprint.clone()),
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
},
|
|
"launch_task",
|
|
"vp-agent",
|
|
Some("compile-linux"),
|
|
"launch-nonce",
|
|
))
|
|
.unwrap_err();
|
|
assert!(denied_external_task
|
|
.to_string()
|
|
.contains("external callers may launch only EntrypointV1"));
|
|
}
|
|
|
|
#[test]
|
|
fn signed_node_and_agent_requests_reject_body_modification() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-bound-body".to_owned(),
|
|
public_key: test_node_public_key("worker-bound-body"),
|
|
})
|
|
.unwrap();
|
|
|
|
let original_node_request = CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-bound-body".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
};
|
|
let original_node_digest =
|
|
signed_request_payload_digest(&serde_json::to_value(&original_node_request).unwrap());
|
|
let node_signature = signed_node_request_with_private_key(
|
|
"worker-bound-body",
|
|
&test_node_private_key("worker-bound-body"),
|
|
"report_node_capabilities",
|
|
&original_node_digest,
|
|
"node-body-modification",
|
|
);
|
|
let mut modified_node_request = original_node_request;
|
|
let CoordinatorRequest::ReportNodeCapabilities { online, .. } = &mut modified_node_request
|
|
else {
|
|
unreachable!();
|
|
};
|
|
*online = false;
|
|
let modified_node = service
|
|
.handle_request(CoordinatorRequest::SignedNode {
|
|
node: "worker-bound-body".to_owned(),
|
|
node_signature,
|
|
request: Box::new(modified_node_request),
|
|
})
|
|
.unwrap_err();
|
|
assert!(modified_node.to_string().contains("signature"));
|
|
|
|
let agent_public_key = test_agent_public_key();
|
|
let agent_fingerprint = Digest::sha256(&agent_public_key);
|
|
service
|
|
.handle_request(CoordinatorRequest::RegisterAgentPublicKey {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
user: "user".to_owned(),
|
|
agent: "agent-ci".to_owned(),
|
|
public_key: agent_public_key,
|
|
})
|
|
.unwrap();
|
|
let original_agent_request = CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: Some("agent-ci".to_owned()),
|
|
agent_public_key_fingerprint: Some(agent_fingerprint),
|
|
agent_signature: None,
|
|
process: "vp-agent-bound-body".to_owned(),
|
|
restart: false,
|
|
};
|
|
let agent_signature = signed_agent_workflow_request(
|
|
&original_agent_request,
|
|
"start_process",
|
|
"vp-agent-bound-body",
|
|
None,
|
|
"agent-body-modification",
|
|
);
|
|
let mut modified_agent_request = original_agent_request;
|
|
let CoordinatorRequest::StartProcess {
|
|
restart,
|
|
agent_signature: request_signature,
|
|
..
|
|
} = &mut modified_agent_request
|
|
else {
|
|
unreachable!();
|
|
};
|
|
*restart = true;
|
|
*request_signature = Some(agent_signature);
|
|
let modified_agent = service.handle_request(modified_agent_request).unwrap_err();
|
|
assert!(modified_agent.to_string().contains("signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_checks_spawn_quota_before_process_or_task_work_starts() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service.quota.set_workflow_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::Spawn, 2)]),
|
|
});
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
public_key: test_node_public_key("worker-linux"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::ProcessStarted { charged_spawns, .. } = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-quota".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process start within spawn quota");
|
|
};
|
|
assert_eq!(charged_spawns, 1);
|
|
|
|
let CoordinatorResponse::TaskLaunched { charged_spawns, .. } = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-quota",
|
|
"compile-linux",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task launch within spawn quota");
|
|
};
|
|
assert_eq!(charged_spawns, 2);
|
|
|
|
let denied_task = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-quota",
|
|
"compile-linux-denied",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/denied.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(denied_task.to_string().contains("Spawn"));
|
|
let now_epoch_seconds = service.current_epoch_seconds().unwrap();
|
|
assert_eq!(
|
|
service.quota.used_workflow_spawns(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
now_epoch_seconds,
|
|
),
|
|
2
|
|
);
|
|
|
|
let denied_task_key = task_control_key(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
&ProcessId::from("vp-quota"),
|
|
&NodeId::from("worker-linux"),
|
|
&TaskInstanceId::from("compile-linux-denied"),
|
|
);
|
|
assert!(!service.active_tasks.contains(&denied_task_key));
|
|
assert!(!service.task_placements.contains_key(&denied_task_key));
|
|
assert_eq!(
|
|
service
|
|
.task_assignments
|
|
.get(&(
|
|
TenantId::from("tenant"),
|
|
ProjectId::from("project"),
|
|
NodeId::from("worker-linux"),
|
|
))
|
|
.map(VecDeque::len),
|
|
Some(1)
|
|
);
|
|
|
|
let other_project_process = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "other-project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-denied".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
other_project_process,
|
|
CoordinatorResponse::ProcessStarted { .. }
|
|
));
|
|
assert_eq!(
|
|
service.quota.used_workflow_spawns(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("other-project"),
|
|
now_epoch_seconds,
|
|
),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
service.quota.used_workflow_spawns(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
now_epoch_seconds,
|
|
),
|
|
2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_quota_resets_at_the_configured_window_boundary() {
|
|
let mut limits = ResourceLimits::unlimited();
|
|
limits.limits.insert(LimitKind::Spawn, 1);
|
|
let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::Spawn, 60)]).unwrap();
|
|
let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota(
|
|
7,
|
|
"test-admin-token",
|
|
None,
|
|
quota,
|
|
)
|
|
.unwrap();
|
|
|
|
service.set_server_time(59);
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-window-one".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::AbortProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp-window-one".to_owned(),
|
|
})
|
|
.unwrap();
|
|
|
|
let exhausted = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-still-window-one".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap_err();
|
|
assert!(exhausted.to_string().contains("Spawn"));
|
|
|
|
service.set_server_time(60);
|
|
let started = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-window-two".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
started,
|
|
CoordinatorResponse::ProcessStarted {
|
|
charged_spawns: 1,
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(service.quota.active_meter_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn authenticated_api_calls_are_metered_per_tenant_and_project_before_dispatch() {
|
|
let mut limits = ResourceLimits::unlimited();
|
|
limits.limits.insert(LimitKind::ApiCall, 1);
|
|
let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::ApiCall, 60)]).unwrap();
|
|
let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota(
|
|
7,
|
|
"test-admin-token",
|
|
None,
|
|
quota,
|
|
)
|
|
.unwrap();
|
|
service.set_server_time(30);
|
|
for (project, secret) in [("project-a", "session-a"), ("project-b", "session-b")] {
|
|
service
|
|
.issue_cli_session(
|
|
TenantId::from("tenant"),
|
|
ProjectId::from(project),
|
|
UserId::from("user"),
|
|
secret,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "session-a".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap();
|
|
let denied = service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "session-a".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap_err();
|
|
assert!(denied.to_string().contains("ApiCall"));
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::Authenticated {
|
|
session_secret: "session-b".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::AuthStatus,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
service
|
|
.quota
|
|
.used_api_calls(&TenantId::from("tenant"), &ProjectId::from("project-a"), 30,),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.quota
|
|
.used_api_calls(&TenantId::from("tenant"), &ProjectId::from("project-b"), 30,),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_node_log_ingestion_checks_scoped_quota_before_accepting_bytes() {
|
|
let mut limits = ResourceLimits::unlimited();
|
|
limits.limits.insert(LimitKind::LogBytes, 4);
|
|
let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::LogBytes, 60)]).unwrap();
|
|
let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota(
|
|
7,
|
|
"test-admin-token",
|
|
None,
|
|
quota,
|
|
)
|
|
.unwrap();
|
|
service.set_server_time(30);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "node".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "task".to_owned(),
|
|
stdout_bytes: 3,
|
|
stderr_bytes: 1,
|
|
stdout_tail: "out".to_owned(),
|
|
stderr_tail: "e".to_owned(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
backpressured: false,
|
|
})
|
|
.unwrap();
|
|
let denied = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "task".to_owned(),
|
|
stdout_bytes: 1,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "x".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
backpressured: true,
|
|
})
|
|
.unwrap_err();
|
|
assert!(denied.to_string().contains("LogBytes"));
|
|
assert_eq!(
|
|
service
|
|
.quota
|
|
.used_log_bytes(&TenantId::from("tenant"), &ProjectId::from("project"), 30,),
|
|
4
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_attaches_node_starts_process_and_records_scoped_task_event() {
|
|
let mut service = CoordinatorService::new(7);
|
|
|
|
let attached = service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(attached, CoordinatorResponse::NodeAttached { .. }));
|
|
|
|
let started = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
started,
|
|
CoordinatorResponse::ProcessStarted {
|
|
process: ProcessId::from("process"),
|
|
epoch: 7,
|
|
actor: WorkflowActor {
|
|
kind: "user".to_owned(),
|
|
user: Some(UserId::from("user")),
|
|
agent: None,
|
|
credential_kind: CredentialKind::BrowserSession,
|
|
public_key_fingerprint: None,
|
|
authenticated_without_browser: false,
|
|
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
|
|
},
|
|
charged_spawns: 1,
|
|
}
|
|
);
|
|
|
|
let heartbeat = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat("node", "task-event-heartbeat")),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
heartbeat,
|
|
CoordinatorResponse::NodeHeartbeat {
|
|
node: NodeId::from("node"),
|
|
epoch: 7,
|
|
}
|
|
);
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "node".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"compile-linux",
|
|
"compile-linux",
|
|
7,
|
|
);
|
|
let recorded = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 12,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "build ok".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/app.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256("artifact")),
|
|
artifact_size_bytes: Some(12),
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
recorded,
|
|
CoordinatorResponse::TaskRecorded {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
events_recorded: 1
|
|
}
|
|
);
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].node, NodeId::from("node"));
|
|
assert_eq!(events[0].stdout_tail, "build ok");
|
|
assert_eq!(events[0].stderr_tail, "");
|
|
assert!(!events[0].stdout_truncated);
|
|
|
|
let oversized_tail = "x".repeat(MAX_TASK_LOG_TAIL_BYTES + 1);
|
|
let oversized_log = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
stdout_bytes: oversized_tail.len() as u64,
|
|
stderr_bytes: 0,
|
|
stdout_tail: oversized_tail.clone(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
backpressured: false,
|
|
})
|
|
.unwrap_err();
|
|
assert!(oversized_log.to_string().contains("stdout_tail"));
|
|
|
|
let oversized_completion = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: oversized_tail.len() as u64,
|
|
stderr_bytes: 0,
|
|
stdout_tail: oversized_tail,
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(oversized_completion.to_string().contains("stdout_tail"));
|
|
|
|
let cross_tenant = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "other".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant
|
|
.to_string()
|
|
.contains("outside the virtual process tenant/project scope"));
|
|
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("other-process".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_revokes_node_credentials_and_live_descriptors() {
|
|
let mut service = CoordinatorService::new(7);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: vec![],
|
|
dependency_cache_digests: vec![],
|
|
source_snapshots: vec![],
|
|
artifact_locations: vec![],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::NodeCredentialRevoked {
|
|
node,
|
|
tenant,
|
|
project,
|
|
actor,
|
|
descriptor_removed,
|
|
queued_assignments_removed,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RevokeNodeCredential {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
node: "node".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected node credential revocation");
|
|
};
|
|
assert_eq!(node, NodeId::from("node"));
|
|
assert_eq!(tenant, TenantId::from("tenant"));
|
|
assert_eq!(project, ProjectId::from("project"));
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert!(descriptor_removed);
|
|
assert_eq!(queued_assignments_removed, 0);
|
|
|
|
let heartbeat = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat("node", "revoked-heartbeat")),
|
|
})
|
|
.unwrap_err();
|
|
assert!(heartbeat.to_string().contains("not enrolled"));
|
|
|
|
let CoordinatorResponse::NodeDescriptors { descriptors, .. } = service
|
|
.handle_request(CoordinatorRequest::ListNodeDescriptors {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected node descriptors");
|
|
};
|
|
assert!(descriptors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() {
|
|
let mut service = CoordinatorService::new(7);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "node".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"compile-linux",
|
|
"compile-linux",
|
|
7,
|
|
);
|
|
|
|
let cancelled = service
|
|
.handle_request(CoordinatorRequest::CancelTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
cancelled,
|
|
CoordinatorResponse::TaskCancellationRequested {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
node: NodeId::from("node"),
|
|
}
|
|
);
|
|
|
|
let control = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
control,
|
|
CoordinatorResponse::TaskControl {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
cancel_requested: true,
|
|
abort_requested: false,
|
|
}
|
|
);
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Cancelled),
|
|
status_code: None,
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
assert_eq!(events[0].terminal_state, TaskTerminalState::Cancelled);
|
|
|
|
let control = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
control,
|
|
CoordinatorResponse::TaskControl {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
cancel_requested: false,
|
|
abort_requested: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_authorizes_debug_attach_through_public_api() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
project: "project".to_owned(),
|
|
name: "Demo".to_owned(),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::DebugAttach {
|
|
process,
|
|
actor,
|
|
authorization,
|
|
audit_event,
|
|
charged_debug_read_bytes,
|
|
used_debug_read_bytes,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::DebugAttach {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected debug attach authorization");
|
|
};
|
|
assert_eq!(process, ProcessId::from("process"));
|
|
assert_eq!(actor, UserId::from("user"));
|
|
assert!(authorization.allowed);
|
|
assert_eq!(audit_event.operation, "debug_attach");
|
|
assert_eq!(audit_event.actor, UserId::from("user"));
|
|
assert!(audit_event.allowed);
|
|
assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES);
|
|
assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES);
|
|
|
|
let CoordinatorResponse::DebugAttach {
|
|
authorization,
|
|
audit_event,
|
|
charged_debug_read_bytes,
|
|
used_debug_read_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::DebugAttach {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "other-user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected denied debug attach authorization");
|
|
};
|
|
assert!(!authorization.allowed);
|
|
assert!(authorization.reason.contains("explicit project permission"));
|
|
assert!(!audit_event.allowed);
|
|
assert_eq!(audit_event.charged_debug_read_bytes, 0);
|
|
assert_eq!(charged_debug_read_bytes, 0);
|
|
assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES);
|
|
|
|
let CoordinatorResponse::DebugAttach {
|
|
authorization,
|
|
audit_event,
|
|
charged_debug_read_bytes,
|
|
used_debug_read_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::DebugAttach {
|
|
tenant: "other-tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected cross-tenant debug attach denial");
|
|
};
|
|
assert!(!authorization.allowed);
|
|
assert!(authorization.reason.contains("tenant or project"));
|
|
assert!(!audit_event.allowed);
|
|
assert_eq!(charged_debug_read_bytes, 0);
|
|
assert_eq!(used_debug_read_bytes, 0);
|
|
assert_eq!(service.debug_audit_events.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
project: "project".to_owned(),
|
|
name: "Demo".to_owned(),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
public_key: test_node_public_key("worker"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: vec![],
|
|
dependency_cache_digests: vec![],
|
|
source_snapshots: vec![],
|
|
artifact_locations: vec![],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"compile-linux",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/out.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap();
|
|
service.main_runtime.controls.insert(
|
|
process_control_key(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
&ProcessId::from("process"),
|
|
),
|
|
super::main_runtime::CoordinatorMainControl {
|
|
task_definition: clusterflux_core::TaskDefinitionId::from("completed-main"),
|
|
task_instance: TaskInstanceId::from("ti:process:main"),
|
|
abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()),
|
|
state: "completed".to_owned(),
|
|
stopped_probe_symbol: None,
|
|
handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
|
launch_id: 1,
|
|
},
|
|
);
|
|
let CoordinatorResponse::DebugBreakpoints {
|
|
probe_symbols,
|
|
hit_epoch,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::SetDebugBreakpoints {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
probe_symbols: vec!["clusterflux.probe.compile_linux".to_owned()],
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected debug breakpoints response");
|
|
};
|
|
assert_eq!(probe_symbols, ["clusterflux.probe.compile_linux"]);
|
|
assert_eq!(hit_epoch, None);
|
|
|
|
let CoordinatorResponse::DebugProbeHit {
|
|
breakpoint_matched,
|
|
debug_epoch,
|
|
probe_symbol,
|
|
..
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportDebugProbeHit {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
probe_symbol: "clusterflux.probe.compile_linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected executing Wasm probe hit response");
|
|
};
|
|
assert!(breakpoint_matched);
|
|
assert_eq!(debug_epoch, Some(1));
|
|
assert_eq!(probe_symbol, "clusterflux.probe.compile_linux");
|
|
|
|
let CoordinatorResponse::DebugBreakpoints {
|
|
hit_epoch,
|
|
hit_task,
|
|
hit_probe_symbol,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::InspectDebugBreakpoints {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected breakpoint hit status");
|
|
};
|
|
assert_eq!(hit_epoch, Some(1));
|
|
assert_eq!(hit_task, Some(TaskInstanceId::from("compile-linux")));
|
|
assert_eq!(
|
|
hit_probe_symbol.as_deref(),
|
|
Some("clusterflux.probe.compile_linux")
|
|
);
|
|
|
|
let CoordinatorResponse::DebugCommand {
|
|
epoch: Some(1),
|
|
command: Some(command),
|
|
..
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected signed node poll to receive freeze command");
|
|
};
|
|
assert_eq!(command, "freeze");
|
|
|
|
let CoordinatorResponse::DebugCommand {
|
|
epoch: None,
|
|
command: None,
|
|
..
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("debug commands should be consumed after poll");
|
|
};
|
|
|
|
let CoordinatorResponse::DebugEpochStatus {
|
|
fully_frozen,
|
|
fully_resumed,
|
|
acknowledgements,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::InspectDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected pending debug epoch status");
|
|
};
|
|
assert!(!fully_frozen);
|
|
assert!(!fully_resumed);
|
|
assert!(acknowledgements.is_empty());
|
|
let early_resume = service
|
|
.handle_request(CoordinatorRequest::ResumeDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(early_resume
|
|
.to_string()
|
|
.contains("no settled frozen participant set"));
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportDebugState {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
epoch: 1,
|
|
state: DebugAcknowledgementState::Frozen,
|
|
stack_frames: vec!["compile_linux::wasm".to_owned()],
|
|
local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())],
|
|
task_args: vec![("target".to_owned(), "linux".to_owned())],
|
|
handles: vec![("artifact".to_owned(), "pending".to_owned())],
|
|
command_status: Some("frozen at Wasm safepoint".to_owned()),
|
|
recent_output: vec![],
|
|
message: None,
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::DebugEpochStatus {
|
|
fully_frozen,
|
|
fully_resumed,
|
|
acknowledgements,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::InspectDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected frozen debug epoch status");
|
|
};
|
|
assert!(fully_frozen);
|
|
assert!(!fully_resumed);
|
|
assert_eq!(acknowledgements.len(), 1);
|
|
assert_eq!(acknowledgements[0].state, DebugAcknowledgementState::Frozen);
|
|
|
|
let CoordinatorResponse::DebugEpoch {
|
|
epoch,
|
|
command,
|
|
affected_tasks,
|
|
all_stop_requested,
|
|
audit_event,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::ResumeDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected debug epoch resume response");
|
|
};
|
|
assert_eq!(epoch, 1);
|
|
assert_eq!(command, "resume");
|
|
assert!(!all_stop_requested);
|
|
assert_eq!(affected_tasks.len(), 1);
|
|
assert_eq!(audit_event.operation, "resume_debug_epoch");
|
|
|
|
let CoordinatorResponse::DebugCommand {
|
|
epoch: Some(1),
|
|
command: Some(command),
|
|
..
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected signed node poll to receive resume command");
|
|
};
|
|
assert_eq!(command, "resume");
|
|
|
|
let CoordinatorResponse::DebugEpochStatus { fully_resumed, .. } = service
|
|
.handle_request(CoordinatorRequest::InspectDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected pending resume status");
|
|
};
|
|
assert!(!fully_resumed);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportDebugState {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
epoch: 1,
|
|
state: DebugAcknowledgementState::Running,
|
|
stack_frames: vec![],
|
|
local_values: vec![],
|
|
task_args: vec![],
|
|
handles: vec![],
|
|
command_status: Some("running".to_owned()),
|
|
recent_output: vec![],
|
|
message: None,
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::DebugEpochStatus {
|
|
fully_frozen,
|
|
fully_resumed,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::InspectDebugEpoch {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected resumed debug epoch status");
|
|
};
|
|
assert!(!fully_frozen);
|
|
assert!(fully_resumed);
|
|
}
|
|
|
|
#[test]
|
|
fn service_reports_task_restart_boundary_through_public_api() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
project: "project".to_owned(),
|
|
name: "Demo".to_owned(),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
|
|
let denied = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "other-user".to_owned(),
|
|
process: "process".to_owned(),
|
|
task: "task".to_owned(),
|
|
replacement_bundle: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(denied.to_string().contains("task restart denied"));
|
|
|
|
let CoordinatorResponse::TaskRestart {
|
|
accepted,
|
|
clean_boundary_available,
|
|
active_task,
|
|
completed_event_observed,
|
|
requires_whole_process_restart,
|
|
restarted_task_instance,
|
|
message,
|
|
audit_event,
|
|
charged_debug_read_bytes,
|
|
used_debug_read_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
task: "task".to_owned(),
|
|
replacement_bundle: None,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task restart response");
|
|
};
|
|
assert!(!accepted);
|
|
assert!(!clean_boundary_available);
|
|
assert!(!active_task);
|
|
assert!(!completed_event_observed);
|
|
assert!(restarted_task_instance.is_none());
|
|
assert!(requires_whole_process_restart);
|
|
assert!(message.contains("not known"));
|
|
assert_eq!(audit_event.operation, "restart_task");
|
|
assert_eq!(audit_event.task, Some(TaskInstanceId::from("task")));
|
|
assert!(audit_event.allowed);
|
|
assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES);
|
|
assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
public_key: test_node_public_key("worker-linux"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"task",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::TaskAssignment {
|
|
assignment: Some(initial_assignment),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected initial task assignment");
|
|
};
|
|
assert_eq!(initial_assignment.task, TaskInstanceId::from("task"));
|
|
|
|
let CoordinatorResponse::TaskRestart {
|
|
accepted,
|
|
clean_boundary_available,
|
|
active_task,
|
|
completed_event_observed,
|
|
requires_whole_process_restart,
|
|
message,
|
|
audit_event,
|
|
used_debug_read_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
task: "task".to_owned(),
|
|
replacement_bundle: None,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected active task restart response");
|
|
};
|
|
assert!(!accepted);
|
|
assert!(!clean_boundary_available);
|
|
assert!(active_task);
|
|
assert!(!completed_event_observed);
|
|
assert!(requires_whole_process_restart);
|
|
assert!(message.contains("still active"));
|
|
assert_eq!(
|
|
audit_event.charged_debug_read_bytes,
|
|
DEBUG_CONTROL_READ_BYTES
|
|
);
|
|
assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 2);
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
task: "task".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::TaskRestart {
|
|
accepted,
|
|
clean_boundary_available,
|
|
active_task,
|
|
completed_event_observed,
|
|
requires_whole_process_restart,
|
|
restarted_task_instance,
|
|
restarted_attempt_id,
|
|
message,
|
|
audit_event,
|
|
used_debug_read_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
task: "task".to_owned(),
|
|
replacement_bundle: None,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected completed task restart response");
|
|
};
|
|
assert!(accepted);
|
|
assert!(clean_boundary_available);
|
|
assert!(!active_task);
|
|
assert!(completed_event_observed);
|
|
assert!(!requires_whole_process_restart);
|
|
let restarted_task_instance = restarted_task_instance.expect("restart returns logical id");
|
|
let restarted_attempt_id = restarted_attempt_id.expect("restart returns a new attempt id");
|
|
assert!(restarted_attempt_id.starts_with("ta_"));
|
|
assert!(message.contains("from clean VFS entry boundary epoch 7"));
|
|
let CoordinatorResponse::TaskAssignment {
|
|
assignment: Some(restarted_assignment),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected restarted task assignment");
|
|
};
|
|
assert_eq!(restarted_assignment.task, restarted_task_instance);
|
|
assert_eq!(restarted_assignment.task, TaskInstanceId::from("task"));
|
|
let mut expected_task_spec = initial_assignment.task_spec.clone();
|
|
expected_task_spec.task_instance = restarted_task_instance;
|
|
assert_eq!(restarted_assignment.task_spec, expected_task_spec);
|
|
assert_eq!(
|
|
restarted_assignment.wasm_module_base64,
|
|
initial_assignment.wasm_module_base64
|
|
);
|
|
assert_eq!(
|
|
audit_event.charged_debug_read_bytes,
|
|
DEBUG_CONTROL_READ_BYTES
|
|
);
|
|
assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 3);
|
|
assert_eq!(service.debug_audit_events.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn service_cancels_whole_process_and_blocks_new_task_launches() {
|
|
let mut service = CoordinatorService::new(7);
|
|
for node in ["node-a", "node-b"] {
|
|
let capabilities = if node == "node-a" {
|
|
linux_capabilities()
|
|
} else {
|
|
windows_capabilities()
|
|
};
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: node.to_owned(),
|
|
public_key: test_node_public_key(node),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: node.to_owned(),
|
|
capabilities,
|
|
cached_environment_digests: vec![],
|
|
dependency_cache_digests: vec![],
|
|
source_snapshots: vec![],
|
|
artifact_locations: vec![],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
}
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
for node in ["node-a", "node-b"] {
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: node.to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
for (task, required_capability, preferred_node) in [
|
|
("compile-linux", Capability::Containers, "node-a"),
|
|
("link-windows", Capability::WindowsCommandDev, "node-b"),
|
|
] {
|
|
let response = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
task,
|
|
7,
|
|
[required_capability],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: format!("/vfs/artifacts/{task}.txt"),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::TaskLaunched { assignment, .. } = response else {
|
|
panic!("expected task launch");
|
|
};
|
|
assert_eq!(assignment.node, NodeId::from(preferred_node));
|
|
}
|
|
|
|
let CoordinatorResponse::ProcessCancellationRequested {
|
|
process,
|
|
cancelled_tasks,
|
|
affected_nodes,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::CancelProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process cancellation");
|
|
};
|
|
assert_eq!(process, ProcessId::from("process"));
|
|
assert_eq!(cancelled_tasks.len(), 2);
|
|
assert_eq!(
|
|
affected_nodes,
|
|
vec![NodeId::from("node-a"), NodeId::from("node-b")]
|
|
);
|
|
|
|
let control = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node-a".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
control,
|
|
CoordinatorResponse::TaskControl {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
cancel_requested: true,
|
|
abort_requested: false,
|
|
}
|
|
);
|
|
|
|
let blocked = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"package-linux",
|
|
7,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/package-linux.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(blocked
|
|
.to_string()
|
|
.contains("virtual process is cancelling"));
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AbortProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap();
|
|
let abort_control = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node-a".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
abort_control,
|
|
CoordinatorResponse::TaskControl {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-linux"),
|
|
cancel_requested: false,
|
|
abort_requested: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_second_active_process_unless_restarting_same_process() {
|
|
let mut service = CoordinatorService::new(7);
|
|
let started = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-a".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
started,
|
|
CoordinatorResponse::ProcessStarted { .. }
|
|
));
|
|
|
|
let same_without_restart = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-a".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap_err();
|
|
assert!(same_without_restart
|
|
.to_string()
|
|
.contains("already has active virtual process"));
|
|
|
|
let other_process = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-b".to_owned(),
|
|
restart: true,
|
|
})
|
|
.unwrap_err();
|
|
assert!(other_process
|
|
.to_string()
|
|
.contains("already has active virtual process"));
|
|
|
|
let retired_main_abort = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let process_key = process_control_key(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
&ProcessId::from("process-a"),
|
|
);
|
|
service.main_runtime.controls.insert(
|
|
process_key.clone(),
|
|
super::main_runtime::CoordinatorMainControl {
|
|
task_definition: clusterflux_core::TaskDefinitionId::from("build"),
|
|
task_instance: TaskInstanceId::from("ti:process-a:main"),
|
|
abort: std::sync::Arc::clone(&retired_main_abort),
|
|
debug: std::sync::Arc::new(clusterflux_wasm_runtime::WasmDebugControl::default()),
|
|
state: "running".to_owned(),
|
|
stopped_probe_symbol: None,
|
|
handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
|
launch_id: 1,
|
|
},
|
|
);
|
|
let restarted = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-a".to_owned(),
|
|
restart: true,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
restarted,
|
|
CoordinatorResponse::ProcessStarted {
|
|
process: ProcessId::from("process-a"),
|
|
epoch: 7,
|
|
actor: WorkflowActor {
|
|
kind: "user".to_owned(),
|
|
user: Some(UserId::from("user")),
|
|
agent: None,
|
|
credential_kind: CredentialKind::BrowserSession,
|
|
public_key_fingerprint: None,
|
|
authenticated_without_browser: false,
|
|
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
|
|
},
|
|
charged_spawns: 2,
|
|
}
|
|
);
|
|
assert!(retired_main_abort.load(std::sync::atomic::Ordering::Acquire));
|
|
assert!(!service.main_runtime.controls.contains_key(&process_key));
|
|
}
|
|
|
|
#[test]
|
|
fn quiescent_cooperative_cancel_releases_slot_immediately() {
|
|
let mut service = CoordinatorService::new(17);
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-a".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service.record_task_completion_event(TaskCompletionEvent {
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from("project"),
|
|
process: ProcessId::from("process-a"),
|
|
node: NodeId::from("node"),
|
|
executor: TaskExecutor::Node,
|
|
task_definition: clusterflux_core::TaskDefinitionId::from("old-task"),
|
|
task: TaskInstanceId::from("ti:process-a:old"),
|
|
attempt_id: None,
|
|
placement: None,
|
|
terminal_state: TaskTerminalState::Completed,
|
|
status_code: Some(0),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: Some(TaskBoundaryValue::SmallJson(json!("old"))),
|
|
});
|
|
service
|
|
.record_debug_audit_event(
|
|
TenantId::from("tenant"),
|
|
ProjectId::from("project"),
|
|
ProcessId::from("process-a"),
|
|
None,
|
|
UserId::from("user"),
|
|
"old_debug_event",
|
|
false,
|
|
"old process incarnation",
|
|
)
|
|
.unwrap();
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::CancelProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process-a".to_owned(),
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::ProcessStatuses { processes, .. } = service
|
|
.handle_request(CoordinatorRequest::ListProcesses {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected live process statuses");
|
|
};
|
|
assert!(processes.is_empty());
|
|
|
|
let started = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process-a".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
started,
|
|
CoordinatorResponse::ProcessStarted { .. }
|
|
));
|
|
assert!(service.task_events.iter().all(|event| {
|
|
event.tenant != TenantId::from("tenant")
|
|
|| event.project != ProjectId::from("project")
|
|
|| event.process != ProcessId::from("process-a")
|
|
}));
|
|
assert!(service.debug_audit_events.iter().all(|event| {
|
|
event.tenant != TenantId::from("tenant")
|
|
|| event.project != ProjectId::from("project")
|
|
|| event.process != ProcessId::from("process-a")
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn aborted_process_accepts_signed_terminal_event_for_issued_task() {
|
|
let mut service = CoordinatorService::new(17);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "node".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 17,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"compile",
|
|
"compile-1",
|
|
17,
|
|
);
|
|
|
|
let CoordinatorResponse::ProcessAborted { aborted_tasks, .. } = service
|
|
.handle_request(CoordinatorRequest::AbortProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "process".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process abort");
|
|
};
|
|
assert_eq!(aborted_tasks.len(), 1);
|
|
|
|
let recorded = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-1".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Cancelled),
|
|
status_code: None,
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
recorded,
|
|
CoordinatorResponse::TaskRecorded {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("compile-1"),
|
|
events_recorded: 1,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
service.task_events[0].task_definition,
|
|
clusterflux_core::TaskDefinitionId::from("compile")
|
|
);
|
|
assert!(service.task_restart_checkpoints.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_download_links_are_scoped_and_streaming_is_metered() {
|
|
let mut service = CoordinatorService::new(7);
|
|
let artifact_bytes = b"0123456789abcdef0123456789abcdef";
|
|
service.set_server_time(10);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: false,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"compile-linux",
|
|
"compile-linux",
|
|
7,
|
|
);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 32,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "artifact bytes".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/app.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256(artifact_bytes)),
|
|
artifact_size_bytes: Some(artifact_bytes.len() as u64),
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
service.quota.set_download_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 31)]),
|
|
});
|
|
let quota_denied_link = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap_err();
|
|
assert!(quota_denied_link
|
|
.to_string()
|
|
.contains("ArtifactDownloadBytes"));
|
|
service.quota.set_download_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 64)]),
|
|
});
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: vec!["app.txt".to_owned()],
|
|
direct_connectivity: false,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
let private_node_link = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
private_node_link,
|
|
CoordinatorResponse::ArtifactDownloadLink { .. }
|
|
));
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: vec!["app.txt".to_owned()],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::ArtifactDownloadLink { link } = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected download link");
|
|
};
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: vec!["app.txt".to_owned()],
|
|
direct_connectivity: false,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
let private_node_open = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
private_node_open,
|
|
CoordinatorResponse::ArtifactDownloadStream {
|
|
content_bytes_available: false,
|
|
..
|
|
}
|
|
));
|
|
let CoordinatorResponse::ArtifactTransferAssignment {
|
|
transfer: Some(pending_transfer),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollArtifactTransfer {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected reverse transfer assignment");
|
|
};
|
|
let bad_chunk = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::UploadArtifactTransferChunk {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
transfer_id: pending_transfer.transfer_id,
|
|
artifact: pending_transfer.artifact.as_str().to_owned(),
|
|
offset: 0,
|
|
content_base64: BASE64_STANDARD.encode(artifact_bytes),
|
|
chunk_digest: Digest::sha256("wrong bytes"),
|
|
eof: true,
|
|
})
|
|
.unwrap_err();
|
|
assert!(bad_chunk.to_string().contains("chunk digest mismatch"));
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: vec!["app.txt".to_owned()],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(link.tenant, TenantId::from("tenant"));
|
|
assert_eq!(link.project, ProjectId::from("project"));
|
|
assert_eq!(link.process, ProcessId::from("process"));
|
|
assert_eq!(link.actor, Actor::User(UserId::from("user")));
|
|
assert_eq!(link.max_bytes, 64);
|
|
assert!(link.policy_context_digest.is_valid_sha256());
|
|
assert_eq!(link.expires_at_epoch_seconds, 70);
|
|
assert!(link.url_path.contains("/artifacts/tenant/project/process"));
|
|
|
|
let cross_tenant = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "other".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant.to_string().contains("tenant mismatch"));
|
|
|
|
let cross_project = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "other-project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_project.to_string().contains("project mismatch"));
|
|
|
|
let cross_tenant_open = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "other".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant_open.to_string().contains("tenant mismatch"));
|
|
|
|
let cross_project_open = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "other-project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_project_open.to_string().contains("project mismatch"));
|
|
|
|
let guessed = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: Digest::sha256("guessed"),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(guessed.to_string().contains("token is invalid"));
|
|
|
|
let cross_actor_open = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "other-user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_actor_open.to_string().contains("token is invalid"));
|
|
|
|
service.set_server_time(11);
|
|
upload_pending_artifact_transfer(&mut service, "node", artifact_bytes);
|
|
let CoordinatorResponse::ArtifactDownloadStream {
|
|
streamed_bytes,
|
|
charged_download_bytes,
|
|
content_base64: Some(content_base64),
|
|
content_source: Some(content_source),
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 16,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected download stream");
|
|
};
|
|
|
|
assert_eq!(streamed_bytes, 16);
|
|
assert_eq!(charged_download_bytes, 16);
|
|
assert_eq!(
|
|
BASE64_STANDARD.decode(content_base64).unwrap(),
|
|
&artifact_bytes[..16]
|
|
);
|
|
assert_eq!(content_source, "retaining_node_reverse_stream");
|
|
|
|
let CoordinatorResponse::ArtifactDownloadLink {
|
|
link: expiring_link,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
ttl_seconds: 1,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected expiring download link");
|
|
};
|
|
service.set_server_time(13);
|
|
let expired = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: expiring_link.scoped_token_digest,
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(expired.to_string().contains("expired"));
|
|
service.set_server_time(11);
|
|
|
|
let cross_actor_revoke = service
|
|
.handle_request(CoordinatorRequest::RevokeArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "other-user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_actor_revoke.to_string().contains("token is invalid"));
|
|
|
|
let CoordinatorResponse::ArtifactDownloadLinkRevoked { link: revoked } = service
|
|
.handle_request(CoordinatorRequest::RevokeArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected revoked download link");
|
|
};
|
|
assert_eq!(revoked.scoped_token_digest, link.scoped_token_digest);
|
|
|
|
let revoked = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 64,
|
|
token_digest: link.scoped_token_digest,
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(revoked.to_string().contains("revoked"));
|
|
}
|
|
|
|
#[test]
|
|
fn download_quota_cannot_be_reset_by_reconnect_retry_or_scope_switch() {
|
|
let mut service = CoordinatorService::new(7);
|
|
let artifact_bytes = b"0123456789abcdef";
|
|
service.quota.set_download_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 16)]),
|
|
});
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"compile-linux",
|
|
"compile-linux",
|
|
7,
|
|
);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 16,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "artifact bytes".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/app.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256(artifact_bytes)),
|
|
artifact_size_bytes: Some(artifact_bytes.len() as u64),
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::ArtifactDownloadLink { link } = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected initial download link");
|
|
};
|
|
|
|
let pending = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 16,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
pending,
|
|
CoordinatorResponse::ArtifactDownloadStream {
|
|
content_bytes_available: false,
|
|
..
|
|
}
|
|
));
|
|
upload_pending_artifact_transfer(&mut service, "node", artifact_bytes);
|
|
let CoordinatorResponse::ArtifactDownloadStream {
|
|
charged_download_bytes,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 16,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected initial download stream");
|
|
};
|
|
assert_eq!(charged_download_bytes, 16);
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "node".to_owned(),
|
|
process: "process".to_owned(),
|
|
epoch: 7,
|
|
})
|
|
.unwrap();
|
|
let after_reconnect = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap_err();
|
|
assert!(after_reconnect
|
|
.to_string()
|
|
.contains("ArtifactDownloadBytes"));
|
|
|
|
let restarted_cli_retry = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap_err();
|
|
assert!(restarted_cli_retry
|
|
.to_string()
|
|
.contains("ArtifactDownloadBytes"));
|
|
|
|
let cross_tenant_retry = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "other-tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_tenant_retry.to_string().contains("tenant mismatch"));
|
|
|
|
let cross_project_retry = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "other-project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "app.txt".to_owned(),
|
|
max_bytes: 16,
|
|
token_digest: link.scoped_token_digest,
|
|
chunk_bytes: 1,
|
|
})
|
|
.unwrap_err();
|
|
assert!(cross_project_retry.to_string().contains("project mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn retained_artifact_reverse_stream_is_chunked_below_control_frame_limit() {
|
|
let mut service = CoordinatorService::new(7);
|
|
let artifact_bytes = vec![0x5a; 600_000];
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: false,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"node",
|
|
"package",
|
|
"package",
|
|
7,
|
|
);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "package".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/large.bin".to_owned()),
|
|
artifact_digest: Some(Digest::sha256(&artifact_bytes)),
|
|
artifact_size_bytes: Some(artifact_bytes.len() as u64),
|
|
result: Some(TaskBoundaryValue::Artifact(ArtifactHandle {
|
|
id: ArtifactId::from("large.bin"),
|
|
digest: Digest::sha256(&artifact_bytes),
|
|
size_bytes: artifact_bytes.len() as u64,
|
|
})),
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::ArtifactDownloadLink { link } = service
|
|
.handle_request(CoordinatorRequest::CreateArtifactDownloadLink {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "large.bin".to_owned(),
|
|
max_bytes: artifact_bytes.len() as u64,
|
|
ttl_seconds: 60,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected download link");
|
|
};
|
|
let pending = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "large.bin".to_owned(),
|
|
max_bytes: artifact_bytes.len() as u64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: artifact_bytes.len() as u64,
|
|
})
|
|
.unwrap();
|
|
assert!(matches!(
|
|
pending,
|
|
CoordinatorResponse::ArtifactDownloadStream {
|
|
content_bytes_available: false,
|
|
..
|
|
}
|
|
));
|
|
upload_pending_artifact_transfer(&mut service, "node", &artifact_bytes);
|
|
|
|
let mut downloaded = Vec::new();
|
|
loop {
|
|
let CoordinatorResponse::ArtifactDownloadStream {
|
|
streamed_bytes,
|
|
content_offset: Some(offset),
|
|
content_eof,
|
|
content_base64: Some(content),
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::OpenArtifactDownloadStream {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
artifact: "large.bin".to_owned(),
|
|
max_bytes: artifact_bytes.len() as u64,
|
|
token_digest: link.scoped_token_digest.clone(),
|
|
chunk_bytes: artifact_bytes.len() as u64,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected verified reverse-stream content chunk");
|
|
};
|
|
assert_eq!(offset, downloaded.len() as u64);
|
|
assert!(streamed_bytes <= super::artifacts::MAX_ARTIFACT_REVERSE_CHUNK_BYTES);
|
|
downloaded.extend(BASE64_STANDARD.decode(content).unwrap());
|
|
if content_eof {
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(downloaded, artifact_bytes);
|
|
assert_eq!(
|
|
service.quota.used_download_bytes(
|
|
&TenantId::from("tenant"),
|
|
&ProjectId::from("project"),
|
|
service.current_epoch_seconds().unwrap(),
|
|
),
|
|
600_000
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_task_events_share_the_virtual_process_scope() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "windows-node".to_owned(),
|
|
public_key: test_node_public_key("windows-node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "windows-node".to_owned(),
|
|
capabilities: windows_capabilities(),
|
|
cached_environment_digests: vec![Digest::sha256("env-windows-command-dev")],
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"process",
|
|
"windows-node",
|
|
"windows-command-dev",
|
|
"windows-command-dev",
|
|
7,
|
|
);
|
|
|
|
let recorded = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "windows-node".to_owned(),
|
|
task: "windows-command-dev".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 24,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "windows output".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/windows-output.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256("windows-artifact")),
|
|
artifact_size_bytes: Some(24),
|
|
result: None,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
recorded,
|
|
CoordinatorResponse::TaskRecorded {
|
|
process: ProcessId::from("process"),
|
|
task: TaskInstanceId::from("windows-command-dev"),
|
|
events_recorded: 1,
|
|
}
|
|
);
|
|
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].process, ProcessId::from("process"));
|
|
assert_eq!(events[0].node, NodeId::from("windows-node"));
|
|
assert_eq!(events[0].task, TaskInstanceId::from("windows-command-dev"));
|
|
assert_eq!(
|
|
events[0].artifact_path,
|
|
Some(VfsPath::new("/vfs/artifacts/windows-output.txt").unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn service_schedules_task_across_reported_node_descriptors() {
|
|
let mut service = CoordinatorService::new(7);
|
|
for node in ["cold-node", "warm-node"] {
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: node.to_owned(),
|
|
public_key: test_node_public_key(node),
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
let recorded = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "cold-node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: false,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
recorded,
|
|
CoordinatorResponse::NodeCapabilitiesRecorded {
|
|
node: NodeId::from("cold-node"),
|
|
node_descriptors: 1,
|
|
}
|
|
);
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "warm-node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: vec![Digest::sha256("env")],
|
|
dependency_cache_digests: vec![Digest::sha256("deps")],
|
|
source_snapshots: vec![Digest::sha256("source")],
|
|
artifact_locations: vec!["build-output".to_owned()],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::NodeDescriptors { descriptors, actor } = service
|
|
.handle_request(CoordinatorRequest::ListNodeDescriptors {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "operator".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected node descriptor inspector state");
|
|
};
|
|
assert_eq!(actor, UserId::from("operator"));
|
|
assert_eq!(descriptors.len(), 2);
|
|
let warm = descriptors
|
|
.iter()
|
|
.find(|descriptor| descriptor.id == NodeId::from("warm-node"))
|
|
.expect("warm node descriptor is visible to inspector state");
|
|
assert!(warm
|
|
.capabilities
|
|
.capabilities
|
|
.contains(&Capability::Command));
|
|
assert!(warm.cached_environments.contains(&Digest::sha256("env")));
|
|
assert!(warm.dependency_caches.contains(&Digest::sha256("deps")));
|
|
assert!(warm.source_snapshots.contains(&Digest::sha256("source")));
|
|
assert!(warm
|
|
.artifact_locations
|
|
.contains(&ArtifactId::from("build-output")));
|
|
|
|
let CoordinatorResponse::TaskPlacement { placement } = service
|
|
.handle_request(CoordinatorRequest::ScheduleTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
environment: Some(EnvironmentRequirements::linux_container()),
|
|
environment_digest: Some(Digest::sha256("env")),
|
|
required_capabilities: vec![Capability::Command],
|
|
dependency_cache: Some(Digest::sha256("deps")),
|
|
source_snapshot: Some(Digest::sha256("source")),
|
|
required_artifacts: vec!["build-output".to_owned()],
|
|
prefer_node: None,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task placement");
|
|
};
|
|
assert_eq!(placement.node, NodeId::from("warm-node"));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("environment")));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("source")));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("dependency")));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("artifact")));
|
|
|
|
let error = service
|
|
.handle_request(CoordinatorRequest::ScheduleTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
environment: None,
|
|
environment_digest: None,
|
|
required_capabilities: vec![Capability::WindowsCommandDev],
|
|
dependency_cache: None,
|
|
source_snapshot: None,
|
|
required_artifacts: Vec::new(),
|
|
prefer_node: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("WindowsCommandDev"));
|
|
|
|
service.quota.set_workflow_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::Spawn, 0)]),
|
|
});
|
|
let quota_error = service
|
|
.handle_request(CoordinatorRequest::ScheduleTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
environment: None,
|
|
environment_digest: None,
|
|
required_capabilities: vec![Capability::Command],
|
|
dependency_cache: None,
|
|
source_snapshot: None,
|
|
required_artifacts: Vec::new(),
|
|
prefer_node: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(quota_error
|
|
.to_string()
|
|
.contains("quota unavailable for placement"));
|
|
|
|
service.quota.set_workflow_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::Spawn, 10)]),
|
|
});
|
|
service.admission.workflow_placement_allowed = false;
|
|
let policy_error = service
|
|
.handle_request(CoordinatorRequest::ScheduleTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
environment: None,
|
|
environment_digest: None,
|
|
required_capabilities: vec![Capability::Command],
|
|
dependency_cache: None,
|
|
source_snapshot: None,
|
|
required_artifacts: Vec::new(),
|
|
prefer_node: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(policy_error.to_string().contains("policy denied placement"));
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_side_task_launch_queues_worker_assignment() {
|
|
let mut service = CoordinatorService::new(9);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
public_key: test_node_public_key("worker-linux"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: vec![Digest::sha256("env")],
|
|
dependency_cache_digests: vec![Digest::sha256("deps")],
|
|
source_snapshots: vec![Digest::sha256("source")],
|
|
artifact_locations: vec!["bootstrap-artifact".to_owned()],
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::ProcessStarted { epoch, .. } = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-control".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected coordinator-side process start");
|
|
};
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "worker-linux".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
epoch,
|
|
})
|
|
.unwrap();
|
|
service.artifact_registry.flush_metadata(ArtifactFlush {
|
|
id: ArtifactId::from("bootstrap-artifact"),
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from("project"),
|
|
process: ProcessId::from("vp-control"),
|
|
producer_task: TaskInstanceId::from("bootstrap"),
|
|
retaining_node: NodeId::from("worker-linux"),
|
|
digest: Digest::sha256("bootstrap"),
|
|
size: 9,
|
|
});
|
|
|
|
let submitted_task_spec = TaskSpec {
|
|
tenant: TenantId::from("tenant"),
|
|
project: ProjectId::from("project"),
|
|
process: ProcessId::from("vp-control"),
|
|
task_definition: clusterflux_core::TaskDefinitionId::from("compile-linux"),
|
|
task_instance: clusterflux_core::TaskInstanceId::from("compile-linux-1"),
|
|
dispatch: TaskDispatch::CoordinatorNodeWasm {
|
|
export: Some("compile-linux".to_owned()),
|
|
abi: WasmExportAbi::TaskV1,
|
|
},
|
|
environment_id: Some("linux".to_owned()),
|
|
environment: None,
|
|
environment_digest: Some(Digest::sha256("env")),
|
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
|
dependency_cache: Some(Digest::sha256("deps")),
|
|
source_snapshot: Some(Digest::sha256("source")),
|
|
required_artifacts: vec![ArtifactId::from("bootstrap-artifact")],
|
|
args: vec![
|
|
TaskBoundaryValue::SmallJson(serde_json::json!("test")),
|
|
TaskBoundaryValue::SourceSnapshot(Digest::sha256("source")),
|
|
TaskBoundaryValue::Artifact(ArtifactHandle {
|
|
id: ArtifactId::from("bootstrap-artifact"),
|
|
digest: Digest::sha256("bootstrap"),
|
|
size_bytes: 9,
|
|
}),
|
|
],
|
|
vfs_epoch: epoch,
|
|
failure_policy: Default::default(),
|
|
bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)),
|
|
};
|
|
|
|
let CoordinatorResponse::TaskLaunched {
|
|
process,
|
|
task,
|
|
placement,
|
|
assignment,
|
|
..
|
|
} = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: submitted_task_spec.clone(),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected launched task");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-control"));
|
|
assert_eq!(task, TaskInstanceId::from("compile-linux-1"));
|
|
assert_eq!(placement.node, NodeId::from("worker-linux"));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("environment")));
|
|
assert!(placement
|
|
.reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("source")));
|
|
assert_eq!(assignment.node, NodeId::from("worker-linux"));
|
|
assert_eq!(assignment.epoch, epoch);
|
|
assert_eq!(
|
|
assignment.task_spec.bundle_digest,
|
|
Some(Digest::sha256(TEST_WASM_MODULE))
|
|
);
|
|
assert!(assignment.task_spec.product_mode_uses_remote_dispatch());
|
|
assert_eq!(assignment.task_spec, submitted_task_spec);
|
|
assert_eq!(
|
|
assignment.task_spec.environment_id.as_deref(),
|
|
Some("linux")
|
|
);
|
|
assert!(matches!(
|
|
assignment.task_spec.dispatch,
|
|
TaskDispatch::CoordinatorNodeWasm {
|
|
export: Some(ref export),
|
|
abi: WasmExportAbi::TaskV1,
|
|
} if export == "compile-linux"
|
|
));
|
|
assert_eq!(assignment.task_spec.vfs_epoch, epoch);
|
|
assert_eq!(assignment.task_spec.args, submitted_task_spec.args);
|
|
|
|
let CoordinatorResponse::TaskAssignment { assignment } = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected worker assignment poll");
|
|
};
|
|
let assignment = assignment.expect("worker should receive queued assignment");
|
|
assert_eq!(assignment.process, ProcessId::from("vp-control"));
|
|
assert_eq!(assignment.task, TaskInstanceId::from("compile-linux-1"));
|
|
assert!(assignment.task_spec.product_mode_uses_remote_dispatch());
|
|
assert_eq!(
|
|
assignment.task_spec.bundle_digest,
|
|
Some(Digest::sha256(TEST_WASM_MODULE))
|
|
);
|
|
assert_eq!(assignment.wasm_module_base64, test_wasm_module_base64());
|
|
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_request(CoordinatorRequest::JoinTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
task: "compile-linux-1".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected pending join result");
|
|
};
|
|
assert_eq!(join.state, TaskJoinState::Pending);
|
|
assert!(!join.remote_completion_observed);
|
|
|
|
let CoordinatorResponse::TaskAssignment { assignment } = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected empty worker assignment poll");
|
|
};
|
|
assert!(assignment.is_none());
|
|
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
task: "compile-linux-1".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: 12,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "ok".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256("artifact")),
|
|
artifact_size_bytes: Some(12),
|
|
result: Some(TaskBoundaryValue::Artifact(ArtifactHandle {
|
|
id: ArtifactId::from("dap-output.txt"),
|
|
digest: Digest::sha256("artifact"),
|
|
size_bytes: 12,
|
|
})),
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("vp-control".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
let event_placement = events[0]
|
|
.placement
|
|
.as_ref()
|
|
.expect("task event should retain launch placement explanation");
|
|
assert_eq!(event_placement.node, NodeId::from("worker-linux"));
|
|
assert_eq!(event_placement.reasons, placement.reasons);
|
|
assert_eq!(
|
|
events[0].result,
|
|
Some(TaskBoundaryValue::Artifact(ArtifactHandle {
|
|
id: ArtifactId::from("dap-output.txt"),
|
|
digest: Digest::sha256("artifact"),
|
|
size_bytes: 12,
|
|
}))
|
|
);
|
|
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_request(CoordinatorRequest::JoinTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
task: "compile-linux-1".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected completed join result");
|
|
};
|
|
assert_eq!(join.state, TaskJoinState::Completed);
|
|
assert!(join.remote_completion_observed);
|
|
assert_eq!(
|
|
join.result,
|
|
Some(TaskBoundaryValue::Artifact(ArtifactHandle {
|
|
id: ArtifactId::from("dap-output.txt"),
|
|
digest: Digest::sha256("artifact"),
|
|
size_bytes: 12,
|
|
}))
|
|
);
|
|
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant",
|
|
"project",
|
|
"vp-control",
|
|
"worker-linux",
|
|
"wasm-add",
|
|
"wasm-add",
|
|
epoch,
|
|
);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
node: "worker-linux".to_owned(),
|
|
task: "wasm-add".to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: 3,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "42\n".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: Some(TaskBoundaryValue::SmallJson(serde_json::json!(42))),
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_request(CoordinatorRequest::JoinTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp-control".to_owned(),
|
|
task: "wasm-add".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected serialized join result");
|
|
};
|
|
assert_eq!(
|
|
join.result,
|
|
Some(TaskBoundaryValue::SmallJson(serde_json::json!(42)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order() {
|
|
let mut service = CoordinatorService::new(41);
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
project: "project".to_owned(),
|
|
name: "Duplicate instances".to_owned(),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
public_key: test_node_public_key("worker"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "worker".to_owned(),
|
|
process: "vp".to_owned(),
|
|
epoch: 41,
|
|
})
|
|
.unwrap();
|
|
|
|
let restart_compatibility = Digest::sha256("compile-u32-to-u32-abi-v1");
|
|
let (initial_module, initial_bundle_digest) =
|
|
test_edited_task_bundle(&restart_compatibility, "initial-body");
|
|
for (instance, argument) in [("compile-1", 1), ("compile-2", 2)] {
|
|
let mut spec =
|
|
test_task_spec_instance("tenant", "project", "vp", "compile", instance, 41, []);
|
|
spec.args = vec![TaskBoundaryValue::SmallJson(serde_json::json!(argument))];
|
|
spec.dispatch = TaskDispatch::CoordinatorNodeWasm {
|
|
export: Some("compile_export".to_owned()),
|
|
abi: WasmExportAbi::TaskV1,
|
|
};
|
|
spec.bundle_digest = Some(initial_bundle_digest.clone());
|
|
let CoordinatorResponse::TaskLaunched {
|
|
task, assignment, ..
|
|
} = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
task_spec: spec,
|
|
wait_for_node: false,
|
|
artifact_path: format!("/vfs/artifacts/{instance}.json"),
|
|
wasm_module_base64: initial_module.clone(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected launched task instance");
|
|
};
|
|
assert_eq!(task, TaskInstanceId::from(instance));
|
|
assert_eq!(
|
|
assignment.task_spec.task_definition,
|
|
clusterflux_core::TaskDefinitionId::from("compile")
|
|
);
|
|
assert_eq!(
|
|
assignment.task_spec.task_instance,
|
|
clusterflux_core::TaskInstanceId::from(instance)
|
|
);
|
|
}
|
|
for instance in ["compile-1", "compile-2"] {
|
|
let CoordinatorResponse::TaskAssignment {
|
|
assignment: Some(assignment),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected queued task assignment");
|
|
};
|
|
assert_eq!(assignment.task, TaskInstanceId::from(instance));
|
|
}
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::CancelTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: "compile-1".to_owned(),
|
|
})
|
|
.unwrap();
|
|
for (instance, expected_cancelled) in [("compile-1", true), ("compile-2", false)] {
|
|
let CoordinatorResponse::TaskControl {
|
|
cancel_requested,
|
|
abort_requested,
|
|
..
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: instance.to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task control response");
|
|
};
|
|
assert_eq!(cancel_requested, expected_cancelled);
|
|
assert!(!abort_requested);
|
|
}
|
|
|
|
for (instance, result) in [("compile-2", 22), ("compile-1", 11)] {
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
task: instance.to_owned(),
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: Some(TaskBoundaryValue::SmallJson(serde_json::json!(result))),
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
for (instance, expected) in [("compile-1", 11), ("compile-2", 22)] {
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_request(CoordinatorRequest::JoinTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp".to_owned(),
|
|
task: instance.to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task join");
|
|
};
|
|
assert_eq!(
|
|
join.task_instance,
|
|
clusterflux_core::TaskInstanceId::from(instance)
|
|
);
|
|
assert_eq!(
|
|
join.result,
|
|
Some(TaskBoundaryValue::SmallJson(serde_json::json!(expected)))
|
|
);
|
|
}
|
|
|
|
let duplicate = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
task_spec: test_task_spec_instance(
|
|
"tenant",
|
|
"project",
|
|
"vp",
|
|
"compile",
|
|
"compile-1",
|
|
41,
|
|
[],
|
|
),
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/duplicate.json".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(duplicate.to_string().contains("fresh task-instance id"));
|
|
|
|
let (replacement_module, replacement_bundle_digest) =
|
|
test_edited_task_bundle(&restart_compatibility, "edited-body");
|
|
assert_ne!(replacement_bundle_digest, initial_bundle_digest);
|
|
let CoordinatorResponse::TaskRestart {
|
|
accepted,
|
|
restarted_task_instance,
|
|
restarted_attempt_id,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp".to_owned(),
|
|
task: "compile-1".to_owned(),
|
|
replacement_bundle: Some(TaskReplacementBundle {
|
|
bundle_digest: replacement_bundle_digest.clone(),
|
|
wasm_module_base64: replacement_module.clone(),
|
|
}),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task restart response");
|
|
};
|
|
assert!(accepted);
|
|
let restarted = restarted_task_instance.expect("restart returns the logical instance");
|
|
let restarted_attempt_id = restarted_attempt_id.expect("restart creates a new attempt");
|
|
assert!(restarted_attempt_id.starts_with("ta_"));
|
|
assert_eq!(restarted, TaskInstanceId::from("compile-1"));
|
|
assert_ne!(restarted, TaskInstanceId::from("compile-2"));
|
|
let CoordinatorResponse::TaskAssignment {
|
|
assignment: Some(restarted_assignment),
|
|
} = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected restarted assignment");
|
|
};
|
|
assert_eq!(restarted_assignment.task, restarted);
|
|
assert_eq!(
|
|
restarted_assignment.task_spec.task_definition,
|
|
clusterflux_core::TaskDefinitionId::from("compile")
|
|
);
|
|
assert_eq!(
|
|
restarted_assignment.task_spec.args,
|
|
vec![TaskBoundaryValue::SmallJson(serde_json::json!(1))]
|
|
);
|
|
assert_eq!(
|
|
restarted_assignment.task_spec.bundle_digest,
|
|
Some(replacement_bundle_digest)
|
|
);
|
|
assert_eq!(
|
|
restarted_assignment.task_spec.dispatch,
|
|
TaskDispatch::CoordinatorNodeWasm {
|
|
export: Some("compile_export".to_owned()),
|
|
abi: WasmExportAbi::TaskV1,
|
|
}
|
|
);
|
|
assert_eq!(restarted_assignment.wasm_module_base64, replacement_module);
|
|
|
|
let incompatible_compatibility = Digest::sha256("compile-changed-signature");
|
|
let (incompatible_module, incompatible_bundle_digest) =
|
|
test_edited_task_bundle(&incompatible_compatibility, "incompatible-body");
|
|
let CoordinatorResponse::TaskRestart {
|
|
accepted,
|
|
requires_whole_process_restart,
|
|
restarted_task_instance,
|
|
message,
|
|
..
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RestartTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp".to_owned(),
|
|
task: "compile-2".to_owned(),
|
|
replacement_bundle: Some(TaskReplacementBundle {
|
|
bundle_digest: incompatible_bundle_digest,
|
|
wasm_module_base64: incompatible_module,
|
|
}),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected incompatible task restart response");
|
|
};
|
|
assert!(!accepted);
|
|
assert!(requires_whole_process_restart);
|
|
assert!(restarted_task_instance.is_none());
|
|
assert!(message.contains("task ABI changed"));
|
|
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_request(CoordinatorRequest::JoinTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: "vp".to_owned(),
|
|
task: "compile-2".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected unaffected sibling join");
|
|
};
|
|
assert_eq!(join.state, TaskJoinState::Completed);
|
|
assert_eq!(
|
|
join.result,
|
|
Some(TaskBoundaryValue::SmallJson(serde_json::json!(22)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn long_lived_process_state_reaches_a_scoped_bounded_steady_state() {
|
|
let mut service = CoordinatorService::new(73);
|
|
service
|
|
.handle_request(CoordinatorRequest::CreateProject {
|
|
tenant: "tenant-soak".to_owned(),
|
|
actor_user: "user-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
name: "Bounded soak".to_owned(),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
node: "worker-soak".to_owned(),
|
|
public_key: test_node_public_key("worker-soak"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
node: "worker-soak".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
actor_user: Some("user-soak".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-soak".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "worker-soak".to_owned(),
|
|
process: "vp-soak".to_owned(),
|
|
epoch: 73,
|
|
})
|
|
.unwrap();
|
|
|
|
let event_waves = 4;
|
|
for index in 0..super::MAX_TASK_EVENTS_PER_PROCESS * event_waves {
|
|
let task = format!("soak-task-{index}");
|
|
register_test_task_assignment(
|
|
&mut service,
|
|
"tenant-soak",
|
|
"project-soak",
|
|
"vp-soak",
|
|
"worker-soak",
|
|
"soak-task",
|
|
&task,
|
|
73,
|
|
);
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
process: "vp-soak".to_owned(),
|
|
node: "worker-soak".to_owned(),
|
|
task,
|
|
terminal_state: Some(TaskTerminalState::Completed),
|
|
status_code: Some(0),
|
|
stdout_bytes: 8,
|
|
stderr_bytes: 0,
|
|
stdout_tail: "soak-log".to_owned(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: Some(TaskBoundaryValue::SmallJson(json!(index))),
|
|
})
|
|
.unwrap();
|
|
}
|
|
for index in 0..super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS * 3 {
|
|
service
|
|
.record_debug_audit_event(
|
|
TenantId::from("tenant-soak"),
|
|
ProjectId::from("project-soak"),
|
|
ProcessId::from("vp-soak"),
|
|
None,
|
|
UserId::from("user-soak"),
|
|
"soak_inspect",
|
|
true,
|
|
format!("bounded audit {index}"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
for _ in 0..128 {
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant-soak".to_owned(),
|
|
project: "project-soak".to_owned(),
|
|
node: "worker-soak".to_owned(),
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
for index in 0..2_000 {
|
|
service
|
|
.node_replay_nonces
|
|
.insert((NodeId::from("worker-soak"), format!("expired-{index}")), 0);
|
|
}
|
|
service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "worker-soak".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat("worker-soak", "post-expiry-prune")),
|
|
})
|
|
.unwrap();
|
|
|
|
for index in 0..3 {
|
|
service.record_task_completion_event(TaskCompletionEvent {
|
|
tenant: TenantId::from("tenant-other"),
|
|
project: ProjectId::from("project-other"),
|
|
process: ProcessId::from("vp-other"),
|
|
node: NodeId::from("worker-other"),
|
|
executor: TaskExecutor::Node,
|
|
task_definition: clusterflux_core::TaskDefinitionId::from("other"),
|
|
task: TaskInstanceId::new(format!("other-{index}")),
|
|
attempt_id: None,
|
|
placement: None,
|
|
terminal_state: TaskTerminalState::Completed,
|
|
status_code: Some(0),
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
stdout_tail: String::new(),
|
|
stderr_tail: String::new(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: None,
|
|
artifact_digest: None,
|
|
artifact_size_bytes: None,
|
|
result: None,
|
|
});
|
|
}
|
|
|
|
let retained_soak_events = service
|
|
.task_events
|
|
.iter()
|
|
.filter(|event| event.process == ProcessId::from("vp-soak"))
|
|
.count();
|
|
let retained_other_events = service
|
|
.task_events
|
|
.iter()
|
|
.filter(|event| event.process == ProcessId::from("vp-other"))
|
|
.count();
|
|
let retained_soak_audit = service
|
|
.debug_audit_events
|
|
.iter()
|
|
.filter(|event| event.process == ProcessId::from("vp-soak"))
|
|
.count();
|
|
let retained_soak_checkpoints = service
|
|
.task_restart_checkpoints
|
|
.keys()
|
|
.filter(|(_, _, process, _)| process == &ProcessId::from("vp-soak"))
|
|
.count();
|
|
let retained_soak_nonces = service
|
|
.node_replay_nonces
|
|
.keys()
|
|
.filter(|(node, _)| node == &NodeId::from("worker-soak"))
|
|
.count();
|
|
|
|
assert_eq!(retained_soak_events, super::MAX_TASK_EVENTS_PER_PROCESS);
|
|
assert_eq!(retained_other_events, 3);
|
|
assert_eq!(
|
|
retained_soak_audit,
|
|
super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS
|
|
);
|
|
assert_eq!(
|
|
retained_soak_checkpoints,
|
|
super::MAX_RESTART_CHECKPOINTS_PER_PROCESS
|
|
);
|
|
assert!(retained_soak_nonces <= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY);
|
|
assert!(
|
|
super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY
|
|
>= super::NODE_SIGNATURE_WINDOW_SECONDS as usize * 2 * 1_000
|
|
/ clusterflux_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS as usize
|
|
+ 64,
|
|
"the bounded node replay window must sustain artifact and assignment polls at the protocol minimum with control-message headroom"
|
|
);
|
|
assert!(service
|
|
.coordinator
|
|
.active_process(
|
|
&TenantId::from("tenant-soak"),
|
|
&ProjectId::from("project-soak"),
|
|
&ProcessId::from("vp-soak"),
|
|
)
|
|
.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only() {
|
|
let mut service = CoordinatorService::new(12);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
public_key: test_node_public_key("worker"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "worker".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "worker".to_owned(),
|
|
process: "vp".to_owned(),
|
|
epoch: 12,
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
task_spec: test_task_spec("tenant", "project", "vp", "parent", 12, []),
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/parent.json".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap();
|
|
|
|
let child_request = CoordinatorRequest::LaunchChildTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
parent_task: "parent".to_owned(),
|
|
task_spec: test_task_spec("tenant", "project", "vp", "child", 12, []),
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/child.json".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
};
|
|
let unsigned = service.handle_request(child_request.clone()).unwrap_err();
|
|
assert!(unsigned.to_string().contains("signed_node"));
|
|
|
|
let CoordinatorResponse::TaskLaunched { actor, task, .. } = service
|
|
.handle_signed_node_request_auto(child_request)
|
|
.unwrap()
|
|
else {
|
|
panic!("expected signed child launch");
|
|
};
|
|
assert_eq!(task, TaskInstanceId::from("child"));
|
|
assert_eq!(actor.kind, "task");
|
|
assert_eq!(actor.credential_kind, CredentialKind::TaskCredential);
|
|
assert!(actor.scopes.contains(&"process:spawn-child".to_owned()));
|
|
|
|
let wrong_parent = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::JoinChildTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
parent_task: "not-active".to_owned(),
|
|
task: "child".to_owned(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(wrong_parent.to_string().contains("active parent"));
|
|
|
|
let CoordinatorResponse::TaskJoined { join } = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::JoinChildTask {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
process: "vp".to_owned(),
|
|
node: "worker".to_owned(),
|
|
parent_task: "parent".to_owned(),
|
|
task: "child".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected signed child join");
|
|
};
|
|
assert_eq!(join.state, TaskJoinState::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_rejects_named_environment_without_requirements() {
|
|
let mut service = CoordinatorService::new(10);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "uncached-worker".to_owned(),
|
|
public_key: test_node_public_key("uncached-worker"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "uncached-worker".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
let CoordinatorResponse::ProcessStarted { epoch, .. } = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-environment".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process start");
|
|
};
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode {
|
|
node: "uncached-worker".to_owned(),
|
|
process: "vp-environment".to_owned(),
|
|
epoch,
|
|
})
|
|
.unwrap();
|
|
let mut task_spec = test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-environment",
|
|
"compile-linux",
|
|
epoch,
|
|
[],
|
|
);
|
|
task_spec.environment_id = Some("missing-environment".to_owned());
|
|
task_spec.environment_digest = Some(Digest::sha256("missing-environment"));
|
|
|
|
let error = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec,
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/environment-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("named environment cache"));
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() {
|
|
let mut service = CoordinatorService::new(10);
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-control".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-control",
|
|
"compile-linux",
|
|
10,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("no capable node"));
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_side_task_launch_can_wait_for_capable_worker() {
|
|
let mut service = CoordinatorService::new(11);
|
|
let CoordinatorResponse::ProcessStarted { epoch, .. } = service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-wait".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected process start");
|
|
};
|
|
|
|
let CoordinatorResponse::TaskQueued {
|
|
process,
|
|
task,
|
|
reason,
|
|
queued_tasks,
|
|
charged_spawns,
|
|
..
|
|
} = service
|
|
.handle_authorized_test_task_launch(CoordinatorRequest::LaunchTask {
|
|
task_spec: test_task_spec(
|
|
"tenant",
|
|
"project",
|
|
"vp-wait",
|
|
"compile-linux",
|
|
epoch,
|
|
[Capability::Command],
|
|
),
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: Some("user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
wait_for_node: true,
|
|
artifact_path: "/vfs/artifacts/wait-output.txt".to_owned(),
|
|
wasm_module_base64: test_wasm_module_base64(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected queued task launch");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-wait"));
|
|
assert_eq!(task, TaskInstanceId::from("compile-linux"));
|
|
assert!(reason.contains("waiting for any capable node"));
|
|
assert_eq!(queued_tasks, 1);
|
|
assert_eq!(charged_spawns, 2);
|
|
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "late-worker".to_owned(),
|
|
public_key: test_node_public_key("late-worker"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "late-worker".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::TaskAssignment { assignment } = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "late-worker".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected pending assignment poll");
|
|
};
|
|
let assignment = assignment.expect("late worker should receive pending assignment");
|
|
assert_eq!(assignment.process, ProcessId::from("vp-wait"));
|
|
assert_eq!(assignment.task, TaskInstanceId::from("compile-linux"));
|
|
assert_eq!(assignment.node, NodeId::from("late-worker"));
|
|
assert_eq!(assignment.epoch, epoch);
|
|
assert!(assignment.task_spec.product_mode_uses_remote_dispatch());
|
|
assert_eq!(assignment.wasm_module_base64, test_wasm_module_base64());
|
|
|
|
let CoordinatorResponse::TaskAssignment { assignment } = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "late-worker".to_owned(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected empty assignment poll");
|
|
};
|
|
assert!(assignment.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_malformed_node_capability_report() {
|
|
let mut service = CoordinatorService::new(1);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
|
|
let mut capabilities = linux_capabilities();
|
|
capabilities
|
|
.source_providers
|
|
.insert("../checkout".to_owned());
|
|
let error = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities,
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("source provider id"));
|
|
assert!(service.node_descriptors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_meters_rendezvous_before_direct_transfer_plan() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service.quota.set_rendezvous_limits(ResourceLimits {
|
|
limits: BTreeMap::from([(LimitKind::RendezvousAttempt, 2)]),
|
|
});
|
|
|
|
let CoordinatorResponse::RendezvousPlan {
|
|
plan,
|
|
charged_rendezvous_attempts,
|
|
} = service
|
|
.handle_request(CoordinatorRequest::RequestRendezvous {
|
|
scope: data_plane_scope("project"),
|
|
source: endpoint("node-a"),
|
|
destination: endpoint("node-b"),
|
|
direct_connectivity: true,
|
|
failure_reason: String::new(),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected rendezvous plan");
|
|
};
|
|
|
|
assert_eq!(charged_rendezvous_attempts, 1);
|
|
assert_eq!(plan.scope.project, ProjectId::from("project"));
|
|
assert_eq!(plan.source.node, NodeId::from("node-a"));
|
|
assert_eq!(plan.destination.node, NodeId::from("node-b"));
|
|
assert!(plan.coordinator_assisted_rendezvous);
|
|
assert!(!plan.coordinator_bulk_relay_allowed);
|
|
|
|
let unavailable = service
|
|
.handle_request(CoordinatorRequest::RequestRendezvous {
|
|
scope: data_plane_scope("project"),
|
|
source: endpoint("node-a"),
|
|
destination: endpoint("node-b"),
|
|
direct_connectivity: false,
|
|
failure_reason: "nat traversal failed".to_owned(),
|
|
})
|
|
.unwrap_err();
|
|
let unavailable = unavailable.to_string();
|
|
assert!(unavailable.contains("nat traversal failed"));
|
|
assert!(unavailable.contains("coordinator bulk relay is disabled"));
|
|
|
|
let quota = service
|
|
.handle_request(CoordinatorRequest::RequestRendezvous {
|
|
scope: data_plane_scope("project"),
|
|
source: endpoint("node-a"),
|
|
destination: endpoint("node-b"),
|
|
direct_connectivity: true,
|
|
failure_reason: String::new(),
|
|
})
|
|
.unwrap_err();
|
|
assert!(quota.to_string().contains("RendezvousAttempt"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_task_completion_outside_node_scope() {
|
|
let mut service = CoordinatorService::new(1);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
service
|
|
.handle_request(CoordinatorRequest::StartProcess {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
actor_user: None,
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "process".to_owned(),
|
|
restart: false,
|
|
})
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
process: "process".to_owned(),
|
|
node: "node".to_owned(),
|
|
task: "compile-linux".to_owned(),
|
|
terminal_state: None,
|
|
status_code: Some(0),
|
|
stdout_bytes: 128,
|
|
stderr_bytes: 64,
|
|
stdout_tail: "foreign stdout".to_owned(),
|
|
stderr_tail: "foreign stderr".to_owned(),
|
|
stdout_truncated: false,
|
|
stderr_truncated: false,
|
|
artifact_path: Some("/vfs/artifacts/foreign.txt".to_owned()),
|
|
artifact_digest: Some(Digest::sha256("foreign-artifact")),
|
|
artifact_size_bytes: Some(128),
|
|
result: None,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("outside"));
|
|
let CoordinatorResponse::TaskEvents { events } = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap()
|
|
else {
|
|
panic!("expected task events");
|
|
};
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_task_event_access_using_retained_process_scope() {
|
|
let mut service = CoordinatorService::new(1);
|
|
service.process_scope_history.push_back((
|
|
TenantId::from("tenant-a"),
|
|
ProjectId::from("project-a"),
|
|
ProcessId::from("process"),
|
|
));
|
|
|
|
let error = service
|
|
.handle_request(CoordinatorRequest::ListTaskEvents {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
process: Some("process".to_owned()),
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error
|
|
.to_string()
|
|
.contains("outside the virtual process tenant/project scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_node_capability_report_outside_enrollment_scope() {
|
|
let mut service = CoordinatorService::new(1);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("tenant/project scope"));
|
|
assert!(service.node_descriptors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_source_preparation_completion_outside_node_scope() {
|
|
let mut service = CoordinatorService::new(1);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant-a".to_owned(),
|
|
project: "project-a".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.handle_signed_node_request_auto(CoordinatorRequest::CompleteSourcePreparation {
|
|
tenant: "tenant-b".to_owned(),
|
|
project: "project-b".to_owned(),
|
|
node: "node".to_owned(),
|
|
provider: SourceProviderKind::Filesystem,
|
|
source_snapshot: Digest::sha256("foreign-source"),
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("tenant/project scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_unknown_node_heartbeat() {
|
|
let mut service = CoordinatorService::new(1);
|
|
|
|
let error = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "missing".to_owned(),
|
|
node_signature: None,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("not enrolled"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_requires_signed_node_heartbeat_from_enrolled_key() {
|
|
let mut service = CoordinatorService::new(5);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
|
|
let unsigned = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: None,
|
|
})
|
|
.unwrap_err();
|
|
assert!(unsigned.to_string().contains("signed proof"));
|
|
|
|
let wrong_private_key = test_node_private_key("other-node");
|
|
let wrong_signature = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat_with_private_key(
|
|
"node",
|
|
&wrong_private_key,
|
|
"wrong-node-key",
|
|
)),
|
|
})
|
|
.unwrap_err();
|
|
assert!(wrong_signature.to_string().contains("signature"));
|
|
|
|
let signed = signed_node_heartbeat("node", "fresh-node-heartbeat");
|
|
let heartbeat = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed.clone()),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
heartbeat,
|
|
CoordinatorResponse::NodeHeartbeat {
|
|
node: NodeId::from("node"),
|
|
epoch: 5,
|
|
}
|
|
);
|
|
|
|
let replay = service
|
|
.handle_request(CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed),
|
|
})
|
|
.unwrap_err();
|
|
assert!(replay.to_string().contains("nonce"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_rejects_raw_node_originated_requests_without_signed_envelope() {
|
|
let mut service = CoordinatorService::new(5);
|
|
service
|
|
.handle_request(CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
})
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.handle_request(CoordinatorRequest::ReportNodeCapabilities {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
capabilities: linux_capabilities(),
|
|
cached_environment_digests: Vec::new(),
|
|
dependency_cache_digests: Vec::new(),
|
|
source_snapshots: Vec::new(),
|
|
artifact_locations: Vec::new(),
|
|
direct_connectivity: true,
|
|
online: true,
|
|
})
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("signed_node envelope proof"));
|
|
}
|
|
|
|
#[test]
|
|
fn service_stream_accepts_multiple_requests_on_one_connection() {
|
|
let (listener, addr) = bind_listener("127.0.0.1:0").unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
let mut service = CoordinatorService::new(3);
|
|
let (stream, _) = listener.accept().unwrap();
|
|
service.handle_stream_local_trusted(stream).unwrap();
|
|
});
|
|
|
|
let mut stream = TcpStream::connect(addr).unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
|
|
write_coordinator_wire_request(
|
|
&mut stream,
|
|
&CoordinatorRequest::AttachNode {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
node: "node".to_owned(),
|
|
public_key: test_node_public_key("node"),
|
|
},
|
|
"stream-1",
|
|
);
|
|
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert!(matches!(
|
|
serde_json::from_str::<CoordinatorResponse>(&line).unwrap(),
|
|
CoordinatorResponse::NodeAttached { .. }
|
|
));
|
|
|
|
write_coordinator_wire_request(
|
|
&mut stream,
|
|
&CoordinatorRequest::NodeHeartbeat {
|
|
node: "node".to_owned(),
|
|
node_signature: Some(signed_node_heartbeat("node", "stream-heartbeat")),
|
|
},
|
|
"stream-2",
|
|
);
|
|
|
|
line.clear();
|
|
reader.read_line(&mut line).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<CoordinatorResponse>(&line).unwrap(),
|
|
CoordinatorResponse::NodeHeartbeat {
|
|
node: NodeId::from("node"),
|
|
epoch: 3,
|
|
}
|
|
);
|
|
|
|
stream.shutdown(std::net::Shutdown::Both).unwrap();
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn strict_service_stream_rejects_body_authority_and_accepts_cli_session() {
|
|
let (listener, addr) = bind_listener("127.0.0.1:0").unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
let mut service = CoordinatorService::new(3);
|
|
service
|
|
.issue_cli_session(
|
|
TenantId::from("tenant"),
|
|
ProjectId::from("project"),
|
|
UserId::from("user"),
|
|
"strict-stream-session",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let (stream, _) = listener.accept().unwrap();
|
|
service.handle_stream(stream).unwrap();
|
|
});
|
|
|
|
let mut stream = TcpStream::connect(addr).unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
write_coordinator_wire_request(
|
|
&mut stream,
|
|
&CoordinatorRequest::StartProcess {
|
|
tenant: "victim-tenant".to_owned(),
|
|
project: "victim-project".to_owned(),
|
|
actor_user: Some("forged-user".to_owned()),
|
|
actor_agent: None,
|
|
agent_public_key_fingerprint: None,
|
|
agent_signature: None,
|
|
process: "vp-forged".to_owned(),
|
|
restart: false,
|
|
},
|
|
"strict-stream-forged",
|
|
);
|
|
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
let CoordinatorResponse::Error { message } =
|
|
serde_json::from_str::<CoordinatorResponse>(&line).unwrap()
|
|
else {
|
|
panic!("expected strict body-authority denial");
|
|
};
|
|
assert!(message.contains("request-body identity fields are not authority"));
|
|
|
|
write_coordinator_wire_request(
|
|
&mut stream,
|
|
&CoordinatorRequest::Authenticated {
|
|
session_secret: "strict-stream-session".to_owned(),
|
|
request: AuthenticatedCoordinatorRequest::StartProcess {
|
|
process: "vp-authenticated".to_owned(),
|
|
restart: false,
|
|
},
|
|
},
|
|
"strict-stream-authenticated",
|
|
);
|
|
|
|
line.clear();
|
|
reader.read_line(&mut line).unwrap();
|
|
let CoordinatorResponse::ProcessStarted { process, actor, .. } =
|
|
serde_json::from_str::<CoordinatorResponse>(&line).unwrap()
|
|
else {
|
|
panic!("expected authenticated strict process start");
|
|
};
|
|
assert_eq!(process, ProcessId::from("vp-authenticated"));
|
|
assert_eq!(actor.user, Some(UserId::from("user")));
|
|
|
|
stream.shutdown(std::net::Shutdown::Both).unwrap();
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn service_stream_rejects_invalid_versioned_envelope_metadata() {
|
|
let (listener, addr) = bind_listener("127.0.0.1:0").unwrap();
|
|
let server = std::thread::spawn(move || {
|
|
let mut service = CoordinatorService::new(3);
|
|
let (stream, _) = listener.accept().unwrap();
|
|
service.handle_stream(stream).unwrap();
|
|
});
|
|
|
|
let mut stream = TcpStream::connect(addr).unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut wire_request = coordinator_wire_request(
|
|
"bad-operation",
|
|
serde_json::to_value(CoordinatorRequest::Ping).unwrap(),
|
|
);
|
|
wire_request["operation"] = serde_json::Value::String("attach_node".to_owned());
|
|
serde_json::to_writer(&mut stream, &wire_request).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
stream.flush().unwrap();
|
|
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
let response = serde_json::from_str::<CoordinatorResponse>(&line).unwrap();
|
|
let CoordinatorResponse::Error { message } = response else {
|
|
panic!("expected invalid wire envelope response");
|
|
};
|
|
assert!(message.contains("operation attach_node does not match payload operation ping"));
|
|
|
|
stream.shutdown(std::net::Shutdown::Both).unwrap();
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_protocol_rejects_client_supplied_authority_fields() {
|
|
for payload in [
|
|
json!({
|
|
"type": "create_node_enrollment_grant",
|
|
"tenant": "tenant",
|
|
"project": "project",
|
|
"actor_user": "user",
|
|
"grant": "attacker-chosen",
|
|
"ttl_seconds": 60,
|
|
}),
|
|
json!({
|
|
"type": "exchange_node_enrollment_grant",
|
|
"tenant": "tenant",
|
|
"project": "project",
|
|
"node": "node",
|
|
"public_key": "key",
|
|
"enrollment_grant": "grant",
|
|
"now_epoch_seconds": 0,
|
|
}),
|
|
json!({
|
|
"type": "schedule_task",
|
|
"tenant": "tenant",
|
|
"project": "project",
|
|
"environment": null,
|
|
"environment_digest": null,
|
|
"required_capabilities": [],
|
|
"dependency_cache": null,
|
|
"source_snapshot": null,
|
|
"required_artifacts": [],
|
|
"quota_available": true,
|
|
"policy_allowed": true,
|
|
"prefer_node": null,
|
|
}),
|
|
json!({
|
|
"type": "create_artifact_download_link",
|
|
"tenant": "tenant",
|
|
"project": "project",
|
|
"actor_user": "user",
|
|
"artifact": "artifact",
|
|
"max_bytes": 1,
|
|
"token_nonce": "attacker-chosen",
|
|
"ttl_seconds": 60,
|
|
}),
|
|
] {
|
|
let error = serde_json::from_value::<CoordinatorRequest>(payload).unwrap_err();
|
|
assert!(error.to_string().contains("unknown field"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn coordinator_generates_and_bounds_node_enrollment_grants() {
|
|
let mut service = CoordinatorService::new(7);
|
|
service.set_server_time(100);
|
|
|
|
let first = service
|
|
.handle_request(CoordinatorRequest::CreateNodeEnrollmentGrant {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
ttl_seconds: u64::MAX,
|
|
})
|
|
.unwrap();
|
|
let second = service
|
|
.handle_request(CoordinatorRequest::CreateNodeEnrollmentGrant {
|
|
tenant: "tenant".to_owned(),
|
|
project: "project".to_owned(),
|
|
actor_user: "user".to_owned(),
|
|
ttl_seconds: u64::MAX,
|
|
})
|
|
.unwrap();
|
|
|
|
let CoordinatorResponse::NodeEnrollmentGrantCreated {
|
|
grant: first_grant,
|
|
expires_at_epoch_seconds,
|
|
..
|
|
} = first
|
|
else {
|
|
panic!("expected enrollment grant");
|
|
};
|
|
let CoordinatorResponse::NodeEnrollmentGrantCreated {
|
|
grant: second_grant,
|
|
..
|
|
} = second
|
|
else {
|
|
panic!("expected enrollment grant");
|
|
};
|
|
assert!(first_grant.starts_with("node_grant_"));
|
|
assert_ne!(first_grant, second_grant);
|
|
assert_eq!(expires_at_epoch_seconds, 100 + 15 * 60);
|
|
}
|