Public dry run dryrun-20b59dc46b72
Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
This commit is contained in:
commit
2bef715211
221 changed files with 79792 additions and 0 deletions
18
crates/disasmer-coordinator/Cargo.toml
Normal file
18
crates/disasmer-coordinator/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "disasmer-coordinator"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" }
|
||||
postgres.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
wasmparser.workspace = true
|
||||
174
crates/disasmer-coordinator/src/agents.rs
Normal file
174
crates/disasmer-coordinator/src/agents.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
use disasmer_core::{
|
||||
verify_agent_workflow_signature, Actor, AgentId, AgentSignedRequest, AgentWorkflowScope,
|
||||
AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AgentPublicKeyRecord, Coordinator, CoordinatorError, CredentialRecord, ProjectPermissionRecord,
|
||||
};
|
||||
|
||||
impl Coordinator {
|
||||
pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) {
|
||||
self.durable.project_permissions.insert(
|
||||
(tenant.clone(), project.clone(), user.clone()),
|
||||
ProjectPermissionRecord {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
can_debug: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn register_agent_public_key(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
user: UserId,
|
||||
agent: AgentId,
|
||||
public_key: impl Into<String>,
|
||||
) -> AgentPublicKeyRecord {
|
||||
let key = (tenant.clone(), project.clone(), agent.clone());
|
||||
let version = self
|
||||
.durable
|
||||
.agent_public_keys
|
||||
.get(&key)
|
||||
.map_or(1, |record| record.version.saturating_add(1));
|
||||
let public_key = public_key.into();
|
||||
let public_key_fingerprint = Digest::sha256(&public_key);
|
||||
let record = AgentPublicKeyRecord {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
user: user.clone(),
|
||||
agent: agent.clone(),
|
||||
public_key,
|
||||
public_key_fingerprint: public_key_fingerprint.clone(),
|
||||
version,
|
||||
revoked: false,
|
||||
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
|
||||
human_account_creation_privilege: false,
|
||||
browser_interaction_required_each_run: false,
|
||||
};
|
||||
self.durable.agent_public_keys.insert(key, record.clone());
|
||||
let subject = format!("agent:{tenant}:{project}:{agent}");
|
||||
self.durable.credentials.insert(
|
||||
subject.clone(),
|
||||
CredentialRecord {
|
||||
subject,
|
||||
tenant,
|
||||
project: Some(project),
|
||||
kind: CredentialKind::PublicKey,
|
||||
public_key_fingerprint: Some(public_key_fingerprint),
|
||||
},
|
||||
);
|
||||
record
|
||||
}
|
||||
|
||||
pub fn list_agent_public_keys(&self, context: &AuthContext) -> Vec<AgentPublicKeyRecord> {
|
||||
self.durable
|
||||
.agent_public_keys
|
||||
.values()
|
||||
.filter(|record| record.tenant == context.tenant && record.project == context.project)
|
||||
.filter(|record| match &context.actor {
|
||||
Actor::User(user) => &record.user == user,
|
||||
Actor::Agent(agent) => &record.agent == agent,
|
||||
Actor::Node(_) | Actor::Task(_) => false,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn revoke_agent_public_key(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
agent: &AgentId,
|
||||
) -> Result<AgentPublicKeyRecord, CoordinatorError> {
|
||||
let key = (
|
||||
context.tenant.clone(),
|
||||
context.project.clone(),
|
||||
agent.clone(),
|
||||
);
|
||||
let record = self
|
||||
.durable
|
||||
.agent_public_keys
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"agent public key is not registered for this project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
match &context.actor {
|
||||
Actor::User(user) if &record.user == user => {}
|
||||
Actor::User(_) => {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent public key is outside the signed-in user scope".to_owned(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent public-key revocation requires a user identity".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
record.revoked = true;
|
||||
let subject = format!(
|
||||
"agent:{}:{}:{}",
|
||||
context.tenant, context.project, record.agent
|
||||
);
|
||||
self.durable.credentials.remove(&subject);
|
||||
Ok(record.clone())
|
||||
}
|
||||
|
||||
pub fn authorize_agent_project_run(
|
||||
&self,
|
||||
scope: AgentWorkflowScope<'_>,
|
||||
public_key_fingerprint: Option<&Digest>,
|
||||
payload_digest: &Digest,
|
||||
signature: &AgentSignedRequest,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<AgentPublicKeyRecord, CoordinatorError> {
|
||||
let record = self
|
||||
.durable
|
||||
.agent_public_keys
|
||||
.get(&(
|
||||
scope.tenant.clone(),
|
||||
scope.project.clone(),
|
||||
scope.agent.clone(),
|
||||
))
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"agent public key is not registered for this tenant/project".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if record.revoked {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent public key has been revoked".to_owned(),
|
||||
));
|
||||
}
|
||||
if let Some(public_key_fingerprint) = public_key_fingerprint {
|
||||
if &record.public_key_fingerprint != public_key_fingerprint {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent public key fingerprint does not match the registered key".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let max_signature_skew_seconds = 300;
|
||||
if signature
|
||||
.issued_at_epoch_seconds
|
||||
.abs_diff(now_epoch_seconds)
|
||||
> max_signature_skew_seconds
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent signed request is expired or outside the allowed clock skew".to_owned(),
|
||||
));
|
||||
}
|
||||
if !record.scopes.iter().any(|scope| scope == "project:run") {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent public key is not scoped for project runs".to_owned(),
|
||||
));
|
||||
}
|
||||
verify_agent_workflow_signature(&record.public_key, scope, payload_digest, signature)
|
||||
.map_err(CoordinatorError::Unauthorized)?;
|
||||
Ok(record.clone())
|
||||
}
|
||||
}
|
||||
145
crates/disasmer-coordinator/src/durable.rs
Normal file
145
crates/disasmer-coordinator/src/durable.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use disasmer_core::{
|
||||
AgentId, CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TenantRecord {
|
||||
pub id: TenantId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserRecord {
|
||||
pub id: UserId,
|
||||
pub tenant: TenantId,
|
||||
pub credential_kind: CredentialKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectRecord {
|
||||
pub id: ProjectId,
|
||||
pub tenant: TenantId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeIdentityRecord {
|
||||
pub id: NodeId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub public_key: String,
|
||||
pub enrollment_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CredentialRecord {
|
||||
pub subject: String,
|
||||
pub tenant: TenantId,
|
||||
pub project: Option<ProjectId>,
|
||||
pub kind: CredentialKind,
|
||||
pub public_key_fingerprint: Option<Digest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CliSessionRecord {
|
||||
pub session_digest: Digest,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub user: UserId,
|
||||
pub credential_kind: CredentialKind,
|
||||
pub expires_at_epoch_seconds: Option<u64>,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceProviderConfigRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub provider: SourceProviderKind,
|
||||
pub manifest_digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServicePolicyRecord {
|
||||
pub tenant: TenantId,
|
||||
pub name: String,
|
||||
pub digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountPolicyState {
|
||||
pub account_status: String,
|
||||
pub suspended: bool,
|
||||
pub disabled: bool,
|
||||
pub deleted: bool,
|
||||
pub manual_review: bool,
|
||||
pub sanitized_reason: Option<String>,
|
||||
pub next_actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectPermissionRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub user: UserId,
|
||||
pub can_debug: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentPublicKeyRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub user: UserId,
|
||||
pub agent: AgentId,
|
||||
pub public_key: String,
|
||||
pub public_key_fingerprint: Digest,
|
||||
pub version: u64,
|
||||
pub revoked: bool,
|
||||
pub scopes: Vec<String>,
|
||||
pub human_account_creation_privilege: bool,
|
||||
pub browser_interaction_required_each_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct DurableState {
|
||||
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
||||
pub users: BTreeMap<UserId, UserRecord>,
|
||||
pub projects: BTreeMap<ProjectId, ProjectRecord>,
|
||||
pub node_identities: BTreeMap<NodeId, NodeIdentityRecord>,
|
||||
pub credentials: BTreeMap<String, CredentialRecord>,
|
||||
pub cli_sessions: BTreeMap<Digest, CliSessionRecord>,
|
||||
pub source_provider_configs:
|
||||
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
||||
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
||||
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
||||
pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>,
|
||||
}
|
||||
|
||||
pub trait DurableStore {
|
||||
fn load(&self) -> DurableState;
|
||||
fn save(&mut self, state: DurableState);
|
||||
}
|
||||
|
||||
pub trait FallibleDurableStore {
|
||||
type Error;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error>;
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InMemoryDurableStore {
|
||||
state: DurableState,
|
||||
}
|
||||
|
||||
impl DurableStore for InMemoryDurableStore {
|
||||
fn load(&self) -> DurableState {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
fn save(&mut self, state: DurableState) {
|
||||
self.state = state;
|
||||
}
|
||||
}
|
||||
1045
crates/disasmer-coordinator/src/lib.rs
Normal file
1045
crates/disasmer-coordinator/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
62
crates/disasmer-coordinator/src/main.rs
Normal file
62
crates/disasmer-coordinator/src/main.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use std::io::Write;
|
||||
|
||||
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
|
||||
use disasmer_core::{ProjectId, TenantId, UserId};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut listen = "127.0.0.1:0".to_owned();
|
||||
let mut allow_local_trusted = std::env::var("DISASMER_ALLOW_LOCAL_TRUSTED_LOOPBACK")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some("1");
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--listen" {
|
||||
listen = args.next().ok_or("--listen requires an address")?;
|
||||
} else if arg == "--allow-local-trusted-loopback" {
|
||||
allow_local_trusted = true;
|
||||
}
|
||||
}
|
||||
|
||||
let (listener, addr) = bind_listener(&listen)?;
|
||||
let database_url = std::env::var("DATABASE_URL").ok();
|
||||
let mut service = CoordinatorService::new_with_database_url(1, database_url.as_deref())?;
|
||||
let self_hosted_session_secret = std::env::var("DISASMER_SELF_HOSTED_SESSION_SECRET")
|
||||
.ok()
|
||||
.filter(|secret| !secret.trim().is_empty());
|
||||
if let Some(session_secret) = self_hosted_session_secret.as_deref() {
|
||||
service.issue_cli_session(
|
||||
TenantId::new(
|
||||
std::env::var("DISASMER_SELF_HOSTED_TENANT")
|
||||
.unwrap_or_else(|_| "tenant".to_owned()),
|
||||
),
|
||||
ProjectId::new(
|
||||
std::env::var("DISASMER_SELF_HOSTED_PROJECT")
|
||||
.unwrap_or_else(|_| "project".to_owned()),
|
||||
),
|
||||
UserId::new(
|
||||
std::env::var("DISASMER_SELF_HOSTED_USER").unwrap_or_else(|_| "user".to_owned()),
|
||||
),
|
||||
session_secret,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"listen": addr.to_string(),
|
||||
"client_authority": if allow_local_trusted { "local_trusted_loopback" } else { "strict" },
|
||||
"self_hosted_session_bootstrapped": self_hosted_session_secret.is_some(),
|
||||
"durable_store": service.durable_store_kind(),
|
||||
})
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
if allow_local_trusted {
|
||||
service.serve_tcp_local_trusted(listener)?;
|
||||
} else {
|
||||
service.serve_tcp(listener)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
576
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
576
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
use postgres::{Client, NoTls};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
AgentPublicKeyRecord, CliSessionRecord, CredentialRecord, DurableState, FallibleDurableStore,
|
||||
NodeIdentityRecord, ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord,
|
||||
SourceProviderConfigRecord, TenantRecord, UserRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostgresTable {
|
||||
pub name: &'static str,
|
||||
pub durable_record: &'static str,
|
||||
pub restart_surviving: bool,
|
||||
}
|
||||
|
||||
pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[
|
||||
PostgresTable {
|
||||
name: "disasmer_tenants",
|
||||
durable_record: "tenants",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_users",
|
||||
durable_record: "users",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_projects",
|
||||
durable_record: "projects",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_node_identities",
|
||||
durable_record: "node identities",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_credentials",
|
||||
durable_record: "credentials",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_cli_sessions",
|
||||
durable_record: "CLI sessions",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_agent_public_keys",
|
||||
durable_record: "agent public keys",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_source_provider_configs",
|
||||
durable_record: "source-provider configuration",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_service_policy_records",
|
||||
durable_record: "durable service policy records",
|
||||
restart_surviving: true,
|
||||
},
|
||||
PostgresTable {
|
||||
name: "disasmer_project_permissions",
|
||||
durable_record: "explicit project permissions",
|
||||
restart_surviving: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PostgresStoreError {
|
||||
#[error("postgres durable store error: {0}")]
|
||||
Postgres(#[from] postgres::Error),
|
||||
#[error("durable state serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub struct PostgresDurableStore {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl PostgresDurableStore {
|
||||
pub fn connect(connection_string: &str) -> Result<Self, PostgresStoreError> {
|
||||
let mut store = Self {
|
||||
client: Client::connect(connection_string, NoTls)?,
|
||||
};
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn from_client(client: Client) -> Result<Self, PostgresStoreError> {
|
||||
let mut store = Self { client };
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn schema_sql() -> &'static str {
|
||||
POSTGRES_SCHEMA_SQL
|
||||
}
|
||||
|
||||
pub fn durable_tables() -> &'static [PostgresTable] {
|
||||
POSTGRES_DURABLE_TABLES
|
||||
}
|
||||
|
||||
pub fn migrate(&mut self) -> Result<(), PostgresStoreError> {
|
||||
self.client.batch_execute(Self::schema_sql())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn query_records<T: DeserializeOwned>(
|
||||
&mut self,
|
||||
sql: &str,
|
||||
) -> Result<Vec<T>, PostgresStoreError> {
|
||||
self.client
|
||||
.query(sql, &[])?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let value: serde_json::Value = row.get("record");
|
||||
Ok(serde_json::from_value(value)?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_value(record: &impl Serialize) -> Result<serde_json::Value, PostgresStoreError> {
|
||||
Ok(serde_json::to_value(record)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl FallibleDurableStore for PostgresDurableStore {
|
||||
type Error = PostgresStoreError;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||
let mut state = DurableState::default();
|
||||
|
||||
for record in self.query_records::<TenantRecord>(
|
||||
"SELECT record FROM disasmer_tenants ORDER BY tenant_id",
|
||||
)? {
|
||||
state.tenants.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in
|
||||
self.query_records::<UserRecord>("SELECT record FROM disasmer_users ORDER BY user_id")?
|
||||
{
|
||||
state.users.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<ProjectRecord>(
|
||||
"SELECT record FROM disasmer_projects ORDER BY project_id",
|
||||
)? {
|
||||
state.projects.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<NodeIdentityRecord>(
|
||||
"SELECT record FROM disasmer_node_identities ORDER BY node_id",
|
||||
)? {
|
||||
state.node_identities.insert(record.id.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<CredentialRecord>(
|
||||
"SELECT record FROM disasmer_credentials ORDER BY subject",
|
||||
)? {
|
||||
state.credentials.insert(record.subject.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<CliSessionRecord>(
|
||||
"SELECT record FROM disasmer_cli_sessions ORDER BY session_digest",
|
||||
)? {
|
||||
state
|
||||
.cli_sessions
|
||||
.insert(record.session_digest.clone(), record);
|
||||
}
|
||||
for record in self.query_records::<AgentPublicKeyRecord>(
|
||||
"SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id",
|
||||
)? {
|
||||
state.agent_public_keys.insert(
|
||||
(
|
||||
record.tenant.clone(),
|
||||
record.project.clone(),
|
||||
record.agent.clone(),
|
||||
),
|
||||
record,
|
||||
);
|
||||
}
|
||||
for record in self.query_records::<SourceProviderConfigRecord>(
|
||||
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
|
||||
)? {
|
||||
let provider_key = format!("{:?}", record.provider);
|
||||
state.source_provider_configs.insert(
|
||||
(record.tenant.clone(), record.project.clone(), provider_key),
|
||||
record,
|
||||
);
|
||||
}
|
||||
for record in self.query_records::<ServicePolicyRecord>(
|
||||
"SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name",
|
||||
)? {
|
||||
state
|
||||
.service_policy_records
|
||||
.insert((record.tenant.clone(), record.name.clone()), record);
|
||||
}
|
||||
for record in self.query_records::<ProjectPermissionRecord>(
|
||||
"SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id",
|
||||
)? {
|
||||
state.project_permissions.insert(
|
||||
(
|
||||
record.tenant.clone(),
|
||||
record.project.clone(),
|
||||
record.user.clone(),
|
||||
),
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||
let mut tx = self.client.transaction()?;
|
||||
tx.batch_execute(
|
||||
"
|
||||
DELETE FROM disasmer_project_permissions;
|
||||
DELETE FROM disasmer_service_policy_records;
|
||||
DELETE FROM disasmer_source_provider_configs;
|
||||
DELETE FROM disasmer_agent_public_keys;
|
||||
DELETE FROM disasmer_cli_sessions;
|
||||
DELETE FROM disasmer_credentials;
|
||||
DELETE FROM disasmer_node_identities;
|
||||
DELETE FROM disasmer_projects;
|
||||
DELETE FROM disasmer_users;
|
||||
DELETE FROM disasmer_tenants;
|
||||
",
|
||||
)?;
|
||||
|
||||
for record in state.tenants.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)",
|
||||
&[&record.id.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.users.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.projects.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.node_identities.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.id.as_str(),
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for record in state.credentials.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
let project_id = record.project.as_ref().map(|project| project.as_str());
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.cli_sessions.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_cli_sessions (session_digest, tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4, $5)",
|
||||
&[
|
||||
&record.session_digest.as_str(),
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&record.user.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for record in state.agent_public_keys.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)",
|
||||
&[
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&record.user.as_str(),
|
||||
&record.agent.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for ((_, _, provider_key), record) in &state.source_provider_configs {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&provider_key.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for record in state.service_policy_records.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)",
|
||||
&[&record.tenant.as_str(), &record.name.as_str(), &value],
|
||||
)?;
|
||||
}
|
||||
for record in state.project_permissions.values() {
|
||||
let value = Self::record_value(record)?;
|
||||
tx.execute(
|
||||
"INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)",
|
||||
&[
|
||||
&record.tenant.as_str(),
|
||||
&record.project.as_str(),
|
||||
&record.user.as_str(),
|
||||
&value,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const POSTGRES_SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS disasmer_tenants (
|
||||
tenant_id TEXT PRIMARY KEY,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_node_identities (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_credentials (
|
||||
subject TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_cli_sessions (
|
||||
session_digest TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, project_id, agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
provider_key TEXT NOT NULL,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, project_id, provider_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_service_policy_records (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS disasmer_project_permissions (
|
||||
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||
record JSONB NOT NULL,
|
||||
PRIMARY KEY (tenant_id, project_id, user_id)
|
||||
);
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use disasmer_core::{
|
||||
CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore};
|
||||
|
||||
#[test]
|
||||
fn postgres_schema_contains_only_restart_surviving_durable_tables() {
|
||||
let names = PostgresDurableStore::durable_tables()
|
||||
.iter()
|
||||
.map(|table| table.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names.len(), 10);
|
||||
assert!(names.contains(&"disasmer_tenants"));
|
||||
assert!(names.contains(&"disasmer_users"));
|
||||
assert!(names.contains(&"disasmer_projects"));
|
||||
assert!(names.contains(&"disasmer_node_identities"));
|
||||
assert!(names.contains(&"disasmer_credentials"));
|
||||
assert!(names.contains(&"disasmer_cli_sessions"));
|
||||
assert!(names.contains(&"disasmer_agent_public_keys"));
|
||||
assert!(names.contains(&"disasmer_source_provider_configs"));
|
||||
assert!(names.contains(&"disasmer_service_policy_records"));
|
||||
assert!(names.contains(&"disasmer_project_permissions"));
|
||||
assert!(PostgresDurableStore::durable_tables()
|
||||
.iter()
|
||||
.all(|table| table.restart_surviving));
|
||||
|
||||
for runtime_only in [
|
||||
"active_process",
|
||||
"virtual_thread",
|
||||
"scheduler_state",
|
||||
"debug_epoch",
|
||||
"vfs_manifest",
|
||||
"transient_artifact_location",
|
||||
] {
|
||||
assert!(
|
||||
!PostgresDurableStore::schema_sql().contains(runtime_only),
|
||||
"{runtime_only} must remain outside Postgres durable state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() {
|
||||
#[derive(Default)]
|
||||
struct FallibleMemoryStore {
|
||||
inner: InMemoryDurableStore,
|
||||
}
|
||||
|
||||
impl FallibleDurableStore for FallibleMemoryStore {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||
Ok(self.inner.load())
|
||||
}
|
||||
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||
self.inner.save(state.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut store = FallibleMemoryStore::default();
|
||||
let mut first = Coordinator::try_boot(&mut store, 1).unwrap();
|
||||
first.upsert_tenant(TenantId::from("tenant"));
|
||||
first.upsert_user(
|
||||
TenantId::from("tenant"),
|
||||
UserId::from("user"),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||
first.enroll_node(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
);
|
||||
first.upsert_source_provider_config(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
SourceProviderKind::Git,
|
||||
Digest::sha256("git-manifest"),
|
||||
);
|
||||
first.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
disasmer_core::ProcessId::from("process"),
|
||||
);
|
||||
first.try_persist(&mut store).unwrap();
|
||||
|
||||
let restarted = Coordinator::try_boot(&mut store, 2).unwrap();
|
||||
|
||||
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||
assert_eq!(restarted.active_process_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_round_trip_runs_when_dsn_is_configured() {
|
||||
let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut store = PostgresDurableStore::connect(&dsn).unwrap();
|
||||
let mut state = DurableState::default();
|
||||
state.tenants.insert(
|
||||
TenantId::from("tenant"),
|
||||
TenantRecord {
|
||||
id: TenantId::from("tenant"),
|
||||
},
|
||||
);
|
||||
state.projects.insert(
|
||||
ProjectId::from("project"),
|
||||
ProjectRecord {
|
||||
id: ProjectId::from("project"),
|
||||
tenant: TenantId::from("tenant"),
|
||||
name: "demo".to_owned(),
|
||||
},
|
||||
);
|
||||
state.users.insert(
|
||||
UserId::from("user"),
|
||||
UserRecord {
|
||||
id: UserId::from("user"),
|
||||
tenant: TenantId::from("tenant"),
|
||||
credential_kind: CredentialKind::CliDeviceSession,
|
||||
},
|
||||
);
|
||||
let session_digests = [
|
||||
Digest::sha256("postgres-round-trip-session-one"),
|
||||
Digest::sha256("postgres-round-trip-session-two"),
|
||||
];
|
||||
for session_digest in &session_digests {
|
||||
state.cli_sessions.insert(
|
||||
session_digest.clone(),
|
||||
CliSessionRecord {
|
||||
session_digest: session_digest.clone(),
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
user: UserId::from("user"),
|
||||
credential_kind: CredentialKind::CliDeviceSession,
|
||||
expires_at_epoch_seconds: None,
|
||||
revoked: false,
|
||||
},
|
||||
);
|
||||
let subject = format!("cli-session:{}", session_digest.as_str());
|
||||
state.credentials.insert(
|
||||
subject.clone(),
|
||||
CredentialRecord {
|
||||
subject,
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: Some(ProjectId::from("project")),
|
||||
kind: CredentialKind::CliDeviceSession,
|
||||
public_key_fingerprint: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
store.save_state(&state).unwrap();
|
||||
let loaded = store.load_state().unwrap();
|
||||
|
||||
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
|
||||
assert!(session_digests
|
||||
.iter()
|
||||
.all(|session_digest| loaded.cli_sessions.contains_key(session_digest)));
|
||||
assert_eq!(loaded.credentials.len(), 2);
|
||||
}
|
||||
}
|
||||
423
crates/disasmer-coordinator/src/service.rs
Normal file
423
crates/disasmer-coordinator/src/service.rs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Request handlers intentionally spell out their deserialized protocol fields.
|
||||
// Keeping those authority and payload values explicit at this boundary is safer
|
||||
// than passing an unvalidated wire request deeper into the service.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{
|
||||
Actor, AgentId, ArtifactRegistry, CapabilityReportError, CredentialKind, Digest, LimitError,
|
||||
NativeQuicTransport, NodeDescriptor, NodeId, PanelState, Placement, ProcessId, ProjectId,
|
||||
RateLimit, TenantId, TransportError, UserId,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Coordinator, CoordinatorError};
|
||||
|
||||
mod admin;
|
||||
mod artifacts;
|
||||
mod authenticated;
|
||||
mod authorization;
|
||||
mod debug;
|
||||
mod debug_requests;
|
||||
mod durable_runtime;
|
||||
mod keys;
|
||||
mod logs;
|
||||
mod main_runtime;
|
||||
mod nodes;
|
||||
mod panels;
|
||||
mod process_launch;
|
||||
mod processes;
|
||||
mod protocol;
|
||||
mod quota;
|
||||
mod routing;
|
||||
mod signed_nodes;
|
||||
mod tcp;
|
||||
mod wire_protocol;
|
||||
use authorization::authorize_authenticated_user_operation;
|
||||
use durable_runtime::RuntimeDurableStore;
|
||||
use keys::{
|
||||
artifact_id_from_path, enrollment_grant_key, EnrollmentGrantKey, PanelStopKey,
|
||||
ProcessControlKey, TaskAssignmentKey, TaskControlKey, TaskRestartKey,
|
||||
};
|
||||
pub use protocol::{
|
||||
ArtifactTransferAssignment, AuthenticatedCoordinatorRequest, CoordinatorRequest,
|
||||
CoordinatorResponse, DebugAcknowledgementState, DebugAuditEvent,
|
||||
DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus,
|
||||
TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor,
|
||||
TaskReplacementBundle, TaskTerminalState, VirtualProcessStatus, WorkflowActor,
|
||||
};
|
||||
pub use quota::CoordinatorQuotaConfiguration;
|
||||
pub use tcp::{bind_listener, ClientAuthorityMode};
|
||||
pub use wire_protocol::CoordinatorWireRequest;
|
||||
|
||||
const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024;
|
||||
const DEBUG_CONTROL_READ_BYTES: u64 = 1024;
|
||||
const MAX_REPLAY_NONCES_PER_AUTHORITY: usize = 1_024;
|
||||
const NODE_SIGNATURE_WINDOW_SECONDS: u64 = 30;
|
||||
const MAX_NODE_REPLAY_NONCES_PER_AUTHORITY: usize = 4_096;
|
||||
const MAX_ENROLLMENT_GRANTS_PER_PROJECT: usize = 64;
|
||||
const MAX_TASK_EVENTS_PER_PROCESS: usize = 128;
|
||||
const MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS: usize = 256;
|
||||
const MAX_RESTART_CHECKPOINTS_PER_PROCESS: usize = 128;
|
||||
const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256;
|
||||
const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024;
|
||||
fn bounded_ttl(requested: u64, maximum: u64) -> u64 {
|
||||
requested.clamp(1, maximum)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CoordinatorAdmission {
|
||||
pub workflow_placement_allowed: bool,
|
||||
pub max_node_enrollment_ttl_seconds: u64,
|
||||
pub max_artifact_download_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for CoordinatorAdmission {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workflow_placement_allowed: true,
|
||||
max_node_enrollment_ttl_seconds: 15 * 60,
|
||||
max_artifact_download_ttl_seconds: 15 * 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CoordinatorServiceError {
|
||||
#[error("coordinator protocol I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("coordinator protocol JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("coordinator protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("coordinator request failed: {0}")]
|
||||
Coordinator(#[from] CoordinatorError),
|
||||
#[error("artifact download request failed: {0}")]
|
||||
Download(#[from] disasmer_core::DownloadError),
|
||||
#[error("scheduler placement failed: {0}")]
|
||||
Scheduler(#[from] disasmer_core::PlacementError),
|
||||
#[error("transport request failed: {0}")]
|
||||
Transport(#[from] TransportError),
|
||||
#[error("resource limit failed: {0}")]
|
||||
Resource(#[from] LimitError),
|
||||
#[error("operator panel request failed: {0}")]
|
||||
Panel(#[from] disasmer_core::PanelError),
|
||||
#[error("invalid node capability report: {0}")]
|
||||
CapabilityReport(#[from] CapabilityReportError),
|
||||
#[error("invalid VFS artifact path reported by node: {0}")]
|
||||
InvalidArtifactPath(String),
|
||||
#[error("invalid task log tail reported by node: {0}")]
|
||||
InvalidTaskLogTail(String),
|
||||
#[error("durable coordinator state failed: {0}")]
|
||||
Durable(String),
|
||||
}
|
||||
|
||||
pub struct CoordinatorService {
|
||||
coordinator: Coordinator,
|
||||
store: RuntimeDurableStore,
|
||||
node_descriptors: BTreeMap<NodeId, NodeDescriptor>,
|
||||
enrollment_grants: BTreeMap<EnrollmentGrantKey, disasmer_core::EnrollmentGrant>,
|
||||
task_events: VecDeque<TaskCompletionEvent>,
|
||||
debug_audit_events: VecDeque<DebugAuditEvent>,
|
||||
debug_epochs: BTreeMap<ProcessControlKey, u64>,
|
||||
debug_epoch_runtime: BTreeMap<ProcessControlKey, debug::DebugEpochRuntime>,
|
||||
debug_breakpoints: BTreeMap<ProcessControlKey, debug::DebugBreakpointPlan>,
|
||||
debug_commands: BTreeMap<TaskControlKey, debug::DebugPendingCommand>,
|
||||
task_assignments: BTreeMap<TaskAssignmentKey, VecDeque<TaskAssignment>>,
|
||||
task_restart_checkpoints: BTreeMap<TaskRestartKey, processes::TaskRestartCheckpoint>,
|
||||
task_restart_checkpoint_order: VecDeque<TaskRestartKey>,
|
||||
main_runtime: main_runtime::CoordinatorMainRuntime,
|
||||
pending_task_launches: VecDeque<processes::PendingTaskLaunch>,
|
||||
task_placements: BTreeMap<TaskControlKey, Placement>,
|
||||
active_tasks: BTreeSet<TaskControlKey>,
|
||||
task_cancellations: BTreeSet<TaskControlKey>,
|
||||
task_aborts: BTreeSet<TaskControlKey>,
|
||||
process_cancellations: BTreeSet<ProcessControlKey>,
|
||||
process_aborts: BTreeSet<ProcessControlKey>,
|
||||
agent_replay_nonces: BTreeMap<(TenantId, ProjectId, AgentId, String), u64>,
|
||||
node_replay_nonces: BTreeMap<(NodeId, String), u64>,
|
||||
panel_snapshots: BTreeMap<PanelStopKey, PanelState>,
|
||||
stopped_panels: BTreeSet<PanelStopKey>,
|
||||
panel_event_limits: BTreeMap<(TenantId, ProjectId, ProcessId, String), RateLimit>,
|
||||
artifact_registry: ArtifactRegistry,
|
||||
artifact_reverse_transfers: BTreeMap<String, artifacts::ArtifactReverseTransfer>,
|
||||
artifact_transfer_by_token: BTreeMap<Digest, String>,
|
||||
transport: NativeQuicTransport,
|
||||
quota: quota::CoordinatorQuota,
|
||||
admission: CoordinatorAdmission,
|
||||
#[cfg(test)]
|
||||
server_time_override: Option<u64>,
|
||||
admin_token_digest: Option<Digest>,
|
||||
admin_replay_nonces: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn authorize_node_for_process_or_termination(
|
||||
&self,
|
||||
node: &NodeId,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let process_key = keys::process_control_key(tenant, project, process);
|
||||
if self.process_cancellations.contains(&process_key)
|
||||
|| self.process_aborts.contains(&process_key)
|
||||
{
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if &identity.tenant != tenant || &identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node process-control request is outside its enrolled tenant/project scope"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.coordinator
|
||||
.authorize_node_for_process(node, tenant, project, process)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn new(coordinator_epoch: u64) -> Self {
|
||||
Self::new_with_optional_admin_token_and_admission(
|
||||
coordinator_epoch,
|
||||
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
|
||||
CoordinatorAdmission::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_admin_token(coordinator_epoch: u64, admin_token: impl Into<String>) -> Self {
|
||||
Self::new_with_optional_admin_token_and_admission(
|
||||
coordinator_epoch,
|
||||
Some(admin_token.into()),
|
||||
CoordinatorAdmission::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_admission(coordinator_epoch: u64, admission: CoordinatorAdmission) -> Self {
|
||||
Self::new_with_optional_admin_token_and_admission(
|
||||
coordinator_epoch,
|
||||
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
|
||||
admission,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_optional_admin_token_and_admission(
|
||||
coordinator_epoch: u64,
|
||||
admin_token: Option<String>,
|
||||
admission: CoordinatorAdmission,
|
||||
) -> Self {
|
||||
Self::try_new_with_optional_admin_token_admission_and_database_url(
|
||||
coordinator_epoch,
|
||||
admin_token,
|
||||
admission,
|
||||
None,
|
||||
CoordinatorQuotaConfiguration::default(),
|
||||
)
|
||||
.expect("in-memory durable coordinator store initialization cannot fail")
|
||||
}
|
||||
|
||||
pub fn new_with_database_url(
|
||||
coordinator_epoch: u64,
|
||||
database_url: Option<&str>,
|
||||
) -> Result<Self, CoordinatorServiceError> {
|
||||
Self::try_new_with_optional_admin_token_admission_and_database_url(
|
||||
coordinator_epoch,
|
||||
std::env::var("DISASMER_ADMIN_TOKEN").ok(),
|
||||
CoordinatorAdmission::default(),
|
||||
database_url,
|
||||
CoordinatorQuotaConfiguration::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_admin_token_and_database_url(
|
||||
coordinator_epoch: u64,
|
||||
admin_token: impl Into<String>,
|
||||
database_url: Option<&str>,
|
||||
) -> Result<Self, CoordinatorServiceError> {
|
||||
Self::new_with_admin_token_database_url_and_quota(
|
||||
coordinator_epoch,
|
||||
admin_token,
|
||||
database_url,
|
||||
CoordinatorQuotaConfiguration::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_admin_token_database_url_and_quota(
|
||||
coordinator_epoch: u64,
|
||||
admin_token: impl Into<String>,
|
||||
database_url: Option<&str>,
|
||||
quota_configuration: CoordinatorQuotaConfiguration,
|
||||
) -> Result<Self, CoordinatorServiceError> {
|
||||
Self::try_new_with_optional_admin_token_admission_and_database_url(
|
||||
coordinator_epoch,
|
||||
Some(admin_token.into()),
|
||||
CoordinatorAdmission::default(),
|
||||
database_url,
|
||||
quota_configuration,
|
||||
)
|
||||
}
|
||||
|
||||
fn try_new_with_optional_admin_token_admission_and_database_url(
|
||||
coordinator_epoch: u64,
|
||||
admin_token: Option<String>,
|
||||
admission: CoordinatorAdmission,
|
||||
database_url: Option<&str>,
|
||||
quota_configuration: CoordinatorQuotaConfiguration,
|
||||
) -> Result<Self, CoordinatorServiceError> {
|
||||
let mut store = RuntimeDurableStore::from_database_url(database_url)
|
||||
.map_err(CoordinatorServiceError::Durable)?;
|
||||
let coordinator = Coordinator::try_boot(&mut store, coordinator_epoch)
|
||||
.map_err(CoordinatorServiceError::Durable)?;
|
||||
let admin_token_digest = admin_token
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.map(Digest::sha256);
|
||||
Ok(Self {
|
||||
coordinator,
|
||||
store,
|
||||
node_descriptors: BTreeMap::new(),
|
||||
enrollment_grants: BTreeMap::new(),
|
||||
task_events: VecDeque::new(),
|
||||
debug_audit_events: VecDeque::new(),
|
||||
debug_epochs: BTreeMap::new(),
|
||||
debug_epoch_runtime: BTreeMap::new(),
|
||||
debug_breakpoints: BTreeMap::new(),
|
||||
debug_commands: BTreeMap::new(),
|
||||
task_assignments: BTreeMap::new(),
|
||||
task_restart_checkpoints: BTreeMap::new(),
|
||||
task_restart_checkpoint_order: VecDeque::new(),
|
||||
main_runtime: main_runtime::CoordinatorMainRuntime::default(),
|
||||
pending_task_launches: VecDeque::new(),
|
||||
task_placements: BTreeMap::new(),
|
||||
active_tasks: BTreeSet::new(),
|
||||
task_cancellations: BTreeSet::new(),
|
||||
task_aborts: BTreeSet::new(),
|
||||
process_cancellations: BTreeSet::new(),
|
||||
process_aborts: BTreeSet::new(),
|
||||
agent_replay_nonces: BTreeMap::new(),
|
||||
node_replay_nonces: BTreeMap::new(),
|
||||
panel_snapshots: BTreeMap::new(),
|
||||
stopped_panels: BTreeSet::new(),
|
||||
panel_event_limits: BTreeMap::new(),
|
||||
artifact_registry: ArtifactRegistry::default(),
|
||||
artifact_reverse_transfers: BTreeMap::new(),
|
||||
artifact_transfer_by_token: BTreeMap::new(),
|
||||
transport: NativeQuicTransport,
|
||||
quota: quota::CoordinatorQuota::new(quota_configuration),
|
||||
admission,
|
||||
#[cfg(test)]
|
||||
server_time_override: None,
|
||||
admin_token_digest,
|
||||
admin_replay_nonces: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn durable_store_kind(&self) -> &'static str {
|
||||
self.store.kind()
|
||||
}
|
||||
|
||||
fn persist_durable_state(&mut self) -> Result<(), CoordinatorServiceError> {
|
||||
self.coordinator
|
||||
.try_persist(&mut self.store)
|
||||
.map_err(CoordinatorServiceError::Durable)
|
||||
}
|
||||
|
||||
fn current_epoch_seconds(&self) -> Result<u64, CoordinatorServiceError> {
|
||||
#[cfg(test)]
|
||||
if let Some(now) = self.server_time_override {
|
||||
return Ok(now);
|
||||
}
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.map_err(|err| CoordinatorServiceError::Protocol(format!("system clock error: {err}")))
|
||||
}
|
||||
|
||||
fn handle_quota_status(
|
||||
&self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let durable_project = self.coordinator.project(&project).ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized("quota status requires an existing project".to_owned())
|
||||
})?;
|
||||
if durable_project.tenant != tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"quota status project is outside the tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
let status = self
|
||||
.quota
|
||||
.project_status(&tenant, &project, now_epoch_seconds);
|
||||
Ok(CoordinatorResponse::QuotaStatus {
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
policy_label: status.policy_label,
|
||||
limits: status.limits,
|
||||
window_seconds: status.window_seconds,
|
||||
usage: status.usage,
|
||||
window_started_epoch_seconds: status.window_started_epoch_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_server_time(&mut self, now_epoch_seconds: u64) {
|
||||
self.server_time_override = Some(now_epoch_seconds);
|
||||
}
|
||||
|
||||
pub fn issue_cli_session(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
user: UserId,
|
||||
session_secret: &str,
|
||||
expires_at_epoch_seconds: Option<u64>,
|
||||
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
|
||||
if let Some(existing) = self.coordinator.project(&project) {
|
||||
if existing.tenant != tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"CLI session project belongs to a different tenant".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
} else {
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "Session project");
|
||||
}
|
||||
self.coordinator
|
||||
.grant_project_debug(tenant.clone(), project.clone(), user.clone());
|
||||
let record = self.coordinator.issue_cli_session(
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
session_secret,
|
||||
expires_at_epoch_seconds,
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn revoke_cli_session(
|
||||
&mut self,
|
||||
session_secret: &str,
|
||||
) -> Result<crate::CliSessionRecord, CoordinatorServiceError> {
|
||||
let record = self.coordinator.revoke_cli_session(session_secret)?;
|
||||
self.persist_durable_state()?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
147
crates/disasmer-coordinator/src/service/admin.rs
Normal file
147
crates/disasmer-coordinator/src/service/admin.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{
|
||||
admin_request_proof_from_token_digest, CredentialKind, Digest, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
|
||||
|
||||
const ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS: u64 = 300;
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_admin_status(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
admin_proof: Digest,
|
||||
admin_nonce: String,
|
||||
issued_at_epoch_seconds: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
self.verify_admin_request(
|
||||
"admin_status",
|
||||
&tenant,
|
||||
&actor_user,
|
||||
&tenant,
|
||||
&admin_proof,
|
||||
&admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
)?;
|
||||
let tenant = TenantId::new(tenant);
|
||||
let actor = UserId::new(actor_user);
|
||||
Ok(CoordinatorResponse::AdminStatus {
|
||||
suspended: self.coordinator.tenant_suspended(&tenant),
|
||||
tenant,
|
||||
actor,
|
||||
safe_default: "read_only".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_suspend_tenant(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
target_tenant: String,
|
||||
admin_proof: Digest,
|
||||
admin_nonce: String,
|
||||
issued_at_epoch_seconds: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
self.verify_admin_request(
|
||||
"suspend_tenant",
|
||||
&tenant,
|
||||
&actor_user,
|
||||
&target_tenant,
|
||||
&admin_proof,
|
||||
&admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
)?;
|
||||
let actor_tenant = TenantId::new(tenant);
|
||||
let actor = UserId::new(actor_user);
|
||||
let target_tenant = TenantId::new(target_tenant);
|
||||
self.coordinator.upsert_tenant(actor_tenant.clone());
|
||||
self.coordinator.upsert_user(
|
||||
actor_tenant,
|
||||
actor.clone(),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
let policy = self
|
||||
.coordinator
|
||||
.suspend_tenant(target_tenant.clone(), actor.clone());
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::TenantSuspended {
|
||||
tenant: target_tenant,
|
||||
actor,
|
||||
policy,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn verify_admin_request(
|
||||
&mut self,
|
||||
operation: &str,
|
||||
tenant: &str,
|
||||
actor_user: &str,
|
||||
target_tenant: &str,
|
||||
admin_proof: &Digest,
|
||||
admin_nonce: &str,
|
||||
issued_at_epoch_seconds: u64,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let expected = self.admin_token_digest.as_ref().ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"self-hosted admin credential is not configured".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if admin_nonce.trim().is_empty() || admin_nonce.len() > 256 {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"admin request nonce is missing or invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
if now.abs_diff(issued_at_epoch_seconds) > ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"admin request timestamp is outside the allowed 300-second window".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let expected_proof = admin_request_proof_from_token_digest(
|
||||
expected,
|
||||
operation,
|
||||
tenant,
|
||||
actor_user,
|
||||
target_tenant,
|
||||
admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
);
|
||||
if admin_proof != &expected_proof {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"admin request proof is invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.admin_replay_nonces.retain(|_, issued_at| {
|
||||
now <= issued_at.saturating_add(ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS)
|
||||
});
|
||||
if self.admin_replay_nonces.contains_key(admin_nonce) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"admin request nonce was already used".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if self.admin_replay_nonces.len() >= super::MAX_REPLAY_NONCES_PER_AUTHORITY {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"admin request replay window is full; retry after the bounded signature window advances"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.admin_replay_nonces
|
||||
.insert(admin_nonce.to_owned(), issued_at_epoch_seconds);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
649
crates/disasmer-coordinator/src/service/artifacts.rs
Normal file
649
crates/disasmer-coordinator/src/service/artifacts.rs
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use disasmer_core::{
|
||||
generate_opaque_token, Actor, ArtifactId, AuthContext, DataPlaneObject, DataPlaneScope, Digest,
|
||||
DownloadPolicy, NodeEndpoint, NodeId, ProjectId, RendezvousRequest, ResourceLimits,
|
||||
ResourceMeter, StorageLocation, TenantId, UserId,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{
|
||||
bounded_ttl, ArtifactTransferAssignment, CoordinatorResponse, CoordinatorService,
|
||||
CoordinatorServiceError,
|
||||
};
|
||||
|
||||
pub(super) const MAX_ARTIFACT_REVERSE_CHUNK_BYTES: u64 = 256 * 1024;
|
||||
const MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS: usize = 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ArtifactReverseTransfer {
|
||||
transfer_id: String,
|
||||
token_digest: Digest,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
source_node: NodeId,
|
||||
artifact: ArtifactId,
|
||||
expected_digest: Digest,
|
||||
expected_size_bytes: u64,
|
||||
expires_at_epoch_seconds: u64,
|
||||
spool: tempfile::NamedTempFile,
|
||||
received_bytes: u64,
|
||||
content_hasher: Sha256,
|
||||
delivered_offset: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_create_artifact_download_link(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let context = user_context(tenant, project, actor_user);
|
||||
let artifact = ArtifactId::new(artifact);
|
||||
let policy = DownloadPolicy { max_bytes };
|
||||
let action = self
|
||||
.artifact_registry
|
||||
.download_action(&context, &artifact, &policy)?;
|
||||
self.ensure_download_source_connectivity(&action.source)?;
|
||||
let downloadable_size = self
|
||||
.artifact_registry
|
||||
.downloadable_size(&context, &artifact, &policy)?;
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.quota.can_charge_download(
|
||||
&context.tenant,
|
||||
&context.project,
|
||||
downloadable_size,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
let token_nonce = generate_opaque_token("artifact_download")
|
||||
.map_err(CoordinatorServiceError::Protocol)?;
|
||||
let ttl_seconds = bounded_ttl(
|
||||
ttl_seconds,
|
||||
self.admission.max_artifact_download_ttl_seconds,
|
||||
);
|
||||
let link = self.artifact_registry.create_download_link(
|
||||
&context,
|
||||
&artifact,
|
||||
&policy,
|
||||
&token_nonce,
|
||||
now_epoch_seconds,
|
||||
ttl_seconds,
|
||||
)?;
|
||||
Ok(CoordinatorResponse::ArtifactDownloadLink { link })
|
||||
}
|
||||
|
||||
pub(super) fn handle_open_artifact_download_stream(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
token_digest: Digest,
|
||||
chunk_bytes: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let context = user_context(tenant, project, actor_user);
|
||||
let artifact = ArtifactId::new(artifact);
|
||||
let policy = DownloadPolicy { max_bytes };
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.artifact_registry
|
||||
.expire_download_links(now_epoch_seconds);
|
||||
let downloadable_size = self
|
||||
.artifact_registry
|
||||
.downloadable_size(&context, &artifact, &policy)?;
|
||||
let validation_limits = ResourceLimits::unlimited();
|
||||
let mut validation_meter = ResourceMeter::default();
|
||||
let mut stream = self.artifact_registry.open_download_stream(
|
||||
disasmer_core::DownloadStreamRequest {
|
||||
context: &context,
|
||||
artifact: &artifact,
|
||||
policy: &policy,
|
||||
presented_token_digest: &token_digest,
|
||||
now_epoch_seconds,
|
||||
limits: &validation_limits,
|
||||
},
|
||||
&mut validation_meter,
|
||||
)?;
|
||||
self.ensure_download_source_connectivity(&stream.link.source)?;
|
||||
self.expire_artifact_reverse_transfers(now_epoch_seconds);
|
||||
|
||||
if let Some(transfer_id) = self.artifact_transfer_by_token.get(&token_digest).cloned() {
|
||||
if let Some(message) = self
|
||||
.artifact_reverse_transfers
|
||||
.get(&transfer_id)
|
||||
.and_then(|transfer| transfer.error.clone())
|
||||
{
|
||||
self.artifact_reverse_transfers.remove(&transfer_id);
|
||||
self.artifact_transfer_by_token.remove(&token_digest);
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"retaining node could not stream artifact: {message}"
|
||||
)));
|
||||
}
|
||||
let (content_offset, content, end, complete) = {
|
||||
let transfer = self
|
||||
.artifact_reverse_transfers
|
||||
.get_mut(&transfer_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer index is inconsistent".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if transfer.tenant != context.tenant
|
||||
|| transfer.project != context.project
|
||||
|| transfer.artifact != artifact
|
||||
|| transfer.token_digest != token_digest
|
||||
{
|
||||
return Err(disasmer_core::DownloadError::InvalidToken.into());
|
||||
}
|
||||
if transfer.received_bytes != transfer.expected_size_bytes {
|
||||
return Ok(CoordinatorResponse::ArtifactDownloadStream {
|
||||
link: stream.link,
|
||||
streamed_bytes: 0,
|
||||
charged_download_bytes: self.quota.used_download_bytes(
|
||||
&context.tenant,
|
||||
&context.project,
|
||||
now_epoch_seconds,
|
||||
),
|
||||
content_bytes_available: false,
|
||||
content_offset: None,
|
||||
content_eof: false,
|
||||
content_base64: None,
|
||||
content_source: Some("retaining_node_reverse_stream_pending".to_owned()),
|
||||
});
|
||||
}
|
||||
if chunk_bytes == 0 && downloadable_size != 0 {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact download chunk_bytes must be greater than zero".to_owned(),
|
||||
));
|
||||
}
|
||||
let requested = chunk_bytes
|
||||
.min(downloadable_size)
|
||||
.min(MAX_ARTIFACT_REVERSE_CHUNK_BYTES);
|
||||
let start = transfer.delivered_offset;
|
||||
let end = start.saturating_add(requested).min(transfer.received_bytes);
|
||||
let length = usize::try_from(end.saturating_sub(start)).map_err(|_| {
|
||||
CoordinatorServiceError::Protocol(
|
||||
"artifact download chunk length does not fit memory bounds".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let mut content = vec![0_u8; length];
|
||||
let mut spool = transfer.spool.reopen().map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"open bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
spool.seek(SeekFrom::Start(start)).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"seek bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
spool.read_exact(&mut content).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"read bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
(start, content, end, end == transfer.received_bytes)
|
||||
};
|
||||
let streamed_bytes = content.len() as u64;
|
||||
self.artifact_registry.stream_download_chunk(
|
||||
&mut stream,
|
||||
&validation_limits,
|
||||
&mut validation_meter,
|
||||
streamed_bytes,
|
||||
)?;
|
||||
let charged_download_bytes = self.quota.charge_download(
|
||||
&context.tenant,
|
||||
&context.project,
|
||||
streamed_bytes,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
if let Some(transfer) = self.artifact_reverse_transfers.get_mut(&transfer_id) {
|
||||
transfer.delivered_offset = end;
|
||||
}
|
||||
if complete {
|
||||
self.artifact_reverse_transfers.remove(&transfer_id);
|
||||
self.artifact_transfer_by_token.remove(&token_digest);
|
||||
}
|
||||
return Ok(CoordinatorResponse::ArtifactDownloadStream {
|
||||
link: stream.link,
|
||||
streamed_bytes,
|
||||
charged_download_bytes,
|
||||
content_bytes_available: true,
|
||||
content_offset: Some(content_offset),
|
||||
content_eof: complete,
|
||||
content_base64: Some(BASE64_STANDARD.encode(content)),
|
||||
content_source: Some("retaining_node_reverse_stream".to_owned()),
|
||||
});
|
||||
}
|
||||
|
||||
if self.artifact_reverse_transfers.len() >= MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"artifact reverse transfer concurrency limit of {MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS} reached"
|
||||
)));
|
||||
}
|
||||
let StorageLocation::RetainedNode(source_node) = &stream.link.source else {
|
||||
return Err(disasmer_core::DownloadError::Unavailable.into());
|
||||
};
|
||||
let metadata = self
|
||||
.artifact_registry
|
||||
.metadata(&artifact)
|
||||
.ok_or(disasmer_core::DownloadError::NotFound)?;
|
||||
let transfer_id = generate_opaque_token("artifact_transfer")
|
||||
.map_err(CoordinatorServiceError::Protocol)?;
|
||||
let spool = tempfile::Builder::new()
|
||||
.prefix("disasmer-artifact-transfer-")
|
||||
.tempfile()
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"create bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
let transfer = ArtifactReverseTransfer {
|
||||
transfer_id: transfer_id.clone(),
|
||||
token_digest: token_digest.clone(),
|
||||
tenant: context.tenant.clone(),
|
||||
project: context.project.clone(),
|
||||
source_node: source_node.clone(),
|
||||
artifact,
|
||||
expected_digest: metadata.digest.clone(),
|
||||
expected_size_bytes: metadata.size,
|
||||
expires_at_epoch_seconds: stream.link.expires_at_epoch_seconds,
|
||||
spool,
|
||||
received_bytes: 0,
|
||||
content_hasher: Sha256::new(),
|
||||
delivered_offset: 0,
|
||||
error: None,
|
||||
};
|
||||
self.artifact_reverse_transfers
|
||||
.insert(transfer_id.clone(), transfer);
|
||||
self.artifact_transfer_by_token
|
||||
.insert(token_digest, transfer_id);
|
||||
Ok(CoordinatorResponse::ArtifactDownloadStream {
|
||||
link: stream.link,
|
||||
streamed_bytes: 0,
|
||||
charged_download_bytes: self.quota.used_download_bytes(
|
||||
&context.tenant,
|
||||
&context.project,
|
||||
now_epoch_seconds,
|
||||
),
|
||||
content_bytes_available: false,
|
||||
content_offset: None,
|
||||
content_eof: false,
|
||||
content_base64: None,
|
||||
content_source: Some("retaining_node_reverse_stream_pending".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_revoke_artifact_download_link(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
token_digest: Digest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let context = user_context(tenant, project, actor_user);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.artifact_registry
|
||||
.expire_download_links(now_epoch_seconds);
|
||||
let link = self.artifact_registry.revoke_download_link(
|
||||
&context,
|
||||
&ArtifactId::new(artifact),
|
||||
&token_digest,
|
||||
)?;
|
||||
if let Some(transfer_id) = self.artifact_transfer_by_token.remove(&token_digest) {
|
||||
self.artifact_reverse_transfers.remove(&transfer_id);
|
||||
}
|
||||
Ok(CoordinatorResponse::ArtifactDownloadLinkRevoked { link })
|
||||
}
|
||||
|
||||
pub(super) fn handle_export_artifact_to_node(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
receiver_node: String,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let context = user_context(tenant, project, actor_user);
|
||||
let artifact = ArtifactId::new(artifact);
|
||||
let receiver_node = NodeId::new(receiver_node);
|
||||
let action = self.artifact_registry.download_action(
|
||||
&context,
|
||||
&artifact,
|
||||
&DownloadPolicy {
|
||||
max_bytes: u64::MAX,
|
||||
},
|
||||
)?;
|
||||
let StorageLocation::RetainedNode(source_node) = action.source else {
|
||||
return Err(disasmer_core::DownloadError::Unavailable.into());
|
||||
};
|
||||
let metadata = self
|
||||
.artifact_registry
|
||||
.metadata(&artifact)
|
||||
.ok_or(disasmer_core::DownloadError::NotFound)?;
|
||||
let source = self.export_endpoint(&source_node, &context.tenant, &context.project)?;
|
||||
let destination =
|
||||
self.export_endpoint(&receiver_node, &context.tenant, &context.project)?;
|
||||
let plan = self.transport.plan_authenticated_direct_bulk_transfer(
|
||||
RendezvousRequest {
|
||||
scope: DataPlaneScope {
|
||||
tenant: context.tenant.clone(),
|
||||
project: context.project.clone(),
|
||||
process: metadata.process.clone(),
|
||||
object: DataPlaneObject::Artifact(artifact.clone()),
|
||||
authorization_subject: format!(
|
||||
"artifact-export:{}-to-{}",
|
||||
source_node, receiver_node
|
||||
),
|
||||
},
|
||||
source,
|
||||
destination,
|
||||
},
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
)?;
|
||||
Ok(CoordinatorResponse::ArtifactExportPlan {
|
||||
plan,
|
||||
source_node,
|
||||
receiver_node,
|
||||
artifact_size_bytes: metadata.size,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_poll_artifact_transfer(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
|
||||
let transfer = self.artifact_reverse_transfers.values().find(|transfer| {
|
||||
transfer.tenant == tenant
|
||||
&& transfer.project == project
|
||||
&& transfer.source_node == node
|
||||
&& transfer.error.is_none()
|
||||
&& transfer.received_bytes < transfer.expected_size_bytes
|
||||
});
|
||||
Ok(CoordinatorResponse::ArtifactTransferAssignment {
|
||||
transfer: transfer.map(|transfer| ArtifactTransferAssignment {
|
||||
transfer_id: transfer.transfer_id.clone(),
|
||||
artifact: transfer.artifact.clone(),
|
||||
expected_digest: transfer.expected_digest.clone(),
|
||||
expected_size_bytes: transfer.expected_size_bytes,
|
||||
offset: transfer.received_bytes,
|
||||
max_chunk_bytes: MAX_ARTIFACT_REVERSE_CHUNK_BYTES,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_upload_artifact_transfer_chunk(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
transfer_id: String,
|
||||
artifact: String,
|
||||
offset: u64,
|
||||
content_base64: String,
|
||||
chunk_digest: Digest,
|
||||
eof: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
|
||||
let transfer = self
|
||||
.artifact_reverse_transfers
|
||||
.get_mut(&transfer_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned())
|
||||
})?;
|
||||
if transfer.tenant != tenant
|
||||
|| transfer.project != project
|
||||
|| transfer.source_node != node
|
||||
|| transfer.artifact.as_str() != artifact
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"artifact reverse transfer is outside the signed node scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if transfer.received_bytes != offset {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"artifact reverse transfer expected offset {}, received {offset}",
|
||||
transfer.received_bytes
|
||||
)));
|
||||
}
|
||||
let content = BASE64_STANDARD.decode(content_base64).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"artifact reverse transfer chunk is not valid base64: {error}"
|
||||
))
|
||||
})?;
|
||||
if content.len() as u64 > MAX_ARTIFACT_REVERSE_CHUNK_BYTES {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer chunk exceeds the control-plane limit".to_owned(),
|
||||
));
|
||||
}
|
||||
if Digest::sha256(&content) != chunk_digest {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer chunk digest mismatch".to_owned(),
|
||||
));
|
||||
}
|
||||
let next_offset = offset.saturating_add(content.len() as u64);
|
||||
if next_offset > transfer.expected_size_bytes {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer exceeds retained metadata size".to_owned(),
|
||||
));
|
||||
}
|
||||
let complete = next_offset == transfer.expected_size_bytes;
|
||||
if eof != complete {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer EOF does not match retained metadata size".to_owned(),
|
||||
));
|
||||
}
|
||||
transfer
|
||||
.spool
|
||||
.as_file_mut()
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.and_then(|_| transfer.spool.as_file_mut().write_all(&content))
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"write bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
transfer.content_hasher.update(&content);
|
||||
transfer.received_bytes = next_offset;
|
||||
if complete {
|
||||
transfer.spool.as_file().sync_all().map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"sync bounded artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
let digest_hex = format!("{:x}", transfer.content_hasher.clone().finalize());
|
||||
let digest =
|
||||
Digest::from_sha256_hex(&digest_hex).map_err(CoordinatorServiceError::Protocol)?;
|
||||
if digest != transfer.expected_digest {
|
||||
transfer.spool.as_file_mut().set_len(0).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"reset invalid artifact transfer spool: {error}"
|
||||
))
|
||||
})?;
|
||||
transfer.received_bytes = 0;
|
||||
transfer.content_hasher = Sha256::new();
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"artifact reverse transfer content digest mismatch".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(CoordinatorResponse::ArtifactTransferChunkAccepted {
|
||||
transfer_id,
|
||||
next_offset,
|
||||
complete,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_fail_artifact_transfer(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
transfer_id: String,
|
||||
artifact: String,
|
||||
message: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
self.authorize_artifact_transfer_node(&tenant, &project, &node)?;
|
||||
let transfer = self
|
||||
.artifact_reverse_transfers
|
||||
.get_mut(&transfer_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned())
|
||||
})?;
|
||||
if transfer.tenant != tenant
|
||||
|| transfer.project != project
|
||||
|| transfer.source_node != node
|
||||
|| transfer.artifact.as_str() != artifact
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"artifact reverse transfer failure is outside the signed node scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let message = message.trim();
|
||||
transfer.error = Some(if message.is_empty() {
|
||||
"retained artifact bytes are unavailable".to_owned()
|
||||
} else {
|
||||
message.chars().take(1024).collect()
|
||||
});
|
||||
Ok(CoordinatorResponse::ArtifactTransferFailed { transfer_id })
|
||||
}
|
||||
|
||||
fn authorize_artifact_transfer_node(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
node: &NodeId,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if &identity.tenant != tenant || &identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"artifact reverse transfer node is outside its enrolled tenant/project scope"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expire_artifact_reverse_transfers(&mut self, now_epoch_seconds: u64) {
|
||||
let expired = self
|
||||
.artifact_reverse_transfers
|
||||
.iter()
|
||||
.filter(|(_, transfer)| transfer.expires_at_epoch_seconds < now_epoch_seconds)
|
||||
.map(|(id, transfer)| (id.clone(), transfer.token_digest.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
for (id, token) in expired {
|
||||
self.artifact_reverse_transfers.remove(&id);
|
||||
self.artifact_transfer_by_token.remove(&token);
|
||||
}
|
||||
}
|
||||
|
||||
fn export_endpoint(
|
||||
&self,
|
||||
node: &NodeId,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
) -> Result<NodeEndpoint, CoordinatorServiceError> {
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if &identity.tenant != tenant || &identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"artifact export node is outside the tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let descriptor = self.node_descriptors.get(node).ok_or_else(|| {
|
||||
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
|
||||
"node {node} has not reported export connectivity"
|
||||
))
|
||||
})?;
|
||||
if descriptor.tenant != *tenant || descriptor.project != *project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"artifact export node descriptor is outside the tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !descriptor.online {
|
||||
return Err(
|
||||
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
|
||||
"node {node} is offline for artifact export"
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !descriptor.direct_connectivity {
|
||||
return Err(
|
||||
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
|
||||
"direct connectivity unavailable to node {node} for artifact export"
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(NodeEndpoint {
|
||||
node: node.clone(),
|
||||
advertised_addr: format!("{node}.mesh.invalid:4433"),
|
||||
public_key_fingerprint: Digest::sha256(&identity.public_key),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_download_source_connectivity(
|
||||
&self,
|
||||
source: &StorageLocation,
|
||||
) -> Result<(), disasmer_core::DownloadError> {
|
||||
let StorageLocation::RetainedNode(node) = source else {
|
||||
return Ok(());
|
||||
};
|
||||
let descriptor = self.node_descriptors.get(node).ok_or_else(|| {
|
||||
disasmer_core::DownloadError::DirectConnectivityUnavailable(format!(
|
||||
"retaining node {node} has not reported online status for artifact download"
|
||||
))
|
||||
})?;
|
||||
if !descriptor.online {
|
||||
return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable(
|
||||
format!("retaining node {node} is offline for artifact download"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn user_context(tenant: String, project: String, actor_user: String) -> AuthContext {
|
||||
AuthContext {
|
||||
tenant: TenantId::new(tenant),
|
||||
project: ProjectId::new(project),
|
||||
actor: Actor::User(UserId::new(actor_user)),
|
||||
}
|
||||
}
|
||||
139
crates/disasmer-coordinator/src/service/authenticated.rs
Normal file
139
crates/disasmer-coordinator/src/service/authenticated.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use disasmer_core::{AuthContext, UserId};
|
||||
|
||||
use super::{
|
||||
AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService,
|
||||
CoordinatorServiceError,
|
||||
};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_authenticated_agent_key_request(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
actor: &UserId,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let request = match request {
|
||||
AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { agent, public_key } => {
|
||||
CoordinatorRequest::RegisterAgentPublicKey {
|
||||
tenant: context.tenant.as_str().to_owned(),
|
||||
project: context.project.as_str().to_owned(),
|
||||
user: actor.as_str().to_owned(),
|
||||
agent,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::ListAgentPublicKeys => {
|
||||
CoordinatorRequest::ListAgentPublicKeys {
|
||||
tenant: context.tenant.as_str().to_owned(),
|
||||
project: context.project.as_str().to_owned(),
|
||||
user: actor.as_str().to_owned(),
|
||||
}
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::RotateAgentPublicKey { agent, public_key } => {
|
||||
CoordinatorRequest::RotateAgentPublicKey {
|
||||
tenant: context.tenant.as_str().to_owned(),
|
||||
project: context.project.as_str().to_owned(),
|
||||
user: actor.as_str().to_owned(),
|
||||
agent,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { agent } => {
|
||||
CoordinatorRequest::RevokeAgentPublicKey {
|
||||
tenant: context.tenant.as_str().to_owned(),
|
||||
project: context.project.as_str().to_owned(),
|
||||
user: actor.as_str().to_owned(),
|
||||
agent,
|
||||
}
|
||||
}
|
||||
_ => unreachable!("caller filters authenticated agent key operations"),
|
||||
};
|
||||
self.handle_request(request)
|
||||
}
|
||||
|
||||
pub(super) fn handle_authenticated_launch_task(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
actor: &UserId,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let AuthenticatedCoordinatorRequest::LaunchTask {
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
} = request
|
||||
else {
|
||||
unreachable!("caller filters authenticated task launches");
|
||||
};
|
||||
self.handle_launch_task(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
Some(actor.as_str().to_owned()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
*task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_authenticated_schedule_task(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let AuthenticatedCoordinatorRequest::ScheduleTask {
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
dependency_cache,
|
||||
source_snapshot,
|
||||
required_artifacts,
|
||||
prefer_node,
|
||||
} = request
|
||||
else {
|
||||
unreachable!("caller filters authenticated task scheduling");
|
||||
};
|
||||
self.handle_schedule_task(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
dependency_cache,
|
||||
source_snapshot,
|
||||
required_artifacts,
|
||||
prefer_node,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_authenticated_artifact_export(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
actor: &UserId,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let AuthenticatedCoordinatorRequest::ExportArtifactToNode {
|
||||
artifact,
|
||||
receiver_node,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
} = request
|
||||
else {
|
||||
unreachable!("caller filters authenticated artifact exports");
|
||||
};
|
||||
self.handle_export_artifact_to_node(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
artifact,
|
||||
receiver_node,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
198
crates/disasmer-coordinator/src/service/authorization.rs
Normal file
198
crates/disasmer-coordinator/src/service/authorization.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use disasmer_core::{Actor, AuthContext, UserId};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{AuthenticatedCoordinatorRequest, CoordinatorServiceError};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum PublicUserOperation {
|
||||
AuthStatus,
|
||||
RevokeCliSession,
|
||||
CreateProject,
|
||||
SelectProject,
|
||||
ListProjects,
|
||||
RegisterAgentPublicKey,
|
||||
ListAgentPublicKeys,
|
||||
RotateAgentPublicKey,
|
||||
RevokeAgentPublicKey,
|
||||
CreateNodeEnrollmentGrant,
|
||||
ListNodeDescriptors,
|
||||
RevokeNodeCredential,
|
||||
StartProcess,
|
||||
ScheduleTask,
|
||||
LaunchTask,
|
||||
CancelProcess,
|
||||
AbortProcess,
|
||||
ListProcesses,
|
||||
QuotaStatus,
|
||||
RestartTask,
|
||||
DebugAttach,
|
||||
SetDebugBreakpoints,
|
||||
InspectDebugBreakpoints,
|
||||
CreateDebugEpoch,
|
||||
ResumeDebugEpoch,
|
||||
InspectDebugEpoch,
|
||||
ListTaskEvents,
|
||||
JoinTask,
|
||||
CreateArtifactDownloadLink,
|
||||
OpenArtifactDownloadStream,
|
||||
RevokeArtifactDownloadLink,
|
||||
ExportArtifactToNode,
|
||||
}
|
||||
|
||||
impl PublicUserOperation {
|
||||
pub(super) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthStatus => "auth_status",
|
||||
Self::RevokeCliSession => "revoke_cli_session",
|
||||
Self::CreateProject => "create_project",
|
||||
Self::SelectProject => "select_project",
|
||||
Self::ListProjects => "list_projects",
|
||||
Self::RegisterAgentPublicKey => "register_agent_public_key",
|
||||
Self::ListAgentPublicKeys => "list_agent_public_keys",
|
||||
Self::RotateAgentPublicKey => "rotate_agent_public_key",
|
||||
Self::RevokeAgentPublicKey => "revoke_agent_public_key",
|
||||
Self::CreateNodeEnrollmentGrant => "create_node_enrollment_grant",
|
||||
Self::ListNodeDescriptors => "list_node_descriptors",
|
||||
Self::RevokeNodeCredential => "revoke_node_credential",
|
||||
Self::StartProcess => "start_process",
|
||||
Self::ScheduleTask => "schedule_task",
|
||||
Self::LaunchTask => "launch_task",
|
||||
Self::CancelProcess => "cancel_process",
|
||||
Self::AbortProcess => "abort_process",
|
||||
Self::ListProcesses => "list_processes",
|
||||
Self::QuotaStatus => "quota_status",
|
||||
Self::RestartTask => "restart_task",
|
||||
Self::DebugAttach => "debug_attach",
|
||||
Self::SetDebugBreakpoints => "set_debug_breakpoints",
|
||||
Self::InspectDebugBreakpoints => "inspect_debug_breakpoints",
|
||||
Self::CreateDebugEpoch => "create_debug_epoch",
|
||||
Self::ResumeDebugEpoch => "resume_debug_epoch",
|
||||
Self::InspectDebugEpoch => "inspect_debug_epoch",
|
||||
Self::ListTaskEvents => "list_task_events",
|
||||
Self::JoinTask => "join_task",
|
||||
Self::CreateArtifactDownloadLink => "create_artifact_download_link",
|
||||
Self::OpenArtifactDownloadStream => "open_artifact_download_stream",
|
||||
Self::RevokeArtifactDownloadLink => "revoke_artifact_download_link",
|
||||
Self::ExportArtifactToNode => "export_artifact_to_node",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AuthenticatedCoordinatorRequest> for PublicUserOperation {
|
||||
fn from(request: &AuthenticatedCoordinatorRequest) -> Self {
|
||||
match request {
|
||||
AuthenticatedCoordinatorRequest::AuthStatus => Self::AuthStatus,
|
||||
AuthenticatedCoordinatorRequest::RevokeCliSession => Self::RevokeCliSession,
|
||||
AuthenticatedCoordinatorRequest::CreateProject { .. } => Self::CreateProject,
|
||||
AuthenticatedCoordinatorRequest::SelectProject { .. } => Self::SelectProject,
|
||||
AuthenticatedCoordinatorRequest::ListProjects => Self::ListProjects,
|
||||
AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. } => {
|
||||
Self::RegisterAgentPublicKey
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::ListAgentPublicKeys => Self::ListAgentPublicKeys,
|
||||
AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. } => {
|
||||
Self::RotateAgentPublicKey
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. } => {
|
||||
Self::RevokeAgentPublicKey
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { .. } => {
|
||||
Self::CreateNodeEnrollmentGrant
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::ListNodeDescriptors => Self::ListNodeDescriptors,
|
||||
AuthenticatedCoordinatorRequest::RevokeNodeCredential { .. } => {
|
||||
Self::RevokeNodeCredential
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::StartProcess { .. } => Self::StartProcess,
|
||||
AuthenticatedCoordinatorRequest::ScheduleTask { .. } => Self::ScheduleTask,
|
||||
AuthenticatedCoordinatorRequest::LaunchTask { .. } => Self::LaunchTask,
|
||||
AuthenticatedCoordinatorRequest::CancelProcess { .. } => Self::CancelProcess,
|
||||
AuthenticatedCoordinatorRequest::AbortProcess { .. } => Self::AbortProcess,
|
||||
AuthenticatedCoordinatorRequest::ListProcesses => Self::ListProcesses,
|
||||
AuthenticatedCoordinatorRequest::QuotaStatus => Self::QuotaStatus,
|
||||
AuthenticatedCoordinatorRequest::RestartTask { .. } => Self::RestartTask,
|
||||
AuthenticatedCoordinatorRequest::DebugAttach { .. } => Self::DebugAttach,
|
||||
AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } => {
|
||||
Self::SetDebugBreakpoints
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. } => {
|
||||
Self::InspectDebugBreakpoints
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. } => Self::CreateDebugEpoch,
|
||||
AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. } => Self::ResumeDebugEpoch,
|
||||
AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. } => Self::InspectDebugEpoch,
|
||||
AuthenticatedCoordinatorRequest::ListTaskEvents { .. } => Self::ListTaskEvents,
|
||||
AuthenticatedCoordinatorRequest::JoinTask { .. } => Self::JoinTask,
|
||||
AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { .. } => {
|
||||
Self::CreateArtifactDownloadLink
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream { .. } => {
|
||||
Self::OpenArtifactDownloadStream
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink { .. } => {
|
||||
Self::RevokeArtifactDownloadLink
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => {
|
||||
Self::ExportArtifactToNode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct AuthorizedPublicUser {
|
||||
pub(super) actor: UserId,
|
||||
pub(super) operation: PublicUserOperation,
|
||||
}
|
||||
|
||||
pub(super) fn authorize_authenticated_user_operation(
|
||||
context: &AuthContext,
|
||||
request: &AuthenticatedCoordinatorRequest,
|
||||
) -> Result<AuthorizedPublicUser, CoordinatorServiceError> {
|
||||
let operation = PublicUserOperation::from(request);
|
||||
let actor = match &context.actor {
|
||||
Actor::User(user) => user.clone(),
|
||||
_ => {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"authenticated {} request requires a user CLI session",
|
||||
operation.as_str()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
};
|
||||
Ok(AuthorizedPublicUser { actor, operation })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use disasmer_core::{AgentId, ProjectId, TenantId};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authenticated_public_authorization_requires_user_context_and_names_operation() {
|
||||
let request = AuthenticatedCoordinatorRequest::DebugAttach {
|
||||
process: "vp".to_owned(),
|
||||
};
|
||||
let agent_context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::Agent(AgentId::from("agent-ci")),
|
||||
};
|
||||
|
||||
let denied = authorize_authenticated_user_operation(&agent_context, &request).unwrap_err();
|
||||
assert!(denied
|
||||
.to_string()
|
||||
.contains("authenticated debug_attach request requires a user CLI session"));
|
||||
|
||||
let user_context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let authorized = authorize_authenticated_user_operation(&user_context, &request).unwrap();
|
||||
assert_eq!(authorized.actor, UserId::from("user"));
|
||||
assert_eq!(authorized.operation, PublicUserOperation::DebugAttach);
|
||||
}
|
||||
}
|
||||
995
crates/disasmer-coordinator/src/service/debug.rs
Normal file
995
crates/disasmer-coordinator/src/service/debug.rs
Normal file
|
|
@ -0,0 +1,995 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use disasmer_core::{
|
||||
Actor, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId,
|
||||
WasmHostDebugProbeResult, WASM_TASK_ABI_VERSION,
|
||||
};
|
||||
|
||||
use crate::{CoordinatorError, CoordinatorServiceError};
|
||||
|
||||
use super::keys::{process_control_key, task_control_key, task_restart_key, TaskControlKey};
|
||||
use super::{
|
||||
CoordinatorResponse, CoordinatorService, DebugAcknowledgementState, DebugAuditEvent,
|
||||
DebugParticipantAcknowledgement, TaskCancellationTarget, DEBUG_CONTROL_READ_BYTES,
|
||||
};
|
||||
|
||||
mod validation;
|
||||
use validation::{runtime_all_in_state, validate_debug_snapshot, validate_probe_symbols};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct DebugPendingCommand {
|
||||
pub(super) epoch: u64,
|
||||
pub(super) command: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct DebugEpochRuntime {
|
||||
pub(super) epoch: u64,
|
||||
pub(super) command: String,
|
||||
pub(super) expected: BTreeSet<TaskControlKey>,
|
||||
pub(super) acknowledgements: BTreeMap<TaskControlKey, DebugParticipantAcknowledgement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct DebugBreakpointPlan {
|
||||
pub(super) actor: UserId,
|
||||
pub(super) probe_symbols: BTreeSet<String>,
|
||||
pub(super) hit_epoch: Option<u64>,
|
||||
pub(super) hit_task: Option<TaskInstanceId>,
|
||||
pub(super) hit_probe_symbol: Option<String>,
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_debug_attach(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant,
|
||||
project,
|
||||
process.clone(),
|
||||
None,
|
||||
actor.clone(),
|
||||
"debug_attach",
|
||||
authorization.allowed,
|
||||
authorization.reason.clone(),
|
||||
)?;
|
||||
Ok(CoordinatorResponse::DebugAttach {
|
||||
process,
|
||||
actor,
|
||||
authorization,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_set_debug_breakpoints(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
probe_symbols: Vec<String>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let probe_symbols = validate_probe_symbols(probe_symbols)?;
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
None,
|
||||
actor.clone(),
|
||||
"set_debug_breakpoints",
|
||||
authorization.allowed,
|
||||
authorization.reason.clone(),
|
||||
)?;
|
||||
if !authorization.allowed {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"set_debug_breakpoints denied: {}",
|
||||
authorization.reason
|
||||
))
|
||||
.into());
|
||||
}
|
||||
self.debug_breakpoints.insert(
|
||||
process_control_key(&tenant, &project, &process),
|
||||
DebugBreakpointPlan {
|
||||
actor: actor.clone(),
|
||||
probe_symbols: probe_symbols.iter().cloned().collect(),
|
||||
hit_epoch: None,
|
||||
hit_task: None,
|
||||
hit_probe_symbol: None,
|
||||
},
|
||||
);
|
||||
Ok(CoordinatorResponse::DebugBreakpoints {
|
||||
process,
|
||||
actor,
|
||||
probe_symbols,
|
||||
hit_epoch: None,
|
||||
hit_task: None,
|
||||
hit_probe_symbol: None,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_inspect_debug_breakpoints(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
let plan = self
|
||||
.debug_breakpoints
|
||||
.get(&process_control_key(&tenant, &project, &process))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"no debug breakpoints are configured for {process}"
|
||||
))
|
||||
})?;
|
||||
if plan.actor != actor {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"debug breakpoint inspection belongs to another authenticated user".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant,
|
||||
project,
|
||||
process.clone(),
|
||||
plan.hit_task.clone(),
|
||||
actor.clone(),
|
||||
"inspect_debug_breakpoints",
|
||||
authorization.allowed,
|
||||
authorization.reason.clone(),
|
||||
)?;
|
||||
if !authorization.allowed {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"inspect_debug_breakpoints denied: {}",
|
||||
authorization.reason
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(CoordinatorResponse::DebugBreakpoints {
|
||||
process,
|
||||
actor,
|
||||
probe_symbols: plan.probe_symbols.into_iter().collect(),
|
||||
hit_epoch: plan.hit_epoch,
|
||||
hit_task: plan.hit_task,
|
||||
hit_probe_symbol: plan.hit_probe_symbol,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_create_debug_epoch(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
stopped_task: String,
|
||||
reason: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
self.handle_debug_epoch_control(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
Some(stopped_task),
|
||||
None,
|
||||
"create_debug_epoch",
|
||||
"freeze",
|
||||
true,
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_resume_debug_epoch(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
self.handle_debug_epoch_control(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
None,
|
||||
Some(epoch),
|
||||
"resume_debug_epoch",
|
||||
"resume",
|
||||
false,
|
||||
"continue requested by debugger",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_poll_debug_command(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.coordinator
|
||||
.authorize_node_for_process(&node, &tenant, &project, &process)?;
|
||||
let pending = self
|
||||
.debug_commands
|
||||
.remove(&task_control_key(&tenant, &project, &process, &node, &task));
|
||||
Ok(CoordinatorResponse::DebugCommand {
|
||||
process,
|
||||
task,
|
||||
epoch: pending.as_ref().map(|command| command.epoch),
|
||||
command: pending.map(|command| command.command),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_report_debug_state(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
epoch: u64,
|
||||
state: DebugAcknowledgementState,
|
||||
stack_frames: Vec<String>,
|
||||
local_values: Vec<(String, String)>,
|
||||
task_args: Vec<(String, String)>,
|
||||
handles: Vec<(String, String)>,
|
||||
command_status: Option<String>,
|
||||
recent_output: Vec<String>,
|
||||
message: Option<String>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
validate_debug_snapshot(
|
||||
&stack_frames,
|
||||
&local_values,
|
||||
&task_args,
|
||||
&handles,
|
||||
command_status.as_deref(),
|
||||
&recent_output,
|
||||
message.as_deref(),
|
||||
)?;
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.coordinator
|
||||
.authorize_node_for_process(&node, &tenant, &project, &process)?;
|
||||
let participant_key = task_control_key(&tenant, &project, &process, &node, &task);
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
let runtime = self
|
||||
.debug_epoch_runtime
|
||||
.get_mut(&process_key)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"cannot acknowledge debug epoch {epoch} for {process}: no active debug epoch"
|
||||
))
|
||||
})?;
|
||||
if runtime.epoch != epoch {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"cannot acknowledge debug epoch {epoch} for {process}: current debug epoch is {}",
|
||||
runtime.epoch
|
||||
)));
|
||||
}
|
||||
if !runtime.expected.contains(&participant_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"debug acknowledgement is not from an expected active task participant".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let task_definition = self
|
||||
.task_restart_checkpoints
|
||||
.get(&task_restart_key(&tenant, &project, &process, &task))
|
||||
.map(|checkpoint| checkpoint.assignment.task_spec.task_definition.clone())
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"debug acknowledgement does not name a coordinator-issued task instance"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
let valid_state = matches!(
|
||||
(runtime.command.as_str(), &state),
|
||||
("freeze", DebugAcknowledgementState::Frozen)
|
||||
| ("resume", DebugAcknowledgementState::Running)
|
||||
| (_, DebugAcknowledgementState::Failed)
|
||||
);
|
||||
if !valid_state {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"debug epoch {epoch} command `{}` cannot be acknowledged as {state:?}",
|
||||
runtime.command
|
||||
)));
|
||||
}
|
||||
runtime.acknowledgements.insert(
|
||||
participant_key,
|
||||
DebugParticipantAcknowledgement {
|
||||
node: node.clone(),
|
||||
task_definition,
|
||||
task: task.clone(),
|
||||
epoch,
|
||||
state: state.clone(),
|
||||
stack_frames,
|
||||
local_values,
|
||||
task_args,
|
||||
handles,
|
||||
command_status,
|
||||
recent_output,
|
||||
message,
|
||||
},
|
||||
);
|
||||
Ok(CoordinatorResponse::DebugStateRecorded {
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
epoch,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_report_debug_probe_hit(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
probe_symbol: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let probe_symbol = validate_probe_symbols(vec![probe_symbol])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one validated probe symbol remains one symbol");
|
||||
let tenant_id = TenantId::new(tenant.clone());
|
||||
let project_id = ProjectId::new(project.clone());
|
||||
let process_id = ProcessId::new(process.clone());
|
||||
let node_id = NodeId::new(node.clone());
|
||||
let task_id = TaskInstanceId::new(task.clone());
|
||||
self.coordinator.authorize_node_for_process(
|
||||
&node_id,
|
||||
&tenant_id,
|
||||
&project_id,
|
||||
&process_id,
|
||||
)?;
|
||||
let participant_key =
|
||||
task_control_key(&tenant_id, &project_id, &process_id, &node_id, &task_id);
|
||||
if !self.active_tasks.contains(&participant_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"debug probe hit is not from an active task participant".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let process_key = process_control_key(&tenant_id, &project_id, &process_id);
|
||||
let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else {
|
||||
return Ok(CoordinatorResponse::DebugProbeHit {
|
||||
process: process_id,
|
||||
node: node_id,
|
||||
task: task_id,
|
||||
probe_symbol,
|
||||
breakpoint_matched: false,
|
||||
debug_epoch: None,
|
||||
});
|
||||
};
|
||||
if !plan.probe_symbols.contains(&probe_symbol) {
|
||||
return Ok(CoordinatorResponse::DebugProbeHit {
|
||||
process: process_id,
|
||||
node: node_id,
|
||||
task: task_id,
|
||||
probe_symbol,
|
||||
breakpoint_matched: false,
|
||||
debug_epoch: None,
|
||||
});
|
||||
}
|
||||
let response = self.handle_debug_epoch_control(
|
||||
tenant,
|
||||
project,
|
||||
plan.actor.as_str().to_owned(),
|
||||
process,
|
||||
Some(task.clone()),
|
||||
None,
|
||||
"wasm_debug_probe_hit",
|
||||
"freeze",
|
||||
true,
|
||||
format!("executing Wasm reached probe `{probe_symbol}`"),
|
||||
)?;
|
||||
let CoordinatorResponse::DebugEpoch { epoch, .. } = response else {
|
||||
unreachable!("debug epoch control always returns DebugEpoch")
|
||||
};
|
||||
if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) {
|
||||
plan.hit_epoch = Some(epoch);
|
||||
plan.hit_task = Some(task_id.clone());
|
||||
plan.hit_probe_symbol = Some(probe_symbol.clone());
|
||||
}
|
||||
Ok(CoordinatorResponse::DebugProbeHit {
|
||||
process: process_id,
|
||||
node: node_id,
|
||||
task: task_id,
|
||||
probe_symbol,
|
||||
breakpoint_matched: true,
|
||||
debug_epoch: Some(epoch),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_coordinator_main_debug_probe(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
probe_symbol: String,
|
||||
) -> Result<WasmHostDebugProbeResult, CoordinatorServiceError> {
|
||||
let probe_symbol = validate_probe_symbols(vec![probe_symbol])?
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one validated probe symbol remains one symbol");
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
let main_matches = self
|
||||
.main_runtime
|
||||
.controls
|
||||
.get(&process_key)
|
||||
.is_some_and(|control| control.task_instance == task);
|
||||
if !main_matches {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"debug probe does not belong to the active coordinator main participant".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else {
|
||||
return Ok(WasmHostDebugProbeResult {
|
||||
abi_version: WASM_TASK_ABI_VERSION,
|
||||
breakpoint_matched: false,
|
||||
debug_epoch: None,
|
||||
});
|
||||
};
|
||||
if !plan.probe_symbols.contains(&probe_symbol) {
|
||||
return Ok(WasmHostDebugProbeResult {
|
||||
abi_version: WASM_TASK_ABI_VERSION,
|
||||
breakpoint_matched: false,
|
||||
debug_epoch: None,
|
||||
});
|
||||
}
|
||||
if let Some(control) = self.main_runtime.controls.get_mut(&process_key) {
|
||||
control.stopped_probe_symbol = Some(probe_symbol.clone());
|
||||
}
|
||||
let response = self.handle_debug_epoch_control(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
plan.actor.as_str().to_owned(),
|
||||
process.as_str().to_owned(),
|
||||
Some(task.as_str().to_owned()),
|
||||
None,
|
||||
"wasm_debug_probe_hit",
|
||||
"freeze",
|
||||
true,
|
||||
format!("coordinator main reached probe `{probe_symbol}`"),
|
||||
)?;
|
||||
let CoordinatorResponse::DebugEpoch { epoch, .. } = response else {
|
||||
unreachable!("debug epoch control always returns DebugEpoch")
|
||||
};
|
||||
if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) {
|
||||
plan.hit_epoch = Some(epoch);
|
||||
plan.hit_task = Some(task);
|
||||
plan.hit_probe_symbol = Some(probe_symbol);
|
||||
}
|
||||
Ok(WasmHostDebugProbeResult {
|
||||
abi_version: WASM_TASK_ABI_VERSION,
|
||||
breakpoint_matched: true,
|
||||
debug_epoch: Some(epoch),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_inspect_debug_epoch(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch);
|
||||
let runtime = self
|
||||
.debug_epoch_runtime
|
||||
.get(&process_key)
|
||||
.filter(|runtime| runtime.epoch == epoch)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"debug epoch {epoch} is not active for {process}"
|
||||
))
|
||||
})?;
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant,
|
||||
project,
|
||||
process.clone(),
|
||||
None,
|
||||
actor.clone(),
|
||||
"inspect_debug_epoch",
|
||||
authorization.allowed,
|
||||
authorization.reason.clone(),
|
||||
)?;
|
||||
if !authorization.allowed {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"inspect_debug_epoch denied: {}",
|
||||
authorization.reason
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let acknowledgements = runtime
|
||||
.acknowledgements
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let all_acknowledged = !runtime.expected.is_empty()
|
||||
&& runtime
|
||||
.expected
|
||||
.iter()
|
||||
.all(|key| runtime.acknowledgements.contains_key(key));
|
||||
let fully_frozen = runtime.command == "freeze"
|
||||
&& all_acknowledged
|
||||
&& acknowledgements
|
||||
.iter()
|
||||
.all(|ack| ack.state == DebugAcknowledgementState::Frozen);
|
||||
let fully_resumed = runtime.command == "resume"
|
||||
&& all_acknowledged
|
||||
&& acknowledgements
|
||||
.iter()
|
||||
.all(|ack| ack.state == DebugAcknowledgementState::Running);
|
||||
let failed = acknowledgements
|
||||
.iter()
|
||||
.any(|ack| ack.state == DebugAcknowledgementState::Failed);
|
||||
let failure_messages = acknowledgements
|
||||
.iter()
|
||||
.filter(|ack| ack.state == DebugAcknowledgementState::Failed)
|
||||
.map(|ack| {
|
||||
ack.message
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("task {} failed debug control", ack.task))
|
||||
})
|
||||
.collect();
|
||||
let expected_tasks = runtime
|
||||
.expected
|
||||
.into_iter()
|
||||
.map(|(_, _, process, node, task)| TaskCancellationTarget {
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
})
|
||||
.collect();
|
||||
Ok(CoordinatorResponse::DebugEpochStatus {
|
||||
process,
|
||||
actor,
|
||||
epoch,
|
||||
command: runtime.command,
|
||||
expected_tasks,
|
||||
acknowledgements,
|
||||
fully_frozen,
|
||||
fully_resumed,
|
||||
failed,
|
||||
failure_messages,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn clear_debug_state_for_process(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) {
|
||||
self.debug_epochs
|
||||
.remove(&process_control_key(tenant, project, process));
|
||||
self.debug_epoch_runtime
|
||||
.remove(&process_control_key(tenant, project, process));
|
||||
self.debug_breakpoints
|
||||
.remove(&process_control_key(tenant, project, process));
|
||||
self.debug_commands
|
||||
.retain(|(task_tenant, task_project, task_process, _, _), _| {
|
||||
task_tenant != tenant || task_project != project || task_process != process
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_debug_epoch_control(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
stopped_task: Option<String>,
|
||||
expected_epoch: Option<u64>,
|
||||
operation: &'static str,
|
||||
command: &'static str,
|
||||
all_stop_requested: bool,
|
||||
reason: impl Into<String>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
let reason = reason.into();
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
stopped_task.as_ref().map(TaskInstanceId::new),
|
||||
actor.clone(),
|
||||
operation,
|
||||
authorization.allowed,
|
||||
if authorization.allowed {
|
||||
reason
|
||||
} else {
|
||||
authorization.reason.clone()
|
||||
},
|
||||
)?;
|
||||
if !authorization.allowed {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"{operation} denied: {}",
|
||||
authorization.reason
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
let epoch = match expected_epoch {
|
||||
Some(expected) => {
|
||||
let current = self
|
||||
.debug_epochs
|
||||
.get(&process_key)
|
||||
.copied()
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"cannot resume debug epoch {expected} for {process}: no active debug epoch"
|
||||
))
|
||||
})?;
|
||||
if current != expected {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"cannot resume debug epoch {expected} for {process}: current debug epoch is {current}"
|
||||
)));
|
||||
}
|
||||
let runtime = self.debug_epoch_runtime.get(&process_key).ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"cannot resume debug epoch {expected} for {process}: participant state is unavailable"
|
||||
))
|
||||
})?;
|
||||
if runtime.command != "freeze"
|
||||
|| !runtime_all_in_state(runtime, DebugAcknowledgementState::Frozen)
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"cannot resume debug epoch {expected} for {process}: all expected participants have not acknowledged frozen state"
|
||||
)));
|
||||
}
|
||||
current
|
||||
}
|
||||
None => {
|
||||
if let Some(runtime) = self.debug_epoch_runtime.get(&process_key) {
|
||||
let previous_complete = runtime.command == "resume"
|
||||
&& runtime_all_in_state(runtime, DebugAcknowledgementState::Running);
|
||||
if !previous_complete {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"cannot create a new debug epoch for {process}: epoch {} has not fully resumed",
|
||||
runtime.epoch
|
||||
)));
|
||||
}
|
||||
}
|
||||
let next = self.debug_epochs.get(&process_key).copied().unwrap_or(0) + 1;
|
||||
self.debug_epochs.insert(process_key.clone(), next);
|
||||
next
|
||||
}
|
||||
};
|
||||
let mut affected_tasks = self
|
||||
.enqueue_debug_command_for_active_tasks(&tenant, &project, &process, epoch, command);
|
||||
let mut expected = self
|
||||
.active_tasks
|
||||
.iter()
|
||||
.filter(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant == &tenant && task_project == &project && task_process == &process
|
||||
})
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if let Some(control) = self
|
||||
.main_runtime
|
||||
.controls
|
||||
.get(&process_key)
|
||||
.filter(|control| matches!(control.state.as_str(), "running" | "stopping"))
|
||||
{
|
||||
let key = task_control_key(
|
||||
&tenant,
|
||||
&project,
|
||||
&process,
|
||||
&NodeId::from("coordinator-main"),
|
||||
&control.task_instance,
|
||||
);
|
||||
expected.insert(key);
|
||||
affected_tasks.push(TaskCancellationTarget {
|
||||
process: process.clone(),
|
||||
node: NodeId::from("coordinator-main"),
|
||||
task: control.task_instance.clone(),
|
||||
});
|
||||
}
|
||||
self.debug_epoch_runtime.insert(
|
||||
process_key.clone(),
|
||||
DebugEpochRuntime {
|
||||
epoch,
|
||||
command: command.to_owned(),
|
||||
expected,
|
||||
acknowledgements: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
if let Some(control) = self
|
||||
.main_runtime
|
||||
.controls
|
||||
.get(&process_key)
|
||||
.filter(|control| matches!(control.state.as_str(), "running" | "stopping"))
|
||||
{
|
||||
if command == "freeze" {
|
||||
control.debug.request_freeze(epoch);
|
||||
} else {
|
||||
control.debug.request_resume(epoch);
|
||||
}
|
||||
}
|
||||
self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch);
|
||||
Ok(CoordinatorResponse::DebugEpoch {
|
||||
process,
|
||||
actor,
|
||||
epoch,
|
||||
command: command.to_owned(),
|
||||
affected_tasks,
|
||||
all_stop_requested,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_coordinator_main_debug_acknowledgement(
|
||||
&mut self,
|
||||
process_key: &super::keys::ProcessControlKey,
|
||||
epoch: u64,
|
||||
) {
|
||||
let Some(control) = self.main_runtime.controls.get(process_key) else {
|
||||
return;
|
||||
};
|
||||
let Some(runtime) = self.debug_epoch_runtime.get_mut(process_key) else {
|
||||
return;
|
||||
};
|
||||
if runtime.epoch != epoch {
|
||||
return;
|
||||
}
|
||||
let state = if runtime.command == "freeze" && control.debug.frozen_epoch() == Some(epoch) {
|
||||
Some(DebugAcknowledgementState::Frozen)
|
||||
} else if runtime.command == "resume"
|
||||
&& control.debug.resume_requested(epoch)
|
||||
&& control.debug.frozen_epoch() != Some(epoch)
|
||||
{
|
||||
Some(DebugAcknowledgementState::Running)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(state) = state else {
|
||||
return;
|
||||
};
|
||||
let handles = control
|
||||
.handles
|
||||
.lock()
|
||||
.map(|handles| {
|
||||
let mut snapshot = handles
|
||||
.iter()
|
||||
.map(|(handle_id, spec)| {
|
||||
(
|
||||
format!("task_handle_{handle_id}"),
|
||||
format!(
|
||||
"definition={} instance={} state=active",
|
||||
spec.task_definition, spec.task_instance
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
snapshot.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
snapshot
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
vec![(
|
||||
"handle-registry-diagnostic".to_owned(),
|
||||
"coordinator main handle registry was unavailable".to_owned(),
|
||||
)]
|
||||
});
|
||||
let node = NodeId::from("coordinator-main");
|
||||
let key = task_control_key(
|
||||
&process_key.0,
|
||||
&process_key.1,
|
||||
&process_key.2,
|
||||
&node,
|
||||
&control.task_instance,
|
||||
);
|
||||
if !runtime.expected.contains(&key) {
|
||||
return;
|
||||
}
|
||||
runtime.acknowledgements.insert(
|
||||
key,
|
||||
DebugParticipantAcknowledgement {
|
||||
node,
|
||||
task_definition: control.task_definition.clone(),
|
||||
task: control.task_instance.clone(),
|
||||
epoch,
|
||||
state,
|
||||
stack_frames: vec![control
|
||||
.stopped_probe_symbol
|
||||
.as_deref()
|
||||
.and_then(|symbol| symbol.strip_prefix("disasmer.probe."))
|
||||
.map(|function| format!("{function}::wasm"))
|
||||
.unwrap_or_else(|| {
|
||||
format!("coordinator_main::wasm ({})", control.task_definition)
|
||||
})],
|
||||
local_values: Vec::new(),
|
||||
task_args: Vec::new(),
|
||||
handles,
|
||||
command_status: None,
|
||||
recent_output: vec![
|
||||
"coordinator-hosted capless main acknowledged all-stop control".to_owned(),
|
||||
],
|
||||
message: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn enqueue_debug_command_for_active_tasks(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
epoch: u64,
|
||||
command: &str,
|
||||
) -> Vec<TaskCancellationTarget> {
|
||||
let task_keys = self
|
||||
.active_tasks
|
||||
.iter()
|
||||
.filter(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant == tenant && task_project == project && task_process == process
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for key in &task_keys {
|
||||
self.debug_commands.insert(
|
||||
key.clone(),
|
||||
DebugPendingCommand {
|
||||
epoch,
|
||||
command: command.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
task_keys
|
||||
.into_iter()
|
||||
.map(|(_, _, process, node, task)| TaskCancellationTarget {
|
||||
process,
|
||||
task,
|
||||
node,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn record_debug_audit_event(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
task: Option<TaskInstanceId>,
|
||||
actor: UserId,
|
||||
operation: &'static str,
|
||||
allowed: bool,
|
||||
reason: impl Into<String>,
|
||||
) -> Result<DebugAuditEvent, CoordinatorServiceError> {
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
let charged_debug_read_bytes = if allowed {
|
||||
self.quota.charge_debug_read(
|
||||
&tenant,
|
||||
&project,
|
||||
DEBUG_CONTROL_READ_BYTES,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
DEBUG_CONTROL_READ_BYTES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let used_debug_read_bytes =
|
||||
self.quota
|
||||
.used_debug_read_bytes(&tenant, &project, now_epoch_seconds);
|
||||
let event = DebugAuditEvent {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
task,
|
||||
actor,
|
||||
operation: operation.to_owned(),
|
||||
allowed,
|
||||
reason: reason.into(),
|
||||
charged_debug_read_bytes,
|
||||
used_debug_read_bytes,
|
||||
};
|
||||
while self
|
||||
.debug_audit_events
|
||||
.iter()
|
||||
.filter(|retained| {
|
||||
retained.tenant == event.tenant
|
||||
&& retained.project == event.project
|
||||
&& retained.process == event.process
|
||||
})
|
||||
.count()
|
||||
>= super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS
|
||||
{
|
||||
let Some(index) = self.debug_audit_events.iter().position(|retained| {
|
||||
retained.tenant == event.tenant
|
||||
&& retained.project == event.project
|
||||
&& retained.process == event.process
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
self.debug_audit_events.remove(index);
|
||||
}
|
||||
self.debug_audit_events.push_back(event.clone());
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
92
crates/disasmer-coordinator/src/service/debug/validation.rs
Normal file
92
crates/disasmer-coordinator/src/service/debug/validation.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CoordinatorServiceError;
|
||||
|
||||
use super::{DebugAcknowledgementState, DebugEpochRuntime};
|
||||
|
||||
pub(super) fn runtime_all_in_state(
|
||||
runtime: &DebugEpochRuntime,
|
||||
state: DebugAcknowledgementState,
|
||||
) -> bool {
|
||||
!runtime.expected.is_empty()
|
||||
&& runtime.expected.iter().all(|key| {
|
||||
runtime
|
||||
.acknowledgements
|
||||
.get(key)
|
||||
.is_some_and(|ack| ack.state == state)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_probe_symbols(
|
||||
probe_symbols: Vec<String>,
|
||||
) -> Result<Vec<String>, CoordinatorServiceError> {
|
||||
if probe_symbols.len() > 128 {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"debug breakpoint request exceeds 128 probe symbols".to_owned(),
|
||||
));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for symbol in probe_symbols {
|
||||
if symbol.trim().is_empty()
|
||||
|| symbol.len() > 256
|
||||
|| !symbol
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "._:-/".contains(character))
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"debug breakpoint probe symbol is invalid".to_owned(),
|
||||
));
|
||||
}
|
||||
unique.insert(symbol);
|
||||
}
|
||||
Ok(unique.into_iter().collect())
|
||||
}
|
||||
|
||||
pub(super) fn validate_debug_snapshot(
|
||||
stack_frames: &[String],
|
||||
local_values: &[(String, String)],
|
||||
task_args: &[(String, String)],
|
||||
handles: &[(String, String)],
|
||||
command_status: Option<&str>,
|
||||
recent_output: &[String],
|
||||
message: Option<&str>,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
const MAX_ITEMS: usize = 128;
|
||||
const MAX_TEXT_BYTES: usize = 16 * 1024;
|
||||
if [
|
||||
stack_frames.len(),
|
||||
local_values.len(),
|
||||
task_args.len(),
|
||||
handles.len(),
|
||||
recent_output.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|count| count > MAX_ITEMS)
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"debug participant snapshot exceeds the 128-item field limit".to_owned(),
|
||||
));
|
||||
}
|
||||
let text_bytes = stack_frames.iter().map(String::len).sum::<usize>()
|
||||
+ local_values
|
||||
.iter()
|
||||
.map(|(name, value)| name.len() + value.len())
|
||||
.sum::<usize>()
|
||||
+ task_args
|
||||
.iter()
|
||||
.map(|(name, value)| name.len() + value.len())
|
||||
.sum::<usize>()
|
||||
+ handles
|
||||
.iter()
|
||||
.map(|(name, value)| name.len() + value.len())
|
||||
.sum::<usize>()
|
||||
+ recent_output.iter().map(String::len).sum::<usize>()
|
||||
+ command_status.map(str::len).unwrap_or(0)
|
||||
+ message.map(str::len).unwrap_or(0);
|
||||
if text_bytes > MAX_TEXT_BYTES {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"debug participant snapshot is {text_bytes} bytes; maximum is {MAX_TEXT_BYTES}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
470
crates/disasmer-coordinator/src/service/debug_requests.rs
Normal file
470
crates/disasmer-coordinator/src/service/debug_requests.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use disasmer_core::{
|
||||
Actor, Capability, CredentialKind, Digest, EnvironmentResource, ProcessId, ProjectId,
|
||||
RestartDecision, RestartPolicy, RestartRequest, TaskDispatch, TaskInstanceId, TenantId, UserId,
|
||||
WasmExportAbi,
|
||||
};
|
||||
|
||||
use crate::{CoordinatorError, CoordinatorServiceError};
|
||||
|
||||
use super::keys::task_restart_key;
|
||||
use super::{
|
||||
AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService,
|
||||
TaskReplacementBundle, WorkflowActor,
|
||||
};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_debug_request(
|
||||
&mut self,
|
||||
request: CoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
match request {
|
||||
CoordinatorRequest::DebugAttach {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
} => self.handle_debug_attach(tenant, project, actor_user, process),
|
||||
CoordinatorRequest::SetDebugBreakpoints {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
probe_symbols,
|
||||
} => self.handle_set_debug_breakpoints(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
probe_symbols,
|
||||
),
|
||||
CoordinatorRequest::InspectDebugBreakpoints {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
} => self.handle_inspect_debug_breakpoints(tenant, project, actor_user, process),
|
||||
CoordinatorRequest::CreateDebugEpoch {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
stopped_task,
|
||||
reason,
|
||||
} => self.handle_create_debug_epoch(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
stopped_task,
|
||||
reason,
|
||||
),
|
||||
CoordinatorRequest::ResumeDebugEpoch {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
epoch,
|
||||
} => self.handle_resume_debug_epoch(tenant, project, actor_user, process, epoch),
|
||||
CoordinatorRequest::InspectDebugEpoch {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
epoch,
|
||||
} => self.handle_inspect_debug_epoch(tenant, project, actor_user, process, epoch),
|
||||
_ => unreachable!("handle_debug_request only accepts debug coordinator requests"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_authenticated_debug_request(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
actor: &UserId,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
match request {
|
||||
AuthenticatedCoordinatorRequest::DebugAttach { process } => self.handle_debug_attach(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::SetDebugBreakpoints {
|
||||
process,
|
||||
probe_symbols,
|
||||
} => self.handle_set_debug_breakpoints(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
probe_symbols,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self
|
||||
.handle_inspect_debug_breakpoints(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::CreateDebugEpoch {
|
||||
process,
|
||||
stopped_task,
|
||||
reason,
|
||||
} => self.handle_create_debug_epoch(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
stopped_task,
|
||||
reason,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::ResumeDebugEpoch { process, epoch } => self
|
||||
.handle_resume_debug_epoch(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
epoch,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::InspectDebugEpoch { process, epoch } => self
|
||||
.handle_inspect_debug_epoch(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
epoch,
|
||||
),
|
||||
_ => unreachable!(
|
||||
"handle_authenticated_debug_request only accepts debug coordinator requests"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_restart_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
task: String,
|
||||
replacement_bundle: Option<TaskReplacementBundle>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let task = TaskInstanceId::new(task);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let authorization = self.coordinator.authorize_debug_attach(&context, &process);
|
||||
if !authorization.allowed {
|
||||
let _ = self.record_debug_audit_event(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
Some(task),
|
||||
actor,
|
||||
"restart_task",
|
||||
false,
|
||||
authorization.reason.clone(),
|
||||
)?;
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"task restart denied: {}",
|
||||
authorization.reason
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let active_key = self
|
||||
.active_tasks
|
||||
.iter()
|
||||
.find(|(task_tenant, task_project, task_process, _, task_id)| {
|
||||
task_tenant == &tenant
|
||||
&& task_project == &project
|
||||
&& task_process == &process
|
||||
&& task_id == &task
|
||||
})
|
||||
.cloned();
|
||||
let process_key = super::keys::process_control_key(&tenant, &project, &process);
|
||||
let active_main = self
|
||||
.main_runtime
|
||||
.controls
|
||||
.get(&process_key)
|
||||
.is_some_and(|control| {
|
||||
control.task_instance == task
|
||||
&& matches!(control.state.as_str(), "running" | "stopping")
|
||||
});
|
||||
let active_task = active_key.is_some() || active_main;
|
||||
let completed_event_observed = self.task_events.iter().any(|event| {
|
||||
event.tenant == tenant
|
||||
&& event.project == project
|
||||
&& event.process == process
|
||||
&& event.task == task
|
||||
});
|
||||
let checkpoint_key = task_restart_key(&tenant, &project, &process, &task);
|
||||
let checkpoint = self.task_restart_checkpoints.get(&checkpoint_key).cloned();
|
||||
let mut accepted = false;
|
||||
let mut restarted_task_instance = None;
|
||||
let mut clean_boundary_available = false;
|
||||
let mut requires_whole_process_restart = true;
|
||||
let message = if active_main {
|
||||
"selected coordinator main is still active; restart the whole virtual process to rerun its capless entry boundary".to_owned()
|
||||
} else if active_task {
|
||||
"selected task is still active; wait for its terminal event or abort the whole virtual process before restarting from its clean entry boundary".to_owned()
|
||||
} else if !completed_event_observed {
|
||||
"selected task is not known in the active process; restart the whole virtual process or inspect task list".to_owned()
|
||||
} else if let Some(checkpoint) = checkpoint {
|
||||
let vfs_available =
|
||||
checkpoint
|
||||
.checkpoint
|
||||
.vfs_manifest
|
||||
.objects
|
||||
.iter()
|
||||
.all(|(path, object)| {
|
||||
let artifact = super::keys::artifact_id_from_path(path);
|
||||
self.artifact_registry
|
||||
.metadata(&artifact)
|
||||
.is_some_and(|metadata| {
|
||||
metadata.tenant == tenant
|
||||
&& metadata.project == project
|
||||
&& metadata.digest == object.digest
|
||||
&& metadata.size == object.size
|
||||
&& !metadata.retaining_nodes.is_empty()
|
||||
})
|
||||
});
|
||||
if !vfs_available {
|
||||
"selected task checkpoint references VFS artifacts that are no longer retained; restart the whole virtual process".to_owned()
|
||||
} else {
|
||||
let replacement = replacement_bundle
|
||||
.as_ref()
|
||||
.map(|bundle| {
|
||||
validate_task_replacement(
|
||||
bundle,
|
||||
&checkpoint.assignment.task_spec.task_definition,
|
||||
checkpoint.assignment.task_spec.environment_id.as_deref(),
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let request = RestartRequest {
|
||||
task: task.clone(),
|
||||
entrypoint: replacement.as_ref().map_or_else(
|
||||
|| checkpoint.checkpoint.boundary.task_entrypoint.clone(),
|
||||
|replacement| replacement.export.clone(),
|
||||
),
|
||||
serialized_args: checkpoint.checkpoint.boundary.serialized_args.clone(),
|
||||
environment_digest: replacement.as_ref().map_or_else(
|
||||
|| checkpoint.checkpoint.boundary.environment_digest.clone(),
|
||||
|replacement| replacement.environment_digest.clone(),
|
||||
),
|
||||
task_abi: replacement.as_ref().map_or_else(
|
||||
|| checkpoint.checkpoint.boundary.task_abi.clone(),
|
||||
|replacement| replacement.restart_compatibility.clone(),
|
||||
),
|
||||
source_edited: replacement.is_some(),
|
||||
};
|
||||
match RestartPolicy.decide(&checkpoint.checkpoint, &request) {
|
||||
RestartDecision::RestartTask { from_vfs_epoch, .. } => {
|
||||
clean_boundary_available = true;
|
||||
let mut assignment = checkpoint.assignment;
|
||||
let new_task_instance = disasmer_core::TaskInstanceId::new(
|
||||
disasmer_core::generate_opaque_token("ti")
|
||||
.map_err(CoordinatorServiceError::Protocol)?,
|
||||
);
|
||||
assignment.task = new_task_instance.clone();
|
||||
assignment.task_spec.task_instance = new_task_instance.clone();
|
||||
assignment.artifact_path =
|
||||
format!("/vfs/artifacts/{}-result.json", new_task_instance.as_str());
|
||||
if let Some(replacement) = replacement {
|
||||
assignment.task_spec.dispatch = TaskDispatch::CoordinatorNodeWasm {
|
||||
export: Some(replacement.export),
|
||||
abi: WasmExportAbi::TaskV1,
|
||||
};
|
||||
assignment.task_spec.environment = replacement
|
||||
.environment
|
||||
.map(|environment| environment.requirements);
|
||||
assignment.task_spec.environment_digest =
|
||||
Some(replacement.environment_digest);
|
||||
assignment.task_spec.required_capabilities =
|
||||
replacement.required_capabilities;
|
||||
assignment.task_spec.bundle_digest =
|
||||
Some(replacement.bundle_digest.clone());
|
||||
assignment.wasm_module_base64 = replacement.wasm_module_base64;
|
||||
}
|
||||
let launch = self.handle_launch_task_with_actor(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
WorkflowActor {
|
||||
kind: "user".to_owned(),
|
||||
user: Some(actor.clone()),
|
||||
agent: None,
|
||||
credential_kind: CredentialKind::CliDeviceSession,
|
||||
public_key_fingerprint: None,
|
||||
authenticated_without_browser: false,
|
||||
scopes: vec!["process:restart-task".to_owned()],
|
||||
},
|
||||
assignment.task_spec,
|
||||
true,
|
||||
assignment.artifact_path,
|
||||
assignment.wasm_module_base64,
|
||||
)?;
|
||||
accepted = matches!(
|
||||
launch,
|
||||
CoordinatorResponse::TaskLaunched { .. }
|
||||
| CoordinatorResponse::TaskQueued { .. }
|
||||
);
|
||||
if accepted {
|
||||
restarted_task_instance = Some(new_task_instance.clone());
|
||||
}
|
||||
requires_whole_process_restart = !accepted;
|
||||
format!(
|
||||
"selected task restarted as new instance {new_task_instance} from clean VFS entry boundary epoch {from_vfs_epoch}; unflushed task changes were discarded"
|
||||
)
|
||||
}
|
||||
RestartDecision::RestartWholeVirtualProcess { message } => message,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"selected task has terminal metadata but no captured clean VFS entry boundary; restart the whole virtual process".to_owned()
|
||||
};
|
||||
let audit_event = self.record_debug_audit_event(
|
||||
tenant,
|
||||
project,
|
||||
process.clone(),
|
||||
Some(task.clone()),
|
||||
actor.clone(),
|
||||
"restart_task",
|
||||
true,
|
||||
&message,
|
||||
)?;
|
||||
Ok(CoordinatorResponse::TaskRestart {
|
||||
process,
|
||||
task,
|
||||
restarted_task_instance,
|
||||
actor,
|
||||
accepted,
|
||||
clean_boundary_available,
|
||||
active_task,
|
||||
completed_event_observed,
|
||||
requires_whole_process_restart,
|
||||
message,
|
||||
charged_debug_read_bytes: audit_event.charged_debug_read_bytes,
|
||||
used_debug_read_bytes: audit_event.used_debug_read_bytes,
|
||||
audit_event,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ValidatedTaskReplacement {
|
||||
bundle_digest: Digest,
|
||||
wasm_module_base64: String,
|
||||
export: String,
|
||||
restart_compatibility: Digest,
|
||||
environment: Option<EnvironmentResource>,
|
||||
environment_digest: Digest,
|
||||
required_capabilities: BTreeSet<Capability>,
|
||||
}
|
||||
|
||||
fn validate_task_replacement(
|
||||
replacement: &TaskReplacementBundle,
|
||||
task_definition: &disasmer_core::TaskDefinitionId,
|
||||
environment_id: Option<&str>,
|
||||
) -> Result<ValidatedTaskReplacement, CoordinatorServiceError> {
|
||||
let module = BASE64_STANDARD
|
||||
.decode(&replacement.wasm_module_base64)
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"replacement task bundle is not valid base64: {error}"
|
||||
))
|
||||
})?;
|
||||
let actual_digest = Digest::sha256(&module);
|
||||
if actual_digest != replacement.bundle_digest {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"replacement task bundle digest mismatch: expected {}, actual {actual_digest}",
|
||||
replacement.bundle_digest
|
||||
)));
|
||||
}
|
||||
let descriptors = super::main_runtime::task_descriptors(&module)?;
|
||||
let descriptor = descriptors.get(task_definition.as_str()).ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"replacement bundle has no task definition `{task_definition}`"
|
||||
))
|
||||
})?;
|
||||
if descriptor
|
||||
.get("abi_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
!= Some(disasmer_core::WASM_TASK_ABI_VERSION as u64)
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"replacement task `{task_definition}` uses an unsupported task ABI"
|
||||
)));
|
||||
}
|
||||
let export = descriptor
|
||||
.get("export")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|export| !export.is_empty())
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"replacement task `{task_definition}` omitted its export"
|
||||
))
|
||||
})?
|
||||
.to_owned();
|
||||
let restart_compatibility = serde_json::from_value(
|
||||
descriptor
|
||||
.get("restart_compatibility_hash")
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"replacement task `{task_definition}` omitted restart compatibility metadata"
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
let mut required_capabilities = descriptor
|
||||
.get("required_capabilities")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|capability| {
|
||||
super::main_runtime::capability_from_descriptor(capability.as_str().ok_or_else(
|
||||
|| {
|
||||
CoordinatorServiceError::Protocol(
|
||||
"replacement task capability is not a string".to_owned(),
|
||||
)
|
||||
},
|
||||
)?)
|
||||
.map_err(CoordinatorServiceError::Protocol)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
let environment = environment_id
|
||||
.map(|environment_id| {
|
||||
super::main_runtime::bundle_environments(&module)?
|
||||
.remove(environment_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"replacement bundle has no environment `{environment_id}`"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
if let Some(environment) = &environment {
|
||||
required_capabilities.extend(environment.requirements.capabilities.iter().cloned());
|
||||
}
|
||||
let environment_digest = environment.as_ref().map_or_else(
|
||||
|| Digest::sha256("disasmer.environment.unconstrained.v1"),
|
||||
|environment| environment.digest.clone(),
|
||||
);
|
||||
Ok(ValidatedTaskReplacement {
|
||||
bundle_digest: replacement.bundle_digest.clone(),
|
||||
wasm_module_base64: replacement.wasm_module_base64.clone(),
|
||||
export,
|
||||
restart_compatibility,
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
47
crates/disasmer-coordinator/src/service/durable_runtime.rs
Normal file
47
crates/disasmer-coordinator/src/service/durable_runtime.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use crate::{
|
||||
DurableState, DurableStore, FallibleDurableStore, InMemoryDurableStore, PostgresDurableStore,
|
||||
};
|
||||
|
||||
pub(super) enum RuntimeDurableStore {
|
||||
InMemory(InMemoryDurableStore),
|
||||
Postgres(PostgresDurableStore),
|
||||
}
|
||||
|
||||
impl RuntimeDurableStore {
|
||||
pub(super) fn from_database_url(database_url: Option<&str>) -> Result<Self, String> {
|
||||
match database_url.map(str::trim).filter(|url| !url.is_empty()) {
|
||||
Some(url) => PostgresDurableStore::connect(url)
|
||||
.map(Self::Postgres)
|
||||
.map_err(|error| error.to_string()),
|
||||
None => Ok(Self::InMemory(InMemoryDurableStore::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InMemory(_) => "in_memory",
|
||||
Self::Postgres(_) => "postgres",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FallibleDurableStore for RuntimeDurableStore {
|
||||
type Error = String;
|
||||
|
||||
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||
match self {
|
||||
Self::InMemory(store) => Ok(store.load()),
|
||||
Self::Postgres(store) => store.load_state().map_err(|error| error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||
match self {
|
||||
Self::InMemory(store) => {
|
||||
store.save(state.clone());
|
||||
Ok(())
|
||||
}
|
||||
Self::Postgres(store) => store.save_state(state).map_err(|error| error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
71
crates/disasmer-coordinator/src/service/keys.rs
Normal file
71
crates/disasmer-coordinator/src/service/keys.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use disasmer_core::{ArtifactId, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, VfsPath};
|
||||
|
||||
pub(super) type TaskControlKey = (TenantId, ProjectId, ProcessId, NodeId, TaskInstanceId);
|
||||
pub(super) type TaskRestartKey = (TenantId, ProjectId, ProcessId, TaskInstanceId);
|
||||
pub(super) type TaskAssignmentKey = (TenantId, ProjectId, NodeId);
|
||||
pub(super) type PanelStopKey = (TenantId, ProjectId, ProcessId);
|
||||
pub(super) type EnrollmentGrantKey = (TenantId, ProjectId, String);
|
||||
pub(super) type ProcessControlKey = (TenantId, ProjectId, ProcessId);
|
||||
|
||||
pub(super) fn task_control_key(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
node: &NodeId,
|
||||
task: &TaskInstanceId,
|
||||
) -> TaskControlKey {
|
||||
(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
node.clone(),
|
||||
task.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn task_restart_key(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
task: &TaskInstanceId,
|
||||
) -> TaskRestartKey {
|
||||
(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
task.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn process_control_key(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> ProcessControlKey {
|
||||
(tenant.clone(), project.clone(), process.clone())
|
||||
}
|
||||
|
||||
pub(super) fn panel_stop_key(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> PanelStopKey {
|
||||
(tenant.clone(), project.clone(), process.clone())
|
||||
}
|
||||
|
||||
pub(super) fn enrollment_grant_key(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
grant: &str,
|
||||
) -> EnrollmentGrantKey {
|
||||
(tenant.clone(), project.clone(), grant.to_owned())
|
||||
}
|
||||
|
||||
pub(super) fn artifact_id_from_path(path: &VfsPath) -> ArtifactId {
|
||||
let value = path
|
||||
.as_str()
|
||||
.strip_prefix("/vfs/artifacts/")
|
||||
.unwrap_or(path.as_str())
|
||||
.replace('/', ":");
|
||||
ArtifactId::new(value)
|
||||
}
|
||||
535
crates/disasmer-coordinator/src/service/logs.rs
Normal file
535
crates/disasmer-coordinator/src/service/logs.rs
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
use disasmer_core::{
|
||||
ArtifactFlush, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue,
|
||||
TaskInstanceId, TaskJoinResult, TaskJoinState, TenantId, UserId, VfsPath,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::keys::{process_control_key, task_control_key};
|
||||
use super::{
|
||||
artifact_id_from_path, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
|
||||
TaskCompletionEvent, TaskTerminalState, MAX_TASK_LOG_TAIL_BYTES,
|
||||
};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_report_task_log(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
stdout_tail: String,
|
||||
stderr_tail: String,
|
||||
stdout_truncated: bool,
|
||||
stderr_truncated: bool,
|
||||
backpressured: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
validate_task_log_tail("stdout_tail", &stdout_tail)?;
|
||||
validate_task_log_tail("stderr_tail", &stderr_tail)?;
|
||||
let reported_bytes = checked_reported_log_bytes(stdout_bytes, stderr_bytes)?;
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.quota
|
||||
.can_charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?;
|
||||
self.quota
|
||||
.charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?;
|
||||
Ok(CoordinatorResponse::TaskLogRecorded {
|
||||
process,
|
||||
task,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail: if stdout_truncated {
|
||||
format!("{stdout_tail}\n... truncated")
|
||||
} else {
|
||||
stdout_tail
|
||||
},
|
||||
stderr_tail: if stderr_truncated {
|
||||
format!("{stderr_tail}\n... truncated")
|
||||
} else {
|
||||
stderr_tail
|
||||
},
|
||||
backpressured,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_report_vfs_metadata(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
artifact_path: Option<String>,
|
||||
artifact_digest: Option<Digest>,
|
||||
artifact_size_bytes: Option<u64>,
|
||||
large_bytes_uploaded: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let artifact_path = artifact_path
|
||||
.map(VfsPath::new)
|
||||
.transpose()
|
||||
.map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?;
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
if let (Some(path), Some(digest)) = (&artifact_path, artifact_digest) {
|
||||
self.flush_artifact_metadata(ArtifactFlush {
|
||||
id: artifact_id_from_path(path),
|
||||
tenant,
|
||||
project,
|
||||
process: process.clone(),
|
||||
producer_task: task.clone(),
|
||||
retaining_node: node,
|
||||
digest,
|
||||
size: artifact_size_bytes.unwrap_or_default(),
|
||||
})?;
|
||||
}
|
||||
Ok(CoordinatorResponse::VfsMetadataRecorded {
|
||||
process,
|
||||
task,
|
||||
artifact_path,
|
||||
large_bytes_uploaded,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_task_completed(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
terminal_state: Option<TaskTerminalState>,
|
||||
status_code: Option<i32>,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
stdout_tail: String,
|
||||
stderr_tail: String,
|
||||
stdout_truncated: bool,
|
||||
stderr_truncated: bool,
|
||||
artifact_path: Option<String>,
|
||||
artifact_digest: Option<Digest>,
|
||||
artifact_size_bytes: Option<u64>,
|
||||
result: Option<TaskBoundaryValue>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
validate_task_log_tail("stdout_tail", &stdout_tail)?;
|
||||
validate_task_log_tail("stderr_tail", &stderr_tail)?;
|
||||
let artifact_path = artifact_path
|
||||
.map(VfsPath::new)
|
||||
.transpose()
|
||||
.map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?;
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
let checkpoint = self
|
||||
.task_restart_checkpoints
|
||||
.get(&super::keys::task_restart_key(
|
||||
&tenant, &project, &process, &task,
|
||||
))
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"signed node task completion does not name a coordinator-issued task instance"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
if checkpoint.assignment.node != node {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"signed node task completion came from a node other than the assigned node"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut event = TaskCompletionEvent {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
executor: super::TaskExecutor::Node,
|
||||
task_definition: checkpoint.assignment.task_spec.task_definition.clone(),
|
||||
task,
|
||||
placement: None,
|
||||
terminal_state: terminal_state
|
||||
.unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)),
|
||||
status_code,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
artifact_path,
|
||||
artifact_digest: artifact_digest.clone(),
|
||||
artifact_size_bytes,
|
||||
result,
|
||||
};
|
||||
let reported_bytes = checked_reported_log_bytes(event.stdout_bytes, event.stderr_bytes)?;
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.quota.can_charge_log_bytes(
|
||||
&event.tenant,
|
||||
&event.project,
|
||||
reported_bytes,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
self.quota.charge_log_bytes(
|
||||
&event.tenant,
|
||||
&event.project,
|
||||
reported_bytes,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
let task_key = task_control_key(
|
||||
&event.tenant,
|
||||
&event.project,
|
||||
&event.process,
|
||||
&event.node,
|
||||
&event.task,
|
||||
);
|
||||
let process_key = process_control_key(&event.tenant, &event.project, &event.process);
|
||||
let process_was_aborted = self.process_aborts.contains(&process_key);
|
||||
event.placement = self.task_placements.remove(&task_key);
|
||||
if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) {
|
||||
self.flush_artifact_metadata(ArtifactFlush {
|
||||
id: artifact_id_from_path(path),
|
||||
tenant: event.tenant.clone(),
|
||||
project: event.project.clone(),
|
||||
process: event.process.clone(),
|
||||
producer_task: event.task.clone(),
|
||||
retaining_node: event.node.clone(),
|
||||
digest,
|
||||
size: artifact_size_bytes.unwrap_or(stdout_bytes),
|
||||
})?;
|
||||
}
|
||||
self.task_cancellations.remove(&task_key);
|
||||
self.task_aborts.remove(&task_key);
|
||||
self.debug_commands.remove(&task_key);
|
||||
self.active_tasks.remove(&task_key);
|
||||
if !self
|
||||
.active_tasks
|
||||
.iter()
|
||||
.any(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant == &event.tenant
|
||||
&& task_project == &event.project
|
||||
&& task_process == &event.process
|
||||
})
|
||||
{
|
||||
self.process_aborts.remove(&process_key);
|
||||
}
|
||||
if process_was_aborted {
|
||||
let checkpoint_key = super::keys::task_restart_key(
|
||||
&event.tenant,
|
||||
&event.project,
|
||||
&event.process,
|
||||
&event.task,
|
||||
);
|
||||
self.task_restart_checkpoints.remove(&checkpoint_key);
|
||||
self.task_restart_checkpoint_order
|
||||
.retain(|retained| retained != &checkpoint_key);
|
||||
}
|
||||
self.record_task_completion_event(event.clone());
|
||||
self.notify_coordinator_main_waiters(&event);
|
||||
Ok(CoordinatorResponse::TaskRecorded {
|
||||
process: event.process,
|
||||
task: event.task,
|
||||
events_recorded: self.task_events.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_list_task_events(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: Option<String>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let _actor = UserId::new(actor_user);
|
||||
let process = process.map(ProcessId::new);
|
||||
if let Some(process) = &process {
|
||||
self.authorize_task_event_process_scope(&tenant, &project, process)?;
|
||||
}
|
||||
let events = self
|
||||
.task_events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.tenant == tenant
|
||||
&& event.project == project
|
||||
&& process
|
||||
.as_ref()
|
||||
.is_none_or(|process| event.process == *process)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(CoordinatorResponse::TaskEvents { events })
|
||||
}
|
||||
|
||||
fn authorize_task_event_process_scope(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let active_in_scope = self
|
||||
.coordinator
|
||||
.active_process(tenant, project, process)
|
||||
.is_some();
|
||||
let historical_in_scope = self.task_events.iter().any(|event| {
|
||||
event.tenant == *tenant && event.project == *project && event.process == *process
|
||||
});
|
||||
let process_exists_outside_scope = self
|
||||
.coordinator
|
||||
.active_process_exists_outside_scope(tenant, project, process)
|
||||
|| self.task_events.iter().any(|event| {
|
||||
event.process == *process && (event.tenant != *tenant || event.project != *project)
|
||||
});
|
||||
if !active_in_scope && !historical_in_scope && process_exists_outside_scope {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task event access is outside the virtual process tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_join_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
task: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let _actor = UserId::new(actor_user);
|
||||
let process = ProcessId::new(process);
|
||||
let task = TaskInstanceId::new(task);
|
||||
Ok(CoordinatorResponse::TaskJoined {
|
||||
join: self.task_join_result(tenant, project, process, task),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_join_child_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
parent_task: String,
|
||||
task: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let parent_task = TaskInstanceId::new(parent_task);
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
if !self.active_tasks.contains(&super::keys::task_control_key(
|
||||
&tenant,
|
||||
&project,
|
||||
&process,
|
||||
&node,
|
||||
&parent_task,
|
||||
)) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"child task join requires a currently active parent task on the signed node"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(CoordinatorResponse::TaskJoined {
|
||||
join: self.task_join_result(tenant, project, process, TaskInstanceId::new(task)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn task_join_result(
|
||||
&self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
) -> TaskJoinResult {
|
||||
let event = self.task_events.iter().rev().find(|event| {
|
||||
event.tenant == tenant
|
||||
&& event.project == project
|
||||
&& event.process == process
|
||||
&& event.task == task
|
||||
});
|
||||
|
||||
if let Some(event) = event {
|
||||
TaskJoinResult::from_remote_completion(
|
||||
event.process.clone(),
|
||||
event.task.clone(),
|
||||
event.node.clone(),
|
||||
join_state_for_terminal(&event.terminal_state),
|
||||
event.result.clone(),
|
||||
event.status_code,
|
||||
join_message_for_event(event),
|
||||
)
|
||||
} else {
|
||||
let known = self.task_is_known_or_active(&tenant, &project, &process, &task);
|
||||
TaskJoinResult::pending(
|
||||
process,
|
||||
task,
|
||||
if known {
|
||||
"waiting for signed node task_completed event before join returns"
|
||||
} else {
|
||||
"no signed node completion event has been observed for this task"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_task_completion_event(&mut self, mut event: TaskCompletionEvent) {
|
||||
event.stdout_tail = bounded_log_tail(event.stdout_tail, &mut event.stdout_truncated);
|
||||
event.stderr_tail = bounded_log_tail(event.stderr_tail, &mut event.stderr_truncated);
|
||||
while self
|
||||
.task_events
|
||||
.iter()
|
||||
.filter(|retained| {
|
||||
retained.tenant == event.tenant
|
||||
&& retained.project == event.project
|
||||
&& retained.process == event.process
|
||||
})
|
||||
.count()
|
||||
>= super::MAX_TASK_EVENTS_PER_PROCESS
|
||||
{
|
||||
let Some(index) = self.task_events.iter().position(|retained| {
|
||||
retained.tenant == event.tenant
|
||||
&& retained.project == event.project
|
||||
&& retained.process == event.process
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
self.task_events.remove(index);
|
||||
}
|
||||
self.task_events.push_back(event);
|
||||
}
|
||||
|
||||
fn flush_artifact_metadata(
|
||||
&mut self,
|
||||
flush: ArtifactFlush,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.artifact_registry
|
||||
.expire_download_links(now_epoch_seconds);
|
||||
let pinned = self
|
||||
.task_restart_checkpoints
|
||||
.values()
|
||||
.flat_map(|checkpoint| checkpoint.assignment.task_spec.required_artifacts.iter())
|
||||
.chain(
|
||||
self.pending_task_launches
|
||||
.iter()
|
||||
.flat_map(|pending| pending.task_spec.required_artifacts.iter()),
|
||||
)
|
||||
.cloned()
|
||||
.collect::<std::collections::BTreeSet<ArtifactId>>();
|
||||
self.artifact_registry
|
||||
.flush_metadata_bounded(flush, &pinned)
|
||||
.map(|_| ())
|
||||
.map_err(CoordinatorServiceError::Protocol)
|
||||
}
|
||||
|
||||
fn task_is_known_or_active(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
task: &TaskInstanceId,
|
||||
) -> bool {
|
||||
self.active_tasks
|
||||
.iter()
|
||||
.any(|(task_tenant, task_project, task_process, _, task_id)| {
|
||||
task_tenant == tenant
|
||||
&& task_project == project
|
||||
&& task_process == process
|
||||
&& task_id == task
|
||||
})
|
||||
|| self.pending_task_launches.iter().any(|pending| {
|
||||
&pending.tenant == tenant
|
||||
&& &pending.project == project
|
||||
&& &pending.process == process
|
||||
&& &pending.task == task
|
||||
})
|
||||
|| self.task_assignments.values().any(|assignments| {
|
||||
assignments.iter().any(|assignment| {
|
||||
&assignment.tenant == tenant
|
||||
&& &assignment.project == project
|
||||
&& &assignment.process == process
|
||||
&& &assignment.task == task
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_reported_log_bytes(
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
) -> Result<u64, CoordinatorServiceError> {
|
||||
stdout_bytes.checked_add(stderr_bytes).ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(
|
||||
"reported task log byte counts exceed the supported range".to_owned(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_task_log_tail(kind: &str, value: &str) -> Result<(), CoordinatorServiceError> {
|
||||
if value.len() > MAX_TASK_LOG_TAIL_BYTES {
|
||||
return Err(CoordinatorServiceError::InvalidTaskLogTail(format!(
|
||||
"{kind} is {} bytes; max is {MAX_TASK_LOG_TAIL_BYTES}",
|
||||
value.len()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bounded_log_tail(mut value: String, truncated: &mut bool) -> String {
|
||||
if value.len() <= MAX_TASK_LOG_TAIL_BYTES {
|
||||
return value;
|
||||
}
|
||||
let mut boundary = MAX_TASK_LOG_TAIL_BYTES;
|
||||
while !value.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
value.truncate(boundary);
|
||||
*truncated = true;
|
||||
value
|
||||
}
|
||||
|
||||
fn join_state_for_terminal(terminal: &TaskTerminalState) -> TaskJoinState {
|
||||
match terminal {
|
||||
TaskTerminalState::Completed => TaskJoinState::Completed,
|
||||
TaskTerminalState::Failed => TaskJoinState::Failed,
|
||||
TaskTerminalState::Cancelled => TaskJoinState::Cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
fn join_message_for_event(event: &TaskCompletionEvent) -> String {
|
||||
match event.terminal_state {
|
||||
TaskTerminalState::Completed => {
|
||||
"joined result from signed node task_completed event".to_owned()
|
||||
}
|
||||
TaskTerminalState::Failed => {
|
||||
let stderr = event.stderr_tail.trim();
|
||||
if stderr.is_empty() {
|
||||
"remote task failed".to_owned()
|
||||
} else {
|
||||
format!("remote task failed: {stderr}")
|
||||
}
|
||||
}
|
||||
TaskTerminalState::Cancelled => "remote task was cancelled".to_owned(),
|
||||
}
|
||||
}
|
||||
999
crates/disasmer-coordinator/src/service/main_runtime.rs
Normal file
999
crates/disasmer-coordinator/src/service/main_runtime.rs
Normal file
|
|
@ -0,0 +1,999 @@
|
|||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use disasmer_core::{
|
||||
Capability, CredentialKind, Digest, EnvironmentResource, NodeId, ProcessId, ProjectId,
|
||||
TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec,
|
||||
TenantId, WasmExportAbi, WasmHostCommandRequest, WasmHostCommandResult,
|
||||
WasmHostDebugProbeRequest, WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest,
|
||||
WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, WasmHostTaskControlResult,
|
||||
WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest,
|
||||
WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult,
|
||||
};
|
||||
use disasmer_wasm_runtime::{WasmDebugControl, WasmTaskHost, WasmtimeTaskRuntime};
|
||||
use wasmparser::{Parser, Payload};
|
||||
|
||||
use crate::{CoordinatorError, CoordinatorServiceError};
|
||||
|
||||
use super::keys::{process_control_key, task_restart_key, ProcessControlKey, TaskRestartKey};
|
||||
use super::{
|
||||
CoordinatorResponse, CoordinatorService, TaskCompletionEvent, TaskExecutor, TaskTerminalState,
|
||||
WorkflowActor,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MainScope {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
task_definition: TaskDefinitionId,
|
||||
task_instance: TaskInstanceId,
|
||||
epoch: u64,
|
||||
launch_id: u64,
|
||||
}
|
||||
|
||||
enum MainCommand {
|
||||
StartTask {
|
||||
scope: MainScope,
|
||||
handle_id: u64,
|
||||
task_spec: TaskSpec,
|
||||
wasm_module_base64: String,
|
||||
response: SyncSender<Result<WasmHostTaskHandle, String>>,
|
||||
},
|
||||
JoinTask {
|
||||
scope: MainScope,
|
||||
task_instance: TaskInstanceId,
|
||||
response: SyncSender<Result<WasmHostTaskJoinResult, String>>,
|
||||
},
|
||||
DebugProbe {
|
||||
scope: MainScope,
|
||||
request: WasmHostDebugProbeRequest,
|
||||
response: SyncSender<Result<WasmHostDebugProbeResult, String>>,
|
||||
},
|
||||
Finished {
|
||||
scope: MainScope,
|
||||
result: Result<WasmTaskResult, String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct CoordinatorMainControl {
|
||||
pub(super) task_definition: TaskDefinitionId,
|
||||
pub(super) task_instance: TaskInstanceId,
|
||||
pub(super) abort: Arc<AtomicBool>,
|
||||
pub(super) debug: Arc<WasmDebugControl>,
|
||||
pub(super) state: String,
|
||||
pub(super) stopped_probe_symbol: Option<String>,
|
||||
pub(super) handles: Arc<Mutex<HashMap<u64, TaskSpec>>>,
|
||||
pub(super) launch_id: u64,
|
||||
}
|
||||
|
||||
pub(super) struct CoordinatorMainRuntime {
|
||||
sender: Sender<MainCommand>,
|
||||
receiver: Receiver<MainCommand>,
|
||||
pub(super) controls: BTreeMap<ProcessControlKey, CoordinatorMainControl>,
|
||||
join_waiters: BTreeMap<TaskRestartKey, Vec<SyncSender<Result<WasmHostTaskJoinResult, String>>>>,
|
||||
next_launch_id: u64,
|
||||
}
|
||||
|
||||
impl Default for CoordinatorMainRuntime {
|
||||
fn default() -> Self {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
Self {
|
||||
sender,
|
||||
receiver,
|
||||
controls: BTreeMap::new(),
|
||||
join_waiters: BTreeMap::new(),
|
||||
next_launch_id: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CoordinatorMainRuntime {
|
||||
pub(super) fn is_waiting_for_task(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> bool {
|
||||
self.join_waiters
|
||||
.keys()
|
||||
.any(|(waiter_tenant, waiter_project, waiter_process, _)| {
|
||||
waiter_tenant == tenant && waiter_project == project && waiter_process == process
|
||||
})
|
||||
}
|
||||
|
||||
fn drain_commands(&self) -> Vec<MainCommand> {
|
||||
let mut commands = Vec::new();
|
||||
loop {
|
||||
match self.receiver.try_recv() {
|
||||
Ok(command) => commands.push(command),
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
fn launch(
|
||||
&mut self,
|
||||
mut scope: MainScope,
|
||||
export: String,
|
||||
module: Vec<u8>,
|
||||
wasm_module_base64: String,
|
||||
bundle_digest: Digest,
|
||||
task_descriptors: HashMap<String, serde_json::Value>,
|
||||
environments: BTreeMap<String, EnvironmentResource>,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let process_key = process_control_key(&scope.tenant, &scope.project, &scope.process);
|
||||
if self.controls.contains_key(&process_key) {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"virtual process {} already has a coordinator main instance",
|
||||
scope.process
|
||||
)));
|
||||
}
|
||||
scope.launch_id = self.next_launch_id;
|
||||
self.next_launch_id = self.next_launch_id.saturating_add(1);
|
||||
let abort = Arc::new(AtomicBool::new(false));
|
||||
let debug = Arc::new(WasmDebugControl::default());
|
||||
let handles = Arc::new(Mutex::new(HashMap::new()));
|
||||
self.controls.insert(
|
||||
process_key,
|
||||
CoordinatorMainControl {
|
||||
task_definition: scope.task_definition.clone(),
|
||||
task_instance: scope.task_instance.clone(),
|
||||
abort: Arc::clone(&abort),
|
||||
debug: Arc::clone(&debug),
|
||||
state: "running".to_owned(),
|
||||
stopped_probe_symbol: None,
|
||||
handles: Arc::clone(&handles),
|
||||
launch_id: scope.launch_id,
|
||||
},
|
||||
);
|
||||
let sender = self.sender.clone();
|
||||
let invocation = WasmTaskInvocation::new(
|
||||
scope.task_definition.clone(),
|
||||
scope.task_instance.clone(),
|
||||
Vec::new(),
|
||||
);
|
||||
std::thread::Builder::new()
|
||||
.name(format!("disasmer-main-{}", scope.process))
|
||||
.spawn(move || {
|
||||
let host = CoordinatorMainHost {
|
||||
scope: scope.clone(),
|
||||
sender: sender.clone(),
|
||||
abort,
|
||||
debug,
|
||||
task_descriptors,
|
||||
environments,
|
||||
bundle_digest: bundle_digest.clone(),
|
||||
wasm_module_base64,
|
||||
next_handle_id: 1,
|
||||
handles,
|
||||
};
|
||||
let result = WasmtimeTaskRuntime::new()
|
||||
.and_then(|runtime| {
|
||||
runtime.run_task_export_verified_with_task_host(
|
||||
&module,
|
||||
&bundle_digest,
|
||||
&export,
|
||||
&invocation,
|
||||
Box::new(host),
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = sender.send(MainCommand::Finished { scope, result });
|
||||
})
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"start coordinator main runtime thread: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn interrupt_process(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
reason: &str,
|
||||
) {
|
||||
let process_key = process_control_key(tenant, project, process);
|
||||
if let Some(control) = self.controls.get_mut(&process_key) {
|
||||
control.abort.store(true, Ordering::Release);
|
||||
control.state = "stopping".to_owned();
|
||||
}
|
||||
let waiter_keys = self
|
||||
.join_waiters
|
||||
.keys()
|
||||
.filter(|(waiter_tenant, waiter_project, waiter_process, _)| {
|
||||
waiter_tenant == tenant && waiter_project == project && waiter_process == process
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for key in waiter_keys {
|
||||
if let Some(waiters) = self.join_waiters.remove(&key) {
|
||||
for waiter in waiters {
|
||||
let _ = waiter.send(Err(reason.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_current_scope(&self, scope: &MainScope) -> bool {
|
||||
self.controls
|
||||
.get(&process_control_key(
|
||||
&scope.tenant,
|
||||
&scope.project,
|
||||
&scope.process,
|
||||
))
|
||||
.is_some_and(|control| control.launch_id == scope.launch_id)
|
||||
}
|
||||
}
|
||||
|
||||
struct CoordinatorMainHost {
|
||||
scope: MainScope,
|
||||
sender: Sender<MainCommand>,
|
||||
abort: Arc<AtomicBool>,
|
||||
debug: Arc<WasmDebugControl>,
|
||||
task_descriptors: HashMap<String, serde_json::Value>,
|
||||
environments: BTreeMap<String, EnvironmentResource>,
|
||||
bundle_digest: Digest,
|
||||
wasm_module_base64: String,
|
||||
next_handle_id: u64,
|
||||
handles: Arc<Mutex<HashMap<u64, TaskSpec>>>,
|
||||
}
|
||||
|
||||
impl WasmTaskHost for CoordinatorMainHost {
|
||||
fn abort_signal(&self) -> Option<Arc<AtomicBool>> {
|
||||
Some(Arc::clone(&self.abort))
|
||||
}
|
||||
|
||||
fn debug_control(&self) -> Option<Arc<WasmDebugControl>> {
|
||||
Some(Arc::clone(&self.debug))
|
||||
}
|
||||
|
||||
fn start_task(
|
||||
&mut self,
|
||||
request: WasmHostTaskStartRequest,
|
||||
) -> Result<WasmHostTaskHandle, String> {
|
||||
request.validate()?;
|
||||
if self.abort.load(Ordering::Acquire) {
|
||||
return Err("coordinator main is stopping".to_owned());
|
||||
}
|
||||
let descriptor = self
|
||||
.task_descriptors
|
||||
.get(request.task_definition.as_str())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"bundle has no task descriptor named `{}`",
|
||||
request.task_definition
|
||||
)
|
||||
})?;
|
||||
let export = descriptor
|
||||
.get("export")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|export| !export.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"task `{}` descriptor omitted its Wasm export",
|
||||
request.task_definition
|
||||
)
|
||||
})?;
|
||||
let mut required_capabilities = descriptor
|
||||
.get("required_capabilities")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|value| {
|
||||
capability_from_descriptor(
|
||||
value
|
||||
.as_str()
|
||||
.ok_or("task capability descriptor is not a string")?,
|
||||
)
|
||||
})
|
||||
.collect::<Result<BTreeSet<_>, _>>()?;
|
||||
let selected_environment = request
|
||||
.environment_id
|
||||
.as_deref()
|
||||
.map(|environment| {
|
||||
self.environments.get(environment).cloned().ok_or_else(|| {
|
||||
format!("bundle environment manifest has no environment `{environment}`")
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let environment = selected_environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.requirements.clone());
|
||||
let environment_digest = selected_environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.digest.clone());
|
||||
if let Some(environment) = &environment {
|
||||
required_capabilities.extend(environment.capabilities.iter().cloned());
|
||||
}
|
||||
let required_artifacts = request
|
||||
.args
|
||||
.iter()
|
||||
.flat_map(TaskBoundaryValue::required_artifacts)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let source_snapshots = request
|
||||
.args
|
||||
.iter()
|
||||
.flat_map(TaskBoundaryValue::source_snapshots)
|
||||
.collect::<BTreeSet<_>>();
|
||||
if source_snapshots.len() > 1 {
|
||||
return Err(
|
||||
"one task invocation cannot require multiple distinct source snapshots".to_owned(),
|
||||
);
|
||||
}
|
||||
if self
|
||||
.handles
|
||||
.lock()
|
||||
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
|
||||
.len()
|
||||
>= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
|
||||
{
|
||||
return Err(format!(
|
||||
"coordinator main task-handle limit of {} reached",
|
||||
super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
|
||||
));
|
||||
}
|
||||
let handle_id = self.next_handle_id;
|
||||
let task_instance =
|
||||
TaskInstanceId::new(format!("{}:child:{handle_id}", self.scope.task_instance));
|
||||
let task_spec = TaskSpec {
|
||||
tenant: self.scope.tenant.clone(),
|
||||
project: self.scope.project.clone(),
|
||||
process: self.scope.process.clone(),
|
||||
task_definition: request.task_definition,
|
||||
task_instance,
|
||||
dispatch: TaskDispatch::CoordinatorNodeWasm {
|
||||
export: Some(export.to_owned()),
|
||||
abi: WasmExportAbi::TaskV1,
|
||||
},
|
||||
environment_id: request.environment_id,
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
dependency_cache: None,
|
||||
source_snapshot: source_snapshots.into_iter().next(),
|
||||
required_artifacts,
|
||||
args: request.args,
|
||||
vfs_epoch: self.scope.epoch,
|
||||
bundle_digest: Some(self.bundle_digest.clone()),
|
||||
};
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
self.sender
|
||||
.send(MainCommand::StartTask {
|
||||
scope: self.scope.clone(),
|
||||
handle_id,
|
||||
task_spec: task_spec.clone(),
|
||||
wasm_module_base64: self.wasm_module_base64.clone(),
|
||||
response,
|
||||
})
|
||||
.map_err(|_| "coordinator main command channel closed".to_owned())?;
|
||||
let handle = receiver
|
||||
.recv()
|
||||
.map_err(|_| "coordinator main task-start response channel closed".to_owned())??;
|
||||
self.handles
|
||||
.lock()
|
||||
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
|
||||
.insert(handle_id, task_spec);
|
||||
self.next_handle_id = self.next_handle_id.saturating_add(1);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn join_task(
|
||||
&mut self,
|
||||
request: WasmHostTaskJoinRequest,
|
||||
) -> Result<WasmHostTaskJoinResult, String> {
|
||||
let task_spec = self
|
||||
.handles
|
||||
.lock()
|
||||
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
|
||||
.get(&request.handle_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("unknown Wasm task handle {}", request.handle_id))?;
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
self.sender
|
||||
.send(MainCommand::JoinTask {
|
||||
scope: self.scope.clone(),
|
||||
task_instance: task_spec.task_instance,
|
||||
response,
|
||||
})
|
||||
.map_err(|_| "coordinator main command channel closed".to_owned())?;
|
||||
let joined = receiver
|
||||
.recv()
|
||||
.map_err(|_| "coordinator main task-join response channel closed".to_owned())?;
|
||||
self.handles
|
||||
.lock()
|
||||
.map_err(|_| "coordinator main handle registry is unavailable".to_owned())?
|
||||
.remove(&request.handle_id);
|
||||
joined
|
||||
}
|
||||
|
||||
fn run_command(
|
||||
&mut self,
|
||||
_request: WasmHostCommandRequest,
|
||||
) -> Result<WasmHostCommandResult, String> {
|
||||
Err("coordinator main is capless and cannot run native commands".to_owned())
|
||||
}
|
||||
|
||||
fn poll_task_control(
|
||||
&mut self,
|
||||
request: WasmHostTaskControlRequest,
|
||||
) -> Result<WasmHostTaskControlResult, String> {
|
||||
request.validate()?;
|
||||
Ok(WasmHostTaskControlResult {
|
||||
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
|
||||
cancellation_requested: self.abort.load(Ordering::Acquire),
|
||||
})
|
||||
}
|
||||
|
||||
fn debug_probe(
|
||||
&mut self,
|
||||
request: WasmHostDebugProbeRequest,
|
||||
) -> Result<WasmHostDebugProbeResult, String> {
|
||||
request.validate()?;
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
self.sender
|
||||
.send(MainCommand::DebugProbe {
|
||||
scope: self.scope.clone(),
|
||||
request,
|
||||
response,
|
||||
})
|
||||
.map_err(|_| "coordinator main command channel closed".to_owned())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|_| "coordinator main debug-probe response channel closed".to_owned())?
|
||||
}
|
||||
|
||||
fn vfs_operation(&mut self, _request: WasmHostVfsRequest) -> Result<WasmHostVfsResult, String> {
|
||||
Err("coordinator main is capless and cannot access task VFS files".to_owned())
|
||||
}
|
||||
|
||||
fn snapshot_source(
|
||||
&mut self,
|
||||
_request: WasmHostSourceSnapshotRequest,
|
||||
) -> Result<WasmHostSourceSnapshotResult, String> {
|
||||
Err("coordinator main is capless and cannot access source checkouts".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_launch_coordinator_main(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: Option<String>,
|
||||
actor_agent: Option<String>,
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
agent_signature: Option<disasmer_core::AgentSignedRequest>,
|
||||
request_payload_digest: Option<&Digest>,
|
||||
task_spec: TaskSpec,
|
||||
wasm_module_base64: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = task_spec.process.clone();
|
||||
let task_instance = task_spec.task_instance.clone();
|
||||
let actor = self.workflow_actor(
|
||||
&tenant,
|
||||
&project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
request_payload_digest,
|
||||
"launch_task",
|
||||
&process,
|
||||
Some(&task_instance),
|
||||
)?;
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"coordinator main launch requires an active virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
debug_assert_eq!(active.tenant, tenant);
|
||||
debug_assert_eq!(active.project, project);
|
||||
if task_spec.tenant != tenant || task_spec.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"coordinator main TaskSpec is outside the authenticated scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let export = match &task_spec.dispatch {
|
||||
TaskDispatch::CoordinatorNodeWasm {
|
||||
export: Some(export),
|
||||
abi: WasmExportAbi::EntrypointV1,
|
||||
} => export.clone(),
|
||||
_ => {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"coordinator main requires an explicit EntrypointV1 Wasm export".to_owned(),
|
||||
))
|
||||
}
|
||||
};
|
||||
if task_spec.environment_id.is_some()
|
||||
|| task_spec.environment.is_some()
|
||||
|| task_spec.environment_digest.is_some()
|
||||
|| !task_spec.required_capabilities.is_empty()
|
||||
|| task_spec.dependency_cache.is_some()
|
||||
|| task_spec.source_snapshot.is_some()
|
||||
|| !task_spec.required_artifacts.is_empty()
|
||||
|| !task_spec.args.is_empty()
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"coordinator main must be capless and may not receive environment, source, artifact, cache, or argument authority"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let bundle_digest = task_spec.bundle_digest.clone().ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(
|
||||
"coordinator main TaskSpec omitted bundle digest".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let module = BASE64_STANDARD
|
||||
.decode(&wasm_module_base64)
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"coordinator main module is not valid base64: {error}"
|
||||
))
|
||||
})?;
|
||||
let actual_digest = Digest::sha256(&module);
|
||||
if actual_digest != bundle_digest {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"coordinator main module digest mismatch: expected {bundle_digest}, actual {actual_digest}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
WasmTaskInvocation::new(
|
||||
task_spec.task_definition.clone(),
|
||||
task_instance.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.validate()
|
||||
.map_err(CoordinatorServiceError::Protocol)?;
|
||||
let descriptors = task_descriptors(&module)?;
|
||||
let environments = bundle_environments(&module)?;
|
||||
let scope = MainScope {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
process: process.clone(),
|
||||
task_definition: task_spec.task_definition.clone(),
|
||||
task_instance: task_instance.clone(),
|
||||
epoch: task_spec.vfs_epoch,
|
||||
launch_id: 0,
|
||||
};
|
||||
self.main_runtime.launch(
|
||||
scope,
|
||||
export,
|
||||
module,
|
||||
wasm_module_base64,
|
||||
bundle_digest,
|
||||
descriptors,
|
||||
environments,
|
||||
)?;
|
||||
Ok(CoordinatorResponse::MainLaunched {
|
||||
process,
|
||||
task_definition: task_spec.task_definition,
|
||||
task_instance,
|
||||
actor,
|
||||
state: "running".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn pump_main_runtime_commands(&mut self) {
|
||||
let commands = self.main_runtime.drain_commands();
|
||||
for command in commands {
|
||||
match command {
|
||||
MainCommand::StartTask {
|
||||
scope,
|
||||
handle_id,
|
||||
task_spec,
|
||||
wasm_module_base64,
|
||||
response,
|
||||
} => {
|
||||
if !self.main_runtime.is_current_scope(&scope) {
|
||||
let _ = response.send(Err(
|
||||
"coordinator main process incarnation was replaced".to_owned(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let actor = 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()],
|
||||
};
|
||||
let result = self
|
||||
.handle_launch_task_with_actor(
|
||||
scope.tenant,
|
||||
scope.project,
|
||||
actor,
|
||||
task_spec.clone(),
|
||||
true,
|
||||
format!("/vfs/artifacts/{}-result.json", task_spec.task_instance),
|
||||
wasm_module_base64,
|
||||
)
|
||||
.and_then(|launch| match launch {
|
||||
CoordinatorResponse::TaskLaunched { .. }
|
||||
| CoordinatorResponse::TaskQueued { .. } => Ok(WasmHostTaskHandle {
|
||||
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
|
||||
handle_id,
|
||||
task_spec,
|
||||
}),
|
||||
other => Err(CoordinatorServiceError::Protocol(format!(
|
||||
"unexpected coordinator-main child launch response: {other:?}"
|
||||
))),
|
||||
})
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = response.send(result);
|
||||
}
|
||||
MainCommand::JoinTask {
|
||||
scope,
|
||||
task_instance,
|
||||
response,
|
||||
} => {
|
||||
if !self.main_runtime.is_current_scope(&scope) {
|
||||
let _ = response.send(Err(
|
||||
"coordinator main process incarnation was replaced".to_owned(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let join = self.task_join_result(
|
||||
scope.tenant.clone(),
|
||||
scope.project.clone(),
|
||||
scope.process.clone(),
|
||||
task_instance.clone(),
|
||||
);
|
||||
match join.state {
|
||||
TaskJoinState::Completed => {
|
||||
let result = join
|
||||
.result
|
||||
.ok_or_else(|| {
|
||||
"completed child task omitted its boundary result".to_owned()
|
||||
})
|
||||
.map(|result| WasmHostTaskJoinResult {
|
||||
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
|
||||
task_instance,
|
||||
result,
|
||||
});
|
||||
let _ = response.send(result);
|
||||
}
|
||||
TaskJoinState::Failed | TaskJoinState::Cancelled => {
|
||||
let _ = response.send(Err(join.message));
|
||||
}
|
||||
TaskJoinState::Pending => {
|
||||
self.main_runtime
|
||||
.join_waiters
|
||||
.entry(task_restart_key(
|
||||
&scope.tenant,
|
||||
&scope.project,
|
||||
&scope.process,
|
||||
&task_instance,
|
||||
))
|
||||
.or_default()
|
||||
.push(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
MainCommand::DebugProbe {
|
||||
scope,
|
||||
request,
|
||||
response,
|
||||
} => {
|
||||
if !self.main_runtime.is_current_scope(&scope) {
|
||||
let _ = response.send(Err(
|
||||
"coordinator main process incarnation was replaced".to_owned(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let result = self
|
||||
.handle_coordinator_main_debug_probe(
|
||||
scope.tenant,
|
||||
scope.project,
|
||||
scope.process,
|
||||
scope.task_instance,
|
||||
request.symbol,
|
||||
)
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = response.send(result);
|
||||
}
|
||||
MainCommand::Finished { scope, result } => {
|
||||
if !self.main_runtime.is_current_scope(&scope) {
|
||||
continue;
|
||||
}
|
||||
self.record_coordinator_main_completion(scope, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn notify_coordinator_main_waiters(&mut self, event: &TaskCompletionEvent) {
|
||||
let key = task_restart_key(&event.tenant, &event.project, &event.process, &event.task);
|
||||
let Some(waiters) = self.main_runtime.join_waiters.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
let result = match event.terminal_state {
|
||||
TaskTerminalState::Completed => event
|
||||
.result
|
||||
.clone()
|
||||
.ok_or_else(|| "completed child task omitted its boundary result".to_owned())
|
||||
.map(|result| WasmHostTaskJoinResult {
|
||||
abi_version: disasmer_core::WASM_TASK_ABI_VERSION,
|
||||
task_instance: event.task.clone(),
|
||||
result,
|
||||
}),
|
||||
TaskTerminalState::Failed | TaskTerminalState::Cancelled => {
|
||||
Err(event.stderr_tail.clone())
|
||||
}
|
||||
};
|
||||
for waiter in waiters {
|
||||
let _ = waiter.send(result.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn record_coordinator_main_completion(
|
||||
&mut self,
|
||||
scope: MainScope,
|
||||
result: Result<WasmTaskResult, String>,
|
||||
) {
|
||||
let (terminal_state, boundary, error) = match result {
|
||||
Ok(result) if result.outcome == WasmTaskOutcome::Completed => {
|
||||
(TaskTerminalState::Completed, result.result, String::new())
|
||||
}
|
||||
Ok(result) => (
|
||||
TaskTerminalState::Failed,
|
||||
None,
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "coordinator main failed without an error".to_owned()),
|
||||
),
|
||||
Err(error) => (TaskTerminalState::Failed, None, error),
|
||||
};
|
||||
let main_state = match terminal_state {
|
||||
TaskTerminalState::Completed => "completed",
|
||||
TaskTerminalState::Failed => "failed",
|
||||
TaskTerminalState::Cancelled => "cancelled",
|
||||
};
|
||||
let event = TaskCompletionEvent {
|
||||
tenant: scope.tenant.clone(),
|
||||
project: scope.project.clone(),
|
||||
process: scope.process.clone(),
|
||||
node: NodeId::from("coordinator-main"),
|
||||
executor: TaskExecutor::CoordinatorMain,
|
||||
task_definition: scope.task_definition,
|
||||
task: scope.task_instance,
|
||||
placement: None,
|
||||
terminal_state,
|
||||
status_code: if error.is_empty() { Some(0) } else { None },
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: error.len() as u64,
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: error,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
artifact_path: None,
|
||||
artifact_digest: None,
|
||||
artifact_size_bytes: None,
|
||||
result: boundary,
|
||||
};
|
||||
self.record_task_completion_event(event);
|
||||
if let Some(control) = self.main_runtime.controls.get_mut(&process_control_key(
|
||||
&scope.tenant,
|
||||
&scope.project,
|
||||
&scope.process,
|
||||
)) {
|
||||
control.state = main_state.to_owned();
|
||||
control.stopped_probe_symbol = None;
|
||||
if let Ok(mut handles) = control.handles.lock() {
|
||||
handles.clear();
|
||||
}
|
||||
}
|
||||
// Completion keeps the virtual process and its final debug handshake
|
||||
// inspectable. In particular, the DAP client may still be waiting for
|
||||
// every participant's resume acknowledgement when the main Wasm task
|
||||
// exits. A new process incarnation or an explicit abort clears this
|
||||
// ephemeral state.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completed_main_keeps_process_and_debug_handshake_visible_until_explicit_abort() {
|
||||
let mut service = CoordinatorService::new(7);
|
||||
let tenant = TenantId::from("tenant");
|
||||
let project = ProjectId::from("project");
|
||||
let process = ProcessId::from("vp-current");
|
||||
let main_task = TaskInstanceId::from("ti:vp-current:main");
|
||||
let scope = MainScope {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
process: process.clone(),
|
||||
task_definition: TaskDefinitionId::from("build"),
|
||||
task_instance: main_task.clone(),
|
||||
epoch: 7,
|
||||
launch_id: 1,
|
||||
};
|
||||
service
|
||||
.coordinator
|
||||
.start_process(tenant.clone(), project.clone(), process.clone());
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
service.main_runtime.controls.insert(
|
||||
process_key.clone(),
|
||||
CoordinatorMainControl {
|
||||
task_definition: scope.task_definition.clone(),
|
||||
task_instance: main_task.clone(),
|
||||
abort: Arc::new(AtomicBool::new(false)),
|
||||
debug: Arc::new(WasmDebugControl::default()),
|
||||
state: "running".to_owned(),
|
||||
stopped_probe_symbol: None,
|
||||
handles: Arc::new(Mutex::new(HashMap::new())),
|
||||
launch_id: 1,
|
||||
},
|
||||
);
|
||||
service.debug_epochs.insert(process_key.clone(), 2);
|
||||
service.debug_epoch_runtime.insert(
|
||||
process_key.clone(),
|
||||
super::super::debug::DebugEpochRuntime {
|
||||
epoch: 2,
|
||||
command: "resume".to_owned(),
|
||||
expected: BTreeSet::new(),
|
||||
acknowledgements: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
service.record_coordinator_main_completion(
|
||||
scope,
|
||||
Ok(WasmTaskResult::completed(
|
||||
main_task,
|
||||
TaskBoundaryValue::SmallJson(serde_json::Value::Null),
|
||||
)),
|
||||
);
|
||||
|
||||
assert!(service
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.is_some());
|
||||
let CoordinatorResponse::ProcessStatuses { processes, .. } = service
|
||||
.handle_list_processes(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
"user".to_owned(),
|
||||
)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected process statuses");
|
||||
};
|
||||
assert_eq!(processes.len(), 1);
|
||||
assert_eq!(processes[0].state, "completed");
|
||||
assert_eq!(processes[0].main_state.as_deref(), Some("completed"));
|
||||
assert_eq!(service.debug_epochs.get(&process_key), Some(&2));
|
||||
assert_eq!(
|
||||
service
|
||||
.debug_epoch_runtime
|
||||
.get(&process_key)
|
||||
.map(|runtime| runtime.command.as_str()),
|
||||
Some("resume")
|
||||
);
|
||||
|
||||
service
|
||||
.handle_abort_process(
|
||||
tenant.as_str().to_owned(),
|
||||
project.as_str().to_owned(),
|
||||
"user".to_owned(),
|
||||
process.as_str().to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(service
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.is_none());
|
||||
assert!(!service.main_runtime.controls.contains_key(&process_key));
|
||||
assert!(!service.debug_epochs.contains_key(&process_key));
|
||||
assert!(!service.debug_epoch_runtime.contains_key(&process_key));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn task_descriptors(
|
||||
module: &[u8],
|
||||
) -> Result<HashMap<String, serde_json::Value>, CoordinatorServiceError> {
|
||||
let mut descriptors = HashMap::new();
|
||||
for payload in Parser::new(0).parse_all(module) {
|
||||
let Payload::CustomSection(section) = payload.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!("parse coordinator main bundle: {error}"))
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if section.name() != "disasmer.tasks" {
|
||||
continue;
|
||||
}
|
||||
for record in section
|
||||
.data()
|
||||
.split(|byte| *byte == b'\n' || *byte == 0)
|
||||
.filter(|record| !record.is_empty())
|
||||
{
|
||||
let descriptor: serde_json::Value = serde_json::from_slice(record)?;
|
||||
let name = descriptor
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol("task descriptor omitted its name".to_owned())
|
||||
})?
|
||||
.to_owned();
|
||||
descriptors.insert(name, descriptor);
|
||||
}
|
||||
}
|
||||
Ok(descriptors)
|
||||
}
|
||||
|
||||
pub(super) fn bundle_environments(
|
||||
module: &[u8],
|
||||
) -> Result<BTreeMap<String, EnvironmentResource>, CoordinatorServiceError> {
|
||||
for payload in Parser::new(0).parse_all(module) {
|
||||
let Payload::CustomSection(section) = payload.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"parse coordinator main environment manifest: {error}"
|
||||
))
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if section.name() != "disasmer.environments" {
|
||||
continue;
|
||||
}
|
||||
let environments: Vec<EnvironmentResource> = serde_json::from_slice(section.data())
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"bundle environment manifest is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut by_name = BTreeMap::new();
|
||||
for environment in environments {
|
||||
if by_name
|
||||
.insert(environment.name.clone(), environment)
|
||||
.is_some()
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"bundle environment manifest contains duplicate names".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(by_name);
|
||||
}
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
pub(super) fn capability_from_descriptor(capability: &str) -> Result<Capability, String> {
|
||||
match capability.to_ascii_lowercase().as_str() {
|
||||
"command" => Ok(Capability::Command),
|
||||
"rootless_podman" => Ok(Capability::RootlessPodman),
|
||||
"source_git" => Ok(Capability::SourceGit),
|
||||
"source_filesystem" => Ok(Capability::SourceFilesystem),
|
||||
"network" => Ok(Capability::Network),
|
||||
"host_filesystem" => Ok(Capability::HostFilesystem),
|
||||
"secrets" => Ok(Capability::Secrets),
|
||||
"inbound_ports" => Ok(Capability::InboundPorts),
|
||||
"arbitrary_syscalls" => Ok(Capability::ArbitrarySyscalls),
|
||||
"vfs_artifacts" => Ok(Capability::VfsArtifacts),
|
||||
"windows_command_dev" => Ok(Capability::WindowsCommandDev),
|
||||
"quic_direct" => Ok(Capability::QuicDirect),
|
||||
other => Err(format!("unknown task capability `{other}`")),
|
||||
}
|
||||
}
|
||||
407
crates/disasmer-coordinator/src/service/nodes.rs
Normal file
407
crates/disasmer-coordinator/src/service/nodes.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{
|
||||
generate_opaque_token, verify_node_request_signature, Actor, ArtifactId, CredentialKind,
|
||||
Digest, NodeCapabilities, NodeDescriptor, NodeId, NodeSignedRequest, ProjectId,
|
||||
SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{
|
||||
bounded_ttl, enrollment_grant_key, CoordinatorResponse, CoordinatorService,
|
||||
CoordinatorServiceError,
|
||||
};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_attach_node(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator.upsert_user(
|
||||
tenant.clone(),
|
||||
UserId::from("local-user"),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||
self.coordinator.upsert_source_provider_config(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
SourceProviderKind::Filesystem,
|
||||
Digest::sha256("local-filesystem"),
|
||||
);
|
||||
self.coordinator.enroll_node(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
node.clone(),
|
||||
public_key,
|
||||
"node:attach",
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeAttached {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_create_node_enrollment_grant(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator
|
||||
.upsert_user(tenant.clone(), actor, CredentialKind::CliDeviceSession);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||
self.coordinator.upsert_source_provider_config(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
SourceProviderKind::Filesystem,
|
||||
Digest::sha256("local-filesystem"),
|
||||
);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.enrollment_grants.retain(|_, grant| {
|
||||
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
|
||||
});
|
||||
if self
|
||||
.enrollment_grants
|
||||
.values()
|
||||
.filter(|grant| grant.tenant == tenant && grant.project == project)
|
||||
.count()
|
||||
>= super::MAX_ENROLLMENT_GRANTS_PER_PROJECT
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node enrollment grant limit reached for this project; consume a grant or wait for one to expire"
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
let grant =
|
||||
generate_opaque_token("node_grant").map_err(CoordinatorServiceError::Protocol)?;
|
||||
let ttl_seconds = bounded_ttl(ttl_seconds, self.admission.max_node_enrollment_ttl_seconds);
|
||||
let scope = "node:attach".to_owned();
|
||||
let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
|
||||
let enrollment = self.coordinator.create_node_enrollment_grant(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
grant.clone(),
|
||||
scope.clone(),
|
||||
expires_at_epoch_seconds,
|
||||
);
|
||||
self.enrollment_grants
|
||||
.insert(enrollment_grant_key(&tenant, &project, &grant), enrollment);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeEnrollmentGrantCreated {
|
||||
tenant,
|
||||
project,
|
||||
grant,
|
||||
scope,
|
||||
expires_at_epoch_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_exchange_node_enrollment_grant(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
enrollment_grant: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.enrollment_grants.retain(|_, grant| {
|
||||
!grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds
|
||||
});
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
let grant_key = enrollment_grant_key(&tenant, &project, &enrollment_grant);
|
||||
let grant =
|
||||
self.enrollment_grants
|
||||
.get_mut(&grant_key)
|
||||
.ok_or(CoordinatorError::Enrollment(
|
||||
disasmer_core::EnrollmentError::Expired,
|
||||
))?;
|
||||
let credential = self.coordinator.exchange_node_enrollment_grant(
|
||||
grant,
|
||||
node.clone(),
|
||||
&public_key,
|
||||
"node:attach",
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
self.enrollment_grants.remove(&grant_key);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeEnrollmentExchanged {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
credential,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_node_heartbeat(
|
||||
&mut self,
|
||||
node: String,
|
||||
node_signature: Option<NodeSignedRequest>,
|
||||
payload_digest: &Digest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let node = NodeId::new(node);
|
||||
self.authenticate_node_request(&node, node_signature, "node_heartbeat", payload_digest)?;
|
||||
Ok(CoordinatorResponse::NodeHeartbeat {
|
||||
node,
|
||||
epoch: self.coordinator.coordinator_epoch(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_report_node_capabilities(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
capabilities: NodeCapabilities,
|
||||
cached_environment_digests: Vec<Digest>,
|
||||
dependency_cache_digests: Vec<Digest>,
|
||||
source_snapshots: Vec<Digest>,
|
||||
artifact_locations: Vec<String>,
|
||||
direct_connectivity: bool,
|
||||
online: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(&node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if identity.tenant != tenant || identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node capability report is outside the enrolled tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
capabilities.validate_public_report()?;
|
||||
for (kind, count) in [
|
||||
("cached environments", cached_environment_digests.len()),
|
||||
("dependency caches", dependency_cache_digests.len()),
|
||||
("source snapshots", source_snapshots.len()),
|
||||
("artifact locations", artifact_locations.len()),
|
||||
] {
|
||||
if count > super::MAX_NODE_REPORTED_OBJECTS_PER_KIND {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"node capability report contains {count} {kind}; limit is {}",
|
||||
super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
|
||||
)));
|
||||
}
|
||||
}
|
||||
if cached_environment_digests
|
||||
.iter()
|
||||
.chain(&dependency_cache_digests)
|
||||
.chain(&source_snapshots)
|
||||
.any(|digest| !digest.is_valid_sha256())
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node capability report contains an invalid digest".to_owned(),
|
||||
));
|
||||
}
|
||||
if artifact_locations.iter().any(|artifact| {
|
||||
artifact.trim().is_empty()
|
||||
|| artifact.len() > 256
|
||||
|| artifact
|
||||
.chars()
|
||||
.any(|character| matches!(character, '/' | '\\' | '\0'))
|
||||
}) {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"node capability report contains an invalid artifact id".to_owned(),
|
||||
));
|
||||
}
|
||||
let artifact_locations = artifact_locations
|
||||
.into_iter()
|
||||
.map(ArtifactId::new)
|
||||
.collect::<BTreeSet<_>>();
|
||||
self.artifact_registry
|
||||
.reconcile_node_retention(&node, &artifact_locations);
|
||||
|
||||
self.node_descriptors.insert(
|
||||
node.clone(),
|
||||
NodeDescriptor {
|
||||
id: node.clone(),
|
||||
tenant,
|
||||
project,
|
||||
capabilities,
|
||||
cached_environments: cached_environment_digests.into_iter().collect(),
|
||||
dependency_caches: dependency_cache_digests.into_iter().collect(),
|
||||
source_snapshots: source_snapshots.into_iter().collect(),
|
||||
artifact_locations,
|
||||
direct_connectivity,
|
||||
online,
|
||||
},
|
||||
);
|
||||
Ok(CoordinatorResponse::NodeCapabilitiesRecorded {
|
||||
node,
|
||||
node_descriptors: self.node_descriptors.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_list_node_descriptors(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let descriptors = self
|
||||
.node_descriptors
|
||||
.values()
|
||||
.filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor })
|
||||
}
|
||||
|
||||
pub(super) fn handle_revoke_node_credential(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
node: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let node = NodeId::new(node);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
self.coordinator.revoke_node_credential(&context, &node)?;
|
||||
let descriptor_removed = self.node_descriptors.remove(&node).is_some();
|
||||
self.artifact_registry.garbage_collect_node(&node);
|
||||
let queued_assignments_removed = self
|
||||
.task_assignments
|
||||
.remove(&(tenant.clone(), project.clone(), node.clone()))
|
||||
.map_or(0, |assignments| assignments.len());
|
||||
self.active_tasks
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_cancellations
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_aborts
|
||||
.retain(|(task_tenant, task_project, _, task_node, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.task_placements
|
||||
.retain(|(task_tenant, task_project, _, task_node, _), _| {
|
||||
task_tenant != &tenant || task_project != &project || task_node != &node
|
||||
});
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::NodeCredentialRevoked {
|
||||
node,
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
descriptor_removed,
|
||||
queued_assignments_removed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn authenticate_node_request(
|
||||
&mut self,
|
||||
node: &NodeId,
|
||||
node_signature: Option<NodeSignedRequest>,
|
||||
request_kind: &str,
|
||||
payload_digest: &Digest,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
let signature = node_signature.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"node request requires a signed proof of enrolled private-key possession"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request nonce is missing or invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now_epoch_seconds = unix_timestamp_seconds();
|
||||
if signature
|
||||
.issued_at_epoch_seconds
|
||||
.abs_diff(now_epoch_seconds)
|
||||
> super::NODE_SIGNATURE_WINDOW_SECONDS
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request is expired or outside the allowed clock skew".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let replay_key = (node.clone(), signature.nonce.clone());
|
||||
self.node_replay_nonces.retain(|_, accepted_at| {
|
||||
now_epoch_seconds <= accepted_at.saturating_add(super::NODE_SIGNATURE_WINDOW_SECONDS)
|
||||
});
|
||||
if self.node_replay_nonces.contains_key(&replay_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request nonce has already been used".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
verify_node_request_signature(
|
||||
&identity.public_key,
|
||||
node,
|
||||
request_kind,
|
||||
payload_digest,
|
||||
&signature,
|
||||
)
|
||||
.map_err(CoordinatorError::Unauthorized)?;
|
||||
if self
|
||||
.node_replay_nonces
|
||||
.keys()
|
||||
.filter(|(retained_node, _)| retained_node == node)
|
||||
.count()
|
||||
>= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node signed request replay window is full; retry after the bounded signature window advances"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.node_replay_nonces
|
||||
.insert(replay_key, now_epoch_seconds);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
307
crates/disasmer-coordinator/src/service/panels.rs
Normal file
307
crates/disasmer-coordinator/src/service/panels.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
use disasmer_core::{
|
||||
Actor, DownloadPolicy, PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind,
|
||||
ProcessId, ProjectId, RateLimit, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::artifact_id_from_path;
|
||||
use super::keys::panel_stop_key;
|
||||
use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn clear_operator_panel_state(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) {
|
||||
let stop_key = panel_stop_key(tenant, project, process);
|
||||
self.panel_snapshots.remove(&stop_key);
|
||||
self.stopped_panels.remove(&stop_key);
|
||||
self.panel_event_limits
|
||||
.retain(|(event_tenant, event_project, event_process, _), _| {
|
||||
event_tenant != tenant || event_project != project || event_process != process
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn handle_render_operator_panel(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
max_download_bytes: u64,
|
||||
stopped: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let stop_key = panel_stop_key(&tenant, &project, &process);
|
||||
|
||||
let panel = if stopped {
|
||||
self.ensure_operator_panel_scope(&tenant, &project, &process)?;
|
||||
self.stopped_panels.insert(stop_key);
|
||||
let stop_key = panel_stop_key(&tenant, &project, &process);
|
||||
let panel = match self.panel_snapshots.get(&stop_key).cloned() {
|
||||
Some(panel) => stopped_panel_snapshot(panel),
|
||||
None => self.render_operator_panel(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
UserId::new(actor_user),
|
||||
max_download_bytes,
|
||||
true,
|
||||
)?,
|
||||
};
|
||||
self.panel_snapshots.insert(stop_key, panel.clone());
|
||||
panel
|
||||
} else {
|
||||
self.stopped_panels.remove(&stop_key);
|
||||
let panel = self.render_operator_panel(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
UserId::new(actor_user),
|
||||
max_download_bytes,
|
||||
false,
|
||||
)?;
|
||||
self.panel_snapshots.insert(stop_key, panel.clone());
|
||||
panel
|
||||
};
|
||||
Ok(CoordinatorResponse::OperatorPanel { panel })
|
||||
}
|
||||
|
||||
pub(super) fn handle_submit_panel_event(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
widget_id: String,
|
||||
kind: PanelEventKind,
|
||||
max_events: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let stop_key = panel_stop_key(&tenant, &project, &process);
|
||||
let stopped = self.stopped_panels.contains(&stop_key);
|
||||
let panel = if stopped {
|
||||
match self.panel_snapshots.get(&stop_key).cloned() {
|
||||
Some(panel) => stopped_panel_snapshot(panel),
|
||||
None => {
|
||||
let panel = self.render_operator_panel(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
UserId::from("panel-user"),
|
||||
self.quota.download_limit(),
|
||||
true,
|
||||
)?;
|
||||
self.panel_snapshots.insert(stop_key.clone(), panel.clone());
|
||||
panel
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let panel = self.render_operator_panel(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
process.clone(),
|
||||
UserId::from("panel-user"),
|
||||
self.quota.download_limit(),
|
||||
false,
|
||||
)?;
|
||||
self.panel_snapshots.insert(stop_key, panel.clone());
|
||||
panel
|
||||
};
|
||||
let event = PanelEvent {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
process: process.clone(),
|
||||
widget_id: widget_id.clone(),
|
||||
kind,
|
||||
};
|
||||
let limit_key = (tenant, project, process, widget_id);
|
||||
let mut limit = self
|
||||
.panel_event_limits
|
||||
.get(&limit_key)
|
||||
.cloned()
|
||||
.unwrap_or(RateLimit {
|
||||
max_events,
|
||||
used_events: 0,
|
||||
});
|
||||
limit.max_events = max_events;
|
||||
panel.accept_event(&event, &mut limit)?;
|
||||
self.panel_event_limits.insert(limit_key, limit.clone());
|
||||
Ok(CoordinatorResponse::PanelEventAccepted {
|
||||
used_events: limit.used_events,
|
||||
max_events: limit.max_events,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_operator_panel(
|
||||
&self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
actor_user: UserId,
|
||||
max_download_bytes: u64,
|
||||
stopped: bool,
|
||||
) -> Result<PanelState, CoordinatorServiceError> {
|
||||
self.ensure_operator_panel_scope(&tenant, &project, &process)?;
|
||||
|
||||
let events = self
|
||||
.task_events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.tenant == tenant && event.project == project && event.process == process
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let completed = events
|
||||
.iter()
|
||||
.filter(|event| event.status_code == Some(0))
|
||||
.count() as u64;
|
||||
let total = events.len().max(1) as u64;
|
||||
let stdout_bytes = events.iter().map(|event| event.stdout_bytes).sum::<u64>();
|
||||
let stderr_bytes = events.iter().map(|event| event.stderr_bytes).sum::<u64>();
|
||||
let last_task = events.last().map(|event| event.task.clone());
|
||||
|
||||
let mut panel = PanelState::new(tenant.clone(), project.clone(), process.clone());
|
||||
if stopped {
|
||||
panel.freeze_program_ui_events();
|
||||
}
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "process-status".to_owned(),
|
||||
label: "Process Status".to_owned(),
|
||||
kind: PanelWidgetKind::Text {
|
||||
value: if stopped {
|
||||
"stopped".to_owned()
|
||||
} else {
|
||||
"running".to_owned()
|
||||
},
|
||||
},
|
||||
})?;
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "task-progress".to_owned(),
|
||||
label: "Tasks".to_owned(),
|
||||
kind: PanelWidgetKind::Progress {
|
||||
current: completed,
|
||||
total,
|
||||
},
|
||||
})?;
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "task-summary".to_owned(),
|
||||
label: "Task Summary".to_owned(),
|
||||
kind: PanelWidgetKind::Text {
|
||||
value: if events.is_empty() {
|
||||
"no task events recorded".to_owned()
|
||||
} else {
|
||||
events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
format!(
|
||||
"{} [{}]:{:?}:{}",
|
||||
event.task_definition, event.task, event.status_code, event.node
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
},
|
||||
},
|
||||
})?;
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "recent-logs".to_owned(),
|
||||
label: "Recent Logs".to_owned(),
|
||||
kind: PanelWidgetKind::Text {
|
||||
value: format!("stdout={stdout_bytes} stderr={stderr_bytes}"),
|
||||
},
|
||||
})?;
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "debug-process".to_owned(),
|
||||
label: "Debug Process".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "debug-process".to_owned(),
|
||||
},
|
||||
})?;
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "cancel-process".to_owned(),
|
||||
label: "Cancel Process".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "cancel-process".to_owned(),
|
||||
},
|
||||
})?;
|
||||
if last_task.is_some() {
|
||||
panel.add_widget(PanelWidget {
|
||||
id: "restart-selected-task".to_owned(),
|
||||
label: "Restart Selected Task".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "restart-task".to_owned(),
|
||||
},
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut actions = vec![
|
||||
disasmer_core::ControlPlaneAction::DebugProcess,
|
||||
disasmer_core::ControlPlaneAction::CancelProcess,
|
||||
];
|
||||
if let Some(task) = last_task.clone() {
|
||||
actions.push(disasmer_core::ControlPlaneAction::RestartTask(task));
|
||||
}
|
||||
panel.set_control_plane_actions(actions);
|
||||
|
||||
if let Some(path) = events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| event.artifact_path.as_ref())
|
||||
{
|
||||
let artifact = artifact_id_from_path(path);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant,
|
||||
project,
|
||||
actor: Actor::User(actor_user),
|
||||
};
|
||||
panel.add_download_widget_from_action(
|
||||
"download-artifact",
|
||||
"Download Artifact",
|
||||
self.artifact_registry.download_action(
|
||||
&context,
|
||||
&artifact,
|
||||
&DownloadPolicy {
|
||||
max_bytes: max_download_bytes,
|
||||
},
|
||||
),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(panel)
|
||||
}
|
||||
|
||||
fn ensure_operator_panel_scope(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(tenant, project, process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"operator panel requires an active virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
debug_assert_eq!(active.tenant, *tenant);
|
||||
debug_assert_eq!(active.project, *project);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn stopped_panel_snapshot(mut panel: PanelState) -> PanelState {
|
||||
panel.freeze_program_ui_events();
|
||||
if let Some(status) = panel.widgets.get_mut("process-status") {
|
||||
status.kind = PanelWidgetKind::Text {
|
||||
value: "stopped".to_owned(),
|
||||
};
|
||||
}
|
||||
panel
|
||||
}
|
||||
555
crates/disasmer-coordinator/src/service/process_launch.rs
Normal file
555
crates/disasmer-coordinator/src/service/process_launch.rs
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use disasmer_core::{
|
||||
AgentSignedRequest, ArtifactId, CheckpointBoundary, CredentialKind, DefaultScheduler, Digest,
|
||||
NodeId, PlacementRequest, ProcessId, ProjectId, Scheduler, TaskCheckpoint, TaskDispatch,
|
||||
TaskInstanceId, TaskSpec, TenantId, VfsManifest, VfsObject, VfsPath, WasmTaskInvocation,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::keys::{process_control_key, task_control_key, task_restart_key};
|
||||
use super::{
|
||||
CoordinatorResponse, CoordinatorService, CoordinatorServiceError, TaskAssignment, WorkflowActor,
|
||||
};
|
||||
|
||||
use super::processes::*;
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn capture_task_restart_checkpoint(
|
||||
&mut self,
|
||||
assignment: &TaskAssignment,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let task_spec = &assignment.task_spec;
|
||||
let environment_digest = task_spec.environment_digest.clone().unwrap_or_else(|| {
|
||||
task_spec.environment.as_ref().map_or_else(
|
||||
|| Digest::sha256("disasmer.environment.unconstrained.v1"),
|
||||
|environment| {
|
||||
Digest::sha256(
|
||||
serde_json::to_vec(environment)
|
||||
.expect("serializable environment requirements"),
|
||||
)
|
||||
},
|
||||
)
|
||||
});
|
||||
let task_entrypoint = match &task_spec.dispatch {
|
||||
disasmer_core::TaskDispatch::CoordinatorNodeWasm { export, .. } => export
|
||||
.clone()
|
||||
.or_else(|| assignment_task_descriptor(assignment)?.get("export")?.as_str().map(str::to_owned))
|
||||
.ok_or_else(|| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"cannot capture restart checkpoint for task `{}`: bundle descriptor omitted its Wasm export",
|
||||
task_spec.task_definition
|
||||
))
|
||||
})?,
|
||||
};
|
||||
let mut objects = BTreeMap::new();
|
||||
let mut missing_required_artifact = false;
|
||||
for artifact in &task_spec.required_artifacts {
|
||||
let Some(metadata) = self.artifact_registry.metadata(artifact) else {
|
||||
missing_required_artifact = true;
|
||||
continue;
|
||||
};
|
||||
if metadata.tenant != assignment.tenant
|
||||
|| metadata.project != assignment.project
|
||||
|| metadata.retaining_nodes.is_empty()
|
||||
{
|
||||
missing_required_artifact = true;
|
||||
continue;
|
||||
}
|
||||
let path = VfsPath::new(format!("/vfs/artifacts/{artifact}"))
|
||||
.map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?;
|
||||
objects.insert(
|
||||
path.clone(),
|
||||
VfsObject {
|
||||
path,
|
||||
digest: metadata.digest.clone(),
|
||||
size: metadata.size,
|
||||
producer: metadata.producer_task.clone(),
|
||||
node: metadata.producer_node.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let checkpoint = TaskCheckpoint {
|
||||
task: assignment.task.clone(),
|
||||
boundary: CheckpointBoundary {
|
||||
task_entrypoint,
|
||||
serialized_args: Digest::sha256(serde_json::to_vec(&task_spec.args)?),
|
||||
environment_digest,
|
||||
vfs_epoch: task_spec.vfs_epoch,
|
||||
task_abi: assignment_task_compatibility(assignment)
|
||||
.unwrap_or(Digest::sha256(serde_json::to_vec(&task_spec.dispatch)?)),
|
||||
},
|
||||
vfs_manifest: VfsManifest {
|
||||
epoch: task_spec.vfs_epoch,
|
||||
producer: assignment.task.clone(),
|
||||
node: assignment.node.clone(),
|
||||
objects,
|
||||
large_bytes_uploaded: false,
|
||||
},
|
||||
depends_on_live_stack: false,
|
||||
depends_on_live_socket: false,
|
||||
depends_on_ephemeral_artifact_durability: missing_required_artifact,
|
||||
};
|
||||
let key = task_restart_key(
|
||||
&assignment.tenant,
|
||||
&assignment.project,
|
||||
&assignment.process,
|
||||
&assignment.task,
|
||||
);
|
||||
self.task_restart_checkpoint_order
|
||||
.retain(|retained| retained != &key);
|
||||
self.task_restart_checkpoints.insert(
|
||||
key.clone(),
|
||||
TaskRestartCheckpoint {
|
||||
checkpoint,
|
||||
assignment: assignment.clone(),
|
||||
},
|
||||
);
|
||||
self.task_restart_checkpoint_order.push_back(key);
|
||||
while self
|
||||
.task_restart_checkpoint_order
|
||||
.iter()
|
||||
.filter(|(tenant, project, process, _)| {
|
||||
tenant == &assignment.tenant
|
||||
&& project == &assignment.project
|
||||
&& process == &assignment.process
|
||||
})
|
||||
.count()
|
||||
> super::MAX_RESTART_CHECKPOINTS_PER_PROCESS
|
||||
{
|
||||
let Some(index) = self.task_restart_checkpoint_order.iter().position(
|
||||
|(tenant, project, process, _)| {
|
||||
tenant == &assignment.tenant
|
||||
&& project == &assignment.project
|
||||
&& process == &assignment.process
|
||||
},
|
||||
) else {
|
||||
break;
|
||||
};
|
||||
if let Some(expired) = self.task_restart_checkpoint_order.remove(index) {
|
||||
self.task_restart_checkpoints.remove(&expired);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn handle_schedule_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
environment: Option<disasmer_core::EnvironmentRequirements>,
|
||||
environment_digest: Option<Digest>,
|
||||
required_capabilities: Vec<disasmer_core::Capability>,
|
||||
dependency_cache: Option<Digest>,
|
||||
source_snapshot: Option<Digest>,
|
||||
required_artifacts: Vec<String>,
|
||||
prefer_node: Option<String>,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
let request = PlacementRequest {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities: required_capabilities.into_iter().collect(),
|
||||
dependency_cache,
|
||||
source_snapshot,
|
||||
required_artifacts: required_artifacts
|
||||
.into_iter()
|
||||
.map(ArtifactId::new)
|
||||
.collect(),
|
||||
quota_available: self
|
||||
.quota
|
||||
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)
|
||||
.is_ok(),
|
||||
policy_allowed: self.admission.workflow_placement_allowed,
|
||||
prefer_node: prefer_node.map(NodeId::new),
|
||||
};
|
||||
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
|
||||
let placement = DefaultScheduler.place(&nodes, &request)?;
|
||||
Ok(CoordinatorResponse::TaskPlacement { placement })
|
||||
}
|
||||
|
||||
pub(super) fn handle_launch_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: Option<String>,
|
||||
actor_agent: Option<String>,
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
agent_signature: Option<AgentSignedRequest>,
|
||||
request_payload_digest: Option<&Digest>,
|
||||
task_spec: TaskSpec,
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
if matches!(
|
||||
&task_spec.dispatch,
|
||||
TaskDispatch::CoordinatorNodeWasm {
|
||||
abi: disasmer_core::WasmExportAbi::EntrypointV1,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return self.handle_launch_coordinator_main(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
request_payload_digest,
|
||||
task_spec,
|
||||
wasm_module_base64,
|
||||
);
|
||||
}
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = task_spec.process.clone();
|
||||
let task = task_spec.task_instance.clone();
|
||||
let actor = self.workflow_actor(
|
||||
&tenant,
|
||||
&project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
request_payload_digest,
|
||||
"launch_task",
|
||||
&process,
|
||||
Some(&task),
|
||||
)?;
|
||||
self.handle_launch_task_with_actor(
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_launch_child_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
parent_task: String,
|
||||
task_spec: TaskSpec,
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let parent_task = TaskInstanceId::new(parent_task);
|
||||
if task_spec.process != process {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"child task must remain in its parent virtual process".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
let parent_key = task_control_key(&tenant, &project, &process, &node, &parent_task);
|
||||
if !self.active_tasks.contains(&parent_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"child task launch requires a currently active parent task on the signed node"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let actor = 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()],
|
||||
};
|
||||
self.handle_launch_task_with_actor(
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn handle_launch_task_with_actor(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
actor: WorkflowActor,
|
||||
task_spec: TaskSpec,
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
if task_spec.tenant != tenant || task_spec.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task specification is outside the authenticated tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !task_spec.product_mode_uses_remote_dispatch() {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task specification must use the Wasm coordinator/node dispatch ABI".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if task_spec
|
||||
.environment_id
|
||||
.as_deref()
|
||||
.is_some_and(|environment| environment.trim().is_empty() || environment.len() > 128)
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task specification environment id is invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let process = task_spec.process.clone();
|
||||
let task = task_spec.task_instance.clone();
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"task launch requires an active coordinator-side virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
debug_assert_eq!(active.tenant, tenant);
|
||||
debug_assert_eq!(active.project, project);
|
||||
if self
|
||||
.process_cancellations
|
||||
.contains(&process_control_key(&tenant, &project, &process))
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task launch is blocked because the virtual process is cancelling".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if self.task_instance_exists(&tenant, &project, &process, &task) {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"task instance {task} already exists in virtual process {process}; every spawn must use a fresh task-instance id"
|
||||
)));
|
||||
}
|
||||
let in_flight = self
|
||||
.active_tasks
|
||||
.iter()
|
||||
.filter(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant == &tenant && task_project == &project && task_process == &process
|
||||
})
|
||||
.count()
|
||||
+ self
|
||||
.pending_task_launches
|
||||
.iter()
|
||||
.filter(|pending| {
|
||||
pending.tenant == tenant
|
||||
&& pending.project == project
|
||||
&& pending.process == process
|
||||
})
|
||||
.count();
|
||||
if in_flight >= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS {
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"virtual process task limit of {} reached; join or cancel existing work before spawning more",
|
||||
super::MAX_IN_FLIGHT_TASKS_PER_PROCESS
|
||||
)));
|
||||
}
|
||||
if task_spec.vfs_epoch != active.coordinator_epoch {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"task specification VFS epoch {} does not match active process epoch {}",
|
||||
task_spec.vfs_epoch, active.coordinator_epoch
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let bundle_digest = task_spec.bundle_digest.as_ref().ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"Wasm task specification is missing its bundle digest".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !bundle_digest.is_valid_sha256() {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"Wasm task specification has an invalid bundle digest".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let module = BASE64_STANDARD
|
||||
.decode(&wasm_module_base64)
|
||||
.map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"Wasm task module is not valid base64: {error}"
|
||||
))
|
||||
})?;
|
||||
let actual_digest = Digest::sha256(&module);
|
||||
if &actual_digest != bundle_digest {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"Wasm task module digest does not match bundle digest: expected {bundle_digest}, actual {actual_digest}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
WasmTaskInvocation::new(
|
||||
task_spec.task_definition.clone(),
|
||||
task.clone(),
|
||||
task_spec.args.clone(),
|
||||
)
|
||||
.validate()
|
||||
.map_err(CoordinatorServiceError::Protocol)?;
|
||||
for artifact in &task_spec.required_artifacts {
|
||||
let metadata = self.artifact_registry.metadata(artifact).ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(format!(
|
||||
"required artifact {artifact} is unavailable or has expired"
|
||||
))
|
||||
})?;
|
||||
if metadata.tenant != tenant || metadata.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"required artifact {artifact} is outside the task tenant/project scope"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if metadata.retaining_nodes.is_empty() {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"required artifact {artifact} has no retaining node"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
VfsPath::new(&artifact_path)
|
||||
.map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?;
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.quota
|
||||
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
|
||||
let request = PlacementRequest {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
environment: task_spec.environment.clone(),
|
||||
environment_digest: task_spec.environment_digest.clone(),
|
||||
required_capabilities: task_spec.required_capabilities.clone(),
|
||||
dependency_cache: task_spec.dependency_cache.clone(),
|
||||
source_snapshot: task_spec.source_snapshot.clone(),
|
||||
required_artifacts: task_spec.required_artifacts.iter().cloned().collect(),
|
||||
quota_available: self
|
||||
.quota
|
||||
.can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)
|
||||
.is_ok(),
|
||||
policy_allowed: self.admission.workflow_placement_allowed,
|
||||
prefer_node: None,
|
||||
};
|
||||
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
|
||||
let placement = match DefaultScheduler.place(&nodes, &request) {
|
||||
Ok(placement) => placement,
|
||||
Err(err) if wait_for_node => {
|
||||
let reason = if err.message.is_empty() {
|
||||
"waiting for any capable node".to_owned()
|
||||
} else {
|
||||
err.message
|
||||
};
|
||||
let charged_spawns =
|
||||
self.quota
|
||||
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
|
||||
self.pending_task_launches.push_back(PendingTaskLaunch {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
process: process.clone(),
|
||||
task: task.clone(),
|
||||
request,
|
||||
epoch: active.coordinator_epoch,
|
||||
artifact_path,
|
||||
task_spec,
|
||||
wasm_module_base64,
|
||||
});
|
||||
return Ok(CoordinatorResponse::TaskQueued {
|
||||
process,
|
||||
task,
|
||||
actor,
|
||||
reason,
|
||||
charged_spawns,
|
||||
queued_tasks: self.pending_task_launches.len(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let charged_spawns =
|
||||
self.quota
|
||||
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
|
||||
let assignment = TaskAssignment {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
process: process.clone(),
|
||||
task: task.clone(),
|
||||
node: placement.node.clone(),
|
||||
epoch: active.coordinator_epoch,
|
||||
artifact_path,
|
||||
task_spec,
|
||||
wasm_module_base64,
|
||||
};
|
||||
self.capture_task_restart_checkpoint(&assignment)?;
|
||||
let task_key = task_control_key(&tenant, &project, &process, &placement.node, &task);
|
||||
self.task_placements
|
||||
.insert(task_key.clone(), placement.clone());
|
||||
self.active_tasks.insert(task_key);
|
||||
self.task_assignments
|
||||
.entry((tenant, project, placement.node.clone()))
|
||||
.or_default()
|
||||
.push_back(assignment.clone());
|
||||
Ok(CoordinatorResponse::TaskLaunched {
|
||||
process,
|
||||
task,
|
||||
actor,
|
||||
placement,
|
||||
assignment: Box::new(assignment),
|
||||
charged_spawns,
|
||||
})
|
||||
}
|
||||
|
||||
fn task_instance_exists(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
task_instance: &disasmer_core::TaskInstanceId,
|
||||
) -> bool {
|
||||
self.active_tasks.iter().any(
|
||||
|(task_tenant, task_project, task_process, _, existing_instance)| {
|
||||
task_tenant == tenant
|
||||
&& task_project == project
|
||||
&& task_process == process
|
||||
&& existing_instance == task_instance
|
||||
},
|
||||
) || self.pending_task_launches.iter().any(|pending| {
|
||||
&pending.tenant == tenant
|
||||
&& &pending.project == project
|
||||
&& &pending.process == process
|
||||
&& &pending.task == task_instance
|
||||
}) || self.task_events.iter().any(|event| {
|
||||
&event.tenant == tenant
|
||||
&& &event.project == project
|
||||
&& &event.process == process
|
||||
&& &event.task == task_instance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn assignment_task_compatibility(assignment: &TaskAssignment) -> Option<Digest> {
|
||||
let descriptor = assignment_task_descriptor(assignment)?;
|
||||
serde_json::from_value(descriptor.get("restart_compatibility_hash")?.clone()).ok()
|
||||
}
|
||||
|
||||
fn assignment_task_descriptor(assignment: &TaskAssignment) -> Option<serde_json::Value> {
|
||||
let module = BASE64_STANDARD
|
||||
.decode(&assignment.wasm_module_base64)
|
||||
.ok()?;
|
||||
let mut descriptors = super::main_runtime::task_descriptors(&module).ok()?;
|
||||
descriptors.remove(assignment.task_spec.task_definition.as_str())
|
||||
}
|
||||
788
crates/disasmer-coordinator/src/service/processes.rs
Normal file
788
crates/disasmer-coordinator/src/service/processes.rs
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{
|
||||
AgentId, AgentSignedRequest, CredentialKind, DefaultScheduler, Digest, NodeId,
|
||||
PlacementRequest, ProcessId, ProjectId, RendezvousRequest, Scheduler, SourcePreparation,
|
||||
TaskCheckpoint, TaskInstanceId, TaskSpec, TenantId, UserId,
|
||||
};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::keys::{process_control_key, task_control_key};
|
||||
use super::{
|
||||
CoordinatorResponse, CoordinatorService, CoordinatorServiceError, SourcePreparationDisposition,
|
||||
SourcePreparationStatus, TaskAssignment, TaskCancellationTarget, VirtualProcessStatus,
|
||||
WorkflowActor,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PendingTaskLaunch {
|
||||
pub(super) tenant: TenantId,
|
||||
pub(super) project: ProjectId,
|
||||
pub(super) process: ProcessId,
|
||||
pub(super) task: TaskInstanceId,
|
||||
pub(super) request: PlacementRequest,
|
||||
pub(super) epoch: u64,
|
||||
pub(super) artifact_path: String,
|
||||
pub(super) task_spec: TaskSpec,
|
||||
pub(super) wasm_module_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct TaskRestartCheckpoint {
|
||||
pub(super) checkpoint: TaskCheckpoint,
|
||||
pub(super) assignment: TaskAssignment,
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_poll_task_assignment(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(&node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if identity.tenant != tenant || identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task assignment poll is outside the enrolled tenant/project scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let assignment_key = (tenant.clone(), project.clone(), node.clone());
|
||||
let assignment = self
|
||||
.task_assignments
|
||||
.get_mut(&assignment_key)
|
||||
.and_then(VecDeque::pop_front);
|
||||
if assignment.is_some() {
|
||||
return Ok(CoordinatorResponse::TaskAssignment {
|
||||
assignment: assignment.map(Box::new),
|
||||
});
|
||||
}
|
||||
let assignment = self.assign_pending_task_to_node(&tenant, &project, &node)?;
|
||||
Ok(CoordinatorResponse::TaskAssignment {
|
||||
assignment: assignment.map(Box::new),
|
||||
})
|
||||
}
|
||||
|
||||
fn assign_pending_task_to_node(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
node: &NodeId,
|
||||
) -> Result<Option<TaskAssignment>, CoordinatorServiceError> {
|
||||
let Some(descriptor) = self.node_descriptors.get(node).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut remaining = VecDeque::new();
|
||||
let mut selected = None;
|
||||
|
||||
while let Some(pending) = self.pending_task_launches.pop_front() {
|
||||
if selected.is_some() {
|
||||
remaining.push_back(pending);
|
||||
continue;
|
||||
}
|
||||
if &pending.tenant != tenant || &pending.project != project {
|
||||
remaining.push_back(pending);
|
||||
continue;
|
||||
}
|
||||
if self.process_cancellations.contains(&process_control_key(
|
||||
&pending.tenant,
|
||||
&pending.project,
|
||||
&pending.process,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
let Some(active) = self.coordinator.active_process(
|
||||
&pending.tenant,
|
||||
&pending.project,
|
||||
&pending.process,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if active.tenant != pending.tenant
|
||||
|| active.project != pending.project
|
||||
|| active.coordinator_epoch != pending.epoch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(placement) =
|
||||
DefaultScheduler.place(std::slice::from_ref(&descriptor), &pending.request)
|
||||
else {
|
||||
remaining.push_back(pending);
|
||||
continue;
|
||||
};
|
||||
let assignment = TaskAssignment {
|
||||
tenant: pending.tenant.clone(),
|
||||
project: pending.project.clone(),
|
||||
process: pending.process.clone(),
|
||||
task: pending.task.clone(),
|
||||
node: placement.node.clone(),
|
||||
epoch: pending.epoch,
|
||||
artifact_path: pending.artifact_path,
|
||||
task_spec: pending.task_spec,
|
||||
wasm_module_base64: pending.wasm_module_base64,
|
||||
};
|
||||
self.capture_task_restart_checkpoint(&assignment)?;
|
||||
let task_key = task_control_key(
|
||||
&pending.tenant,
|
||||
&pending.project,
|
||||
&pending.process,
|
||||
&placement.node,
|
||||
&pending.task,
|
||||
);
|
||||
self.task_placements.insert(task_key.clone(), placement);
|
||||
self.active_tasks.insert(task_key);
|
||||
selected = Some(assignment);
|
||||
}
|
||||
|
||||
self.pending_task_launches = remaining;
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
pub(super) fn handle_request_rendezvous(
|
||||
&mut self,
|
||||
scope: disasmer_core::DataPlaneScope,
|
||||
source: disasmer_core::NodeEndpoint,
|
||||
destination: disasmer_core::NodeEndpoint,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
let charged_rendezvous_attempts = self.quota.charge_rendezvous_attempt(
|
||||
&scope.tenant,
|
||||
&scope.project,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
let plan = self.transport.plan_authenticated_direct_bulk_transfer(
|
||||
RendezvousRequest {
|
||||
scope,
|
||||
source,
|
||||
destination,
|
||||
},
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
)?;
|
||||
Ok(CoordinatorResponse::RendezvousPlan {
|
||||
plan,
|
||||
charged_rendezvous_attempts,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_request_source_preparation(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
provider: disasmer_core::SourceProviderKind,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let preparation = SourcePreparation::node_task(tenant.clone(), project.clone(), provider);
|
||||
let request = PlacementRequest {
|
||||
tenant,
|
||||
project,
|
||||
environment: None,
|
||||
environment_digest: None,
|
||||
required_capabilities: preparation.required_capabilities.clone(),
|
||||
dependency_cache: None,
|
||||
source_snapshot: None,
|
||||
required_artifacts: Default::default(),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
let nodes = self.node_descriptors.values().cloned().collect::<Vec<_>>();
|
||||
let disposition = match DefaultScheduler.place(&nodes, &request) {
|
||||
Ok(placement) => SourcePreparationDisposition::Assigned {
|
||||
node: placement.node,
|
||||
},
|
||||
Err(err) => SourcePreparationDisposition::Pending {
|
||||
reason: if err.message.is_empty() {
|
||||
"waiting for any capable node to prepare source".to_owned()
|
||||
} else {
|
||||
err.message
|
||||
},
|
||||
},
|
||||
};
|
||||
Ok(CoordinatorResponse::SourcePreparation {
|
||||
status: SourcePreparationStatus {
|
||||
preparation,
|
||||
disposition,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_complete_source_preparation(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
provider: disasmer_core::SourceProviderKind,
|
||||
source_snapshot: Digest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let node = NodeId::new(node);
|
||||
let identity = self
|
||||
.coordinator
|
||||
.node_identity(&node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if identity.tenant != tenant || identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"source preparation completion is outside the enrolled tenant/project scope"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let descriptor = self.node_descriptors.get_mut(&node).ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"source preparation completion requires a node capability report".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !descriptor.source_snapshots.contains(&source_snapshot)
|
||||
&& descriptor.source_snapshots.len() >= super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
|
||||
{
|
||||
return Err(CoordinatorServiceError::Protocol(format!(
|
||||
"node source snapshot retention limit of {} reached; refresh the node capability report",
|
||||
super::MAX_NODE_REPORTED_OBJECTS_PER_KIND
|
||||
)));
|
||||
}
|
||||
descriptor.source_snapshots.insert(source_snapshot.clone());
|
||||
Ok(CoordinatorResponse::SourcePreparationCompleted {
|
||||
node,
|
||||
provider,
|
||||
source_snapshot,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_start_process(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: Option<String>,
|
||||
actor_agent: Option<String>,
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
agent_signature: Option<AgentSignedRequest>,
|
||||
request_payload_digest: Option<&Digest>,
|
||||
process: String,
|
||||
restart: bool,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
let actor = self.workflow_actor(
|
||||
&tenant,
|
||||
&project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
request_payload_digest,
|
||||
"start_process",
|
||||
&process,
|
||||
None,
|
||||
)?;
|
||||
let replacing_existing = if let Some(active) = self
|
||||
.coordinator
|
||||
.active_process_for_project(&tenant, &project)
|
||||
{
|
||||
if active.id != process || !restart {
|
||||
return Err(CoordinatorError::Unauthorized(format!(
|
||||
"project already has active virtual process {}; attach to or restart it, request cooperative cancellation, abort it, or use another Coordinator Project",
|
||||
active.id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if replacing_existing {
|
||||
self.main_runtime.interrupt_process(
|
||||
&tenant,
|
||||
&project,
|
||||
&process,
|
||||
"virtual process incarnation replaced",
|
||||
);
|
||||
self.main_runtime
|
||||
.controls
|
||||
.remove(&process_control_key(&tenant, &project, &process));
|
||||
}
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
let charged_spawns =
|
||||
self.quota
|
||||
.charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?;
|
||||
self.process_cancellations
|
||||
.remove(&process_control_key(&tenant, &project, &process));
|
||||
self.process_aborts
|
||||
.remove(&process_control_key(&tenant, &project, &process));
|
||||
self.clear_debug_state_for_process(&tenant, &project, &process);
|
||||
self.clear_operator_panel_state(&tenant, &project, &process);
|
||||
self.task_cancellations
|
||||
.retain(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_process != &process
|
||||
});
|
||||
self.task_aborts
|
||||
.retain(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_process != &process
|
||||
});
|
||||
self.active_tasks
|
||||
.retain(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_process != &process
|
||||
});
|
||||
self.task_placements
|
||||
.retain(|(task_tenant, task_project, task_process, _, _), _| {
|
||||
task_tenant != &tenant || task_project != &project || task_process != &process
|
||||
});
|
||||
self.task_assignments.retain(|_, assignments| {
|
||||
assignments.retain(|assignment| {
|
||||
assignment.tenant != tenant
|
||||
|| assignment.project != project
|
||||
|| assignment.process != process
|
||||
});
|
||||
!assignments.is_empty()
|
||||
});
|
||||
self.pending_task_launches.retain(|pending| {
|
||||
pending.tenant != tenant || pending.project != project || pending.process != process
|
||||
});
|
||||
self.task_restart_checkpoints.retain(
|
||||
|(checkpoint_tenant, checkpoint_project, checkpoint_process, _), _| {
|
||||
checkpoint_tenant != &tenant
|
||||
|| checkpoint_project != &project
|
||||
|| checkpoint_process != &process
|
||||
},
|
||||
);
|
||||
self.task_restart_checkpoint_order.retain(
|
||||
|(checkpoint_tenant, checkpoint_project, checkpoint_process, _)| {
|
||||
checkpoint_tenant != &tenant
|
||||
|| checkpoint_project != &project
|
||||
|| checkpoint_process != &process
|
||||
},
|
||||
);
|
||||
self.task_events.retain(|event| {
|
||||
event.tenant != tenant || event.project != project || event.process != process
|
||||
});
|
||||
self.debug_audit_events.retain(|event| {
|
||||
event.tenant != tenant || event.project != project || event.process != process
|
||||
});
|
||||
self.coordinator
|
||||
.start_process(tenant, project, process.clone());
|
||||
Ok(CoordinatorResponse::ProcessStarted {
|
||||
process,
|
||||
epoch: self.coordinator.coordinator_epoch(),
|
||||
actor,
|
||||
charged_spawns,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_reconnect_node(
|
||||
&mut self,
|
||||
node: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let node = NodeId::new(node);
|
||||
let process = ProcessId::new(process);
|
||||
self.coordinator
|
||||
.reconnect_node(&node, Some((&process, epoch)))?;
|
||||
Ok(CoordinatorResponse::NodeReconnected { node, process })
|
||||
}
|
||||
|
||||
pub(super) fn handle_cancel_task(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.coordinator
|
||||
.authorize_node_for_process(&node, &tenant, &project, &process)?;
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"task cancellation requires an active virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if !active.connected_nodes.contains(&node) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"task cancellation target node is not connected to the virtual process".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.task_cancellations
|
||||
.insert(task_control_key(&tenant, &project, &process, &node, &task));
|
||||
Ok(CoordinatorResponse::TaskCancellationRequested {
|
||||
process,
|
||||
task,
|
||||
node,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_cancel_process(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let _actor_user = actor_user;
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"process cancellation requires an active virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
debug_assert_eq!(active.tenant, tenant);
|
||||
debug_assert_eq!(active.project, project);
|
||||
self.process_cancellations
|
||||
.insert(process_control_key(&tenant, &project, &process));
|
||||
self.main_runtime.interrupt_process(
|
||||
&tenant,
|
||||
&project,
|
||||
&process,
|
||||
"virtual process cancellation requested",
|
||||
);
|
||||
self.clear_debug_state_for_process(&tenant, &project, &process);
|
||||
self.pending_task_launches.retain(|pending| {
|
||||
pending.tenant != tenant || pending.project != project || pending.process != process
|
||||
});
|
||||
let mut cancelled_tasks = Vec::new();
|
||||
let mut affected_nodes = BTreeSet::new();
|
||||
for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() {
|
||||
if task_tenant == &tenant && task_project == &project && task_process == &process {
|
||||
self.task_cancellations
|
||||
.insert(task_control_key(&tenant, &project, &process, node, task));
|
||||
affected_nodes.insert(node.clone());
|
||||
cancelled_tasks.push(TaskCancellationTarget {
|
||||
process: process.clone(),
|
||||
task: task.clone(),
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CoordinatorResponse::ProcessCancellationRequested {
|
||||
process,
|
||||
cancelled_tasks,
|
||||
affected_nodes: affected_nodes.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_abort_process(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let _actor_user = actor_user;
|
||||
let active = self
|
||||
.coordinator
|
||||
.active_process(&tenant, &project, &process)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"process abort requires an active virtual process".to_owned(),
|
||||
)
|
||||
})?;
|
||||
debug_assert_eq!(active.tenant, tenant);
|
||||
debug_assert_eq!(active.project, project);
|
||||
|
||||
let process_key = process_control_key(&tenant, &project, &process);
|
||||
self.process_cancellations.remove(&process_key);
|
||||
self.task_cancellations
|
||||
.retain(|(task_tenant, task_project, task_process, _, _)| {
|
||||
task_tenant != &tenant || task_project != &project || task_process != &process
|
||||
});
|
||||
self.process_aborts.insert(process_key);
|
||||
self.main_runtime
|
||||
.interrupt_process(&tenant, &project, &process, "virtual process aborted");
|
||||
self.main_runtime
|
||||
.controls
|
||||
.remove(&process_control_key(&tenant, &project, &process));
|
||||
self.clear_debug_state_for_process(&tenant, &project, &process);
|
||||
self.clear_operator_panel_state(&tenant, &project, &process);
|
||||
self.pending_task_launches.retain(|pending| {
|
||||
pending.tenant != tenant || pending.project != project || pending.process != process
|
||||
});
|
||||
self.task_assignments.retain(|_, assignments| {
|
||||
assignments.retain(|assignment| {
|
||||
assignment.tenant != tenant
|
||||
|| assignment.project != project
|
||||
|| assignment.process != process
|
||||
});
|
||||
!assignments.is_empty()
|
||||
});
|
||||
let mut aborted_tasks = Vec::new();
|
||||
let mut affected_nodes = BTreeSet::new();
|
||||
for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() {
|
||||
if task_tenant == &tenant && task_project == &project && task_process == &process {
|
||||
self.task_aborts
|
||||
.insert(task_control_key(&tenant, &project, &process, node, task));
|
||||
affected_nodes.insert(node.clone());
|
||||
aborted_tasks.push(TaskCancellationTarget {
|
||||
process: process.clone(),
|
||||
task: task.clone(),
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.coordinator
|
||||
.abort_process(&tenant, &project, &process)?;
|
||||
let active_restart_tasks = aborted_tasks
|
||||
.iter()
|
||||
.map(|target| target.task.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
self.task_restart_checkpoints.retain(
|
||||
|(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task), _| {
|
||||
checkpoint_tenant != &tenant
|
||||
|| checkpoint_project != &project
|
||||
|| checkpoint_process != &process
|
||||
|| active_restart_tasks.contains(checkpoint_task)
|
||||
},
|
||||
);
|
||||
self.task_restart_checkpoint_order.retain(
|
||||
|(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task)| {
|
||||
checkpoint_tenant != &tenant
|
||||
|| checkpoint_project != &project
|
||||
|| checkpoint_process != &process
|
||||
|| active_restart_tasks.contains(checkpoint_task)
|
||||
},
|
||||
);
|
||||
if aborted_tasks.is_empty() {
|
||||
self.process_aborts
|
||||
.remove(&process_control_key(&tenant, &project, &process));
|
||||
}
|
||||
Ok(CoordinatorResponse::ProcessAborted {
|
||||
process,
|
||||
aborted_tasks,
|
||||
affected_nodes: affected_nodes.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_list_processes(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let processes = self
|
||||
.coordinator
|
||||
.active_processes_for_project(&tenant, &project)
|
||||
.into_iter()
|
||||
.map(|active| {
|
||||
let process_key = process_control_key(&active.tenant, &active.project, &active.id);
|
||||
let main = self.main_runtime.controls.get(&process_key);
|
||||
let state = if self.process_cancellations.contains(&process_key) {
|
||||
"cancelling"
|
||||
} else {
|
||||
main.map_or("running", |main| main.state.as_str())
|
||||
};
|
||||
let main_wait_state = main.and_then(|main| {
|
||||
if main.state != "running" {
|
||||
return None;
|
||||
}
|
||||
if self.pending_task_launches.iter().any(|pending| {
|
||||
pending.tenant == active.tenant
|
||||
&& pending.project == active.project
|
||||
&& pending.process == active.id
|
||||
}) {
|
||||
Some("waiting_for_node".to_owned())
|
||||
} else if self.main_runtime.is_waiting_for_task(
|
||||
&active.tenant,
|
||||
&active.project,
|
||||
&active.id,
|
||||
) {
|
||||
Some("waiting_for_task".to_owned())
|
||||
} else {
|
||||
Some("executing".to_owned())
|
||||
}
|
||||
});
|
||||
VirtualProcessStatus {
|
||||
process: active.id,
|
||||
state: state.to_owned(),
|
||||
main_task_definition: main.map(|main| main.task_definition.clone()),
|
||||
main_task_instance: main.map(|main| main.task_instance.clone()),
|
||||
main_state: main.map(|main| main.state.clone()),
|
||||
main_wait_state,
|
||||
main_debug_epoch: main.and_then(|main| main.debug.requested_epoch()),
|
||||
connected_nodes: active.connected_nodes.into_iter().collect(),
|
||||
coordinator_epoch: active.coordinator_epoch,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(CoordinatorResponse::ProcessStatuses { processes, actor })
|
||||
}
|
||||
|
||||
pub(super) fn handle_poll_task_control(
|
||||
&mut self,
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let process = ProcessId::new(process);
|
||||
let node = NodeId::new(node);
|
||||
let task = TaskInstanceId::new(task);
|
||||
self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?;
|
||||
let cancel_requested = self
|
||||
.task_cancellations
|
||||
.contains(&task_control_key(&tenant, &project, &process, &node, &task))
|
||||
|| self
|
||||
.process_cancellations
|
||||
.contains(&process_control_key(&tenant, &project, &process));
|
||||
let abort_requested = self
|
||||
.task_aborts
|
||||
.contains(&task_control_key(&tenant, &project, &process, &node, &task))
|
||||
|| self
|
||||
.process_aborts
|
||||
.contains(&process_control_key(&tenant, &project, &process));
|
||||
Ok(CoordinatorResponse::TaskControl {
|
||||
process,
|
||||
task,
|
||||
cancel_requested,
|
||||
abort_requested,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn workflow_actor(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
actor_user: Option<String>,
|
||||
actor_agent: Option<String>,
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
agent_signature: Option<AgentSignedRequest>,
|
||||
request_payload_digest: Option<&Digest>,
|
||||
request_kind: &str,
|
||||
process: &ProcessId,
|
||||
task: Option<&TaskInstanceId>,
|
||||
) -> Result<WorkflowActor, CoordinatorServiceError> {
|
||||
if let Some(agent) = actor_agent {
|
||||
let agent = AgentId::new(agent);
|
||||
let signature = agent_signature.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"agent workflow dispatch requires a signed request proving private-key possession"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
let request_payload_digest = request_payload_digest.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"agent workflow dispatch requires a canonical signed request payload"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent signed request nonce is missing or invalid".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now_epoch_seconds = unix_timestamp_seconds();
|
||||
let replay_key = (
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
agent.clone(),
|
||||
signature.nonce.clone(),
|
||||
);
|
||||
const AGENT_SIGNATURE_WINDOW_SECONDS: u64 = 300;
|
||||
self.agent_replay_nonces.retain(|_, issued_at| {
|
||||
now_epoch_seconds <= issued_at.saturating_add(AGENT_SIGNATURE_WINDOW_SECONDS)
|
||||
});
|
||||
if self.agent_replay_nonces.contains_key(&replay_key) {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent signed request nonce has already been used".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let record = self.coordinator.authorize_agent_project_run(
|
||||
disasmer_core::AgentWorkflowScope {
|
||||
tenant,
|
||||
project,
|
||||
agent: &agent,
|
||||
request_kind,
|
||||
process,
|
||||
task,
|
||||
},
|
||||
agent_public_key_fingerprint.as_ref(),
|
||||
request_payload_digest,
|
||||
&signature,
|
||||
now_epoch_seconds,
|
||||
)?;
|
||||
if self
|
||||
.agent_replay_nonces
|
||||
.keys()
|
||||
.filter(|(retained_tenant, retained_project, retained_agent, _)| {
|
||||
retained_tenant == tenant
|
||||
&& retained_project == project
|
||||
&& retained_agent == &agent
|
||||
})
|
||||
.count()
|
||||
>= super::MAX_REPLAY_NONCES_PER_AUTHORITY
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"agent signed request replay window is full; retry after the bounded signature window advances"
|
||||
.to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.agent_replay_nonces
|
||||
.insert(replay_key, signature.issued_at_epoch_seconds);
|
||||
return Ok(WorkflowActor {
|
||||
kind: "agent".to_owned(),
|
||||
user: Some(record.user),
|
||||
agent: Some(agent),
|
||||
credential_kind: CredentialKind::PublicKey,
|
||||
public_key_fingerprint: Some(record.public_key_fingerprint),
|
||||
authenticated_without_browser: true,
|
||||
scopes: record.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
let actor = UserId::new(actor_user.unwrap_or_else(|| "user".to_owned()));
|
||||
Ok(WorkflowActor {
|
||||
kind: "user".to_owned(),
|
||||
user: Some(actor),
|
||||
agent: None,
|
||||
credential_kind: CredentialKind::BrowserSession,
|
||||
public_key_fingerprint: None,
|
||||
authenticated_without_browser: false,
|
||||
scopes: vec!["project:read".to_owned(), "project:run".to_owned()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
644
crates/disasmer-coordinator/src/service/protocol.rs
Normal file
644
crates/disasmer-coordinator/src/service/protocol.rs
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use disasmer_core::{
|
||||
AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind,
|
||||
DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements,
|
||||
LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, NodeSignedRequest,
|
||||
PanelEventKind, PanelState, Placement, ProcessId, ProjectId, ResourceLimits, SourcePreparation,
|
||||
SourceProviderKind, TaskBoundaryValue, TaskInstanceId, TaskJoinResult, TaskSpec, TenantId,
|
||||
UserId, VfsPath,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod responses;
|
||||
pub use responses::*;
|
||||
|
||||
use crate::{AgentPublicKeyRecord, ProjectRecord};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskReplacementBundle {
|
||||
pub bundle_digest: Digest,
|
||||
pub wasm_module_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CoordinatorRequest {
|
||||
Ping,
|
||||
Authenticated {
|
||||
session_secret: String,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
},
|
||||
AuthStatus {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
},
|
||||
AdminStatus {
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
admin_proof: Digest,
|
||||
admin_nonce: String,
|
||||
issued_at_epoch_seconds: u64,
|
||||
},
|
||||
SuspendTenant {
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
target_tenant: String,
|
||||
admin_proof: Digest,
|
||||
admin_nonce: String,
|
||||
issued_at_epoch_seconds: u64,
|
||||
},
|
||||
CreateProject {
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
project: String,
|
||||
name: String,
|
||||
},
|
||||
SelectProject {
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
project: String,
|
||||
},
|
||||
ListProjects {
|
||||
tenant: String,
|
||||
actor_user: String,
|
||||
},
|
||||
RegisterAgentPublicKey {
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
agent: String,
|
||||
public_key: String,
|
||||
},
|
||||
ListAgentPublicKeys {
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
},
|
||||
RotateAgentPublicKey {
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
agent: String,
|
||||
public_key: String,
|
||||
},
|
||||
RevokeAgentPublicKey {
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
agent: String,
|
||||
},
|
||||
AttachNode {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
},
|
||||
CreateNodeEnrollmentGrant {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
#[serde(default = "default_node_enrollment_ttl_seconds")]
|
||||
ttl_seconds: u64,
|
||||
},
|
||||
ExchangeNodeEnrollmentGrant {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
public_key: String,
|
||||
enrollment_grant: String,
|
||||
},
|
||||
NodeHeartbeat {
|
||||
node: String,
|
||||
#[serde(default)]
|
||||
node_signature: Option<NodeSignedRequest>,
|
||||
},
|
||||
SignedNode {
|
||||
node: String,
|
||||
node_signature: NodeSignedRequest,
|
||||
request: Box<CoordinatorRequest>,
|
||||
},
|
||||
ReportNodeCapabilities {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
capabilities: NodeCapabilities,
|
||||
cached_environment_digests: Vec<Digest>,
|
||||
#[serde(default)]
|
||||
dependency_cache_digests: Vec<Digest>,
|
||||
source_snapshots: Vec<Digest>,
|
||||
artifact_locations: Vec<String>,
|
||||
direct_connectivity: bool,
|
||||
online: bool,
|
||||
},
|
||||
ListNodeDescriptors {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
},
|
||||
RevokeNodeCredential {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
node: String,
|
||||
},
|
||||
ScheduleTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
environment: Option<EnvironmentRequirements>,
|
||||
environment_digest: Option<Digest>,
|
||||
required_capabilities: Vec<Capability>,
|
||||
#[serde(default)]
|
||||
dependency_cache: Option<Digest>,
|
||||
source_snapshot: Option<Digest>,
|
||||
required_artifacts: Vec<String>,
|
||||
prefer_node: Option<String>,
|
||||
},
|
||||
LaunchTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
#[serde(default)]
|
||||
actor_user: Option<String>,
|
||||
#[serde(default)]
|
||||
actor_agent: Option<String>,
|
||||
#[serde(default)]
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
#[serde(default)]
|
||||
agent_signature: Option<AgentSignedRequest>,
|
||||
task_spec: TaskSpec,
|
||||
#[serde(default)]
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
},
|
||||
LaunchChildTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
parent_task: String,
|
||||
task_spec: TaskSpec,
|
||||
#[serde(default)]
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
},
|
||||
JoinChildTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
parent_task: String,
|
||||
task: String,
|
||||
},
|
||||
PollTaskAssignment {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
},
|
||||
PollArtifactTransfer {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
},
|
||||
UploadArtifactTransferChunk {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
transfer_id: String,
|
||||
artifact: String,
|
||||
offset: u64,
|
||||
content_base64: String,
|
||||
chunk_digest: Digest,
|
||||
eof: bool,
|
||||
},
|
||||
FailArtifactTransfer {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
transfer_id: String,
|
||||
artifact: String,
|
||||
message: String,
|
||||
},
|
||||
RequestRendezvous {
|
||||
scope: DataPlaneScope,
|
||||
source: NodeEndpoint,
|
||||
destination: NodeEndpoint,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: String,
|
||||
},
|
||||
RequestSourcePreparation {
|
||||
tenant: String,
|
||||
project: String,
|
||||
provider: SourceProviderKind,
|
||||
},
|
||||
CompleteSourcePreparation {
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
provider: SourceProviderKind,
|
||||
source_snapshot: Digest,
|
||||
},
|
||||
StartProcess {
|
||||
tenant: String,
|
||||
project: String,
|
||||
#[serde(default)]
|
||||
actor_user: Option<String>,
|
||||
#[serde(default)]
|
||||
actor_agent: Option<String>,
|
||||
#[serde(default)]
|
||||
agent_public_key_fingerprint: Option<Digest>,
|
||||
#[serde(default)]
|
||||
agent_signature: Option<AgentSignedRequest>,
|
||||
process: String,
|
||||
#[serde(default)]
|
||||
restart: bool,
|
||||
},
|
||||
ReconnectNode {
|
||||
node: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
},
|
||||
CancelTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
},
|
||||
CancelProcess {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
},
|
||||
AbortProcess {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
},
|
||||
ListProcesses {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
},
|
||||
QuotaStatus {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
},
|
||||
PollTaskControl {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
},
|
||||
RestartTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
task: String,
|
||||
#[serde(default)]
|
||||
replacement_bundle: Option<TaskReplacementBundle>,
|
||||
},
|
||||
DebugAttach {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
},
|
||||
SetDebugBreakpoints {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
probe_symbols: Vec<String>,
|
||||
},
|
||||
InspectDebugBreakpoints {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
},
|
||||
CreateDebugEpoch {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
stopped_task: String,
|
||||
reason: String,
|
||||
},
|
||||
ResumeDebugEpoch {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
},
|
||||
InspectDebugEpoch {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
epoch: u64,
|
||||
},
|
||||
PollDebugCommand {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
},
|
||||
ReportDebugState {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
epoch: u64,
|
||||
state: DebugAcknowledgementState,
|
||||
#[serde(default)]
|
||||
stack_frames: Vec<String>,
|
||||
#[serde(default)]
|
||||
local_values: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
task_args: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
handles: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
command_status: Option<String>,
|
||||
#[serde(default)]
|
||||
recent_output: Vec<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
ReportDebugProbeHit {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
probe_symbol: String,
|
||||
},
|
||||
ReportTaskLog {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
#[serde(default)]
|
||||
stdout_tail: String,
|
||||
#[serde(default)]
|
||||
stderr_tail: String,
|
||||
stdout_truncated: bool,
|
||||
stderr_truncated: bool,
|
||||
backpressured: bool,
|
||||
},
|
||||
ReportVfsMetadata {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
artifact_path: Option<String>,
|
||||
artifact_digest: Option<Digest>,
|
||||
artifact_size_bytes: Option<u64>,
|
||||
large_bytes_uploaded: bool,
|
||||
},
|
||||
TaskCompleted {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
node: String,
|
||||
task: String,
|
||||
#[serde(default)]
|
||||
terminal_state: Option<TaskTerminalState>,
|
||||
status_code: Option<i32>,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
#[serde(default)]
|
||||
stdout_tail: String,
|
||||
#[serde(default)]
|
||||
stderr_tail: String,
|
||||
#[serde(default)]
|
||||
stdout_truncated: bool,
|
||||
#[serde(default)]
|
||||
stderr_truncated: bool,
|
||||
artifact_path: Option<String>,
|
||||
artifact_digest: Option<Digest>,
|
||||
artifact_size_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
result: Option<TaskBoundaryValue>,
|
||||
},
|
||||
ListTaskEvents {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
#[serde(default)]
|
||||
process: Option<String>,
|
||||
},
|
||||
JoinTask {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
process: String,
|
||||
task: String,
|
||||
},
|
||||
RenderOperatorPanel {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
actor_user: String,
|
||||
max_download_bytes: u64,
|
||||
stopped: bool,
|
||||
},
|
||||
SubmitPanelEvent {
|
||||
tenant: String,
|
||||
project: String,
|
||||
process: String,
|
||||
widget_id: String,
|
||||
kind: PanelEventKind,
|
||||
max_events: u64,
|
||||
},
|
||||
CreateArtifactDownloadLink {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
#[serde(default = "default_download_ttl_seconds")]
|
||||
ttl_seconds: u64,
|
||||
},
|
||||
OpenArtifactDownloadStream {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
token_digest: Digest,
|
||||
chunk_bytes: u64,
|
||||
},
|
||||
RevokeArtifactDownloadLink {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
token_digest: Digest,
|
||||
},
|
||||
ExportArtifactToNode {
|
||||
tenant: String,
|
||||
project: String,
|
||||
actor_user: String,
|
||||
artifact: String,
|
||||
receiver_node: String,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CoordinatorRequest {
|
||||
pub fn operation(&self) -> Result<String, String> {
|
||||
serde_json::to_value(self)
|
||||
.map_err(|err| format!("failed to encode coordinator request operation: {err}"))
|
||||
.map(|value| disasmer_core::coordinator_payload_operation(&value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum AuthenticatedCoordinatorRequest {
|
||||
AuthStatus,
|
||||
RevokeCliSession,
|
||||
CreateProject {
|
||||
project: String,
|
||||
name: String,
|
||||
},
|
||||
SelectProject {
|
||||
project: String,
|
||||
},
|
||||
ListProjects,
|
||||
RegisterAgentPublicKey {
|
||||
agent: String,
|
||||
public_key: String,
|
||||
},
|
||||
ListAgentPublicKeys,
|
||||
RotateAgentPublicKey {
|
||||
agent: String,
|
||||
public_key: String,
|
||||
},
|
||||
RevokeAgentPublicKey {
|
||||
agent: String,
|
||||
},
|
||||
CreateNodeEnrollmentGrant {
|
||||
#[serde(default = "default_node_enrollment_ttl_seconds")]
|
||||
ttl_seconds: u64,
|
||||
},
|
||||
ListNodeDescriptors,
|
||||
RevokeNodeCredential {
|
||||
node: String,
|
||||
},
|
||||
StartProcess {
|
||||
process: String,
|
||||
#[serde(default)]
|
||||
restart: bool,
|
||||
},
|
||||
ScheduleTask {
|
||||
environment: Option<EnvironmentRequirements>,
|
||||
environment_digest: Option<Digest>,
|
||||
required_capabilities: Vec<Capability>,
|
||||
#[serde(default)]
|
||||
dependency_cache: Option<Digest>,
|
||||
source_snapshot: Option<Digest>,
|
||||
required_artifacts: Vec<String>,
|
||||
prefer_node: Option<String>,
|
||||
},
|
||||
LaunchTask {
|
||||
task_spec: Box<TaskSpec>,
|
||||
#[serde(default)]
|
||||
wait_for_node: bool,
|
||||
artifact_path: String,
|
||||
wasm_module_base64: String,
|
||||
},
|
||||
CancelProcess {
|
||||
process: String,
|
||||
},
|
||||
AbortProcess {
|
||||
process: String,
|
||||
},
|
||||
ListProcesses,
|
||||
QuotaStatus,
|
||||
RestartTask {
|
||||
process: String,
|
||||
task: String,
|
||||
#[serde(default)]
|
||||
replacement_bundle: Option<TaskReplacementBundle>,
|
||||
},
|
||||
DebugAttach {
|
||||
process: String,
|
||||
},
|
||||
SetDebugBreakpoints {
|
||||
process: String,
|
||||
probe_symbols: Vec<String>,
|
||||
},
|
||||
InspectDebugBreakpoints {
|
||||
process: String,
|
||||
},
|
||||
CreateDebugEpoch {
|
||||
process: String,
|
||||
stopped_task: String,
|
||||
reason: String,
|
||||
},
|
||||
ResumeDebugEpoch {
|
||||
process: String,
|
||||
epoch: u64,
|
||||
},
|
||||
InspectDebugEpoch {
|
||||
process: String,
|
||||
epoch: u64,
|
||||
},
|
||||
ListTaskEvents {
|
||||
#[serde(default)]
|
||||
process: Option<String>,
|
||||
},
|
||||
JoinTask {
|
||||
process: String,
|
||||
task: String,
|
||||
},
|
||||
CreateArtifactDownloadLink {
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
#[serde(default = "default_download_ttl_seconds")]
|
||||
ttl_seconds: u64,
|
||||
},
|
||||
OpenArtifactDownloadStream {
|
||||
artifact: String,
|
||||
max_bytes: u64,
|
||||
token_digest: Digest,
|
||||
chunk_bytes: u64,
|
||||
},
|
||||
RevokeArtifactDownloadLink {
|
||||
artifact: String,
|
||||
token_digest: Digest,
|
||||
},
|
||||
ExportArtifactToNode {
|
||||
artifact: String,
|
||||
receiver_node: String,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_download_ttl_seconds() -> u64 {
|
||||
900
|
||||
}
|
||||
|
||||
fn default_node_enrollment_ttl_seconds() -> u64 {
|
||||
900
|
||||
}
|
||||
492
crates/disasmer-coordinator/src/service/protocol/responses.rs
Normal file
492
crates/disasmer-coordinator/src/service/protocol/responses.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskTerminalState {
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TaskTerminalState {
|
||||
pub(crate) fn from_status_code(status_code: Option<i32>) -> Self {
|
||||
match status_code {
|
||||
Some(0) => Self::Completed,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskExecutor {
|
||||
CoordinatorMain,
|
||||
Node,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskCompletionEvent {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub node: NodeId,
|
||||
pub executor: TaskExecutor,
|
||||
pub task_definition: disasmer_core::TaskDefinitionId,
|
||||
pub task: TaskInstanceId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub placement: Option<Placement>,
|
||||
pub terminal_state: TaskTerminalState,
|
||||
pub status_code: Option<i32>,
|
||||
pub stdout_bytes: u64,
|
||||
pub stderr_bytes: u64,
|
||||
pub stdout_tail: String,
|
||||
pub stderr_tail: String,
|
||||
pub stdout_truncated: bool,
|
||||
pub stderr_truncated: bool,
|
||||
pub artifact_path: Option<VfsPath>,
|
||||
pub artifact_digest: Option<Digest>,
|
||||
pub artifact_size_bytes: Option<u64>,
|
||||
pub result: Option<TaskBoundaryValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DebugAuditEvent {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub task: Option<TaskInstanceId>,
|
||||
pub actor: UserId,
|
||||
pub operation: String,
|
||||
pub allowed: bool,
|
||||
pub reason: String,
|
||||
pub charged_debug_read_bytes: u64,
|
||||
pub used_debug_read_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkflowActor {
|
||||
pub kind: String,
|
||||
pub user: Option<UserId>,
|
||||
pub agent: Option<AgentId>,
|
||||
pub credential_kind: CredentialKind,
|
||||
pub public_key_fingerprint: Option<Digest>,
|
||||
pub authenticated_without_browser: bool,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskAssignment {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub task: TaskInstanceId,
|
||||
pub node: NodeId,
|
||||
pub epoch: u64,
|
||||
pub artifact_path: String,
|
||||
pub task_spec: TaskSpec,
|
||||
pub wasm_module_base64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ArtifactTransferAssignment {
|
||||
pub transfer_id: String,
|
||||
pub artifact: ArtifactId,
|
||||
pub expected_digest: Digest,
|
||||
pub expected_size_bytes: u64,
|
||||
pub offset: u64,
|
||||
pub max_chunk_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskCancellationTarget {
|
||||
pub process: ProcessId,
|
||||
pub task: TaskInstanceId,
|
||||
pub node: NodeId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DebugAcknowledgementState {
|
||||
Frozen,
|
||||
Running,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DebugParticipantAcknowledgement {
|
||||
pub node: NodeId,
|
||||
pub task_definition: disasmer_core::TaskDefinitionId,
|
||||
pub task: TaskInstanceId,
|
||||
pub epoch: u64,
|
||||
pub state: DebugAcknowledgementState,
|
||||
pub stack_frames: Vec<String>,
|
||||
pub local_values: Vec<(String, String)>,
|
||||
pub task_args: Vec<(String, String)>,
|
||||
pub handles: Vec<(String, String)>,
|
||||
pub command_status: Option<String>,
|
||||
pub recent_output: Vec<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VirtualProcessStatus {
|
||||
pub process: ProcessId,
|
||||
pub state: String,
|
||||
pub main_task_definition: Option<disasmer_core::TaskDefinitionId>,
|
||||
pub main_task_instance: Option<TaskInstanceId>,
|
||||
pub main_state: Option<String>,
|
||||
pub main_wait_state: Option<String>,
|
||||
pub main_debug_epoch: Option<u64>,
|
||||
pub connected_nodes: Vec<NodeId>,
|
||||
pub coordinator_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SourcePreparationDisposition {
|
||||
Pending { reason: String },
|
||||
Assigned { node: NodeId },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourcePreparationStatus {
|
||||
pub preparation: SourcePreparation,
|
||||
pub disposition: SourcePreparationDisposition,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CoordinatorResponse {
|
||||
Pong {
|
||||
epoch: u64,
|
||||
},
|
||||
AuthStatus {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
actor: UserId,
|
||||
authenticated: bool,
|
||||
account_status: String,
|
||||
suspended: bool,
|
||||
disabled: bool,
|
||||
deleted: bool,
|
||||
manual_review: bool,
|
||||
sanitized_reason: Option<String>,
|
||||
next_actions: Vec<String>,
|
||||
private_moderation_details_exposed: bool,
|
||||
signup_failure_details_exposed: bool,
|
||||
},
|
||||
AdminStatus {
|
||||
tenant: TenantId,
|
||||
actor: UserId,
|
||||
suspended: bool,
|
||||
safe_default: String,
|
||||
},
|
||||
TenantSuspended {
|
||||
tenant: TenantId,
|
||||
actor: UserId,
|
||||
policy: crate::ServicePolicyRecord,
|
||||
},
|
||||
ProjectCreated {
|
||||
project: ProjectRecord,
|
||||
actor: UserId,
|
||||
},
|
||||
ProjectSelected {
|
||||
project: ProjectRecord,
|
||||
actor: UserId,
|
||||
},
|
||||
Projects {
|
||||
projects: Vec<ProjectRecord>,
|
||||
actor: UserId,
|
||||
},
|
||||
CliSessionRevoked {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
actor: UserId,
|
||||
},
|
||||
AgentPublicKey {
|
||||
record: AgentPublicKeyRecord,
|
||||
actor: UserId,
|
||||
},
|
||||
AgentPublicKeys {
|
||||
records: Vec<AgentPublicKeyRecord>,
|
||||
actor: UserId,
|
||||
},
|
||||
NodeAttached {
|
||||
node: NodeId,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
},
|
||||
NodeEnrollmentGrantCreated {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
grant: String,
|
||||
scope: String,
|
||||
expires_at_epoch_seconds: u64,
|
||||
},
|
||||
NodeEnrollmentExchanged {
|
||||
node: NodeId,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
credential: disasmer_core::NodeCredential,
|
||||
},
|
||||
NodeHeartbeat {
|
||||
node: NodeId,
|
||||
epoch: u64,
|
||||
},
|
||||
NodeCapabilitiesRecorded {
|
||||
node: NodeId,
|
||||
node_descriptors: usize,
|
||||
},
|
||||
NodeDescriptors {
|
||||
descriptors: Vec<NodeDescriptor>,
|
||||
actor: UserId,
|
||||
},
|
||||
NodeCredentialRevoked {
|
||||
node: NodeId,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
actor: UserId,
|
||||
descriptor_removed: bool,
|
||||
queued_assignments_removed: usize,
|
||||
},
|
||||
TaskPlacement {
|
||||
placement: Placement,
|
||||
},
|
||||
TaskLaunched {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
actor: WorkflowActor,
|
||||
placement: Placement,
|
||||
assignment: Box<TaskAssignment>,
|
||||
charged_spawns: u64,
|
||||
},
|
||||
MainLaunched {
|
||||
process: ProcessId,
|
||||
task_definition: disasmer_core::TaskDefinitionId,
|
||||
task_instance: TaskInstanceId,
|
||||
actor: WorkflowActor,
|
||||
state: String,
|
||||
},
|
||||
TaskQueued {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
actor: WorkflowActor,
|
||||
reason: String,
|
||||
charged_spawns: u64,
|
||||
queued_tasks: usize,
|
||||
},
|
||||
TaskAssignment {
|
||||
assignment: Option<Box<TaskAssignment>>,
|
||||
},
|
||||
ArtifactTransferAssignment {
|
||||
transfer: Option<ArtifactTransferAssignment>,
|
||||
},
|
||||
ArtifactTransferChunkAccepted {
|
||||
transfer_id: String,
|
||||
next_offset: u64,
|
||||
complete: bool,
|
||||
},
|
||||
ArtifactTransferFailed {
|
||||
transfer_id: String,
|
||||
},
|
||||
RendezvousPlan {
|
||||
plan: DirectBulkTransferPlan,
|
||||
charged_rendezvous_attempts: u64,
|
||||
},
|
||||
SourcePreparation {
|
||||
status: SourcePreparationStatus,
|
||||
},
|
||||
SourcePreparationCompleted {
|
||||
node: NodeId,
|
||||
provider: SourceProviderKind,
|
||||
source_snapshot: Digest,
|
||||
},
|
||||
ProcessStarted {
|
||||
process: ProcessId,
|
||||
epoch: u64,
|
||||
actor: WorkflowActor,
|
||||
charged_spawns: u64,
|
||||
},
|
||||
NodeReconnected {
|
||||
node: NodeId,
|
||||
process: ProcessId,
|
||||
},
|
||||
TaskCancellationRequested {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
node: NodeId,
|
||||
},
|
||||
ProcessCancellationRequested {
|
||||
process: ProcessId,
|
||||
cancelled_tasks: Vec<TaskCancellationTarget>,
|
||||
affected_nodes: Vec<NodeId>,
|
||||
},
|
||||
ProcessAborted {
|
||||
process: ProcessId,
|
||||
aborted_tasks: Vec<TaskCancellationTarget>,
|
||||
affected_nodes: Vec<NodeId>,
|
||||
},
|
||||
ProcessStatuses {
|
||||
processes: Vec<VirtualProcessStatus>,
|
||||
actor: UserId,
|
||||
},
|
||||
QuotaStatus {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
actor: UserId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
policy_label: Option<String>,
|
||||
limits: ResourceLimits,
|
||||
window_seconds: BTreeMap<LimitKind, u64>,
|
||||
usage: BTreeMap<LimitKind, u64>,
|
||||
window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
|
||||
},
|
||||
TaskControl {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
cancel_requested: bool,
|
||||
abort_requested: bool,
|
||||
},
|
||||
TaskRestart {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
restarted_task_instance: Option<disasmer_core::TaskInstanceId>,
|
||||
actor: UserId,
|
||||
accepted: bool,
|
||||
clean_boundary_available: bool,
|
||||
active_task: bool,
|
||||
completed_event_observed: bool,
|
||||
requires_whole_process_restart: bool,
|
||||
message: String,
|
||||
audit_event: DebugAuditEvent,
|
||||
charged_debug_read_bytes: u64,
|
||||
used_debug_read_bytes: u64,
|
||||
},
|
||||
DebugCommand {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
epoch: Option<u64>,
|
||||
command: Option<String>,
|
||||
},
|
||||
DebugStateRecorded {
|
||||
process: ProcessId,
|
||||
node: NodeId,
|
||||
task: TaskInstanceId,
|
||||
epoch: u64,
|
||||
state: DebugAcknowledgementState,
|
||||
},
|
||||
DebugAttach {
|
||||
process: ProcessId,
|
||||
actor: UserId,
|
||||
authorization: Authorization,
|
||||
audit_event: DebugAuditEvent,
|
||||
charged_debug_read_bytes: u64,
|
||||
used_debug_read_bytes: u64,
|
||||
},
|
||||
DebugBreakpoints {
|
||||
process: ProcessId,
|
||||
actor: UserId,
|
||||
probe_symbols: Vec<String>,
|
||||
hit_epoch: Option<u64>,
|
||||
hit_task: Option<TaskInstanceId>,
|
||||
hit_probe_symbol: Option<String>,
|
||||
audit_event: DebugAuditEvent,
|
||||
charged_debug_read_bytes: u64,
|
||||
used_debug_read_bytes: u64,
|
||||
},
|
||||
DebugProbeHit {
|
||||
process: ProcessId,
|
||||
node: NodeId,
|
||||
task: TaskInstanceId,
|
||||
probe_symbol: String,
|
||||
breakpoint_matched: bool,
|
||||
debug_epoch: Option<u64>,
|
||||
},
|
||||
DebugEpoch {
|
||||
process: ProcessId,
|
||||
actor: UserId,
|
||||
epoch: u64,
|
||||
command: String,
|
||||
affected_tasks: Vec<TaskCancellationTarget>,
|
||||
all_stop_requested: bool,
|
||||
audit_event: DebugAuditEvent,
|
||||
charged_debug_read_bytes: u64,
|
||||
used_debug_read_bytes: u64,
|
||||
},
|
||||
DebugEpochStatus {
|
||||
process: ProcessId,
|
||||
actor: UserId,
|
||||
epoch: u64,
|
||||
command: String,
|
||||
expected_tasks: Vec<TaskCancellationTarget>,
|
||||
acknowledgements: Vec<DebugParticipantAcknowledgement>,
|
||||
fully_frozen: bool,
|
||||
fully_resumed: bool,
|
||||
failed: bool,
|
||||
failure_messages: Vec<String>,
|
||||
audit_event: DebugAuditEvent,
|
||||
charged_debug_read_bytes: u64,
|
||||
used_debug_read_bytes: u64,
|
||||
},
|
||||
TaskLogRecorded {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
stdout_tail: String,
|
||||
stderr_tail: String,
|
||||
backpressured: bool,
|
||||
},
|
||||
VfsMetadataRecorded {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
artifact_path: Option<VfsPath>,
|
||||
large_bytes_uploaded: bool,
|
||||
},
|
||||
TaskRecorded {
|
||||
process: ProcessId,
|
||||
task: TaskInstanceId,
|
||||
events_recorded: usize,
|
||||
},
|
||||
TaskEvents {
|
||||
events: Vec<TaskCompletionEvent>,
|
||||
},
|
||||
TaskJoined {
|
||||
join: TaskJoinResult,
|
||||
},
|
||||
OperatorPanel {
|
||||
panel: PanelState,
|
||||
},
|
||||
PanelEventAccepted {
|
||||
used_events: u64,
|
||||
max_events: u64,
|
||||
},
|
||||
ArtifactDownloadLink {
|
||||
link: DownloadLink,
|
||||
},
|
||||
ArtifactDownloadLinkRevoked {
|
||||
link: DownloadLink,
|
||||
},
|
||||
ArtifactDownloadStream {
|
||||
link: DownloadLink,
|
||||
streamed_bytes: u64,
|
||||
charged_download_bytes: u64,
|
||||
content_bytes_available: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content_offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
content_eof: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content_base64: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content_source: Option<String>,
|
||||
},
|
||||
ArtifactExportPlan {
|
||||
plan: DirectBulkTransferPlan,
|
||||
source_node: NodeId,
|
||||
receiver_node: NodeId,
|
||||
artifact_size_bytes: u64,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
421
crates/disasmer-coordinator/src/service/quota.rs
Normal file
421
crates/disasmer-coordinator/src/service/quota.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use disasmer_core::{LimitError, LimitKind, ProjectId, ResourceLimits, ResourceMeter, TenantId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CoordinatorQuotaConfiguration {
|
||||
pub limits: ResourceLimits,
|
||||
pub window_seconds: BTreeMap<LimitKind, u64>,
|
||||
pub policy_label: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct CoordinatorQuotaStatus {
|
||||
pub(super) policy_label: Option<String>,
|
||||
pub(super) limits: ResourceLimits,
|
||||
pub(super) window_seconds: BTreeMap<LimitKind, u64>,
|
||||
pub(super) usage: BTreeMap<LimitKind, u64>,
|
||||
pub(super) window_started_epoch_seconds: BTreeMap<LimitKind, u64>,
|
||||
}
|
||||
|
||||
impl CoordinatorQuotaConfiguration {
|
||||
pub fn new(
|
||||
limits: ResourceLimits,
|
||||
window_seconds: impl IntoIterator<Item = (LimitKind, u64)>,
|
||||
) -> Result<Self, String> {
|
||||
let window_seconds = window_seconds.into_iter().collect::<BTreeMap<_, _>>();
|
||||
if window_seconds.values().any(|seconds| *seconds == 0) {
|
||||
return Err("quota windows must be at least one second".to_owned());
|
||||
}
|
||||
Ok(Self {
|
||||
limits,
|
||||
window_seconds,
|
||||
policy_label: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_policy_label(mut self, label: impl Into<String>) -> Self {
|
||||
let label = label.into();
|
||||
self.policy_label = (!label.trim().is_empty()).then_some(label);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unlimited() -> Self {
|
||||
Self {
|
||||
limits: ResourceLimits::unlimited(),
|
||||
window_seconds: LimitKind::ALL
|
||||
.into_iter()
|
||||
.map(|kind| (kind, u64::MAX))
|
||||
.collect(),
|
||||
policy_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn window_seconds(&self, kind: LimitKind) -> u64 {
|
||||
self.window_seconds
|
||||
.get(&kind)
|
||||
.copied()
|
||||
.unwrap_or(u64::MAX)
|
||||
.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CoordinatorQuotaConfiguration {
|
||||
fn default() -> Self {
|
||||
Self::unlimited()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct ProjectQuotaScope {
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct MeterKey {
|
||||
scope: ProjectQuotaScope,
|
||||
kind: LimitKind,
|
||||
window: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct CoordinatorQuota {
|
||||
configuration: CoordinatorQuotaConfiguration,
|
||||
meters: BTreeMap<MeterKey, ResourceMeter>,
|
||||
}
|
||||
|
||||
impl Default for CoordinatorQuota {
|
||||
fn default() -> Self {
|
||||
Self::new(CoordinatorQuotaConfiguration::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl CoordinatorQuota {
|
||||
pub(super) fn new(configuration: CoordinatorQuotaConfiguration) -> Self {
|
||||
Self {
|
||||
configuration,
|
||||
meters: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn key(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
now_epoch_seconds: u64,
|
||||
) -> MeterKey {
|
||||
MeterKey {
|
||||
scope: ProjectQuotaScope {
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
},
|
||||
kind,
|
||||
window: now_epoch_seconds / self.configuration.window_seconds(kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn meter(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Option<&ResourceMeter> {
|
||||
self.meters
|
||||
.get(&self.key(tenant, project, kind, now_epoch_seconds))
|
||||
}
|
||||
|
||||
fn meter_mut(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
now_epoch_seconds: u64,
|
||||
) -> &mut ResourceMeter {
|
||||
let key = self.key(tenant, project, kind, now_epoch_seconds);
|
||||
self.meters.retain(|existing, _| {
|
||||
existing.scope != key.scope || existing.kind != kind || existing.window == key.window
|
||||
});
|
||||
self.meters.entry(key).or_default()
|
||||
}
|
||||
|
||||
fn can_charge(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
amount: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
self.meter(tenant, project, kind, now_epoch_seconds)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.can_charge(&self.configuration.limits, kind, amount)
|
||||
}
|
||||
|
||||
fn charge(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
amount: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
let limits = self.configuration.limits.clone();
|
||||
let meter = self.meter_mut(tenant, project, kind, now_epoch_seconds);
|
||||
meter.charge(&limits, kind, amount)?;
|
||||
Ok(meter.used(&kind))
|
||||
}
|
||||
|
||||
fn used(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
kind: LimitKind,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.meter(tenant, project, kind, now_epoch_seconds)
|
||||
.map_or(0, |meter| meter.used(&kind))
|
||||
}
|
||||
|
||||
pub(super) fn can_charge_workflow_spawn(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
self.can_charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds)
|
||||
}
|
||||
|
||||
pub(super) fn charge_api_call(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(tenant, project, LimitKind::ApiCall, 1, now_epoch_seconds)
|
||||
}
|
||||
|
||||
pub(super) fn can_charge_log_bytes(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
bytes: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
self.can_charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::LogBytes,
|
||||
bytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn charge_log_bytes(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
bytes: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::LogBytes,
|
||||
bytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn charge_workflow_spawn(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn used_workflow_spawns(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.used(tenant, project, LimitKind::Spawn, now_epoch_seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn used_api_calls(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.used(tenant, project, LimitKind::ApiCall, now_epoch_seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn used_log_bytes(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.used(tenant, project, LimitKind::LogBytes, now_epoch_seconds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn active_meter_count(&self) -> usize {
|
||||
self.meters.len()
|
||||
}
|
||||
|
||||
pub(super) fn charge_rendezvous_attempt(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::RendezvousAttempt,
|
||||
1,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn can_charge_download(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
bytes: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
self.can_charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::ArtifactDownloadBytes,
|
||||
bytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn charge_download(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
bytes: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::ArtifactDownloadBytes,
|
||||
bytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn download_limit(&self) -> u64 {
|
||||
self.configuration
|
||||
.limits
|
||||
.limit(&LimitKind::ArtifactDownloadBytes)
|
||||
}
|
||||
|
||||
pub(super) fn used_download_bytes(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.used(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::ArtifactDownloadBytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn charge_debug_read(
|
||||
&mut self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
bytes: u64,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<u64, LimitError> {
|
||||
self.charge(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::DebugReadBytes,
|
||||
bytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn used_debug_read_bytes(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> u64 {
|
||||
self.used(
|
||||
tenant,
|
||||
project,
|
||||
LimitKind::DebugReadBytes,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn project_status(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
now_epoch_seconds: u64,
|
||||
) -> CoordinatorQuotaStatus {
|
||||
let mut usage = BTreeMap::new();
|
||||
let mut window_starts = BTreeMap::new();
|
||||
for kind in LimitKind::ALL {
|
||||
let seconds = self.configuration.window_seconds(kind);
|
||||
usage.insert(kind, self.used(tenant, project, kind, now_epoch_seconds));
|
||||
window_starts.insert(kind, (now_epoch_seconds / seconds).saturating_mul(seconds));
|
||||
}
|
||||
CoordinatorQuotaStatus {
|
||||
policy_label: self.configuration.policy_label.clone(),
|
||||
limits: self.configuration.limits.clone(),
|
||||
window_seconds: self.configuration.window_seconds.clone(),
|
||||
usage,
|
||||
window_started_epoch_seconds: window_starts,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_workflow_limits(&mut self, limits: ResourceLimits) {
|
||||
self.configuration
|
||||
.limits
|
||||
.limits
|
||||
.insert(LimitKind::Spawn, limits.limit(&LimitKind::Spawn));
|
||||
self.meters.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_download_limits(&mut self, limits: ResourceLimits) {
|
||||
self.configuration.limits.limits.insert(
|
||||
LimitKind::ArtifactDownloadBytes,
|
||||
limits.limit(&LimitKind::ArtifactDownloadBytes),
|
||||
);
|
||||
self.meters.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn set_rendezvous_limits(&mut self, limits: ResourceLimits) {
|
||||
self.configuration.limits.limits.insert(
|
||||
LimitKind::RendezvousAttempt,
|
||||
limits.limit(&LimitKind::RendezvousAttempt),
|
||||
);
|
||||
self.meters.clear();
|
||||
}
|
||||
}
|
||||
778
crates/disasmer-coordinator/src/service/routing.rs
Normal file
778
crates/disasmer-coordinator/src/service/routing.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
use super::*;
|
||||
|
||||
impl CoordinatorService {
|
||||
pub fn handle_request(
|
||||
&mut self,
|
||||
request: CoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
self.pump_main_runtime_commands();
|
||||
let request_payload = serde_json::to_value(&request).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"failed to canonicalize coordinator request for authentication: {error}"
|
||||
))
|
||||
})?;
|
||||
let request_payload_digest = disasmer_core::signed_request_payload_digest(&request_payload);
|
||||
match request {
|
||||
CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong {
|
||||
epoch: self.coordinator.coordinator_epoch(),
|
||||
}),
|
||||
CoordinatorRequest::Authenticated {
|
||||
session_secret,
|
||||
request,
|
||||
} => self.handle_authenticated_request(session_secret, request),
|
||||
CoordinatorRequest::AuthStatus {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(actor_user);
|
||||
let account_state = self.coordinator.account_policy_state(&tenant);
|
||||
Ok(CoordinatorResponse::AuthStatus {
|
||||
tenant,
|
||||
project,
|
||||
actor,
|
||||
authenticated: true,
|
||||
account_status: account_state.account_status,
|
||||
suspended: account_state.suspended,
|
||||
disabled: account_state.disabled,
|
||||
deleted: account_state.deleted,
|
||||
manual_review: account_state.manual_review,
|
||||
sanitized_reason: account_state.sanitized_reason,
|
||||
next_actions: account_state.next_actions,
|
||||
private_moderation_details_exposed: false,
|
||||
signup_failure_details_exposed: false,
|
||||
})
|
||||
}
|
||||
CoordinatorRequest::AdminStatus {
|
||||
tenant,
|
||||
actor_user,
|
||||
admin_proof,
|
||||
admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
} => self.handle_admin_status(
|
||||
tenant,
|
||||
actor_user,
|
||||
admin_proof,
|
||||
admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
),
|
||||
CoordinatorRequest::SuspendTenant {
|
||||
tenant,
|
||||
actor_user,
|
||||
target_tenant,
|
||||
admin_proof,
|
||||
admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
} => self.handle_suspend_tenant(
|
||||
tenant,
|
||||
actor_user,
|
||||
target_tenant,
|
||||
admin_proof,
|
||||
admin_nonce,
|
||||
issued_at_epoch_seconds,
|
||||
),
|
||||
CoordinatorRequest::CreateProject {
|
||||
tenant,
|
||||
actor_user,
|
||||
project,
|
||||
name,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let actor = UserId::new(actor_user);
|
||||
let project = ProjectId::new(project);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
if let Some(existing) = self.coordinator.project(&project) {
|
||||
if existing.tenant != tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"project id is outside the signed-in tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator.upsert_user(
|
||||
tenant.clone(),
|
||||
actor.clone(),
|
||||
CredentialKind::BrowserSession,
|
||||
);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), name);
|
||||
self.coordinator.grant_project_debug(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
actor.clone(),
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
let project = self
|
||||
.coordinator
|
||||
.project(&project)
|
||||
.expect("project was just created")
|
||||
.clone();
|
||||
Ok(CoordinatorResponse::ProjectCreated { project, actor })
|
||||
}
|
||||
CoordinatorRequest::SelectProject {
|
||||
tenant,
|
||||
actor_user,
|
||||
project,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let actor = UserId::new(actor_user);
|
||||
let project_id = ProjectId::new(project);
|
||||
let project = self
|
||||
.coordinator
|
||||
.project(&project_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"project is not visible to the signed-in user".to_owned(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
if project.tenant != tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"project is outside the signed-in tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.coordinator
|
||||
.upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::ProjectSelected { project, actor })
|
||||
}
|
||||
CoordinatorRequest::ListProjects { tenant, actor_user } => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let actor = UserId::new(actor_user);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant: tenant.clone(),
|
||||
project: ProjectId::from("__project_listing__"),
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
self.coordinator
|
||||
.upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::Projects {
|
||||
projects: self.coordinator.list_projects(&context),
|
||||
actor,
|
||||
})
|
||||
}
|
||||
CoordinatorRequest::RegisterAgentPublicKey {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
agent,
|
||||
public_key,
|
||||
}
|
||||
| CoordinatorRequest::RotateAgentPublicKey {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
agent,
|
||||
public_key,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(user);
|
||||
let agent = AgentId::new(agent);
|
||||
self.coordinator.ensure_tenant_active(&tenant)?;
|
||||
if let Some(existing) = self.coordinator.project(&project) {
|
||||
if existing.tenant != tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"project id is outside the signed-in tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
self.coordinator.upsert_tenant(tenant.clone());
|
||||
self.coordinator.upsert_user(
|
||||
tenant.clone(),
|
||||
actor.clone(),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
self.coordinator
|
||||
.upsert_project(tenant.clone(), project.clone(), "local");
|
||||
let record = self.coordinator.register_agent_public_key(
|
||||
tenant,
|
||||
project,
|
||||
actor.clone(),
|
||||
agent,
|
||||
public_key,
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
|
||||
}
|
||||
CoordinatorRequest::ListAgentPublicKeys {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(user);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant,
|
||||
project,
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
Ok(CoordinatorResponse::AgentPublicKeys {
|
||||
records: self.coordinator.list_agent_public_keys(&context),
|
||||
actor,
|
||||
})
|
||||
}
|
||||
CoordinatorRequest::RevokeAgentPublicKey {
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
agent,
|
||||
} => {
|
||||
let tenant = TenantId::new(tenant);
|
||||
let project = ProjectId::new(project);
|
||||
let actor = UserId::new(user);
|
||||
let agent = AgentId::new(agent);
|
||||
let context = disasmer_core::AuthContext {
|
||||
tenant,
|
||||
project,
|
||||
actor: Actor::User(actor.clone()),
|
||||
};
|
||||
let record = self.coordinator.revoke_agent_public_key(&context, &agent)?;
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::AgentPublicKey { record, actor })
|
||||
}
|
||||
CoordinatorRequest::AttachNode {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
public_key,
|
||||
} => self.handle_attach_node(tenant, project, node, public_key),
|
||||
CoordinatorRequest::CreateNodeEnrollmentGrant {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
ttl_seconds,
|
||||
} => self.handle_create_node_enrollment_grant(tenant, project, actor_user, ttl_seconds),
|
||||
CoordinatorRequest::ExchangeNodeEnrollmentGrant {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
public_key,
|
||||
enrollment_grant,
|
||||
} => self.handle_exchange_node_enrollment_grant(
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
public_key,
|
||||
enrollment_grant,
|
||||
),
|
||||
CoordinatorRequest::NodeHeartbeat {
|
||||
node,
|
||||
node_signature,
|
||||
} => self.handle_node_heartbeat(node, node_signature, &request_payload_digest),
|
||||
CoordinatorRequest::SignedNode {
|
||||
node,
|
||||
node_signature,
|
||||
request,
|
||||
} => self.handle_signed_node_request(node, node_signature, *request),
|
||||
CoordinatorRequest::ReportNodeCapabilities { .. } => {
|
||||
self.reject_unsigned_node_request()
|
||||
}
|
||||
CoordinatorRequest::ListNodeDescriptors {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
} => self.handle_list_node_descriptors(tenant, project, actor_user),
|
||||
CoordinatorRequest::RevokeNodeCredential {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
node,
|
||||
} => self.handle_revoke_node_credential(tenant, project, actor_user, node),
|
||||
CoordinatorRequest::ScheduleTask {
|
||||
tenant,
|
||||
project,
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
dependency_cache,
|
||||
source_snapshot,
|
||||
required_artifacts,
|
||||
prefer_node,
|
||||
} => self.handle_schedule_task(
|
||||
tenant,
|
||||
project,
|
||||
environment,
|
||||
environment_digest,
|
||||
required_capabilities,
|
||||
dependency_cache,
|
||||
source_snapshot,
|
||||
required_artifacts,
|
||||
prefer_node,
|
||||
),
|
||||
CoordinatorRequest::LaunchTask {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
} => self.handle_launch_task(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
Some(&request_payload_digest),
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
),
|
||||
CoordinatorRequest::LaunchChildTask { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::JoinChildTask { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::PollTaskAssignment { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::RequestRendezvous {
|
||||
scope,
|
||||
source,
|
||||
destination,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
} => self.handle_request_rendezvous(
|
||||
scope,
|
||||
source,
|
||||
destination,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
),
|
||||
CoordinatorRequest::RequestSourcePreparation {
|
||||
tenant,
|
||||
project,
|
||||
provider,
|
||||
} => self.handle_request_source_preparation(tenant, project, provider),
|
||||
CoordinatorRequest::CompleteSourcePreparation { .. } => {
|
||||
self.reject_unsigned_node_request()
|
||||
}
|
||||
CoordinatorRequest::StartProcess {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
process,
|
||||
restart,
|
||||
} => self.handle_start_process(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
actor_agent,
|
||||
agent_public_key_fingerprint,
|
||||
agent_signature,
|
||||
Some(&request_payload_digest),
|
||||
process,
|
||||
restart,
|
||||
),
|
||||
CoordinatorRequest::ReconnectNode { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::CancelTask {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
} => self.handle_cancel_task(tenant, project, process, node, task),
|
||||
CoordinatorRequest::CancelProcess {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
} => self.handle_cancel_process(tenant, project, actor_user, process),
|
||||
CoordinatorRequest::AbortProcess {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
} => self.handle_abort_process(tenant, project, actor_user, process),
|
||||
CoordinatorRequest::ListProcesses {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
} => self.handle_list_processes(tenant, project, actor_user),
|
||||
CoordinatorRequest::QuotaStatus {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
} => self.handle_quota_status(tenant, project, actor_user),
|
||||
CoordinatorRequest::PollTaskControl { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::PollArtifactTransfer { .. }
|
||||
| CoordinatorRequest::UploadArtifactTransferChunk { .. }
|
||||
| CoordinatorRequest::FailArtifactTransfer { .. } => {
|
||||
self.reject_unsigned_node_request()
|
||||
}
|
||||
CoordinatorRequest::RestartTask {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
task,
|
||||
replacement_bundle,
|
||||
} => self.handle_restart_task(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
task,
|
||||
replacement_bundle,
|
||||
),
|
||||
request @ (CoordinatorRequest::DebugAttach { .. }
|
||||
| CoordinatorRequest::SetDebugBreakpoints { .. }
|
||||
| CoordinatorRequest::InspectDebugBreakpoints { .. }
|
||||
| CoordinatorRequest::CreateDebugEpoch { .. }
|
||||
| CoordinatorRequest::ResumeDebugEpoch { .. }
|
||||
| CoordinatorRequest::InspectDebugEpoch { .. }) => self.handle_debug_request(request),
|
||||
CoordinatorRequest::PollDebugCommand { .. }
|
||||
| CoordinatorRequest::ReportDebugState { .. }
|
||||
| CoordinatorRequest::ReportDebugProbeHit { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::ReportTaskLog { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::ReportVfsMetadata { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::TaskCompleted { .. } => self.reject_unsigned_node_request(),
|
||||
CoordinatorRequest::ListTaskEvents {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
} => self.handle_list_task_events(tenant, project, actor_user, process),
|
||||
CoordinatorRequest::JoinTask {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
task,
|
||||
} => self.handle_join_task(tenant, project, actor_user, process, task),
|
||||
CoordinatorRequest::RenderOperatorPanel {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
actor_user,
|
||||
max_download_bytes,
|
||||
stopped,
|
||||
} => self.handle_render_operator_panel(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
process,
|
||||
max_download_bytes,
|
||||
stopped,
|
||||
),
|
||||
CoordinatorRequest::SubmitPanelEvent {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
widget_id,
|
||||
kind,
|
||||
max_events,
|
||||
} => self
|
||||
.handle_submit_panel_event(tenant, project, process, widget_id, kind, max_events),
|
||||
CoordinatorRequest::CreateArtifactDownloadLink {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
max_bytes,
|
||||
ttl_seconds,
|
||||
} => self.handle_create_artifact_download_link(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
max_bytes,
|
||||
ttl_seconds,
|
||||
),
|
||||
CoordinatorRequest::OpenArtifactDownloadStream {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
max_bytes,
|
||||
token_digest,
|
||||
chunk_bytes,
|
||||
} => self.handle_open_artifact_download_stream(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
max_bytes,
|
||||
token_digest,
|
||||
chunk_bytes,
|
||||
),
|
||||
CoordinatorRequest::RevokeArtifactDownloadLink {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
token_digest,
|
||||
} => self.handle_revoke_artifact_download_link(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
token_digest,
|
||||
),
|
||||
CoordinatorRequest::ExportArtifactToNode {
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
receiver_node,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
} => self.handle_export_artifact_to_node(
|
||||
tenant,
|
||||
project,
|
||||
actor_user,
|
||||
artifact,
|
||||
receiver_node,
|
||||
direct_connectivity,
|
||||
failure_reason,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_authenticated_request(
|
||||
&mut self,
|
||||
session_secret: String,
|
||||
request: AuthenticatedCoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let context = self.coordinator.authenticate_cli_session(&session_secret)?;
|
||||
let authorized = authorize_authenticated_user_operation(&context, &request)?;
|
||||
let now_epoch_seconds = self.current_epoch_seconds()?;
|
||||
self.quota
|
||||
.charge_api_call(&context.tenant, &context.project, now_epoch_seconds)?;
|
||||
let _authorized_operation = authorized.operation;
|
||||
let actor = authorized.actor;
|
||||
match request {
|
||||
AuthenticatedCoordinatorRequest::AuthStatus => {
|
||||
let account_state = self.coordinator.account_policy_state(&context.tenant);
|
||||
Ok(CoordinatorResponse::AuthStatus {
|
||||
tenant: context.tenant,
|
||||
project: context.project,
|
||||
actor,
|
||||
authenticated: true,
|
||||
account_status: account_state.account_status,
|
||||
suspended: account_state.suspended,
|
||||
disabled: account_state.disabled,
|
||||
deleted: account_state.deleted,
|
||||
manual_review: account_state.manual_review,
|
||||
sanitized_reason: account_state.sanitized_reason,
|
||||
next_actions: account_state.next_actions,
|
||||
private_moderation_details_exposed: false,
|
||||
signup_failure_details_exposed: false,
|
||||
})
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::RevokeCliSession => {
|
||||
self.coordinator.revoke_cli_session(&session_secret)?;
|
||||
self.persist_durable_state()?;
|
||||
Ok(CoordinatorResponse::CliSessionRevoked {
|
||||
tenant: context.tenant,
|
||||
project: context.project,
|
||||
actor,
|
||||
})
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::CreateProject { project, name } => {
|
||||
let project = ProjectId::new(project);
|
||||
self.coordinator.ensure_tenant_active(&context.tenant)?;
|
||||
if let Some(existing) = self.coordinator.project(&project) {
|
||||
if existing.tenant != context.tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"project id is outside the authenticated tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
self.coordinator
|
||||
.upsert_project(context.tenant.clone(), project.clone(), name);
|
||||
self.coordinator.grant_project_debug(
|
||||
context.tenant.clone(),
|
||||
project.clone(),
|
||||
actor.clone(),
|
||||
);
|
||||
self.persist_durable_state()?;
|
||||
let project = self
|
||||
.coordinator
|
||||
.project(&project)
|
||||
.expect("project was just created")
|
||||
.clone();
|
||||
Ok(CoordinatorResponse::ProjectCreated { project, actor })
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::SelectProject { project } => {
|
||||
let project_id = ProjectId::new(project);
|
||||
let project = self
|
||||
.coordinator
|
||||
.project(&project_id)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"project is not visible to the authenticated user".to_owned(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
if project.tenant != context.tenant {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"project is outside the authenticated tenant scope".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(CoordinatorResponse::ProjectSelected { project, actor })
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::ListProjects => Ok(CoordinatorResponse::Projects {
|
||||
projects: self.coordinator.list_projects(&context),
|
||||
actor,
|
||||
}),
|
||||
request @ (AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. }
|
||||
| AuthenticatedCoordinatorRequest::ListAgentPublicKeys
|
||||
| AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. }
|
||||
| AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. }) => {
|
||||
self.handle_authenticated_agent_key_request(&context, &actor, request)
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { ttl_seconds } => self
|
||||
.handle_create_node_enrollment_grant(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
ttl_seconds,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::ListNodeDescriptors => self
|
||||
.handle_list_node_descriptors(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::RevokeNodeCredential { node } => self
|
||||
.handle_revoke_node_credential(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
node,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::StartProcess { process, restart } => self
|
||||
.handle_start_process(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
Some(actor.as_str().to_owned()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
process,
|
||||
restart,
|
||||
),
|
||||
request @ AuthenticatedCoordinatorRequest::ScheduleTask { .. } => {
|
||||
self.handle_authenticated_schedule_task(&context, request)
|
||||
}
|
||||
request @ AuthenticatedCoordinatorRequest::LaunchTask { .. } => {
|
||||
self.handle_authenticated_launch_task(&context, &actor, request)
|
||||
}
|
||||
AuthenticatedCoordinatorRequest::CancelProcess { process } => self
|
||||
.handle_cancel_process(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::AbortProcess { process } => self.handle_abort_process(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::ListProcesses => self.handle_list_processes(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::QuotaStatus => self.handle_quota_status(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::RestartTask {
|
||||
process,
|
||||
task,
|
||||
replacement_bundle,
|
||||
} => self.handle_restart_task(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
task,
|
||||
replacement_bundle,
|
||||
),
|
||||
request @ (AuthenticatedCoordinatorRequest::DebugAttach { .. }
|
||||
| AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. }
|
||||
| AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. }
|
||||
| AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. }
|
||||
| AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. }
|
||||
| AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. }) => self
|
||||
.handle_authenticated_debug_request(
|
||||
&context.tenant,
|
||||
&context.project,
|
||||
&actor,
|
||||
request,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::ListTaskEvents { process } => self
|
||||
.handle_list_task_events(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::JoinTask { process, task } => self.handle_join_task(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
process,
|
||||
task,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink {
|
||||
artifact,
|
||||
max_bytes,
|
||||
ttl_seconds,
|
||||
} => self.handle_create_artifact_download_link(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
artifact,
|
||||
max_bytes,
|
||||
ttl_seconds,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream {
|
||||
artifact,
|
||||
max_bytes,
|
||||
token_digest,
|
||||
chunk_bytes,
|
||||
} => self.handle_open_artifact_download_stream(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
artifact,
|
||||
max_bytes,
|
||||
token_digest,
|
||||
chunk_bytes,
|
||||
),
|
||||
AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink {
|
||||
artifact,
|
||||
token_digest,
|
||||
} => self.handle_revoke_artifact_download_link(
|
||||
context.tenant.as_str().to_owned(),
|
||||
context.project.as_str().to_owned(),
|
||||
actor.as_str().to_owned(),
|
||||
artifact,
|
||||
token_digest,
|
||||
),
|
||||
request @ AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => {
|
||||
self.handle_authenticated_artifact_export(&context, &actor, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
365
crates/disasmer-coordinator/src/service/signed_nodes.rs
Normal file
365
crates/disasmer-coordinator/src/service/signed_nodes.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
use disasmer_core::{NodeId, NodeSignedRequest};
|
||||
|
||||
use crate::CoordinatorError;
|
||||
|
||||
use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
|
||||
|
||||
impl CoordinatorService {
|
||||
pub(super) fn handle_signed_node_request(
|
||||
&mut self,
|
||||
signed_node: String,
|
||||
node_signature: NodeSignedRequest,
|
||||
request: CoordinatorRequest,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
let request_kind = signed_node_request_kind(&request)?;
|
||||
let request_node = signed_node_request_node(&request)?;
|
||||
let request_payload = serde_json::to_value(&request).map_err(|error| {
|
||||
CoordinatorServiceError::Protocol(format!(
|
||||
"failed to canonicalize signed node request: {error}"
|
||||
))
|
||||
})?;
|
||||
let payload_digest = disasmer_core::signed_request_payload_digest(&request_payload);
|
||||
let signed_node = NodeId::new(signed_node);
|
||||
if request_node != signed_node {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"signed node request node does not match the wrapped request node".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.authenticate_node_request(
|
||||
&signed_node,
|
||||
Some(node_signature),
|
||||
request_kind,
|
||||
&payload_digest,
|
||||
)?;
|
||||
match request {
|
||||
CoordinatorRequest::ReportNodeCapabilities {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
capabilities,
|
||||
cached_environment_digests,
|
||||
dependency_cache_digests,
|
||||
source_snapshots,
|
||||
artifact_locations,
|
||||
direct_connectivity,
|
||||
online,
|
||||
} => self.handle_report_node_capabilities(
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
capabilities,
|
||||
cached_environment_digests,
|
||||
dependency_cache_digests,
|
||||
source_snapshots,
|
||||
artifact_locations,
|
||||
direct_connectivity,
|
||||
online,
|
||||
),
|
||||
CoordinatorRequest::PollTaskAssignment {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
} => self.handle_poll_task_assignment(tenant, project, node),
|
||||
CoordinatorRequest::PollArtifactTransfer {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
} => self.handle_poll_artifact_transfer(tenant, project, node),
|
||||
CoordinatorRequest::UploadArtifactTransferChunk {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
transfer_id,
|
||||
artifact,
|
||||
offset,
|
||||
content_base64,
|
||||
chunk_digest,
|
||||
eof,
|
||||
} => self.handle_upload_artifact_transfer_chunk(
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
transfer_id,
|
||||
artifact,
|
||||
offset,
|
||||
content_base64,
|
||||
chunk_digest,
|
||||
eof,
|
||||
),
|
||||
CoordinatorRequest::FailArtifactTransfer {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
transfer_id,
|
||||
artifact,
|
||||
message,
|
||||
} => self.handle_fail_artifact_transfer(
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
transfer_id,
|
||||
artifact,
|
||||
message,
|
||||
),
|
||||
CoordinatorRequest::LaunchChildTask {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
parent_task,
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
} => self.handle_launch_child_task(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
parent_task,
|
||||
task_spec,
|
||||
wait_for_node,
|
||||
artifact_path,
|
||||
wasm_module_base64,
|
||||
),
|
||||
CoordinatorRequest::JoinChildTask {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
parent_task,
|
||||
task,
|
||||
} => self.handle_join_child_task(tenant, project, process, node, parent_task, task),
|
||||
CoordinatorRequest::CompleteSourcePreparation {
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
provider,
|
||||
source_snapshot,
|
||||
} => self.handle_complete_source_preparation(
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
provider,
|
||||
source_snapshot,
|
||||
),
|
||||
CoordinatorRequest::ReconnectNode {
|
||||
node,
|
||||
process,
|
||||
epoch,
|
||||
} => self.handle_reconnect_node(node, process, epoch),
|
||||
CoordinatorRequest::PollTaskControl {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
} => self.handle_poll_task_control(tenant, project, process, node, task),
|
||||
CoordinatorRequest::PollDebugCommand {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
} => self.handle_poll_debug_command(tenant, project, process, node, task),
|
||||
CoordinatorRequest::ReportDebugState {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
epoch,
|
||||
state,
|
||||
stack_frames,
|
||||
local_values,
|
||||
task_args,
|
||||
handles,
|
||||
command_status,
|
||||
recent_output,
|
||||
message,
|
||||
} => self.handle_report_debug_state(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
epoch,
|
||||
state,
|
||||
stack_frames,
|
||||
local_values,
|
||||
task_args,
|
||||
handles,
|
||||
command_status,
|
||||
recent_output,
|
||||
message,
|
||||
),
|
||||
CoordinatorRequest::ReportDebugProbeHit {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
probe_symbol,
|
||||
} => self.handle_report_debug_probe_hit(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
probe_symbol,
|
||||
),
|
||||
CoordinatorRequest::ReportTaskLog {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
backpressured,
|
||||
} => self.handle_report_task_log(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
backpressured,
|
||||
),
|
||||
CoordinatorRequest::ReportVfsMetadata {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
artifact_path,
|
||||
artifact_digest,
|
||||
artifact_size_bytes,
|
||||
large_bytes_uploaded,
|
||||
} => self.handle_report_vfs_metadata(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
artifact_path,
|
||||
artifact_digest,
|
||||
artifact_size_bytes,
|
||||
large_bytes_uploaded,
|
||||
),
|
||||
CoordinatorRequest::TaskCompleted {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
terminal_state,
|
||||
status_code,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
artifact_path,
|
||||
artifact_digest,
|
||||
artifact_size_bytes,
|
||||
result,
|
||||
} => self.handle_task_completed(
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
node,
|
||||
task,
|
||||
terminal_state,
|
||||
status_code,
|
||||
stdout_bytes,
|
||||
stderr_bytes,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
artifact_path,
|
||||
artifact_digest,
|
||||
artifact_size_bytes,
|
||||
result,
|
||||
),
|
||||
_ => self.reject_unsigned_node_request(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reject_unsigned_node_request(
|
||||
&self,
|
||||
) -> Result<CoordinatorResponse, CoordinatorServiceError> {
|
||||
Err(CoordinatorError::Unauthorized(
|
||||
"node-originated request requires signed_node envelope proof".to_owned(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_node_request_kind(
|
||||
request: &CoordinatorRequest,
|
||||
) -> Result<&'static str, CoordinatorServiceError> {
|
||||
match request {
|
||||
CoordinatorRequest::ReportNodeCapabilities { .. } => Ok("report_node_capabilities"),
|
||||
CoordinatorRequest::PollTaskAssignment { .. } => Ok("poll_task_assignment"),
|
||||
CoordinatorRequest::PollArtifactTransfer { .. } => Ok("poll_artifact_transfer"),
|
||||
CoordinatorRequest::UploadArtifactTransferChunk { .. } => {
|
||||
Ok("upload_artifact_transfer_chunk")
|
||||
}
|
||||
CoordinatorRequest::FailArtifactTransfer { .. } => Ok("fail_artifact_transfer"),
|
||||
CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"),
|
||||
CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"),
|
||||
CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"),
|
||||
CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"),
|
||||
CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"),
|
||||
CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"),
|
||||
CoordinatorRequest::ReportDebugState { .. } => Ok("report_debug_state"),
|
||||
CoordinatorRequest::ReportDebugProbeHit { .. } => Ok("report_debug_probe_hit"),
|
||||
CoordinatorRequest::ReportTaskLog { .. } => Ok("report_task_log"),
|
||||
CoordinatorRequest::ReportVfsMetadata { .. } => Ok("report_vfs_metadata"),
|
||||
CoordinatorRequest::TaskCompleted { .. } => Ok("task_completed"),
|
||||
_ => Err(CoordinatorError::Unauthorized(
|
||||
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_node_request_node(
|
||||
request: &CoordinatorRequest,
|
||||
) -> Result<NodeId, CoordinatorServiceError> {
|
||||
match request {
|
||||
CoordinatorRequest::ReportNodeCapabilities { node, .. }
|
||||
| CoordinatorRequest::PollTaskAssignment { node, .. }
|
||||
| CoordinatorRequest::PollArtifactTransfer { node, .. }
|
||||
| CoordinatorRequest::UploadArtifactTransferChunk { node, .. }
|
||||
| CoordinatorRequest::FailArtifactTransfer { node, .. }
|
||||
| CoordinatorRequest::LaunchChildTask { node, .. }
|
||||
| CoordinatorRequest::JoinChildTask { node, .. }
|
||||
| CoordinatorRequest::CompleteSourcePreparation { node, .. }
|
||||
| CoordinatorRequest::ReconnectNode { node, .. }
|
||||
| CoordinatorRequest::PollTaskControl { node, .. }
|
||||
| CoordinatorRequest::PollDebugCommand { node, .. }
|
||||
| CoordinatorRequest::ReportDebugState { node, .. }
|
||||
| CoordinatorRequest::ReportDebugProbeHit { node, .. }
|
||||
| CoordinatorRequest::ReportTaskLog { node, .. }
|
||||
| CoordinatorRequest::ReportVfsMetadata { node, .. }
|
||||
| CoordinatorRequest::TaskCompleted { node, .. } => Ok(NodeId::new(node.clone())),
|
||||
_ => Err(CoordinatorError::Unauthorized(
|
||||
"signed_node envelope only accepts node-originated coordinator requests".to_owned(),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
200
crates/disasmer-coordinator/src/service/tcp.rs
Normal file
200
crates/disasmer-coordinator/src/service/tcp.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ClientAuthorityMode {
|
||||
Strict,
|
||||
LocalTrustedLoopback,
|
||||
}
|
||||
|
||||
impl CoordinatorService {
|
||||
pub fn serve_tcp(self, listener: TcpListener) -> Result<(), CoordinatorServiceError> {
|
||||
if !listener.local_addr()?.ip().is_loopback() {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"the native coordinator transport is plaintext and restricted to loopback; expose a remote coordinator only through a secure transport"
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
self.serve_tcp_with_authority(listener, ClientAuthorityMode::Strict)
|
||||
}
|
||||
|
||||
pub fn serve_tcp_local_trusted(
|
||||
self,
|
||||
listener: TcpListener,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
if !listener.local_addr()?.ip().is_loopback() {
|
||||
return Err(CoordinatorServiceError::Protocol(
|
||||
"local trusted request mode is restricted to a loopback listener".to_owned(),
|
||||
));
|
||||
}
|
||||
self.serve_tcp_with_authority(listener, ClientAuthorityMode::LocalTrustedLoopback)
|
||||
}
|
||||
|
||||
fn serve_tcp_with_authority(
|
||||
self,
|
||||
listener: TcpListener,
|
||||
authority_mode: ClientAuthorityMode,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let shared = Arc::new(Mutex::new(self));
|
||||
for stream in listener.incoming() {
|
||||
let stream = stream?;
|
||||
let service = Arc::clone(&shared);
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = handle_shared_stream(service, stream, authority_mode) {
|
||||
eprintln!("coordinator stream failed: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_stream(&mut self, stream: TcpStream) -> Result<(), CoordinatorServiceError> {
|
||||
self.handle_stream_with_authority(stream, ClientAuthorityMode::Strict)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn handle_stream_local_trusted(
|
||||
&mut self,
|
||||
stream: TcpStream,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
self.handle_stream_with_authority(stream, ClientAuthorityMode::LocalTrustedLoopback)
|
||||
}
|
||||
|
||||
fn handle_stream_with_authority(
|
||||
&mut self,
|
||||
stream: TcpStream,
|
||||
authority_mode: ClientAuthorityMode,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line)? == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let response = match decode_wire_request(&line) {
|
||||
Ok(request) => match authorize_client_request(&request, authority_mode)
|
||||
.and_then(|()| self.handle_request(request))
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => CoordinatorResponse::Error {
|
||||
message: err.to_string(),
|
||||
},
|
||||
},
|
||||
Err(err) => CoordinatorResponse::Error {
|
||||
message: err.to_string(),
|
||||
},
|
||||
};
|
||||
serde_json::to_writer(&mut writer, &response)?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_shared_stream(
|
||||
service: Arc<Mutex<CoordinatorService>>,
|
||||
stream: TcpStream,
|
||||
authority_mode: ClientAuthorityMode,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut writer = stream;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line)? == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let response = match decode_wire_request(&line) {
|
||||
Ok(request) => match authorize_client_request(&request, authority_mode) {
|
||||
Ok(()) => match service.lock() {
|
||||
Ok(mut service) => match service.handle_request(request) {
|
||||
Ok(response) => response,
|
||||
Err(err) => CoordinatorResponse::Error {
|
||||
message: err.to_string(),
|
||||
},
|
||||
},
|
||||
Err(_) => CoordinatorResponse::Error {
|
||||
message: "coordinator service lock poisoned".to_owned(),
|
||||
},
|
||||
},
|
||||
Err(err) => CoordinatorResponse::Error {
|
||||
message: err.to_string(),
|
||||
},
|
||||
},
|
||||
Err(err) => CoordinatorResponse::Error {
|
||||
message: err.to_string(),
|
||||
},
|
||||
};
|
||||
serde_json::to_writer(&mut writer, &response)?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bind_listener(addr: &str) -> Result<(TcpListener, SocketAddr), CoordinatorServiceError> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
let addr = listener.local_addr()?;
|
||||
Ok((listener, addr))
|
||||
}
|
||||
|
||||
fn decode_wire_request(line: &str) -> Result<CoordinatorRequest, CoordinatorServiceError> {
|
||||
serde_json::from_str::<super::CoordinatorWireRequest>(line)?
|
||||
.into_request()
|
||||
.map_err(CoordinatorServiceError::Protocol)
|
||||
}
|
||||
|
||||
fn authorize_client_request(
|
||||
request: &CoordinatorRequest,
|
||||
authority_mode: ClientAuthorityMode,
|
||||
) -> Result<(), CoordinatorServiceError> {
|
||||
if authority_mode == ClientAuthorityMode::LocalTrustedLoopback {
|
||||
return Ok(());
|
||||
}
|
||||
match request {
|
||||
CoordinatorRequest::Ping
|
||||
| CoordinatorRequest::Authenticated { .. }
|
||||
| CoordinatorRequest::ExchangeNodeEnrollmentGrant { .. }
|
||||
| CoordinatorRequest::SignedNode { .. }
|
||||
| CoordinatorRequest::NodeHeartbeat {
|
||||
node_signature: Some(_),
|
||||
..
|
||||
}
|
||||
| CoordinatorRequest::StartProcess {
|
||||
actor_agent: Some(_),
|
||||
agent_signature: Some(_),
|
||||
..
|
||||
}
|
||||
| CoordinatorRequest::LaunchTask {
|
||||
actor_agent: Some(_),
|
||||
agent_signature: Some(_),
|
||||
..
|
||||
}
|
||||
| CoordinatorRequest::AdminStatus { .. }
|
||||
| CoordinatorRequest::SuspendTenant { .. } => Ok(()),
|
||||
_ => Err(CoordinatorServiceError::Protocol(
|
||||
"strict Core Client authority requires an authenticated CLI session, signed Agent, signed Node, enrollment grant exchange, or admin credential; request-body identity fields are not authority"
|
||||
.to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transport_boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_plaintext_service_refuses_non_loopback_listener() {
|
||||
let (listener, _) = bind_listener("0.0.0.0:0").unwrap();
|
||||
let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err();
|
||||
assert!(error.to_string().contains("restricted to loopback"));
|
||||
}
|
||||
}
|
||||
6323
crates/disasmer-coordinator/src/service/tests.rs
Normal file
6323
crates/disasmer-coordinator/src/service/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
59
crates/disasmer-coordinator/src/service/wire_protocol.rs
Normal file
59
crates/disasmer-coordinator/src/service/wire_protocol.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use disasmer_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::CoordinatorRequest;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CoordinatorWireRequest {
|
||||
Envelope(CoordinatorRequestEnvelope),
|
||||
}
|
||||
|
||||
impl CoordinatorWireRequest {
|
||||
pub fn into_request(self) -> Result<CoordinatorRequest, String> {
|
||||
match self {
|
||||
Self::Envelope(envelope) => envelope.into_request(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CoordinatorRequestEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
pub envelope_type: String,
|
||||
pub protocol_version: u64,
|
||||
pub request_id: String,
|
||||
pub operation: String,
|
||||
#[serde(default)]
|
||||
pub authentication: Option<Value>,
|
||||
pub payload: CoordinatorRequest,
|
||||
}
|
||||
|
||||
impl CoordinatorRequestEnvelope {
|
||||
pub fn into_request(self) -> Result<CoordinatorRequest, String> {
|
||||
if self.envelope_type != COORDINATOR_WIRE_REQUEST_TYPE {
|
||||
return Err(format!(
|
||||
"unsupported coordinator wire request type {}; expected {}",
|
||||
self.envelope_type, COORDINATOR_WIRE_REQUEST_TYPE
|
||||
));
|
||||
}
|
||||
if self.protocol_version != COORDINATOR_PROTOCOL_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported coordinator protocol version {}; expected {}",
|
||||
self.protocol_version, COORDINATOR_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
if self.request_id.trim().is_empty() {
|
||||
return Err("coordinator wire request_id must be non-empty".to_owned());
|
||||
}
|
||||
let payload_operation = self.payload.operation()?;
|
||||
if self.operation != payload_operation {
|
||||
return Err(format!(
|
||||
"coordinator wire operation {} does not match payload operation {}",
|
||||
self.operation, payload_operation
|
||||
));
|
||||
}
|
||||
Ok(self.payload)
|
||||
}
|
||||
}
|
||||
167
crates/disasmer-coordinator/src/sessions.rs
Normal file
167
crates/disasmer-coordinator/src/sessions.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{Actor, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId};
|
||||
|
||||
use crate::{CliSessionRecord, Coordinator, CoordinatorError, CredentialRecord};
|
||||
|
||||
impl Coordinator {
|
||||
pub fn issue_cli_session(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
user: UserId,
|
||||
session_secret: &str,
|
||||
expires_at_epoch_seconds: Option<u64>,
|
||||
) -> CliSessionRecord {
|
||||
self.upsert_tenant(tenant.clone());
|
||||
self.upsert_user(
|
||||
tenant.clone(),
|
||||
user.clone(),
|
||||
CredentialKind::CliDeviceSession,
|
||||
);
|
||||
let session_digest = Digest::sha256(session_secret);
|
||||
let record = CliSessionRecord {
|
||||
session_digest: session_digest.clone(),
|
||||
tenant: tenant.clone(),
|
||||
project: project.clone(),
|
||||
user: user.clone(),
|
||||
credential_kind: CredentialKind::CliDeviceSession,
|
||||
expires_at_epoch_seconds,
|
||||
revoked: false,
|
||||
};
|
||||
self.durable
|
||||
.cli_sessions
|
||||
.insert(session_digest.clone(), record.clone());
|
||||
let credential_subject = format!("cli-session:{}", session_digest.as_str());
|
||||
self.durable.credentials.insert(
|
||||
credential_subject.clone(),
|
||||
CredentialRecord {
|
||||
subject: credential_subject,
|
||||
tenant,
|
||||
project: Some(project),
|
||||
kind: CredentialKind::CliDeviceSession,
|
||||
public_key_fingerprint: None,
|
||||
},
|
||||
);
|
||||
record
|
||||
}
|
||||
|
||||
pub fn authenticate_cli_session(
|
||||
&self,
|
||||
session_secret: &str,
|
||||
) -> Result<AuthContext, CoordinatorError> {
|
||||
self.authenticate_cli_session_at(session_secret, unix_timestamp_seconds())
|
||||
}
|
||||
|
||||
pub fn authenticate_cli_session_at(
|
||||
&self,
|
||||
session_secret: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<AuthContext, CoordinatorError> {
|
||||
if session_secret.trim().is_empty() {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"CLI session credential is missing".to_owned(),
|
||||
));
|
||||
}
|
||||
let session_digest = Digest::sha256(session_secret);
|
||||
let record = self
|
||||
.durable
|
||||
.cli_sessions
|
||||
.get(&session_digest)
|
||||
.ok_or_else(|| {
|
||||
CoordinatorError::Unauthorized(
|
||||
"CLI session credential is not recognized".to_owned(),
|
||||
)
|
||||
})?;
|
||||
if record.revoked {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"CLI session credential has been revoked".to_owned(),
|
||||
));
|
||||
}
|
||||
if record
|
||||
.expires_at_epoch_seconds
|
||||
.is_some_and(|expires_at| expires_at <= now_epoch_seconds)
|
||||
{
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"CLI session credential has expired; run disasmer login --browser again".to_owned(),
|
||||
));
|
||||
}
|
||||
if record.credential_kind != CredentialKind::CliDeviceSession {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"credential is not a CLI session".to_owned(),
|
||||
));
|
||||
}
|
||||
self.ensure_tenant_active(&record.tenant)?;
|
||||
Ok(AuthContext {
|
||||
tenant: record.tenant.clone(),
|
||||
project: record.project.clone(),
|
||||
actor: Actor::User(record.user.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke_cli_session(
|
||||
&mut self,
|
||||
session_secret: &str,
|
||||
) -> Result<CliSessionRecord, CoordinatorError> {
|
||||
self.authenticate_cli_session(session_secret)?;
|
||||
let session_digest = Digest::sha256(session_secret);
|
||||
let record = self
|
||||
.durable
|
||||
.cli_sessions
|
||||
.get_mut(&session_digest)
|
||||
.expect("authenticated session must exist");
|
||||
record.revoked = true;
|
||||
Ok(record.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_timestamp_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::InMemoryDurableStore;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repeated_logins_for_one_user_create_distinct_session_credentials() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
let tenant = TenantId::from("tenant");
|
||||
let user = UserId::from("user");
|
||||
|
||||
coordinator.issue_cli_session(
|
||||
tenant.clone(),
|
||||
ProjectId::from("project-one"),
|
||||
user.clone(),
|
||||
"session-one",
|
||||
None,
|
||||
);
|
||||
coordinator.issue_cli_session(
|
||||
tenant,
|
||||
ProjectId::from("project-two"),
|
||||
user,
|
||||
"session-two",
|
||||
None,
|
||||
);
|
||||
|
||||
let subjects = coordinator
|
||||
.durable
|
||||
.credentials
|
||||
.values()
|
||||
.map(|credential| credential.subject.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(coordinator.durable.cli_sessions.len(), 2);
|
||||
assert_eq!(subjects.len(), 2);
|
||||
assert!(subjects
|
||||
.iter()
|
||||
.all(|subject| subject.starts_with("cli-session:sha256:")));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue