Public dry run dryrun-309831e1e021
Source commit: 309831e1e021f962c118452336776fd9a94025f9 Public tree identity: sha256:6fa95c1745579bd6256dbeb3d476db0b07c2f24aa9213b0f7783ce1adfc8aca5
This commit is contained in:
commit
f22d0a5791
113 changed files with 39348 additions and 0 deletions
20
crates/disasmer-cli/Cargo.toml
Normal file
20
crates/disasmer-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "disasmer-cli"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "disasmer"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
1631
crates/disasmer-cli/src/main.rs
Normal file
1631
crates/disasmer-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
13
crates/disasmer-coordinator/Cargo.toml
Normal file
13
crates/disasmer-coordinator/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "disasmer-coordinator"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
postgres.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
709
crates/disasmer-coordinator/src/lib.rs
Normal file
709
crates/disasmer-coordinator/src/lib.rs
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use disasmer_core::{
|
||||
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
|
||||
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod postgres_store;
|
||||
pub mod service;
|
||||
pub use postgres_store::{
|
||||
PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES,
|
||||
};
|
||||
pub use service::{
|
||||
CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
|
||||
SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCompletionEvent,
|
||||
TaskTerminalState,
|
||||
};
|
||||
|
||||
#[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 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 ProjectPermissionRecord {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub user: UserId,
|
||||
pub can_debug: 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 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActiveProcess {
|
||||
pub id: ProcessId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub connected_nodes: BTreeSet<NodeId>,
|
||||
pub coordinator_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Coordinator {
|
||||
durable: DurableState,
|
||||
active_processes: BTreeMap<ProcessId, ActiveProcess>,
|
||||
coordinator_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CoordinatorError {
|
||||
#[error("node identity is not enrolled")]
|
||||
UnknownNode,
|
||||
#[error("node enrollment failed: {0:?}")]
|
||||
Enrollment(EnrollmentError),
|
||||
#[error("stale virtual process state from coordinator epoch {stale_epoch}; current epoch is {current_epoch}")]
|
||||
StaleProcessEpoch {
|
||||
stale_epoch: u64,
|
||||
current_epoch: u64,
|
||||
},
|
||||
#[error("unauthorized coordinator action: {0}")]
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
pub fn boot(store: &impl DurableStore, coordinator_epoch: u64) -> Self {
|
||||
Self {
|
||||
durable: store.load(),
|
||||
active_processes: BTreeMap::new(),
|
||||
coordinator_epoch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_boot<S: FallibleDurableStore>(
|
||||
store: &mut S,
|
||||
coordinator_epoch: u64,
|
||||
) -> Result<Self, S::Error> {
|
||||
Ok(Self {
|
||||
durable: store.load_state()?,
|
||||
active_processes: BTreeMap::new(),
|
||||
coordinator_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn persist(&self, store: &mut impl DurableStore) {
|
||||
store.save(self.durable.clone());
|
||||
}
|
||||
|
||||
pub fn try_persist<S: FallibleDurableStore>(&self, store: &mut S) -> Result<(), S::Error> {
|
||||
store.save_state(&self.durable)
|
||||
}
|
||||
|
||||
pub fn coordinator_epoch(&self) -> u64 {
|
||||
self.coordinator_epoch
|
||||
}
|
||||
|
||||
pub fn upsert_tenant(&mut self, id: TenantId) {
|
||||
self.durable.tenants.insert(id.clone(), TenantRecord { id });
|
||||
}
|
||||
|
||||
pub fn upsert_user(&mut self, tenant: TenantId, id: UserId, credential_kind: CredentialKind) {
|
||||
self.durable.users.insert(
|
||||
id.clone(),
|
||||
UserRecord {
|
||||
id,
|
||||
tenant,
|
||||
credential_kind,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn upsert_project(&mut self, tenant: TenantId, id: ProjectId, name: impl Into<String>) {
|
||||
self.durable.projects.insert(
|
||||
id.clone(),
|
||||
ProjectRecord {
|
||||
id,
|
||||
tenant,
|
||||
name: name.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn enroll_node(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
node: NodeId,
|
||||
public_key: impl Into<String>,
|
||||
enrollment_scope: impl Into<String>,
|
||||
) {
|
||||
self.durable.node_identities.insert(
|
||||
node.clone(),
|
||||
NodeIdentityRecord {
|
||||
id: node,
|
||||
tenant,
|
||||
project,
|
||||
public_key: public_key.into(),
|
||||
enrollment_scope: enrollment_scope.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn create_node_enrollment_grant(
|
||||
&self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
grant_id: impl Into<String>,
|
||||
scope: impl Into<String>,
|
||||
expires_at_epoch_seconds: u64,
|
||||
) -> EnrollmentGrant {
|
||||
EnrollmentGrant {
|
||||
tenant,
|
||||
project,
|
||||
grant_id: grant_id.into(),
|
||||
scope: scope.into(),
|
||||
expires_at_epoch_seconds,
|
||||
consumed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exchange_node_enrollment_grant(
|
||||
&mut self,
|
||||
grant: &mut EnrollmentGrant,
|
||||
node: NodeId,
|
||||
public_key: &str,
|
||||
requested_scope: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<NodeCredential, CoordinatorError> {
|
||||
let credential = grant
|
||||
.exchange_for_node_identity(
|
||||
node.clone(),
|
||||
public_key,
|
||||
requested_scope,
|
||||
now_epoch_seconds,
|
||||
)
|
||||
.map_err(CoordinatorError::Enrollment)?;
|
||||
self.enroll_node(
|
||||
credential.tenant.clone(),
|
||||
credential.project.clone(),
|
||||
node.clone(),
|
||||
public_key,
|
||||
credential.scope.clone(),
|
||||
);
|
||||
self.durable.credentials.insert(
|
||||
format!("node:{node}"),
|
||||
CredentialRecord {
|
||||
subject: format!("node:{node}"),
|
||||
tenant: credential.tenant.clone(),
|
||||
project: Some(credential.project.clone()),
|
||||
kind: credential.credential_kind.clone(),
|
||||
public_key_fingerprint: Some(credential.public_key_fingerprint.clone()),
|
||||
},
|
||||
);
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
pub fn upsert_source_provider_config(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
provider: SourceProviderKind,
|
||||
manifest_digest: Digest,
|
||||
) {
|
||||
let provider_key = format!("{provider:?}");
|
||||
self.durable.source_provider_configs.insert(
|
||||
(tenant.clone(), project.clone(), provider_key),
|
||||
SourceProviderConfigRecord {
|
||||
tenant,
|
||||
project,
|
||||
provider,
|
||||
manifest_digest,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn upsert_service_policy_record(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
name: impl Into<String>,
|
||||
digest: Digest,
|
||||
) {
|
||||
let name = name.into();
|
||||
self.durable.service_policy_records.insert(
|
||||
(tenant.clone(), name.clone()),
|
||||
ServicePolicyRecord {
|
||||
tenant,
|
||||
name,
|
||||
digest,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 start_process(
|
||||
&mut self,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
id: ProcessId,
|
||||
) -> ActiveProcess {
|
||||
let process = ActiveProcess {
|
||||
id: id.clone(),
|
||||
tenant,
|
||||
project,
|
||||
connected_nodes: BTreeSet::new(),
|
||||
coordinator_epoch: self.coordinator_epoch,
|
||||
};
|
||||
self.active_processes.insert(id, process.clone());
|
||||
process
|
||||
}
|
||||
|
||||
pub fn authorize_node_for_process(
|
||||
&self,
|
||||
node: &NodeId,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
process: &ProcessId,
|
||||
) -> Result<(), CoordinatorError> {
|
||||
let identity = self
|
||||
.durable
|
||||
.node_identities
|
||||
.get(node)
|
||||
.ok_or(CoordinatorError::UnknownNode)?;
|
||||
if &identity.tenant != tenant || &identity.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node identity is outside the requested tenant/project scope".to_owned(),
|
||||
));
|
||||
}
|
||||
let Some(active) = self.active_processes.get(process) else {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"virtual process is not active in coordinator memory".to_owned(),
|
||||
));
|
||||
};
|
||||
if &active.tenant != tenant || &active.project != project {
|
||||
return Err(CoordinatorError::Unauthorized(
|
||||
"node cannot claim tasks or publish artifacts outside its process scope".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reconnect_node(
|
||||
&mut self,
|
||||
node: &NodeId,
|
||||
process: Option<(&ProcessId, u64)>,
|
||||
) -> Result<(), CoordinatorError> {
|
||||
if !self.durable.node_identities.contains_key(node) {
|
||||
return Err(CoordinatorError::UnknownNode);
|
||||
}
|
||||
|
||||
if let Some((process_id, stale_epoch)) = process {
|
||||
if stale_epoch != self.coordinator_epoch {
|
||||
return Err(CoordinatorError::StaleProcessEpoch {
|
||||
stale_epoch,
|
||||
current_epoch: self.coordinator_epoch,
|
||||
});
|
||||
}
|
||||
if let Some(active) = self.active_processes.get_mut(process_id) {
|
||||
active.connected_nodes.insert(node.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
|
||||
self.durable
|
||||
.projects
|
||||
.values()
|
||||
.filter(|project| project.tenant == context.tenant)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn authorize_debug_attach(
|
||||
&self,
|
||||
context: &AuthContext,
|
||||
process: &ProcessId,
|
||||
) -> Authorization {
|
||||
let Some(active) = self.active_processes.get(process) else {
|
||||
return Authorization::deny("virtual process is not active");
|
||||
};
|
||||
if active.tenant != context.tenant || active.project != context.project {
|
||||
return Authorization::deny("tenant or project mismatch");
|
||||
}
|
||||
let Actor::User(user) = &context.actor else {
|
||||
return Authorization::deny("debug attach requires a user identity");
|
||||
};
|
||||
let permission = self.durable.project_permissions.get(&(
|
||||
active.tenant.clone(),
|
||||
active.project.clone(),
|
||||
user.clone(),
|
||||
));
|
||||
if !permission.is_some_and(|permission| permission.can_debug) {
|
||||
return Authorization::deny("debug attach requires explicit project permission");
|
||||
}
|
||||
Authorization::allow("debug attach authorized for project")
|
||||
}
|
||||
|
||||
pub fn project(&self, id: &ProjectId) -> Option<&ProjectRecord> {
|
||||
self.durable.projects.get(id)
|
||||
}
|
||||
|
||||
pub fn active_process(&self, id: &ProcessId) -> Option<&ActiveProcess> {
|
||||
self.active_processes.get(id)
|
||||
}
|
||||
|
||||
pub fn active_process_count(&self) -> usize {
|
||||
self.active_processes.len()
|
||||
}
|
||||
|
||||
pub fn node_identity(&self, id: &NodeId) -> Option<&NodeIdentityRecord> {
|
||||
self.durable.node_identities.get(id)
|
||||
}
|
||||
|
||||
pub fn source_provider_config(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
provider: &str,
|
||||
) -> Option<&SourceProviderConfigRecord> {
|
||||
self.durable.source_provider_configs.get(&(
|
||||
tenant.clone(),
|
||||
project.clone(),
|
||||
provider.to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn service_policy_record(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
name: &str,
|
||||
) -> Option<&ServicePolicyRecord> {
|
||||
self.durable
|
||||
.service_policy_records
|
||||
.get(&(tenant.clone(), name.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coordinator_restart_preserves_project_but_not_live_processes() {
|
||||
let mut store = InMemoryDurableStore::default();
|
||||
let mut first = Coordinator::boot(&store, 1);
|
||||
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.upsert_source_provider_config(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
SourceProviderKind::Git,
|
||||
Digest::sha256("git-manifest"),
|
||||
);
|
||||
first.upsert_service_policy_record(
|
||||
TenantId::from("tenant"),
|
||||
"community tier",
|
||||
Digest::sha256("policy"),
|
||||
);
|
||||
let mut grant = first.create_node_enrollment_grant(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
"grant",
|
||||
"node:attach",
|
||||
100,
|
||||
);
|
||||
first
|
||||
.exchange_node_enrollment_grant(
|
||||
&mut grant,
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
99,
|
||||
)
|
||||
.unwrap();
|
||||
first.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
);
|
||||
first.persist(&mut store);
|
||||
|
||||
let mut restarted = Coordinator::boot(&store, 2);
|
||||
|
||||
assert!(restarted
|
||||
.durable
|
||||
.tenants
|
||||
.contains_key(&TenantId::from("tenant")));
|
||||
assert!(restarted.durable.users.contains_key(&UserId::from("user")));
|
||||
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||
assert_eq!(
|
||||
restarted
|
||||
.durable
|
||||
.credentials
|
||||
.get("node:node")
|
||||
.map(|credential| &credential.kind),
|
||||
Some(&CredentialKind::NodeCredential)
|
||||
);
|
||||
assert!(restarted
|
||||
.source_provider_config(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("project"),
|
||||
"Git"
|
||||
)
|
||||
.is_some());
|
||||
assert!(restarted
|
||||
.service_policy_record(&TenantId::from("tenant"), "community tier")
|
||||
.is_some());
|
||||
assert_eq!(restarted.active_process_count(), 0);
|
||||
|
||||
let process = ProcessId::from("process-rerun");
|
||||
let rerun = restarted.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
process.clone(),
|
||||
);
|
||||
assert_eq!(rerun.coordinator_epoch, 2);
|
||||
restarted
|
||||
.reconnect_node(&NodeId::from("node"), Some((&process, 2)))
|
||||
.unwrap();
|
||||
assert!(restarted
|
||||
.active_process(&process)
|
||||
.unwrap()
|
||||
.connected_nodes
|
||||
.contains(&NodeId::from("node")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_reconnect_rejects_stale_process_epoch_after_restart() {
|
||||
let mut store = InMemoryDurableStore::default();
|
||||
let mut first = Coordinator::boot(&store, 1);
|
||||
first.upsert_tenant(TenantId::from("tenant"));
|
||||
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",
|
||||
);
|
||||
first.persist(&mut store);
|
||||
|
||||
let mut restarted = Coordinator::boot(&store, 2);
|
||||
restarted
|
||||
.reconnect_node(&NodeId::from("node"), None)
|
||||
.unwrap();
|
||||
|
||||
let error = restarted
|
||||
.reconnect_node(
|
||||
&NodeId::from("node"),
|
||||
Some((&ProcessId::from("process"), 1)),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, CoordinatorError::StaleProcessEpoch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_enrollment_grant_becomes_persistent_node_identity() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
let mut grant = coordinator.create_node_enrollment_grant(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
"grant",
|
||||
"node:attach",
|
||||
100,
|
||||
);
|
||||
|
||||
let credential = coordinator
|
||||
.exchange_node_enrollment_grant(
|
||||
&mut grant,
|
||||
NodeId::from("node"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
99,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
||||
assert!(coordinator.node_identity(&NodeId::from("node")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_listing_is_filtered_by_tenant() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.upsert_project(
|
||||
TenantId::from("tenant-a"),
|
||||
ProjectId::from("project-a"),
|
||||
"a",
|
||||
);
|
||||
coordinator.upsert_project(
|
||||
TenantId::from("tenant-b"),
|
||||
ProjectId::from("project-b"),
|
||||
"b",
|
||||
);
|
||||
|
||||
let projects = coordinator.list_projects(&AuthContext {
|
||||
tenant: TenantId::from("tenant-a"),
|
||||
project: ProjectId::from("project-a"),
|
||||
actor: Actor::User(UserId::from("user-a")),
|
||||
});
|
||||
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_cannot_claim_process_outside_authorized_scope() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.enroll_node(
|
||||
TenantId::from("tenant-a"),
|
||||
ProjectId::from("project-a"),
|
||||
NodeId::from("node-a"),
|
||||
"public-key",
|
||||
"node",
|
||||
);
|
||||
coordinator.start_process(
|
||||
TenantId::from("tenant-b"),
|
||||
ProjectId::from("project-b"),
|
||||
ProcessId::from("process-b"),
|
||||
);
|
||||
|
||||
let error = coordinator
|
||||
.authorize_node_for_process(
|
||||
&NodeId::from("node-a"),
|
||||
&TenantId::from("tenant-b"),
|
||||
&ProjectId::from("project-b"),
|
||||
&ProcessId::from("process-b"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, CoordinatorError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_attach_requires_explicit_project_permission() {
|
||||
let store = InMemoryDurableStore::default();
|
||||
let mut coordinator = Coordinator::boot(&store, 1);
|
||||
coordinator.start_process(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
);
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let denied = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||
coordinator.grant_project_debug(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
UserId::from("user"),
|
||||
);
|
||||
let allowed = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||
|
||||
assert!(!denied.allowed);
|
||||
assert!(denied.reason.contains("explicit project permission"));
|
||||
assert!(allowed.allowed);
|
||||
}
|
||||
}
|
||||
22
crates/disasmer-coordinator/src/main.rs
Normal file
22
crates/disasmer-coordinator/src/main.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use std::io::Write;
|
||||
|
||||
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut listen = "127.0.0.1:0".to_owned();
|
||||
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")?;
|
||||
}
|
||||
}
|
||||
|
||||
let (listener, addr) = bind_listener(&listen)?;
|
||||
println!("{}", json!({ "listen": addr.to_string() }));
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let service = CoordinatorService::new(1);
|
||||
service.serve_tcp(listener)?;
|
||||
Ok(())
|
||||
}
|
||||
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
use postgres::{Client, NoTls};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
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_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::<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_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 ((_, _, 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_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(), 8);
|
||||
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_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(),
|
||||
},
|
||||
);
|
||||
store.save_state(&state).unwrap();
|
||||
let loaded = store.load_state().unwrap();
|
||||
|
||||
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
|
||||
}
|
||||
}
|
||||
3591
crates/disasmer-coordinator/src/service.rs
Normal file
3591
crates/disasmer-coordinator/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
16
crates/disasmer-core/Cargo.toml
Normal file
16
crates/disasmer-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "disasmer-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
hex.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
939
crates/disasmer-core/src/artifact.rs
Normal file
939
crates/disasmer-core/src/artifact.rs
Normal file
|
|
@ -0,0 +1,939 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
auth::same_tenant_project, Actor, ArtifactId, AuthContext, Digest, LimitError, LimitKind,
|
||||
NodeId, ProcessId, ProjectId, ResourceLimits, ResourceMeter, Scope, TaskId, TenantId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StorageLocation {
|
||||
RetainedNode(NodeId),
|
||||
ExplicitStore(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RetentionPolicy {
|
||||
pub best_effort_node_retention: bool,
|
||||
pub max_download_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for RetentionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
best_effort_node_retention: true,
|
||||
max_download_bytes: 256 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadPolicy {
|
||||
pub max_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadAction {
|
||||
pub artifact: ArtifactId,
|
||||
pub source: StorageLocation,
|
||||
pub scoped_token_subject: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DownloadLink {
|
||||
pub artifact: ArtifactId,
|
||||
pub source: StorageLocation,
|
||||
pub url_path: String,
|
||||
pub scoped_token_digest: Digest,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub actor: Actor,
|
||||
pub max_bytes: u64,
|
||||
pub policy_context_digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IssuedDownloadLink {
|
||||
pub link: DownloadLink,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ArtifactDownloadStream {
|
||||
pub link: DownloadLink,
|
||||
pub streamed_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum DownloadError {
|
||||
#[error("artifact does not exist")]
|
||||
NotFound,
|
||||
#[error("artifact is unavailable from current retention or explicit storage")]
|
||||
Unavailable,
|
||||
#[error("artifact download direct connectivity unavailable: {0}")]
|
||||
DirectConnectivityUnavailable(String),
|
||||
#[error("artifact download denied: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("artifact size {size} exceeds download limit {limit}")]
|
||||
LimitExceeded { size: u64, limit: u64 },
|
||||
#[error("download link token is invalid for this scoped artifact link")]
|
||||
InvalidToken,
|
||||
#[error("download link has expired")]
|
||||
Expired,
|
||||
#[error("download link has been revoked")]
|
||||
Revoked,
|
||||
#[error("download usage limit failed: {0}")]
|
||||
Usage(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
#[error("artifact is unavailable because node-local unsynced bytes were lost")]
|
||||
pub struct ArtifactUnavailable;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ArtifactMetadata {
|
||||
pub id: ArtifactId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub producer_task: TaskId,
|
||||
pub producer_node: NodeId,
|
||||
pub digest: Digest,
|
||||
pub size: u64,
|
||||
pub flushed_epoch: u64,
|
||||
pub retaining_nodes: BTreeSet<NodeId>,
|
||||
pub explicit_locations: Vec<String>,
|
||||
pub coordinator_has_large_bytes: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArtifactRegistry {
|
||||
artifacts: BTreeMap<ArtifactId, ArtifactMetadata>,
|
||||
issued_download_links: BTreeMap<Digest, IssuedDownloadLink>,
|
||||
next_epoch: u64,
|
||||
}
|
||||
|
||||
impl ArtifactRegistry {
|
||||
pub fn flush_metadata(
|
||||
&mut self,
|
||||
id: ArtifactId,
|
||||
tenant: TenantId,
|
||||
project: ProjectId,
|
||||
process: ProcessId,
|
||||
producer_task: TaskId,
|
||||
retaining_node: NodeId,
|
||||
digest: Digest,
|
||||
size: u64,
|
||||
) -> ArtifactMetadata {
|
||||
self.next_epoch += 1;
|
||||
let metadata = ArtifactMetadata {
|
||||
id: id.clone(),
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
producer_task,
|
||||
producer_node: retaining_node.clone(),
|
||||
digest,
|
||||
size,
|
||||
flushed_epoch: self.next_epoch,
|
||||
retaining_nodes: BTreeSet::from([retaining_node]),
|
||||
explicit_locations: Vec::new(),
|
||||
coordinator_has_large_bytes: false,
|
||||
};
|
||||
self.artifacts.insert(id, metadata.clone());
|
||||
metadata
|
||||
}
|
||||
|
||||
pub fn sync_to_explicit_store(
|
||||
&mut self,
|
||||
artifact: &ArtifactId,
|
||||
location: impl Into<String>,
|
||||
) -> Result<(), ArtifactUnavailable> {
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get_mut(artifact)
|
||||
.ok_or(ArtifactUnavailable)?;
|
||||
metadata.explicit_locations.push(location.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn garbage_collect_node(&mut self, node: &NodeId) {
|
||||
for metadata in self.artifacts.values_mut() {
|
||||
metadata.retaining_nodes.remove(node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(&self, artifact: &ArtifactId) -> Option<&ArtifactMetadata> {
|
||||
self.artifacts.get(artifact)
|
||||
}
|
||||
|
||||
pub fn download_action(
|
||||
&self,
|
||||
context: &AuthContext,
|
||||
artifact: &ArtifactId,
|
||||
policy: &DownloadPolicy,
|
||||
) -> Result<DownloadAction, DownloadError> {
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get(artifact)
|
||||
.ok_or(DownloadError::NotFound)?;
|
||||
let scope = Scope {
|
||||
tenant: metadata.tenant.clone(),
|
||||
project: metadata.project.clone(),
|
||||
process: Some(metadata.process.clone()),
|
||||
task: Some(metadata.producer_task.clone()),
|
||||
node: None,
|
||||
artifact: Some(metadata.id.clone()),
|
||||
};
|
||||
let authz = same_tenant_project(context, &scope);
|
||||
if !authz.allowed {
|
||||
return Err(DownloadError::Unauthorized(authz.reason));
|
||||
}
|
||||
if metadata.size > policy.max_bytes {
|
||||
return Err(DownloadError::LimitExceeded {
|
||||
size: metadata.size,
|
||||
limit: policy.max_bytes,
|
||||
});
|
||||
}
|
||||
let source = metadata
|
||||
.retaining_nodes
|
||||
.iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.map(StorageLocation::RetainedNode)
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.explicit_locations
|
||||
.first()
|
||||
.cloned()
|
||||
.map(StorageLocation::ExplicitStore)
|
||||
})
|
||||
.ok_or(DownloadError::Unavailable)?;
|
||||
|
||||
Ok(DownloadAction {
|
||||
artifact: artifact.clone(),
|
||||
source,
|
||||
scoped_token_subject: format!(
|
||||
"{}/{}/{}/{}",
|
||||
metadata.tenant, metadata.project, metadata.process, metadata.id
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn downloadable_size(
|
||||
&self,
|
||||
context: &AuthContext,
|
||||
artifact: &ArtifactId,
|
||||
policy: &DownloadPolicy,
|
||||
) -> Result<u64, DownloadError> {
|
||||
self.download_action(context, artifact, policy)?;
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get(artifact)
|
||||
.ok_or(DownloadError::NotFound)?;
|
||||
Ok(metadata.size)
|
||||
}
|
||||
|
||||
pub fn create_download_link(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
artifact: &ArtifactId,
|
||||
policy: &DownloadPolicy,
|
||||
token_nonce: &str,
|
||||
now_epoch_seconds: u64,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<DownloadLink, DownloadError> {
|
||||
let action = self.download_action(context, artifact, policy)?;
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get(artifact)
|
||||
.ok_or(DownloadError::NotFound)?;
|
||||
let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
|
||||
let policy_context_digest =
|
||||
download_policy_context_digest(metadata, &action.source, policy);
|
||||
let scoped_token_digest = Digest::from_parts([
|
||||
b"artifact-download-token:v2".as_slice(),
|
||||
action.scoped_token_subject.as_bytes(),
|
||||
actor_subject(&context.actor).as_bytes(),
|
||||
token_nonce.as_bytes(),
|
||||
metadata.digest.as_str().as_bytes(),
|
||||
metadata.size.to_string().as_bytes(),
|
||||
policy_context_digest.as_str().as_bytes(),
|
||||
expires_at_epoch_seconds.to_string().as_bytes(),
|
||||
]);
|
||||
let link = DownloadLink {
|
||||
artifact: artifact.clone(),
|
||||
source: action.source,
|
||||
url_path: format!(
|
||||
"/artifacts/{}/{}/{}/{}",
|
||||
metadata.tenant, metadata.project, metadata.process, metadata.id
|
||||
),
|
||||
scoped_token_digest,
|
||||
expires_at_epoch_seconds,
|
||||
tenant: metadata.tenant.clone(),
|
||||
project: metadata.project.clone(),
|
||||
process: metadata.process.clone(),
|
||||
actor: context.actor.clone(),
|
||||
max_bytes: policy.max_bytes,
|
||||
policy_context_digest,
|
||||
};
|
||||
self.issued_download_links.insert(
|
||||
link.scoped_token_digest.clone(),
|
||||
IssuedDownloadLink {
|
||||
link: link.clone(),
|
||||
revoked: false,
|
||||
},
|
||||
);
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
pub fn revoke_download_link(
|
||||
&mut self,
|
||||
context: &AuthContext,
|
||||
artifact: &ArtifactId,
|
||||
presented_token_digest: &Digest,
|
||||
) -> Result<DownloadLink, DownloadError> {
|
||||
let issued = self
|
||||
.issued_download_links
|
||||
.get(presented_token_digest)
|
||||
.ok_or(DownloadError::InvalidToken)?;
|
||||
if issued.link.artifact != *artifact || issued.link.actor != context.actor {
|
||||
return Err(DownloadError::InvalidToken);
|
||||
}
|
||||
self.download_action(
|
||||
context,
|
||||
artifact,
|
||||
&DownloadPolicy {
|
||||
max_bytes: issued.link.max_bytes,
|
||||
},
|
||||
)?;
|
||||
|
||||
let issued = self
|
||||
.issued_download_links
|
||||
.get_mut(presented_token_digest)
|
||||
.ok_or(DownloadError::InvalidToken)?;
|
||||
issued.revoked = true;
|
||||
Ok(issued.link.clone())
|
||||
}
|
||||
|
||||
pub fn open_download_stream(
|
||||
&self,
|
||||
context: &AuthContext,
|
||||
artifact: &ArtifactId,
|
||||
policy: &DownloadPolicy,
|
||||
presented_token_digest: &Digest,
|
||||
now_epoch_seconds: u64,
|
||||
limits: &ResourceLimits,
|
||||
meter: &mut ResourceMeter,
|
||||
) -> Result<ArtifactDownloadStream, DownloadError> {
|
||||
let issued = self
|
||||
.issued_download_links
|
||||
.get(presented_token_digest)
|
||||
.ok_or(DownloadError::InvalidToken)?;
|
||||
if issued.link.artifact != *artifact
|
||||
|| issued.link.max_bytes != policy.max_bytes
|
||||
|| issued.link.actor != context.actor
|
||||
{
|
||||
return Err(DownloadError::InvalidToken);
|
||||
}
|
||||
if issued.revoked {
|
||||
return Err(DownloadError::Revoked);
|
||||
}
|
||||
if now_epoch_seconds > issued.link.expires_at_epoch_seconds {
|
||||
return Err(DownloadError::Expired);
|
||||
}
|
||||
let action = self.download_action(context, artifact, policy)?;
|
||||
if action.source != issued.link.source {
|
||||
return Err(DownloadError::Unavailable);
|
||||
}
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get(artifact)
|
||||
.ok_or(DownloadError::NotFound)?;
|
||||
if download_policy_context_digest(metadata, &action.source, policy)
|
||||
!= issued.link.policy_context_digest
|
||||
{
|
||||
return Err(DownloadError::InvalidToken);
|
||||
}
|
||||
meter
|
||||
.charge(limits, LimitKind::ArtifactDownloadBytes, 0)
|
||||
.map_err(download_usage_error)?;
|
||||
Ok(ArtifactDownloadStream {
|
||||
link: issued.link.clone(),
|
||||
streamed_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_download_chunk(
|
||||
&self,
|
||||
stream: &mut ArtifactDownloadStream,
|
||||
limits: &ResourceLimits,
|
||||
meter: &mut ResourceMeter,
|
||||
bytes: u64,
|
||||
) -> Result<(), DownloadError> {
|
||||
let metadata = self
|
||||
.artifacts
|
||||
.get(&stream.link.artifact)
|
||||
.ok_or(DownloadError::NotFound)?;
|
||||
if !source_is_available(metadata, &stream.link.source) {
|
||||
return Err(DownloadError::Unavailable);
|
||||
}
|
||||
stream.stream_chunk(limits, meter, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactDownloadStream {
|
||||
pub fn stream_chunk(
|
||||
&mut self,
|
||||
limits: &ResourceLimits,
|
||||
meter: &mut ResourceMeter,
|
||||
bytes: u64,
|
||||
) -> Result<(), DownloadError> {
|
||||
if self.streamed_bytes.saturating_add(bytes) > self.link.max_bytes {
|
||||
return Err(DownloadError::LimitExceeded {
|
||||
size: self.streamed_bytes.saturating_add(bytes),
|
||||
limit: self.link.max_bytes,
|
||||
});
|
||||
}
|
||||
meter
|
||||
.charge(limits, LimitKind::ArtifactDownloadBytes, bytes)
|
||||
.map_err(download_usage_error)?;
|
||||
self.streamed_bytes += bytes;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn download_usage_error(error: LimitError) -> DownloadError {
|
||||
DownloadError::Usage(error.to_string())
|
||||
}
|
||||
|
||||
fn download_policy_context_digest(
|
||||
metadata: &ArtifactMetadata,
|
||||
source: &StorageLocation,
|
||||
policy: &DownloadPolicy,
|
||||
) -> Digest {
|
||||
Digest::from_parts([
|
||||
b"artifact-download-policy-context:v1".as_slice(),
|
||||
metadata.tenant.as_str().as_bytes(),
|
||||
metadata.project.as_str().as_bytes(),
|
||||
metadata.process.as_str().as_bytes(),
|
||||
metadata.id.as_str().as_bytes(),
|
||||
metadata.digest.as_str().as_bytes(),
|
||||
metadata.size.to_string().as_bytes(),
|
||||
storage_location_key(source).as_bytes(),
|
||||
policy.max_bytes.to_string().as_bytes(),
|
||||
])
|
||||
}
|
||||
|
||||
fn actor_subject(actor: &Actor) -> String {
|
||||
match actor {
|
||||
Actor::User(id) => format!("user:{id}"),
|
||||
Actor::Agent(id) => format!("agent:{id}"),
|
||||
Actor::Node(id) => format!("node:{id}"),
|
||||
Actor::Task(id) => format!("task:{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_location_key(source: &StorageLocation) -> String {
|
||||
match source {
|
||||
StorageLocation::RetainedNode(node) => format!("retained-node:{node}"),
|
||||
StorageLocation::ExplicitStore(location) => format!("explicit-store:{location}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_is_available(metadata: &ArtifactMetadata, source: &StorageLocation) -> bool {
|
||||
match source {
|
||||
StorageLocation::RetainedNode(node) => metadata.retaining_nodes.contains(node),
|
||||
StorageLocation::ExplicitStore(location) => metadata.explicit_locations.contains(location),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{Actor, LimitKind, ResourceLimits, ResourceMeter, UserId};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn registry_with_artifact() -> ArtifactRegistry {
|
||||
let mut registry = ArtifactRegistry::default();
|
||||
registry.flush_metadata(
|
||||
ArtifactId::from("artifact"),
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
TaskId::from("task"),
|
||||
NodeId::from("node"),
|
||||
Digest::sha256("bytes"),
|
||||
32,
|
||||
);
|
||||
registry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_publishes_metadata_without_coordinator_bytes() {
|
||||
let registry = registry_with_artifact();
|
||||
let metadata = registry.metadata(&ArtifactId::from("artifact")).unwrap();
|
||||
|
||||
assert!(!metadata.coordinator_has_large_bytes);
|
||||
assert_eq!(metadata.id, ArtifactId::from("artifact"));
|
||||
assert_eq!(metadata.tenant, TenantId::from("tenant"));
|
||||
assert_eq!(metadata.project, ProjectId::from("project"));
|
||||
assert_eq!(metadata.process, ProcessId::from("process"));
|
||||
assert_eq!(metadata.producer_task, TaskId::from("task"));
|
||||
assert_eq!(metadata.producer_node, NodeId::from("node"));
|
||||
assert_eq!(metadata.digest, Digest::sha256("bytes"));
|
||||
assert_eq!(metadata.size, 32);
|
||||
assert_eq!(metadata.flushed_epoch, 1);
|
||||
assert!(metadata.retaining_nodes.contains(&NodeId::from("node")));
|
||||
assert!(metadata.explicit_locations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsynced_node_loss_surfaces_as_unavailable() {
|
||||
let mut registry = registry_with_artifact();
|
||||
registry.garbage_collect_node(&NodeId::from("node"));
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let error = registry
|
||||
.download_action(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, DownloadError::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_user_storage_location_survives_node_retention_loss() {
|
||||
let mut registry = registry_with_artifact();
|
||||
registry
|
||||
.sync_to_explicit_store(&ArtifactId::from("artifact"), "s3://bucket/app")
|
||||
.unwrap();
|
||||
registry.garbage_collect_node(&NodeId::from("node"));
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let action = registry
|
||||
.download_action(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
action.source,
|
||||
StorageLocation::ExplicitStore("s3://bucket/app".to_owned())
|
||||
);
|
||||
assert!(
|
||||
!registry
|
||||
.metadata(&ArtifactId::from("artifact"))
|
||||
.unwrap()
|
||||
.coordinator_has_large_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_retention_policy_is_best_effort_node_retention() {
|
||||
let policy = RetentionPolicy::default();
|
||||
|
||||
assert!(policy.best_effort_node_retention);
|
||||
assert_eq!(policy.max_download_bytes, 256 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_tenant_download_is_denied_even_with_known_artifact_id() {
|
||||
let registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("other"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let error = registry
|
||||
.download_action(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, DownloadError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_project_download_is_denied_even_with_known_artifact_id() {
|
||||
let registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("other-project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
let error = registry
|
||||
.download_action(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, DownloadError::Unauthorized(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_link_is_not_created_when_artifact_is_unavailable_or_too_large() {
|
||||
let mut registry = registry_with_artifact();
|
||||
registry.garbage_collect_node(&NodeId::from("node"));
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
"nonce",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap_err(),
|
||||
DownloadError::Unavailable
|
||||
);
|
||||
|
||||
let mut registry = registry_with_artifact();
|
||||
assert!(matches!(
|
||||
registry.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 1 },
|
||||
"nonce",
|
||||
10,
|
||||
60,
|
||||
),
|
||||
Err(DownloadError::LimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_link_is_authenticated_scoped_and_not_guessable() {
|
||||
let mut registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let link = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
"nonce-a",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let other = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
"nonce-b",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(link.tenant, TenantId::from("tenant"));
|
||||
assert_eq!(link.project, ProjectId::from("project"));
|
||||
assert_eq!(link.process, ProcessId::from("process"));
|
||||
assert_eq!(link.actor, Actor::User(UserId::from("user")));
|
||||
assert_eq!(link.max_bytes, 100);
|
||||
assert!(link.policy_context_digest.is_valid_sha256());
|
||||
assert_eq!(link.expires_at_epoch_seconds, 70);
|
||||
assert!(link
|
||||
.url_path
|
||||
.contains("/artifacts/tenant/project/process/artifact"));
|
||||
assert_ne!(link.scoped_token_digest, other.scoped_token_digest);
|
||||
assert!(matches!(link.source, StorageLocation::RetainedNode(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_link_is_bound_to_actor_and_policy_context() {
|
||||
let mut registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let other_actor = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("other-user")),
|
||||
};
|
||||
let policy = DownloadPolicy { max_bytes: 100 };
|
||||
let link = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
"nonce",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
|
||||
assert_eq!(
|
||||
registry
|
||||
.open_download_stream(
|
||||
&other_actor,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
&link.scoped_token_digest,
|
||||
11,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap_err(),
|
||||
DownloadError::InvalidToken
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 99 },
|
||||
&link.scoped_token_digest,
|
||||
11,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap_err(),
|
||||
DownloadError::InvalidToken
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.revoke_download_link(
|
||||
&other_actor,
|
||||
&ArtifactId::from("artifact"),
|
||||
&link.scoped_token_digest,
|
||||
)
|
||||
.unwrap_err(),
|
||||
DownloadError::InvalidToken
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_link_expires_and_can_be_revoked() {
|
||||
let mut registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let policy = DownloadPolicy { max_bytes: 100 };
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
let expired = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
"expired",
|
||||
10,
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
&expired.scoped_token_digest,
|
||||
16,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error, DownloadError::Expired);
|
||||
|
||||
let active = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
"active",
|
||||
20,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let revoked = registry
|
||||
.revoke_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&active.scoped_token_digest,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(revoked.scoped_token_digest, active.scoped_token_digest);
|
||||
|
||||
let error = registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
&active.scoped_token_digest,
|
||||
21,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error, DownloadError::Revoked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_stream_accounts_usage_before_and_during_streaming() {
|
||||
let mut registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let policy = DownloadPolicy { max_bytes: 100 };
|
||||
let link = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
"nonce",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
let mut stream = registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
&link.scoped_token_digest,
|
||||
11,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
stream.stream_chunk(&limits, &mut meter, 16).unwrap();
|
||||
stream.stream_chunk(&limits, &mut meter, 16).unwrap();
|
||||
assert!(matches!(
|
||||
stream.stream_chunk(&limits, &mut meter, 1),
|
||||
Err(DownloadError::Usage(_))
|
||||
));
|
||||
assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_stream_fails_honestly_when_source_disappears_mid_stream() {
|
||||
let mut registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let policy = DownloadPolicy { max_bytes: 100 };
|
||||
let link = registry
|
||||
.create_download_link(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
"nonce",
|
||||
10,
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
let mut stream = registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&policy,
|
||||
&link.scoped_token_digest,
|
||||
11,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
registry
|
||||
.stream_download_chunk(&mut stream, &limits, &mut meter, 16)
|
||||
.unwrap();
|
||||
registry.garbage_collect_node(&NodeId::from("node"));
|
||||
|
||||
assert_eq!(
|
||||
registry
|
||||
.stream_download_chunk(&mut stream, &limits, &mut meter, 1)
|
||||
.unwrap_err(),
|
||||
DownloadError::Unavailable
|
||||
);
|
||||
assert_eq!(stream.streamed_bytes, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guessed_download_token_is_rejected() {
|
||||
let registry = registry_with_artifact();
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
|
||||
let error = registry
|
||||
.open_download_stream(
|
||||
&context,
|
||||
&ArtifactId::from("artifact"),
|
||||
&DownloadPolicy { max_bytes: 100 },
|
||||
&Digest::sha256("guessed"),
|
||||
11,
|
||||
&limits,
|
||||
&mut meter,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, DownloadError::InvalidToken);
|
||||
}
|
||||
}
|
||||
369
crates/disasmer-core/src/auth.rs
Normal file
369
crates/disasmer-core/src/auth.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Actor {
|
||||
User(UserId),
|
||||
Agent(AgentId),
|
||||
Node(NodeId),
|
||||
Task(TaskId),
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn kind(&self) -> IdentityKind {
|
||||
match self {
|
||||
Self::User(_) => IdentityKind::User,
|
||||
Self::Agent(_) => IdentityKind::Agent,
|
||||
Self::Node(_) => IdentityKind::Node,
|
||||
Self::Task(_) => IdentityKind::Task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum IdentityKind {
|
||||
User,
|
||||
Agent,
|
||||
Node,
|
||||
Project,
|
||||
Task,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CredentialKind {
|
||||
BrowserSession,
|
||||
CliDeviceSession,
|
||||
PublicKey,
|
||||
NodeCredential,
|
||||
TaskCredential,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthContext {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub actor: Actor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
CreateProject,
|
||||
AttachNode,
|
||||
CreateNodeEnrollmentGrant,
|
||||
ExchangeNodeEnrollmentGrant,
|
||||
LoginBrowser,
|
||||
LoginCli,
|
||||
EnrollAgent,
|
||||
List,
|
||||
Inspect,
|
||||
Mutate,
|
||||
ClaimTask,
|
||||
DebugAttach,
|
||||
DebugRead,
|
||||
DownloadArtifact,
|
||||
PublishArtifact,
|
||||
RunNativeCommand,
|
||||
RunContainer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BrowserLoginFlow {
|
||||
pub authorization_url: String,
|
||||
pub callback_path: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CliLoginFlow {
|
||||
pub verification_url: String,
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
pub expires_in_seconds: u64,
|
||||
pub yields_long_lived_secret_directly: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublicKeyIdentity {
|
||||
pub subject: Actor,
|
||||
pub public_key: String,
|
||||
pub fingerprint: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnrollmentGrant {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub grant_id: String,
|
||||
pub scope: String,
|
||||
pub expires_at_epoch_seconds: u64,
|
||||
pub consumed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeCredential {
|
||||
pub node: NodeId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub public_key_fingerprint: Digest,
|
||||
pub scope: String,
|
||||
pub capability_policy_digest: Digest,
|
||||
pub credential_kind: CredentialKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EnrollmentError {
|
||||
Expired,
|
||||
AlreadyConsumed,
|
||||
WrongScope,
|
||||
}
|
||||
|
||||
impl EnrollmentGrant {
|
||||
pub fn exchange_for_node_identity(
|
||||
&mut self,
|
||||
node: NodeId,
|
||||
public_key: &str,
|
||||
requested_scope: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<NodeCredential, EnrollmentError> {
|
||||
if self.consumed {
|
||||
return Err(EnrollmentError::AlreadyConsumed);
|
||||
}
|
||||
if now_epoch_seconds > self.expires_at_epoch_seconds {
|
||||
return Err(EnrollmentError::Expired);
|
||||
}
|
||||
if requested_scope != self.scope {
|
||||
return Err(EnrollmentError::WrongScope);
|
||||
}
|
||||
self.consumed = true;
|
||||
let capability_policy_digest =
|
||||
node_capability_policy_digest(&self.tenant, &self.project, &self.scope);
|
||||
Ok(NodeCredential {
|
||||
node,
|
||||
tenant: self.tenant.clone(),
|
||||
project: self.project.clone(),
|
||||
public_key_fingerprint: Digest::sha256(public_key),
|
||||
scope: self.scope.clone(),
|
||||
capability_policy_digest,
|
||||
credential_kind: CredentialKind::NodeCredential,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn node_capability_policy_digest(
|
||||
tenant: &TenantId,
|
||||
project: &ProjectId,
|
||||
scope: &str,
|
||||
) -> Digest {
|
||||
Digest::from_parts([
|
||||
b"node-capability-policy:v1".as_slice(),
|
||||
tenant.as_str().as_bytes(),
|
||||
project.as_str().as_bytes(),
|
||||
scope.as_bytes(),
|
||||
])
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Scope {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: Option<ProcessId>,
|
||||
pub task: Option<TaskId>,
|
||||
pub node: Option<NodeId>,
|
||||
pub artifact: Option<ArtifactId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Authorization {
|
||||
pub allowed: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Authorization {
|
||||
pub fn allow(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deny(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization {
|
||||
if context.tenant != scope.tenant {
|
||||
return Authorization::deny("tenant mismatch");
|
||||
}
|
||||
if context.project != scope.project {
|
||||
return Authorization::deny("project mismatch");
|
||||
}
|
||||
Authorization::allow("same tenant and project")
|
||||
}
|
||||
|
||||
pub fn task_credentials_do_not_contain_user_session(
|
||||
task: &Actor,
|
||||
credentials: &[CredentialKind],
|
||||
) -> Authorization {
|
||||
if !matches!(task, Actor::Task(_)) {
|
||||
return Authorization::deny("credential check requires task actor");
|
||||
}
|
||||
if credentials.iter().any(|credential| {
|
||||
matches!(
|
||||
credential,
|
||||
CredentialKind::BrowserSession | CredentialKind::CliDeviceSession
|
||||
)
|
||||
}) {
|
||||
return Authorization::deny(
|
||||
"user OAuth/session tokens must not be passed to nodes as task credentials",
|
||||
);
|
||||
}
|
||||
Authorization::allow("task credentials are scoped runtime credentials")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tenant_project_scope_denies_cross_tenant_access() {
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant-a"),
|
||||
project: ProjectId::from("project-a"),
|
||||
actor: Actor::User(UserId::from("user-a")),
|
||||
};
|
||||
let scope = Scope {
|
||||
tenant: TenantId::from("tenant-b"),
|
||||
project: ProjectId::from("project-a"),
|
||||
process: None,
|
||||
task: None,
|
||||
node: None,
|
||||
artifact: None,
|
||||
};
|
||||
|
||||
assert!(!same_tenant_project(&context, &scope).allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_enrollment_exchanges_short_lived_grant_once() {
|
||||
let mut grant = EnrollmentGrant {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
grant_id: "grant".to_owned(),
|
||||
scope: "node:attach".to_owned(),
|
||||
expires_at_epoch_seconds: 100,
|
||||
consumed: false,
|
||||
};
|
||||
|
||||
let credential = grant
|
||||
.exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
||||
assert_eq!(credential.tenant, TenantId::from("tenant"));
|
||||
assert_eq!(credential.project, ProjectId::from("project"));
|
||||
assert_eq!(credential.node, NodeId::from("node"));
|
||||
assert_eq!(credential.scope, "node:attach");
|
||||
assert_eq!(
|
||||
credential.capability_policy_digest,
|
||||
node_capability_policy_digest(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("project"),
|
||||
"node:attach"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
grant.exchange_for_node_identity(
|
||||
NodeId::from("node2"),
|
||||
"public-key",
|
||||
"node:attach",
|
||||
99
|
||||
),
|
||||
Err(EnrollmentError::AlreadyConsumed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_capability_policy_digest_is_scoped() {
|
||||
let base = node_capability_policy_digest(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("project"),
|
||||
"node:attach",
|
||||
);
|
||||
let other_project = node_capability_policy_digest(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("other"),
|
||||
"node:attach",
|
||||
);
|
||||
let other_scope = node_capability_policy_digest(
|
||||
&TenantId::from("tenant"),
|
||||
&ProjectId::from("project"),
|
||||
"node:limited",
|
||||
);
|
||||
|
||||
assert!(base.is_valid_sha256());
|
||||
assert_ne!(base, other_project);
|
||||
assert_ne!(base, other_scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_credentials_reject_user_session_tokens() {
|
||||
for credential in [
|
||||
CredentialKind::BrowserSession,
|
||||
CredentialKind::CliDeviceSession,
|
||||
] {
|
||||
let authz = task_credentials_do_not_contain_user_session(
|
||||
&Actor::Task(TaskId::from("task")),
|
||||
&[CredentialKind::TaskCredential, credential],
|
||||
);
|
||||
|
||||
assert!(!authz.allowed);
|
||||
assert!(authz.reason.contains("must not be passed"));
|
||||
}
|
||||
|
||||
let scoped = task_credentials_do_not_contain_user_session(
|
||||
&Actor::Task(TaskId::from("task")),
|
||||
&[
|
||||
CredentialKind::TaskCredential,
|
||||
CredentialKind::NodeCredential,
|
||||
],
|
||||
);
|
||||
assert!(scoped.allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_remain_distinct_for_authorization() {
|
||||
assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User);
|
||||
assert_eq!(
|
||||
Actor::Agent(AgentId::from("agent")).kind(),
|
||||
IdentityKind::Agent
|
||||
);
|
||||
assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node);
|
||||
assert_eq!(Actor::Task(TaskId::from("task")).kind(), IdentityKind::Task);
|
||||
|
||||
let scope = Scope {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: Some(ProcessId::from("process")),
|
||||
task: Some(TaskId::from("task")),
|
||||
node: Some(NodeId::from("node")),
|
||||
artifact: Some(ArtifactId::from("artifact")),
|
||||
};
|
||||
assert_eq!(scope.process, Some(ProcessId::from("process")));
|
||||
assert_eq!(scope.artifact, Some(ArtifactId::from("artifact")));
|
||||
|
||||
assert_ne!(
|
||||
CredentialKind::BrowserSession,
|
||||
CredentialKind::CliDeviceSession
|
||||
);
|
||||
assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential);
|
||||
assert_ne!(
|
||||
CredentialKind::NodeCredential,
|
||||
CredentialKind::TaskCredential
|
||||
);
|
||||
}
|
||||
}
|
||||
117
crates/disasmer-core/src/bundle.rs
Normal file
117
crates/disasmer-core/src/bundle.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Digest, EnvironmentResource};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SelectedInput {
|
||||
pub path: String,
|
||||
pub digest: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BundleIdentityInputs {
|
||||
pub wasm_code: Digest,
|
||||
pub task_abi: Digest,
|
||||
pub environments: Vec<EnvironmentResource>,
|
||||
pub source_provider_manifest: Digest,
|
||||
pub selected_inputs: Vec<SelectedInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BundleMetadata {
|
||||
pub identity: Digest,
|
||||
pub environments: Vec<EnvironmentResource>,
|
||||
pub selected_inputs: Vec<SelectedInput>,
|
||||
pub embeds_full_container_images: bool,
|
||||
}
|
||||
|
||||
impl BundleIdentityInputs {
|
||||
pub fn identity(&self) -> Digest {
|
||||
let mut parts = vec![
|
||||
b"bundle:v1".to_vec(),
|
||||
self.wasm_code.as_str().as_bytes().to_vec(),
|
||||
self.task_abi.as_str().as_bytes().to_vec(),
|
||||
self.source_provider_manifest.as_str().as_bytes().to_vec(),
|
||||
];
|
||||
|
||||
let mut environments = self.environments.clone();
|
||||
environments.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
for environment in environments {
|
||||
parts.push(environment.name.as_bytes().to_vec());
|
||||
parts.push(format!("{:?}", environment.kind).into_bytes());
|
||||
parts.push(environment.digest.as_str().as_bytes().to_vec());
|
||||
}
|
||||
|
||||
let mut inputs = self.selected_inputs.clone();
|
||||
inputs.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
for input in inputs {
|
||||
parts.push(input.path.into_bytes());
|
||||
parts.push(input.digest.as_str().as_bytes().to_vec());
|
||||
}
|
||||
|
||||
Digest::from_parts(parts)
|
||||
}
|
||||
|
||||
pub fn inspectable_metadata(&self) -> BundleMetadata {
|
||||
BundleMetadata {
|
||||
identity: self.identity(),
|
||||
environments: self.environments.clone(),
|
||||
selected_inputs: self.selected_inputs.clone(),
|
||||
embeds_full_container_images: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{EnvironmentKind, EnvironmentRequirements};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env(digest: &str) -> EnvironmentResource {
|
||||
EnvironmentResource {
|
||||
name: "linux".to_owned(),
|
||||
kind: EnvironmentKind::Containerfile,
|
||||
recipe_path: PathBuf::from("envs/linux/Containerfile"),
|
||||
context_path: PathBuf::from("envs/linux"),
|
||||
digest: Digest::sha256(digest),
|
||||
requirements: EnvironmentRequirements::linux_container(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_identity_changes_when_environment_recipe_changes() {
|
||||
let base = BundleIdentityInputs {
|
||||
wasm_code: Digest::sha256("wasm"),
|
||||
task_abi: Digest::sha256("abi"),
|
||||
environments: vec![env("recipe-a")],
|
||||
source_provider_manifest: Digest::sha256("source"),
|
||||
selected_inputs: vec![],
|
||||
};
|
||||
let mut changed = base.clone();
|
||||
changed.environments = vec![env("recipe-b")];
|
||||
|
||||
assert_ne!(base.identity(), changed.identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_metadata_is_inspectable_and_does_not_vendor_images_by_default() {
|
||||
let inputs = BundleIdentityInputs {
|
||||
wasm_code: Digest::sha256("wasm"),
|
||||
task_abi: Digest::sha256("abi"),
|
||||
environments: vec![env("recipe")],
|
||||
source_provider_manifest: Digest::sha256("source"),
|
||||
selected_inputs: vec![SelectedInput {
|
||||
path: "inputs/config.json".to_owned(),
|
||||
digest: Digest::sha256("config"),
|
||||
}],
|
||||
};
|
||||
|
||||
let metadata = inputs.inspectable_metadata();
|
||||
|
||||
assert_eq!(metadata.environments.len(), 1);
|
||||
assert!(!metadata.embeds_full_container_images);
|
||||
}
|
||||
}
|
||||
189
crates/disasmer-core/src/capability.rs
Normal file
189
crates/disasmer-core/src/capability.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Capability {
|
||||
Command,
|
||||
Containers,
|
||||
RootlessPodman,
|
||||
SourceFilesystem,
|
||||
SourceGit,
|
||||
HostFilesystem,
|
||||
Network,
|
||||
Secrets,
|
||||
InboundPorts,
|
||||
ArbitrarySyscalls,
|
||||
VfsArtifacts,
|
||||
Wasmtime,
|
||||
WindowsCommandDev,
|
||||
QuicDirect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EnvironmentBackend {
|
||||
Container,
|
||||
NixFlake,
|
||||
WindowsCommandDev,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Os {
|
||||
Linux,
|
||||
Windows,
|
||||
Macos,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Os {
|
||||
pub fn current() -> Self {
|
||||
match std::env::consts::OS {
|
||||
"linux" => Self::Linux,
|
||||
"windows" => Self::Windows,
|
||||
"macos" => Self::Macos,
|
||||
other => Self::Other(other.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeCapabilities {
|
||||
pub os: Os,
|
||||
pub arch: String,
|
||||
pub capabilities: BTreeSet<Capability>,
|
||||
pub environment_backends: BTreeSet<EnvironmentBackend>,
|
||||
pub source_providers: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CapabilityReportError {
|
||||
#[error("node architecture `{0}` is invalid")]
|
||||
InvalidArchitecture(String),
|
||||
#[error("node OS label `{0}` is invalid")]
|
||||
InvalidOsLabel(String),
|
||||
#[error("source provider id `{0}` is invalid")]
|
||||
InvalidSourceProvider(String),
|
||||
}
|
||||
|
||||
impl NodeCapabilities {
|
||||
pub fn detect_current() -> Self {
|
||||
let os = Os::current();
|
||||
let mut capabilities = BTreeSet::from([
|
||||
Capability::Command,
|
||||
Capability::SourceFilesystem,
|
||||
Capability::VfsArtifacts,
|
||||
Capability::Wasmtime,
|
||||
]);
|
||||
let mut environment_backends = BTreeSet::new();
|
||||
|
||||
match os {
|
||||
Os::Linux => {
|
||||
capabilities.insert(Capability::Containers);
|
||||
capabilities.insert(Capability::RootlessPodman);
|
||||
environment_backends.insert(EnvironmentBackend::Container);
|
||||
}
|
||||
Os::Windows => {
|
||||
capabilities.insert(Capability::WindowsCommandDev);
|
||||
environment_backends.insert(EnvironmentBackend::WindowsCommandDev);
|
||||
}
|
||||
Os::Macos | Os::Other(_) => {}
|
||||
}
|
||||
|
||||
Self {
|
||||
os,
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
capabilities,
|
||||
environment_backends,
|
||||
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_capability(mut self, capability: Capability) -> Self {
|
||||
self.capabilities.insert(capability);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_all(&self, required: &BTreeSet<Capability>) -> bool {
|
||||
required
|
||||
.iter()
|
||||
.all(|capability| self.capabilities.contains(capability))
|
||||
}
|
||||
|
||||
pub fn validate_public_report(&self) -> Result<(), CapabilityReportError> {
|
||||
if !valid_capability_label(&self.arch) {
|
||||
return Err(CapabilityReportError::InvalidArchitecture(
|
||||
self.arch.clone(),
|
||||
));
|
||||
}
|
||||
if let Os::Other(label) = &self.os {
|
||||
if !valid_capability_label(label) {
|
||||
return Err(CapabilityReportError::InvalidOsLabel(label.clone()));
|
||||
}
|
||||
}
|
||||
for provider in &self.source_providers {
|
||||
if !valid_source_provider_id(provider) {
|
||||
return Err(CapabilityReportError::InvalidSourceProvider(
|
||||
provider.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_capability_label(label: &str) -> bool {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 64
|
||||
&& label.bytes().all(
|
||||
|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'),
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_source_provider_id(provider: &str) -> bool {
|
||||
!provider.is_empty()
|
||||
&& provider.len() <= 64
|
||||
&& provider
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn capabilities() -> NodeCapabilities {
|
||||
NodeCapabilities {
|
||||
os: Os::Linux,
|
||||
arch: "x86_64".to_owned(),
|
||||
capabilities: BTreeSet::from([Capability::Command]),
|
||||
environment_backends: BTreeSet::new(),
|
||||
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_reports_validate_hostile_strings() {
|
||||
assert!(capabilities().validate_public_report().is_ok());
|
||||
|
||||
let mut invalid_arch = capabilities();
|
||||
invalid_arch.arch = "x86_64\nmalicious".to_owned();
|
||||
assert_eq!(
|
||||
invalid_arch.validate_public_report(),
|
||||
Err(CapabilityReportError::InvalidArchitecture(
|
||||
"x86_64\nmalicious".to_owned()
|
||||
))
|
||||
);
|
||||
|
||||
let mut invalid_provider = capabilities();
|
||||
invalid_provider
|
||||
.source_providers
|
||||
.insert("../checkout".to_owned());
|
||||
assert_eq!(
|
||||
invalid_provider.validate_public_report(),
|
||||
Err(CapabilityReportError::InvalidSourceProvider(
|
||||
"../checkout".to_owned()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
282
crates/disasmer-core/src/checkpoint.rs
Normal file
282
crates/disasmer-core/src/checkpoint.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Digest, EnvironmentResource, TaskId, VfsManifest};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CheckpointBoundary {
|
||||
pub task_entrypoint: String,
|
||||
pub serialized_args: Digest,
|
||||
pub environment_digest: Digest,
|
||||
pub vfs_epoch: u64,
|
||||
pub task_abi: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskCheckpoint {
|
||||
pub task: TaskId,
|
||||
pub boundary: CheckpointBoundary,
|
||||
pub vfs_manifest: VfsManifest,
|
||||
pub depends_on_live_stack: bool,
|
||||
pub depends_on_live_socket: bool,
|
||||
pub depends_on_ephemeral_artifact_durability: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RestartRequest {
|
||||
pub task: TaskId,
|
||||
pub entrypoint: String,
|
||||
pub serialized_args: Digest,
|
||||
pub environment: EnvironmentResource,
|
||||
pub task_abi: Digest,
|
||||
pub source_edited: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RestartDecision {
|
||||
RestartTask {
|
||||
task: TaskId,
|
||||
from_vfs_epoch: u64,
|
||||
discard_unflushed_changes: bool,
|
||||
},
|
||||
RestartWholeVirtualProcess {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RestartPolicy;
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CompatibilityFailure {
|
||||
#[error("task entrypoint changed")]
|
||||
Entrypoint,
|
||||
#[error("serialized task arguments changed")]
|
||||
Args,
|
||||
#[error("environment digest changed")]
|
||||
Environment,
|
||||
#[error("task ABI changed")]
|
||||
TaskAbi,
|
||||
#[error("checkpoint depends on unsupported live stack migration")]
|
||||
LiveStack,
|
||||
#[error("checkpoint depends on unsupported live socket checkpointing")]
|
||||
LiveSocket,
|
||||
#[error("checkpoint incorrectly treats ephemeral artifacts as durable")]
|
||||
EphemeralArtifactDurability,
|
||||
}
|
||||
|
||||
impl RestartPolicy {
|
||||
pub fn decide(&self, checkpoint: &TaskCheckpoint, request: &RestartRequest) -> RestartDecision {
|
||||
match compatibility_failure(checkpoint, request) {
|
||||
None => RestartDecision::RestartTask {
|
||||
task: checkpoint.task.clone(),
|
||||
from_vfs_epoch: checkpoint.boundary.vfs_epoch,
|
||||
discard_unflushed_changes: true,
|
||||
},
|
||||
Some(failure) => RestartDecision::RestartWholeVirtualProcess {
|
||||
message: format!(
|
||||
"cannot restart selected task `{}` from checkpoint: {failure}; restart the whole virtual process",
|
||||
checkpoint.task
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compatibility_failure(
|
||||
checkpoint: &TaskCheckpoint,
|
||||
request: &RestartRequest,
|
||||
) -> Option<CompatibilityFailure> {
|
||||
if checkpoint.depends_on_live_stack {
|
||||
return Some(CompatibilityFailure::LiveStack);
|
||||
}
|
||||
if checkpoint.depends_on_live_socket {
|
||||
return Some(CompatibilityFailure::LiveSocket);
|
||||
}
|
||||
if checkpoint.depends_on_ephemeral_artifact_durability {
|
||||
return Some(CompatibilityFailure::EphemeralArtifactDurability);
|
||||
}
|
||||
if checkpoint.boundary.task_entrypoint != request.entrypoint {
|
||||
return Some(CompatibilityFailure::Entrypoint);
|
||||
}
|
||||
if checkpoint.boundary.serialized_args != request.serialized_args {
|
||||
return Some(CompatibilityFailure::Args);
|
||||
}
|
||||
if checkpoint.boundary.environment_digest != request.environment.digest {
|
||||
return Some(CompatibilityFailure::Environment);
|
||||
}
|
||||
if checkpoint.boundary.task_abi != request.task_abi {
|
||||
return Some(CompatibilityFailure::TaskAbi);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{EnvironmentKind, EnvironmentRequirements, NodeId, VfsOverlay, VfsPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env(digest_input: &str) -> EnvironmentResource {
|
||||
EnvironmentResource {
|
||||
name: "linux".to_owned(),
|
||||
kind: EnvironmentKind::Containerfile,
|
||||
recipe_path: PathBuf::from("envs/linux/Containerfile"),
|
||||
context_path: PathBuf::from("envs/linux"),
|
||||
digest: Digest::sha256(digest_input),
|
||||
requirements: EnvironmentRequirements::linux_container(),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint() -> (TaskCheckpoint, EnvironmentResource) {
|
||||
let environment = env("env");
|
||||
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node"));
|
||||
overlay.write(
|
||||
VfsPath::new("/vfs/artifacts/app").unwrap(),
|
||||
Digest::sha256("app"),
|
||||
3,
|
||||
);
|
||||
let manifest = overlay.flush();
|
||||
(
|
||||
TaskCheckpoint {
|
||||
task: TaskId::from("task"),
|
||||
boundary: CheckpointBoundary {
|
||||
task_entrypoint: "compile_linux".to_owned(),
|
||||
serialized_args: Digest::sha256("args"),
|
||||
environment_digest: environment.digest.clone(),
|
||||
vfs_epoch: manifest.epoch,
|
||||
task_abi: Digest::sha256("abi"),
|
||||
},
|
||||
vfs_manifest: manifest,
|
||||
depends_on_live_stack: false,
|
||||
depends_on_live_socket: false,
|
||||
depends_on_ephemeral_artifact_durability: false,
|
||||
},
|
||||
environment,
|
||||
)
|
||||
}
|
||||
|
||||
fn restart_request(environment: EnvironmentResource) -> RestartRequest {
|
||||
RestartRequest {
|
||||
task: TaskId::from("task"),
|
||||
entrypoint: "compile_linux".to_owned(),
|
||||
serialized_args: Digest::sha256("args"),
|
||||
environment,
|
||||
task_abi: Digest::sha256("abi"),
|
||||
source_edited: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_whole_process_restart(decision: RestartDecision, expected_reason: &str) {
|
||||
match decision {
|
||||
RestartDecision::RestartWholeVirtualProcess { message } => {
|
||||
assert!(
|
||||
message.contains(expected_reason),
|
||||
"restart message `{message}` did not include `{expected_reason}`"
|
||||
);
|
||||
assert!(
|
||||
message.contains("restart the whole virtual process"),
|
||||
"restart message `{message}` did not direct a whole-process restart"
|
||||
);
|
||||
}
|
||||
RestartDecision::RestartTask { .. } => {
|
||||
panic!("incompatible checkpoint unexpectedly restarted selected task")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_restart_uses_task_boundary_and_discards_unflushed_changes() {
|
||||
let (checkpoint, environment) = checkpoint();
|
||||
let request = restart_request(environment);
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
RestartDecision::RestartTask {
|
||||
task: TaskId::from("task"),
|
||||
from_vfs_epoch: 1,
|
||||
discard_unflushed_changes: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_environment_requires_whole_process_restart() {
|
||||
let (checkpoint, _) = checkpoint();
|
||||
let request = restart_request(env("changed-env"));
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "environment digest changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_entrypoint_requires_whole_process_restart() {
|
||||
let (checkpoint, environment) = checkpoint();
|
||||
let mut request = restart_request(environment);
|
||||
request.entrypoint = "package_linux".to_owned();
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "task entrypoint changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_serialized_args_require_whole_process_restart() {
|
||||
let (checkpoint, environment) = checkpoint();
|
||||
let mut request = restart_request(environment);
|
||||
request.serialized_args = Digest::sha256("changed-args");
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "serialized task arguments changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incompatible_task_abi_requires_whole_process_restart() {
|
||||
let (checkpoint, environment) = checkpoint();
|
||||
let mut request = restart_request(environment);
|
||||
request.task_abi = Digest::sha256("changed-abi");
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "task ABI changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_never_claims_live_stack_migration() {
|
||||
let (mut checkpoint, environment) = checkpoint();
|
||||
checkpoint.depends_on_live_stack = true;
|
||||
let request = restart_request(environment);
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "live stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_never_claims_live_socket_checkpointing() {
|
||||
let (mut checkpoint, environment) = checkpoint();
|
||||
checkpoint.depends_on_live_socket = true;
|
||||
let request = restart_request(environment);
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "live socket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_never_depends_on_ephemeral_artifact_durability() {
|
||||
let (mut checkpoint, environment) = checkpoint();
|
||||
checkpoint.depends_on_ephemeral_artifact_durability = true;
|
||||
let request = restart_request(environment);
|
||||
|
||||
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||
|
||||
assert_whole_process_restart(decision, "ephemeral artifacts");
|
||||
}
|
||||
}
|
||||
276
crates/disasmer-core/src/debug.rs
Normal file
276
crates/disasmer-core/src/debug.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ProcessId, TaskId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DebugParticipantKind {
|
||||
WasmTask,
|
||||
ControlledNativeCommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DebugRuntimeState {
|
||||
Running,
|
||||
Frozen,
|
||||
Completed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DebugParticipant {
|
||||
pub task: TaskId,
|
||||
pub name: String,
|
||||
pub kind: DebugParticipantKind,
|
||||
pub can_freeze: bool,
|
||||
pub state: DebugRuntimeState,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DebugStopReason {
|
||||
Breakpoint { task: TaskId, line: u32 },
|
||||
PauseRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThreadInspection {
|
||||
pub task: TaskId,
|
||||
pub name: String,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DebugEpoch {
|
||||
pub process: ProcessId,
|
||||
pub epoch: u64,
|
||||
pub reason: DebugStopReason,
|
||||
participants: BTreeMap<TaskId, DebugParticipant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum DebugEpochError {
|
||||
#[error("participant `{task}` cannot freeze, so all-stop failed")]
|
||||
CannotFreeze { task: TaskId },
|
||||
#[error("participant `{0}` is not part of this debug epoch")]
|
||||
UnknownParticipant(TaskId),
|
||||
}
|
||||
|
||||
impl DebugEpoch {
|
||||
pub fn all_stop(
|
||||
process: ProcessId,
|
||||
epoch: u64,
|
||||
reason: DebugStopReason,
|
||||
participants: Vec<DebugParticipant>,
|
||||
) -> Result<Self, DebugEpochError> {
|
||||
for participant in &participants {
|
||||
if matches!(
|
||||
participant.kind,
|
||||
DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand
|
||||
) && !participant.can_freeze
|
||||
{
|
||||
return Err(DebugEpochError::CannotFreeze {
|
||||
task: participant.task.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let participants = participants
|
||||
.into_iter()
|
||||
.map(|mut participant| {
|
||||
if matches!(participant.state, DebugRuntimeState::Running) {
|
||||
participant.state = DebugRuntimeState::Frozen;
|
||||
}
|
||||
(participant.task.clone(), participant)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
process,
|
||||
epoch,
|
||||
reason,
|
||||
participants,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pause(
|
||||
process: ProcessId,
|
||||
epoch: u64,
|
||||
participants: Vec<DebugParticipant>,
|
||||
) -> Result<Self, DebugEpochError> {
|
||||
Self::all_stop(process, epoch, DebugStopReason::PauseRequest, participants)
|
||||
}
|
||||
|
||||
pub fn continue_all(&mut self) {
|
||||
for participant in self.participants.values_mut() {
|
||||
if participant.state == DebugRuntimeState::Frozen {
|
||||
participant.state = DebugRuntimeState::Running;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inspection(&self, task: &TaskId) -> Result<ThreadInspection, DebugEpochError> {
|
||||
let participant = self
|
||||
.participants
|
||||
.get(task)
|
||||
.ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?;
|
||||
Ok(ThreadInspection {
|
||||
task: participant.task.clone(),
|
||||
name: participant.name.clone(),
|
||||
stack_frames: participant.stack_frames.clone(),
|
||||
local_values: participant.local_values.clone(),
|
||||
task_args: participant.task_args.clone(),
|
||||
handles: participant.handles.clone(),
|
||||
command_status: participant.command_status.clone(),
|
||||
recent_output: participant.recent_output.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn participant_state(&self, task: &TaskId) -> Option<&DebugRuntimeState> {
|
||||
self.participants
|
||||
.get(task)
|
||||
.map(|participant| &participant.state)
|
||||
}
|
||||
|
||||
pub fn thread_names(&self) -> Vec<String> {
|
||||
self.participants
|
||||
.values()
|
||||
.map(|participant| participant.name.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn participant(task: &str, kind: DebugParticipantKind, can_freeze: bool) -> DebugParticipant {
|
||||
DebugParticipant {
|
||||
task: TaskId::from(task),
|
||||
name: task.to_owned(),
|
||||
kind,
|
||||
can_freeze,
|
||||
state: DebugRuntimeState::Running,
|
||||
stack_frames: vec![format!("{task}::run")],
|
||||
local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())],
|
||||
task_args: vec![("target".to_owned(), "linux".to_owned())],
|
||||
handles: vec![("artifact".to_owned(), "artifact-1".to_owned())],
|
||||
command_status: Some("running".to_owned()),
|
||||
recent_output: vec!["building".to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks() {
|
||||
let epoch = DebugEpoch::all_stop(
|
||||
ProcessId::from("process"),
|
||||
1,
|
||||
DebugStopReason::Breakpoint {
|
||||
task: TaskId::from("compile-linux"),
|
||||
line: 42,
|
||||
},
|
||||
vec![
|
||||
participant("main", DebugParticipantKind::WasmTask, true),
|
||||
participant(
|
||||
"compile-linux",
|
||||
DebugParticipantKind::ControlledNativeCommand,
|
||||
true,
|
||||
),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
epoch.participant_state(&TaskId::from("main")),
|
||||
Some(&DebugRuntimeState::Frozen)
|
||||
);
|
||||
assert_eq!(
|
||||
epoch.participant_state(&TaskId::from("compile-linux")),
|
||||
Some(&DebugRuntimeState::Frozen)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() {
|
||||
let error = DebugEpoch::pause(
|
||||
ProcessId::from("process"),
|
||||
1,
|
||||
vec![participant(
|
||||
"compile-linux",
|
||||
DebugParticipantKind::ControlledNativeCommand,
|
||||
false,
|
||||
)],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, DebugEpochError::CannotFreeze { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_resumes_every_frozen_participant() {
|
||||
let mut epoch = DebugEpoch::pause(
|
||||
ProcessId::from("process"),
|
||||
1,
|
||||
vec![
|
||||
participant("main", DebugParticipantKind::WasmTask, true),
|
||||
participant("task", DebugParticipantKind::WasmTask, true),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
epoch.continue_all();
|
||||
|
||||
assert_eq!(
|
||||
epoch.participant_state(&TaskId::from("main")),
|
||||
Some(&DebugRuntimeState::Running)
|
||||
);
|
||||
assert_eq!(
|
||||
epoch.participant_state(&TaskId::from("task")),
|
||||
Some(&DebugRuntimeState::Running)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_exposes_stack_args_handles_command_status_and_output() {
|
||||
let epoch = DebugEpoch::pause(
|
||||
ProcessId::from("process"),
|
||||
1,
|
||||
vec![participant(
|
||||
"compile-linux",
|
||||
DebugParticipantKind::ControlledNativeCommand,
|
||||
true,
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let inspection = epoch.inspection(&TaskId::from("compile-linux")).unwrap();
|
||||
|
||||
assert_eq!(inspection.stack_frames, vec!["compile-linux::run"]);
|
||||
assert_eq!(
|
||||
inspection.local_values[0],
|
||||
("wasm_local_0".to_owned(), "I32(41)".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.task_args[0],
|
||||
("target".to_owned(), "linux".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
inspection.handles[0],
|
||||
("artifact".to_owned(), "artifact-1".to_owned())
|
||||
);
|
||||
assert_eq!(inspection.command_status, Some("running".to_owned()));
|
||||
assert_eq!(inspection.recent_output, vec!["building"]);
|
||||
}
|
||||
}
|
||||
56
crates/disasmer-core/src/digest.rs
Normal file
56
crates/disasmer-core/src/digest.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as ShaDigest, Sha256};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct Digest(String);
|
||||
|
||||
impl Digest {
|
||||
pub fn sha256(bytes: impl AsRef<[u8]>) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes.as_ref());
|
||||
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
|
||||
}
|
||||
|
||||
pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
let part = part.as_ref();
|
||||
hasher.update((part.len() as u64).to_be_bytes());
|
||||
hasher.update(part);
|
||||
}
|
||||
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn is_valid_sha256(&self) -> bool {
|
||||
let Some(hex) = self.0.strip_prefix("sha256:") else {
|
||||
return false;
|
||||
};
|
||||
hex.len() == 64
|
||||
&& hex
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Digest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn digest_validates_strict_sha256_syntax() {
|
||||
assert!(Digest::sha256("bytes").is_valid_sha256());
|
||||
assert!(!Digest("sha1:abc".to_owned()).is_valid_sha256());
|
||||
assert!(!Digest("sha256:ABCDEF".to_owned()).is_valid_sha256());
|
||||
assert!(!Digest("sha256:not-hex".to_owned()).is_valid_sha256());
|
||||
}
|
||||
}
|
||||
288
crates/disasmer-core/src/environment.rs
Normal file
288
crates/disasmer-core/src/environment.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Capability, Digest, Os};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EnvironmentKind {
|
||||
Containerfile,
|
||||
Dockerfile,
|
||||
NixFlake,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnvironmentRequirements {
|
||||
pub os: Option<Os>,
|
||||
pub arch: Option<String>,
|
||||
pub capabilities: BTreeSet<Capability>,
|
||||
}
|
||||
|
||||
impl EnvironmentRequirements {
|
||||
pub fn linux_container() -> Self {
|
||||
Self {
|
||||
os: Some(Os::Linux),
|
||||
arch: None,
|
||||
capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn windows_command_dev() -> Self {
|
||||
Self {
|
||||
os: Some(Os::Windows),
|
||||
arch: None,
|
||||
capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unconstrained() -> Self {
|
||||
Self {
|
||||
os: None,
|
||||
arch: None,
|
||||
capabilities: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnvironmentResource {
|
||||
pub name: String,
|
||||
pub kind: EnvironmentKind,
|
||||
pub recipe_path: PathBuf,
|
||||
pub context_path: PathBuf,
|
||||
pub digest: Digest,
|
||||
pub requirements: EnvironmentRequirements,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnvironmentReference {
|
||||
pub name: String,
|
||||
pub byte_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnvironmentDiagnostic {
|
||||
pub reference: EnvironmentReference,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EnvironmentError {
|
||||
#[error("failed to read environment resources under {path}: {source}")]
|
||||
Read {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn discover_environments(
|
||||
project_root: &Path,
|
||||
) -> Result<Vec<EnvironmentResource>, EnvironmentError> {
|
||||
let envs_dir = project_root.join("envs");
|
||||
if !envs_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut resources = Vec::new();
|
||||
let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read {
|
||||
path: envs_dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|source| EnvironmentError::Read {
|
||||
path: envs_dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(resource) = discover_one(project_root, name, &path)? {
|
||||
resources.push(resource);
|
||||
}
|
||||
}
|
||||
|
||||
resources.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
pub fn diagnose_environment_references(
|
||||
source: &str,
|
||||
environments: &[EnvironmentResource],
|
||||
) -> Vec<EnvironmentDiagnostic> {
|
||||
let known = environments
|
||||
.iter()
|
||||
.map(|environment| environment.name.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
find_env_macro_references(source)
|
||||
.into_iter()
|
||||
.filter(|reference| !known.contains(reference.name.as_str()))
|
||||
.map(|reference| EnvironmentDiagnostic {
|
||||
message: format!(
|
||||
"missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile",
|
||||
reference.name, reference.name, reference.name
|
||||
),
|
||||
reference,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn discover_one(
|
||||
project_root: &Path,
|
||||
name: &str,
|
||||
env_dir: &Path,
|
||||
) -> Result<Option<EnvironmentResource>, EnvironmentError> {
|
||||
let candidates = [
|
||||
("Containerfile", EnvironmentKind::Containerfile),
|
||||
("Dockerfile", EnvironmentKind::Dockerfile),
|
||||
("flake.nix", EnvironmentKind::NixFlake),
|
||||
];
|
||||
|
||||
for (file_name, kind) in candidates {
|
||||
let recipe_path = env_dir.join(file_name);
|
||||
if !recipe_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read {
|
||||
path: recipe_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let relative_recipe = recipe_path
|
||||
.strip_prefix(project_root)
|
||||
.unwrap_or(&recipe_path)
|
||||
.to_string_lossy();
|
||||
let digest = Digest::from_parts([
|
||||
b"environment:v1".as_slice(),
|
||||
name.as_bytes(),
|
||||
format!("{kind:?}").as_bytes(),
|
||||
relative_recipe.as_bytes(),
|
||||
recipe_bytes.as_slice(),
|
||||
]);
|
||||
let requirements = match kind {
|
||||
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile
|
||||
if name.eq_ignore_ascii_case("windows") =>
|
||||
{
|
||||
EnvironmentRequirements::windows_command_dev()
|
||||
}
|
||||
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {
|
||||
EnvironmentRequirements::linux_container()
|
||||
}
|
||||
EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(),
|
||||
};
|
||||
|
||||
return Ok(Some(EnvironmentResource {
|
||||
name: name.to_owned(),
|
||||
kind,
|
||||
recipe_path,
|
||||
context_path: env_dir.to_path_buf(),
|
||||
digest,
|
||||
requirements,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_env_macro_references(source: &str) -> Vec<EnvironmentReference> {
|
||||
let mut references = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
while let Some(index) = source[cursor..].find("env!(") {
|
||||
let start = cursor + index;
|
||||
let mut pos = start + "env!(".len();
|
||||
while source[pos..].starts_with(char::is_whitespace) {
|
||||
pos += source[pos..]
|
||||
.chars()
|
||||
.next()
|
||||
.map(char::len_utf8)
|
||||
.unwrap_or(1);
|
||||
}
|
||||
if !source[pos..].starts_with('"') {
|
||||
cursor = pos;
|
||||
continue;
|
||||
}
|
||||
pos += 1;
|
||||
let name_start = pos;
|
||||
while pos < source.len() && !source[pos..].starts_with('"') {
|
||||
pos += source[pos..]
|
||||
.chars()
|
||||
.next()
|
||||
.map(char::len_utf8)
|
||||
.unwrap_or(1);
|
||||
}
|
||||
if pos < source.len() {
|
||||
references.push(EnvironmentReference {
|
||||
name: source[name_start..pos].to_owned(),
|
||||
byte_offset: start,
|
||||
});
|
||||
}
|
||||
cursor = pos.saturating_add(1);
|
||||
}
|
||||
|
||||
references
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discovers_containerfile_environments_by_logical_name() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let linux = temp.path().join("envs/linux");
|
||||
fs::create_dir_all(&linux).unwrap();
|
||||
fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap();
|
||||
|
||||
let envs = discover_environments(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(envs.len(), 1);
|
||||
assert_eq!(envs[0].name, "linux");
|
||||
assert_eq!(envs[0].kind, EnvironmentKind::Containerfile);
|
||||
assert!(!envs[0].digest.as_str().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_env_macro_reference_reports_clear_diagnostic() {
|
||||
let source = r#"fn main() { let _ = env!("windows"); }"#;
|
||||
let diagnostics = diagnose_environment_references(source, &[]);
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert!(diagnostics[0]
|
||||
.message
|
||||
.contains("envs/windows/Containerfile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_environment_name_uses_windows_development_requirements() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let windows = temp.path().join("envs/windows");
|
||||
fs::create_dir_all(&windows).unwrap();
|
||||
fs::write(
|
||||
windows.join("Dockerfile"),
|
||||
"# user-attached windows dev contract\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let envs = discover_environments(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(envs[0].name, "windows");
|
||||
assert_eq!(envs[0].requirements.os, Some(Os::Windows));
|
||||
assert!(envs[0]
|
||||
.requirements
|
||||
.capabilities
|
||||
.contains(&Capability::WindowsCommandDev));
|
||||
}
|
||||
}
|
||||
90
crates/disasmer-core/src/execution.rs
Normal file
90
crates/disasmer-core/src/execution.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Capability, EnvironmentResource};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GuestRuntimeKind {
|
||||
Wasmtime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CommandBackendKind {
|
||||
LinuxRootlessPodman,
|
||||
WindowsCommandDev,
|
||||
StubbedWindowsSandbox,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandInvocation {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Option<EnvironmentResource>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandPlan {
|
||||
pub guest_runtime: GuestRuntimeKind,
|
||||
pub backend: CommandBackendKind,
|
||||
pub required_capability: Capability,
|
||||
pub user_attached_development_execution: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NativeCommandPolicy {
|
||||
pub hosted_control_plane: bool,
|
||||
pub node_has_command_capability: bool,
|
||||
}
|
||||
|
||||
impl NativeCommandPolicy {
|
||||
pub fn authorize(&self) -> Result<(), String> {
|
||||
if self.hosted_control_plane {
|
||||
return Err("hosted coordinator control plane cannot run native commands".to_owned());
|
||||
}
|
||||
if !self.node_has_command_capability {
|
||||
return Err("selected node or task lacks native command capability".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TaskBoundaryValue {
|
||||
SmallJson(serde_json::Value),
|
||||
SourceSnapshot(crate::Digest),
|
||||
Blob(crate::Digest),
|
||||
Artifact(crate::ArtifactId),
|
||||
VfsManifest(crate::Digest),
|
||||
}
|
||||
|
||||
impl TaskBoundaryValue {
|
||||
pub fn reject_host_only(type_name: &str) -> Result<Self, String> {
|
||||
Err(format!(
|
||||
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hosted_control_plane_cannot_authorize_native_command() {
|
||||
let policy = NativeCommandPolicy {
|
||||
hosted_control_plane: true,
|
||||
node_has_command_capability: true,
|
||||
};
|
||||
|
||||
assert!(policy
|
||||
.authorize()
|
||||
.unwrap_err()
|
||||
.contains("hosted coordinator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_pointer_style_task_argument_is_rejected() {
|
||||
let error = TaskBoundaryValue::reject_host_only("*const u8").unwrap_err();
|
||||
|
||||
assert!(error.contains("host-only"));
|
||||
}
|
||||
}
|
||||
44
crates/disasmer-core/src/ids.rs
Normal file
44
crates/disasmer-core/src/ids.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! id_type {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
let value = value.into();
|
||||
assert!(
|
||||
!value.trim().is_empty(),
|
||||
concat!(stringify!($name), " cannot be empty")
|
||||
);
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for $name {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_type!(AgentId);
|
||||
id_type!(ArtifactId);
|
||||
id_type!(NodeId);
|
||||
id_type!(ProcessId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(TaskId);
|
||||
id_type!(TenantId);
|
||||
id_type!(UserId);
|
||||
75
crates/disasmer-core/src/lib.rs
Normal file
75
crates/disasmer-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
pub mod artifact;
|
||||
pub mod auth;
|
||||
pub mod bundle;
|
||||
pub mod capability;
|
||||
pub mod checkpoint;
|
||||
pub mod debug;
|
||||
pub mod digest;
|
||||
pub mod environment;
|
||||
pub mod execution;
|
||||
pub mod ids;
|
||||
pub mod limits;
|
||||
pub mod operator_panel;
|
||||
pub mod policy;
|
||||
pub mod project;
|
||||
pub mod scheduler;
|
||||
pub mod source;
|
||||
pub mod transport;
|
||||
pub mod vfs;
|
||||
|
||||
pub use artifact::{
|
||||
ArtifactDownloadStream, ArtifactMetadata, ArtifactRegistry, ArtifactUnavailable,
|
||||
DownloadAction, DownloadError, DownloadLink, DownloadPolicy, RetentionPolicy, StorageLocation,
|
||||
};
|
||||
pub use auth::{
|
||||
node_capability_policy_digest, Action, Actor, AuthContext, Authorization, BrowserLoginFlow,
|
||||
CliLoginFlow, CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential,
|
||||
PublicKeyIdentity, Scope,
|
||||
};
|
||||
pub use bundle::{BundleIdentityInputs, BundleMetadata, SelectedInput};
|
||||
pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os};
|
||||
pub use checkpoint::{
|
||||
CheckpointBoundary, CompatibilityFailure, RestartDecision, RestartPolicy, RestartRequest,
|
||||
TaskCheckpoint,
|
||||
};
|
||||
pub use debug::{
|
||||
DebugEpoch, DebugEpochError, DebugParticipant, DebugParticipantKind, DebugRuntimeState,
|
||||
DebugStopReason, ThreadInspection,
|
||||
};
|
||||
pub use digest::Digest;
|
||||
pub use environment::{
|
||||
diagnose_environment_references, discover_environments, EnvironmentDiagnostic, EnvironmentKind,
|
||||
EnvironmentReference, EnvironmentRequirements, EnvironmentResource,
|
||||
};
|
||||
pub use execution::{
|
||||
CommandBackendKind, CommandInvocation, CommandPlan, GuestRuntimeKind, NativeCommandPolicy,
|
||||
TaskBoundaryValue,
|
||||
};
|
||||
pub use ids::{AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
|
||||
pub use limits::{
|
||||
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
|
||||
ResourceMeter, TaskArgumentBudget,
|
||||
};
|
||||
pub use operator_panel::{
|
||||
ControlPlaneAction, PanelError, PanelEvent, PanelEventKind, PanelState, PanelWidget,
|
||||
PanelWidgetKind, RateLimit,
|
||||
};
|
||||
pub use policy::{
|
||||
CapabilityPolicy, Decision, LocalTrustedPolicy, PolicyReason, ResourceRequest, ServicePolicy,
|
||||
};
|
||||
pub use project::{Entrypoint, ProjectModel, ProjectModelError};
|
||||
pub use scheduler::{
|
||||
DefaultScheduler, NodeDescriptor, Placement, PlacementError, PlacementRequest, Scheduler,
|
||||
};
|
||||
pub use source::{
|
||||
SourceManifestError, SourcePreparation, SourceProviderKind, SourceProviderManifest,
|
||||
SourceProviderModule, SourceTransferMode, SourceTransferPolicy,
|
||||
};
|
||||
pub use transport::{
|
||||
BulkTransferDecision, DataPlaneObject, DataPlaneScope, DirectBulkTransferPlan,
|
||||
NativeQuicTransport, NodeEndpoint, RendezvousRequest, Transport, TransportError, TransportKind,
|
||||
};
|
||||
pub use vfs::{
|
||||
ReuseDecision, SyncPolicy, VfsError, VfsManifest, VfsObject, VfsOverlay, VfsPath,
|
||||
VfsSyncDecision,
|
||||
};
|
||||
255
crates/disasmer-core/src/limits.rs
Normal file
255
crates/disasmer-core/src/limits.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::TaskId;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum LimitKind {
|
||||
ApiCall,
|
||||
Spawn,
|
||||
LogBytes,
|
||||
MetadataBytes,
|
||||
DebugReadBytes,
|
||||
UiEvent,
|
||||
RendezvousAttempt,
|
||||
ArtifactDownloadBytes,
|
||||
HostedFuel,
|
||||
HostedMemoryBytes,
|
||||
HostedWallClockMs,
|
||||
HostedStateBytes,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResourceLimits {
|
||||
pub limits: BTreeMap<LimitKind, u64>,
|
||||
}
|
||||
|
||||
impl ResourceLimits {
|
||||
pub fn community_tier_defaults() -> Self {
|
||||
Self {
|
||||
limits: BTreeMap::from([
|
||||
(LimitKind::ApiCall, 10_000),
|
||||
(LimitKind::Spawn, 64),
|
||||
(LimitKind::LogBytes, 256 * 1024),
|
||||
(LimitKind::MetadataBytes, 512 * 1024),
|
||||
(LimitKind::DebugReadBytes, 128 * 1024),
|
||||
(LimitKind::UiEvent, 1_000),
|
||||
(LimitKind::RendezvousAttempt, 128),
|
||||
(LimitKind::ArtifactDownloadBytes, 256 * 1024 * 1024),
|
||||
(LimitKind::HostedFuel, 10_000_000),
|
||||
(LimitKind::HostedMemoryBytes, 64 * 1024 * 1024),
|
||||
(LimitKind::HostedWallClockMs, 30_000),
|
||||
(LimitKind::HostedStateBytes, 1024 * 1024),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limit(&self, kind: &LimitKind) -> u64 {
|
||||
*self.limits.get(kind).unwrap_or(&0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResourceMeter {
|
||||
used: BTreeMap<LimitKind, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum LimitError {
|
||||
#[error(
|
||||
"resource limit exceeded for {kind:?}: requested {requested}, used {used}, limit {limit}"
|
||||
)]
|
||||
Exceeded {
|
||||
kind: LimitKind,
|
||||
requested: u64,
|
||||
used: u64,
|
||||
limit: u64,
|
||||
},
|
||||
#[error("task argument is too large: {size} bytes exceeds {limit} bytes")]
|
||||
LargeTaskArgument { size: u64, limit: u64 },
|
||||
}
|
||||
|
||||
impl ResourceMeter {
|
||||
pub fn can_charge(
|
||||
&self,
|
||||
limits: &ResourceLimits,
|
||||
kind: LimitKind,
|
||||
amount: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
let used = self.used.get(&kind).copied().unwrap_or(0);
|
||||
let limit = limits.limit(&kind);
|
||||
if used.saturating_add(amount) > limit {
|
||||
return Err(LimitError::Exceeded {
|
||||
kind,
|
||||
requested: amount,
|
||||
used,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn charge(
|
||||
&mut self,
|
||||
limits: &ResourceLimits,
|
||||
kind: LimitKind,
|
||||
amount: u64,
|
||||
) -> Result<(), LimitError> {
|
||||
self.can_charge(limits, kind.clone(), amount)?;
|
||||
let used = self.used.get(&kind).copied().unwrap_or(0);
|
||||
self.used.insert(kind, used + amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn used(&self, kind: &LimitKind) -> u64 {
|
||||
self.used.get(kind).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogRecord {
|
||||
pub task: TaskId,
|
||||
pub bytes: Vec<u8>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LogBuffer {
|
||||
max_bytes: usize,
|
||||
used_bytes: usize,
|
||||
records: Vec<LogRecord>,
|
||||
backpressured: bool,
|
||||
}
|
||||
|
||||
impl LogBuffer {
|
||||
pub fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
max_bytes,
|
||||
used_bytes: 0,
|
||||
records: Vec::new(),
|
||||
backpressured: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, task: TaskId, bytes: impl AsRef<[u8]>) {
|
||||
let bytes = bytes.as_ref();
|
||||
let remaining = self.max_bytes.saturating_sub(self.used_bytes);
|
||||
let truncated = bytes.len() > remaining;
|
||||
let stored = bytes[..bytes.len().min(remaining)].to_vec();
|
||||
self.used_bytes += stored.len();
|
||||
if truncated {
|
||||
self.backpressured = true;
|
||||
}
|
||||
self.records.push(LogRecord {
|
||||
task,
|
||||
bytes: stored,
|
||||
truncated,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn records(&self) -> &[LogRecord] {
|
||||
&self.records
|
||||
}
|
||||
|
||||
pub fn backpressured(&self) -> bool {
|
||||
self.backpressured
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LargeArgumentPolicy {
|
||||
Allow,
|
||||
Warn,
|
||||
Reject,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskArgumentBudget {
|
||||
pub max_inline_bytes: u64,
|
||||
pub policy: LargeArgumentPolicy,
|
||||
}
|
||||
|
||||
impl TaskArgumentBudget {
|
||||
pub fn validate(&self, size: u64) -> Result<Option<String>, LimitError> {
|
||||
if size <= self.max_inline_bytes {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match self.policy {
|
||||
LargeArgumentPolicy::Allow => Ok(None),
|
||||
LargeArgumentPolicy::Warn => Ok(Some(format!(
|
||||
"task argument is {size} bytes; prefer SourceSnapshot, Blob, Artifact, or VFS handles"
|
||||
))),
|
||||
LargeArgumentPolicy::Reject => Err(LimitError::LargeTaskArgument {
|
||||
size,
|
||||
limit: self.max_inline_bytes,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_meter_rejects_usage_before_work_starts() {
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::Spawn, 1)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
|
||||
meter.charge(&limits, LimitKind::Spawn, 1).unwrap();
|
||||
let error = meter.charge(&limits, LimitKind::Spawn, 1).unwrap_err();
|
||||
|
||||
assert!(matches!(error, LimitError::Exceeded { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_meter_can_check_limits_without_consuming() {
|
||||
let limits = ResourceLimits {
|
||||
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 4)]),
|
||||
};
|
||||
let mut meter = ResourceMeter::default();
|
||||
|
||||
meter
|
||||
.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 4)
|
||||
.unwrap();
|
||||
assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 0);
|
||||
meter
|
||||
.charge(&limits, LimitKind::ArtifactDownloadBytes, 3)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
meter.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 2),
|
||||
Err(LimitError::Exceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_buffer_caps_backpressures_and_keeps_task_association() {
|
||||
let mut logs = LogBuffer::new(4);
|
||||
logs.push(TaskId::from("task-a"), b"abcdef");
|
||||
|
||||
assert!(logs.backpressured());
|
||||
assert_eq!(logs.records()[0].task, TaskId::from("task-a"));
|
||||
assert_eq!(logs.records()[0].bytes, b"abcd");
|
||||
assert!(logs.records()[0].truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_task_arguments_are_rejected_or_warned() {
|
||||
let reject = TaskArgumentBudget {
|
||||
max_inline_bytes: 4,
|
||||
policy: LargeArgumentPolicy::Reject,
|
||||
};
|
||||
assert!(reject.validate(5).is_err());
|
||||
|
||||
let warn = TaskArgumentBudget {
|
||||
max_inline_bytes: 4,
|
||||
policy: LargeArgumentPolicy::Warn,
|
||||
};
|
||||
assert!(warn.validate(5).unwrap().unwrap().contains("Artifact"));
|
||||
}
|
||||
}
|
||||
375
crates/disasmer-core/src/operator_panel.rs
Normal file
375
crates/disasmer-core/src/operator_panel.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskId, TenantId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelWidgetKind {
|
||||
Text {
|
||||
value: String,
|
||||
},
|
||||
Progress {
|
||||
current: u64,
|
||||
total: u64,
|
||||
},
|
||||
Button {
|
||||
action: String,
|
||||
},
|
||||
Toggle {
|
||||
value: bool,
|
||||
},
|
||||
Select {
|
||||
options: Vec<String>,
|
||||
selected: String,
|
||||
},
|
||||
ArtifactDownload {
|
||||
artifact: ArtifactId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelWidget {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub kind: PanelWidgetKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ControlPlaneAction {
|
||||
RestartTask(TaskId),
|
||||
CancelProcess,
|
||||
DebugProcess,
|
||||
DownloadArtifact(ArtifactId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelState {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub widgets: BTreeMap<String, PanelWidget>,
|
||||
pub program_ui_events_enabled: bool,
|
||||
pub control_plane_actions: Vec<ControlPlaneAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PanelEventKind {
|
||||
ButtonClicked,
|
||||
ToggleChanged(bool),
|
||||
SelectChanged(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PanelEvent {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub widget_id: String,
|
||||
pub kind: PanelEventKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RateLimit {
|
||||
pub max_events: u64,
|
||||
pub used_events: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum PanelError {
|
||||
#[error("custom HTML or JavaScript is not supported in operator panels")]
|
||||
CustomContentDenied,
|
||||
#[error(
|
||||
"operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials"
|
||||
)]
|
||||
CredentialCollectionDenied(String),
|
||||
#[error("panel event scope does not match tenant/project/process")]
|
||||
ScopeMismatch,
|
||||
#[error("program UI events are disabled while debug process is stopped")]
|
||||
ProgramEventsDisabled,
|
||||
#[error("panel event rate limit exceeded")]
|
||||
RateLimited,
|
||||
#[error("unknown panel widget `{0}`")]
|
||||
UnknownWidget(String),
|
||||
#[error("artifact download action is unavailable: {0}")]
|
||||
DownloadUnavailable(String),
|
||||
}
|
||||
|
||||
impl PanelState {
|
||||
pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self {
|
||||
Self {
|
||||
tenant,
|
||||
project,
|
||||
process,
|
||||
widgets: BTreeMap::new(),
|
||||
program_ui_events_enabled: true,
|
||||
control_plane_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> {
|
||||
validate_widget(&widget)?;
|
||||
self.widgets.insert(widget.id.clone(), widget);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_download_widget_from_action(
|
||||
&mut self,
|
||||
widget_id: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
action: Result<DownloadAction, DownloadError>,
|
||||
) -> Result<(), PanelError> {
|
||||
let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?;
|
||||
let artifact = action.artifact;
|
||||
self.add_widget(PanelWidget {
|
||||
id: widget_id.into(),
|
||||
label: label.into(),
|
||||
kind: PanelWidgetKind::ArtifactDownload {
|
||||
artifact: artifact.clone(),
|
||||
},
|
||||
})?;
|
||||
self.control_plane_actions
|
||||
.push(ControlPlaneAction::DownloadArtifact(artifact));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> {
|
||||
Err(PanelError::CustomContentDenied)
|
||||
}
|
||||
|
||||
pub fn freeze_program_ui_events(&mut self) {
|
||||
self.program_ui_events_enabled = false;
|
||||
}
|
||||
|
||||
pub fn set_control_plane_actions(&mut self, actions: Vec<ControlPlaneAction>) {
|
||||
self.control_plane_actions = actions;
|
||||
}
|
||||
|
||||
pub fn accept_event(
|
||||
&self,
|
||||
event: &PanelEvent,
|
||||
limit: &mut RateLimit,
|
||||
) -> Result<(), PanelError> {
|
||||
if !self.program_ui_events_enabled {
|
||||
return Err(PanelError::ProgramEventsDisabled);
|
||||
}
|
||||
if self.tenant != event.tenant
|
||||
|| self.project != event.project
|
||||
|| self.process != event.process
|
||||
{
|
||||
return Err(PanelError::ScopeMismatch);
|
||||
}
|
||||
if !self.widgets.contains_key(&event.widget_id) {
|
||||
return Err(PanelError::UnknownWidget(event.widget_id.clone()));
|
||||
}
|
||||
if limit.used_events >= limit.max_events {
|
||||
return Err(PanelError::RateLimited);
|
||||
}
|
||||
limit.used_events += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] {
|
||||
&self.control_plane_actions
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> {
|
||||
let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()];
|
||||
match &widget.kind {
|
||||
PanelWidgetKind::Button { action } => checked_text.push(action),
|
||||
PanelWidgetKind::Select { options, selected } => {
|
||||
checked_text.push(selected);
|
||||
checked_text.extend(options.iter().map(String::as_str));
|
||||
}
|
||||
PanelWidgetKind::Text { .. }
|
||||
| PanelWidgetKind::Progress { .. }
|
||||
| PanelWidgetKind::Toggle { .. }
|
||||
| PanelWidgetKind::ArtifactDownload { .. } => {}
|
||||
}
|
||||
|
||||
let combined = checked_text.join(" ").to_ascii_lowercase();
|
||||
if combined.contains("password")
|
||||
|| combined.contains("token")
|
||||
|| combined.contains("oauth")
|
||||
|| combined.contains("secret")
|
||||
{
|
||||
return Err(PanelError::CredentialCollectionDenied(widget.id.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn panel() -> PanelState {
|
||||
PanelState::new(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
ProcessId::from("process"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_uses_typed_widgets_and_rejects_custom_content() {
|
||||
let mut panel = panel();
|
||||
panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "progress".to_owned(),
|
||||
label: "Build".to_owned(),
|
||||
kind: PanelWidgetKind::Progress {
|
||||
current: 1,
|
||||
total: 2,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(PanelState::reject_custom_content("<script>alert(1)</script>").is_err());
|
||||
assert!(panel.widgets.contains_key("progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rejects_password_or_oauth_collection_widgets() {
|
||||
let mut panel = panel();
|
||||
let error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "oauth_token".to_owned(),
|
||||
label: "OAuth Token".to_owned(),
|
||||
kind: PanelWidgetKind::Text {
|
||||
value: String::new(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PanelError::CredentialCollectionDenied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rejects_credential_collection_in_interactive_fields() {
|
||||
let mut panel = panel();
|
||||
let button_error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "continue".to_owned(),
|
||||
label: "Continue".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "collect-secret".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
button_error,
|
||||
PanelError::CredentialCollectionDenied(_)
|
||||
));
|
||||
|
||||
let select_error = panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "auth-mode".to_owned(),
|
||||
label: "Auth Mode".to_owned(),
|
||||
kind: PanelWidgetKind::Select {
|
||||
options: vec!["password".to_owned(), "public key".to_owned()],
|
||||
selected: "public key".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
select_error,
|
||||
PanelError::CredentialCollectionDenied(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_events_are_scoped_and_rate_limited() {
|
||||
let mut panel = panel();
|
||||
panel
|
||||
.add_widget(PanelWidget {
|
||||
id: "restart".to_owned(),
|
||||
label: "Restart".to_owned(),
|
||||
kind: PanelWidgetKind::Button {
|
||||
action: "restart".to_owned(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let event = PanelEvent {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: ProcessId::from("process"),
|
||||
widget_id: "restart".to_owned(),
|
||||
kind: PanelEventKind::ButtonClicked,
|
||||
};
|
||||
let mut limit = RateLimit {
|
||||
max_events: 1,
|
||||
used_events: 0,
|
||||
};
|
||||
|
||||
panel.accept_event(&event, &mut limit).unwrap();
|
||||
assert_eq!(
|
||||
panel.accept_event(&event, &mut limit),
|
||||
Err(PanelError::RateLimited)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_debug_process_keeps_control_plane_actions_available() {
|
||||
let mut panel = panel();
|
||||
panel.freeze_program_ui_events();
|
||||
panel.set_control_plane_actions(vec![
|
||||
ControlPlaneAction::RestartTask(TaskId::from("task")),
|
||||
ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")),
|
||||
]);
|
||||
|
||||
let event = PanelEvent {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: ProcessId::from("process"),
|
||||
widget_id: "missing".to_owned(),
|
||||
kind: PanelEventKind::ButtonClicked,
|
||||
};
|
||||
let mut limit = RateLimit {
|
||||
max_events: 1,
|
||||
used_events: 0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
panel.accept_event(&event, &mut limit),
|
||||
Err(PanelError::ProgramEventsDisabled)
|
||||
);
|
||||
assert_eq!(panel.control_plane_actions_available().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_widget_is_only_created_from_available_action() {
|
||||
let mut panel = panel();
|
||||
let action = Ok(DownloadAction {
|
||||
artifact: ArtifactId::from("artifact"),
|
||||
source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")),
|
||||
scoped_token_subject: "tenant/project/process/artifact".to_owned(),
|
||||
});
|
||||
|
||||
panel
|
||||
.add_download_widget_from_action("download-artifact", "Download", action)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
panel.widgets["download-artifact"].kind,
|
||||
PanelWidgetKind::ArtifactDownload { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
panel.control_plane_actions_available()[0],
|
||||
ControlPlaneAction::DownloadArtifact(_)
|
||||
));
|
||||
|
||||
let before = panel.widgets.len();
|
||||
let error = panel
|
||||
.add_download_widget_from_action(
|
||||
"missing-download",
|
||||
"Download",
|
||||
Err(DownloadError::Unavailable),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(panel.widgets.len(), before);
|
||||
assert!(matches!(error, PanelError::DownloadUnavailable(_)));
|
||||
}
|
||||
}
|
||||
134
crates/disasmer-core/src/policy.rs
Normal file
134
crates/disasmer-core/src/policy.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Action, AuthContext, Capability, Scope};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PolicyReason {
|
||||
Allowed,
|
||||
MissingCapability(Capability),
|
||||
HostedNativeComputeDenied,
|
||||
HostedContainerDenied,
|
||||
QuotaExceeded(String),
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Decision {
|
||||
pub allowed: bool,
|
||||
pub reason: PolicyReason,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn allow() -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
reason: PolicyReason::Allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deny(reason: PolicyReason) -> Self {
|
||||
Self {
|
||||
allowed: false,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResourceRequest {
|
||||
pub action: Action,
|
||||
pub required_capabilities: BTreeSet<Capability>,
|
||||
pub hosted_control_plane: bool,
|
||||
}
|
||||
|
||||
pub trait CapabilityPolicy {
|
||||
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision;
|
||||
}
|
||||
|
||||
pub trait ServicePolicy: CapabilityPolicy + Send + Sync {}
|
||||
|
||||
impl<T> ServicePolicy for T where T: CapabilityPolicy + Send + Sync {}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LocalTrustedPolicy;
|
||||
|
||||
impl CapabilityPolicy for LocalTrustedPolicy {
|
||||
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision {
|
||||
let authz = crate::auth::same_tenant_project(context, scope);
|
||||
if !authz.allowed {
|
||||
return Decision::deny(PolicyReason::Unauthorized(authz.reason));
|
||||
}
|
||||
if request.hosted_control_plane && request.action == Action::RunNativeCommand {
|
||||
return Decision::deny(PolicyReason::HostedNativeComputeDenied);
|
||||
}
|
||||
if request.hosted_control_plane && request.action == Action::RunContainer {
|
||||
return Decision::deny(PolicyReason::HostedContainerDenied);
|
||||
}
|
||||
Decision::allow()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Actor, ProjectId, TenantId, UserId};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_policy_interface_denies_hosted_native_compute() {
|
||||
let policy = LocalTrustedPolicy;
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("user")),
|
||||
};
|
||||
let scope = Scope {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: None,
|
||||
task: None,
|
||||
node: None,
|
||||
artifact: None,
|
||||
};
|
||||
let request = ResourceRequest {
|
||||
action: Action::RunNativeCommand,
|
||||
required_capabilities: BTreeSet::new(),
|
||||
hosted_control_plane: true,
|
||||
};
|
||||
|
||||
let decision = policy.decide(&context, &scope, &request);
|
||||
|
||||
assert!(!decision.allowed);
|
||||
assert_eq!(decision.reason, PolicyReason::HostedNativeComputeDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_trusted_policy_allows_owner_controlled_native_capability_request() {
|
||||
let policy = LocalTrustedPolicy;
|
||||
let context = AuthContext {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
actor: Actor::User(UserId::from("owner")),
|
||||
};
|
||||
let scope = Scope {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: None,
|
||||
task: None,
|
||||
node: None,
|
||||
artifact: None,
|
||||
};
|
||||
let request = ResourceRequest {
|
||||
action: Action::RunNativeCommand,
|
||||
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||
hosted_control_plane: false,
|
||||
};
|
||||
|
||||
let decision = policy.decide(&context, &scope, &request);
|
||||
|
||||
assert!(decision.allowed);
|
||||
assert_eq!(decision.reason, PolicyReason::Allowed);
|
||||
}
|
||||
}
|
||||
125
crates/disasmer-core/src/project.rs
Normal file
125
crates/disasmer-core/src/project.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Entrypoint {
|
||||
pub name: String,
|
||||
pub function: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectModel {
|
||||
pub root: PathBuf,
|
||||
pub environments: Vec<EnvironmentResource>,
|
||||
pub entrypoints: BTreeMap<String, Entrypoint>,
|
||||
pub default_entrypoint: String,
|
||||
pub required_config_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum ProjectModelError {
|
||||
#[error("environment discovery failed: {0}")]
|
||||
Environment(String),
|
||||
#[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")]
|
||||
UnknownEntrypoint {
|
||||
name: String,
|
||||
available: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProjectModel {
|
||||
pub fn discover_without_config(root: &Path) -> Result<Self, ProjectModelError> {
|
||||
let environments = discover_environments(root).map_err(|err| {
|
||||
ProjectModelError::Environment(match err {
|
||||
EnvironmentError::Read { path, source } => {
|
||||
format!("failed to read {}: {source}", path.display())
|
||||
}
|
||||
})
|
||||
})?;
|
||||
Ok(Self {
|
||||
root: root.to_path_buf(),
|
||||
environments,
|
||||
entrypoints: default_entrypoints(),
|
||||
default_entrypoint: "build".to_owned(),
|
||||
required_config_file: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> {
|
||||
let name = name.unwrap_or(&self.default_entrypoint);
|
||||
self.entrypoints
|
||||
.get(name)
|
||||
.ok_or_else(|| ProjectModelError::UnknownEntrypoint {
|
||||
name: name.to_owned(),
|
||||
available: self.entrypoints.keys().cloned().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_entrypoints() -> BTreeMap<String, Entrypoint> {
|
||||
["build", "test", "package", "release", "watch"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Entrypoint {
|
||||
name: name.to_owned(),
|
||||
function: format!("{name}_main"),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn project_works_without_hand_written_configuration_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(temp.path().join("envs/linux")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("envs/linux/Containerfile"),
|
||||
"FROM alpine\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(model.required_config_file, None);
|
||||
assert_eq!(model.environments[0].name, "linux");
|
||||
assert_eq!(model.select_entrypoint(None).unwrap().name, "build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_can_define_multiple_default_entrypoints() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
model.select_entrypoint(Some("test")).unwrap().function,
|
||||
"test_main"
|
||||
);
|
||||
assert_eq!(
|
||||
model.select_entrypoint(Some("release")).unwrap().function,
|
||||
"release_main"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_entrypoint_lists_available_choices() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||
let error = model.select_entrypoint(Some("deploy")).unwrap_err();
|
||||
|
||||
assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. }));
|
||||
}
|
||||
}
|
||||
429
crates/disasmer-core/src/scheduler.rs
Normal file
429
crates/disasmer-core/src/scheduler.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
ArtifactId, Capability, Digest, EnvironmentRequirements, NodeCapabilities, NodeId, ProjectId,
|
||||
TenantId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeDescriptor {
|
||||
pub id: NodeId,
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub capabilities: NodeCapabilities,
|
||||
pub cached_environments: BTreeSet<Digest>,
|
||||
pub dependency_caches: BTreeSet<Digest>,
|
||||
pub source_snapshots: BTreeSet<Digest>,
|
||||
pub artifact_locations: BTreeSet<ArtifactId>,
|
||||
pub direct_connectivity: bool,
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlacementRequest {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub environment: Option<EnvironmentRequirements>,
|
||||
pub environment_digest: Option<Digest>,
|
||||
pub required_capabilities: BTreeSet<Capability>,
|
||||
pub dependency_cache: Option<Digest>,
|
||||
pub source_snapshot: Option<Digest>,
|
||||
pub required_artifacts: BTreeSet<ArtifactId>,
|
||||
pub quota_available: bool,
|
||||
pub policy_allowed: bool,
|
||||
pub prefer_node: Option<NodeId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Placement {
|
||||
pub node: NodeId,
|
||||
pub score: i64,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
#[error("no capable node for placement: {message}")]
|
||||
pub struct PlacementError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub trait Scheduler {
|
||||
fn place(
|
||||
&self,
|
||||
nodes: &[NodeDescriptor],
|
||||
request: &PlacementRequest,
|
||||
) -> Result<Placement, PlacementError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DefaultScheduler;
|
||||
|
||||
impl Scheduler for DefaultScheduler {
|
||||
fn place(
|
||||
&self,
|
||||
nodes: &[NodeDescriptor],
|
||||
request: &PlacementRequest,
|
||||
) -> Result<Placement, PlacementError> {
|
||||
let mut scored = Vec::new();
|
||||
let mut rejection_counts = BTreeMap::<String, usize>::new();
|
||||
|
||||
for node in nodes {
|
||||
match compatibility(node, request) {
|
||||
Ok(mut placement) => {
|
||||
locality_score(node, request, &mut placement);
|
||||
scored.push(placement);
|
||||
}
|
||||
Err(reasons) => {
|
||||
for reason in reasons {
|
||||
*rejection_counts.entry(reason).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scored
|
||||
.into_iter()
|
||||
.max_by_key(|placement| placement.score)
|
||||
.ok_or_else(|| PlacementError {
|
||||
message: rejection_counts
|
||||
.into_iter()
|
||||
.map(|(reason, count)| format!("{reason} ({count} node(s))"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn compatibility(
|
||||
node: &NodeDescriptor,
|
||||
request: &PlacementRequest,
|
||||
) -> Result<Placement, Vec<String>> {
|
||||
let mut reasons = Vec::new();
|
||||
if !node.online {
|
||||
reasons.push("node offline".to_owned());
|
||||
}
|
||||
if node.tenant != request.tenant {
|
||||
reasons.push("tenant mismatch".to_owned());
|
||||
}
|
||||
if node.project != request.project {
|
||||
reasons.push("project mismatch".to_owned());
|
||||
}
|
||||
if !request.quota_available {
|
||||
reasons.push("quota unavailable for placement".to_owned());
|
||||
}
|
||||
if !request.policy_allowed {
|
||||
reasons.push("policy denied placement".to_owned());
|
||||
}
|
||||
for capability in &request.required_capabilities {
|
||||
if !node.capabilities.capabilities.contains(capability) {
|
||||
reasons.push(format!("missing capability {capability:?}"));
|
||||
}
|
||||
}
|
||||
if let Some(environment) = &request.environment {
|
||||
if let Some(required_os) = &environment.os {
|
||||
if &node.capabilities.os != required_os {
|
||||
reasons.push(format!("environment requires os {required_os:?}"));
|
||||
}
|
||||
}
|
||||
if let Some(required_arch) = &environment.arch {
|
||||
if &node.capabilities.arch != required_arch {
|
||||
reasons.push(format!("environment requires arch {required_arch}"));
|
||||
}
|
||||
}
|
||||
for capability in &environment.capabilities {
|
||||
if !node.capabilities.capabilities.contains(capability) {
|
||||
reasons.push(format!("environment requires capability {capability:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let source_transfer_required = request
|
||||
.source_snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|digest| !node.source_snapshots.contains(digest));
|
||||
if source_transfer_required && !node.direct_connectivity {
|
||||
reasons.push("source snapshot unavailable and direct connectivity unavailable".to_owned());
|
||||
}
|
||||
let missing_artifacts = request
|
||||
.required_artifacts
|
||||
.iter()
|
||||
.filter(|artifact| !node.artifact_locations.contains(*artifact))
|
||||
.count();
|
||||
if missing_artifacts > 0 && !node.direct_connectivity {
|
||||
reasons.push(format!(
|
||||
"{missing_artifacts} required artifact(s) unavailable and direct connectivity unavailable"
|
||||
));
|
||||
}
|
||||
|
||||
if reasons.is_empty() {
|
||||
Ok(Placement {
|
||||
node: node.id.clone(),
|
||||
score: 0,
|
||||
reasons: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
Err(reasons)
|
||||
}
|
||||
}
|
||||
|
||||
fn locality_score(node: &NodeDescriptor, request: &PlacementRequest, placement: &mut Placement) {
|
||||
if request.prefer_node.as_ref() == Some(&node.id) {
|
||||
placement.score += 100;
|
||||
placement.reasons.push("preferred node".to_owned());
|
||||
}
|
||||
if request
|
||||
.environment_digest
|
||||
.as_ref()
|
||||
.is_some_and(|digest| node.cached_environments.contains(digest))
|
||||
{
|
||||
placement.score += 50;
|
||||
placement.reasons.push("warm environment cache".to_owned());
|
||||
}
|
||||
if request
|
||||
.source_snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|digest| node.source_snapshots.contains(digest))
|
||||
{
|
||||
placement.score += 40;
|
||||
placement
|
||||
.reasons
|
||||
.push("source snapshot already local".to_owned());
|
||||
}
|
||||
if request
|
||||
.dependency_cache
|
||||
.as_ref()
|
||||
.is_some_and(|digest| node.dependency_caches.contains(digest))
|
||||
{
|
||||
placement.score += 30;
|
||||
placement.reasons.push("warm dependency cache".to_owned());
|
||||
}
|
||||
let artifact_hits = request
|
||||
.required_artifacts
|
||||
.iter()
|
||||
.filter(|artifact| node.artifact_locations.contains(*artifact))
|
||||
.count() as i64;
|
||||
if artifact_hits > 0 {
|
||||
placement.score += 10 * artifact_hits;
|
||||
placement.reasons.push(format!(
|
||||
"{artifact_hits} required artifact(s) already local"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{EnvironmentBackend, Os};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn node(id: &str, cached_source: bool) -> NodeDescriptor {
|
||||
let source = Digest::sha256("source");
|
||||
NodeDescriptor {
|
||||
id: NodeId::from(id),
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
capabilities: NodeCapabilities {
|
||||
os: Os::Linux,
|
||||
arch: "x86_64".to_owned(),
|
||||
capabilities: BTreeSet::from([
|
||||
Capability::Command,
|
||||
Capability::Containers,
|
||||
Capability::RootlessPodman,
|
||||
]),
|
||||
environment_backends: BTreeSet::from([EnvironmentBackend::Container]),
|
||||
source_providers: BTreeSet::from(["filesystem".to_owned()]),
|
||||
},
|
||||
cached_environments: BTreeSet::from([Digest::sha256("env")]),
|
||||
dependency_caches: if cached_source {
|
||||
BTreeSet::from([Digest::sha256("deps")])
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
source_snapshots: if cached_source {
|
||||
BTreeSet::from([source])
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
artifact_locations: BTreeSet::new(),
|
||||
direct_connectivity: true,
|
||||
online: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_prefers_warm_source_and_environment() {
|
||||
let request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: Some(EnvironmentRequirements::linux_container()),
|
||||
environment_digest: Some(Digest::sha256("env")),
|
||||
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||
dependency_cache: Some(Digest::sha256("deps")),
|
||||
source_snapshot: Some(Digest::sha256("source")),
|
||||
required_artifacts: BTreeSet::new(),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
|
||||
let placement = DefaultScheduler
|
||||
.place(&[node("cold", false), node("warm", true)], &request)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(placement.node, NodeId::from("warm"));
|
||||
assert!(placement
|
||||
.reasons
|
||||
.iter()
|
||||
.any(|reason| reason.contains("source")));
|
||||
assert!(placement
|
||||
.reasons
|
||||
.iter()
|
||||
.any(|reason| reason.contains("dependency")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_failure_names_missing_constraint() {
|
||||
let mut request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: None,
|
||||
environment_digest: None,
|
||||
required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
|
||||
dependency_cache: None,
|
||||
source_snapshot: None,
|
||||
required_artifacts: BTreeSet::new(),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
request.required_capabilities.insert(Capability::Command);
|
||||
|
||||
let error = DefaultScheduler
|
||||
.place(&[node("linux", false)], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.message.contains("WindowsCommandDev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_failure_names_environment_constraint() {
|
||||
let request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: Some(EnvironmentRequirements::windows_command_dev()),
|
||||
environment_digest: None,
|
||||
required_capabilities: BTreeSet::new(),
|
||||
dependency_cache: None,
|
||||
source_snapshot: None,
|
||||
required_artifacts: BTreeSet::new(),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
|
||||
let error = DefaultScheduler
|
||||
.place(&[node("linux", false)], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.message.contains("environment requires os Windows"));
|
||||
assert!(error
|
||||
.message
|
||||
.contains("environment requires capability WindowsCommandDev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_requires_direct_connectivity_when_transfer_is_needed() {
|
||||
let mut disconnected = node("disconnected", false);
|
||||
disconnected.direct_connectivity = false;
|
||||
let mut local = node("local", true);
|
||||
local.direct_connectivity = false;
|
||||
let request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: None,
|
||||
environment_digest: None,
|
||||
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||
dependency_cache: None,
|
||||
source_snapshot: Some(Digest::sha256("source")),
|
||||
required_artifacts: BTreeSet::new(),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
|
||||
let placement = DefaultScheduler
|
||||
.place(&[disconnected, local], &request)
|
||||
.unwrap();
|
||||
assert_eq!(placement.node, NodeId::from("local"));
|
||||
|
||||
let mut disconnected = node("disconnected", false);
|
||||
disconnected.direct_connectivity = false;
|
||||
let error = DefaultScheduler
|
||||
.place(&[disconnected], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error
|
||||
.message
|
||||
.contains("source snapshot unavailable and direct connectivity unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_failure_names_required_artifact_transfer_constraint() {
|
||||
let mut disconnected = node("disconnected", true);
|
||||
disconnected.direct_connectivity = false;
|
||||
let request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: None,
|
||||
environment_digest: None,
|
||||
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||
dependency_cache: None,
|
||||
source_snapshot: None,
|
||||
required_artifacts: BTreeSet::from([ArtifactId::from("cache")]),
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
|
||||
let error = DefaultScheduler
|
||||
.place(&[disconnected], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error
|
||||
.message
|
||||
.contains("1 required artifact(s) unavailable and direct connectivity unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_failure_names_quota_and_policy_constraints() {
|
||||
let mut request = PlacementRequest {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
environment: None,
|
||||
environment_digest: None,
|
||||
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||
dependency_cache: None,
|
||||
source_snapshot: None,
|
||||
required_artifacts: BTreeSet::new(),
|
||||
quota_available: false,
|
||||
policy_allowed: true,
|
||||
prefer_node: None,
|
||||
};
|
||||
|
||||
let error = DefaultScheduler
|
||||
.place(&[node("linux", false)], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.message.contains("quota unavailable for placement"));
|
||||
|
||||
request.quota_available = true;
|
||||
request.policy_allowed = false;
|
||||
let error = DefaultScheduler
|
||||
.place(&[node("linux", false)], &request)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.message.contains("policy denied placement"));
|
||||
}
|
||||
}
|
||||
324
crates/disasmer-core/src/source.rs
Normal file
324
crates/disasmer-core/src/source.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Capability, Digest, ProjectId, TenantId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum SourceProviderKind {
|
||||
Filesystem,
|
||||
Git,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl SourceProviderKind {
|
||||
pub fn provider_id(&self) -> &str {
|
||||
match self {
|
||||
SourceProviderKind::Filesystem => "filesystem",
|
||||
SourceProviderKind::Git => "git",
|
||||
SourceProviderKind::Custom(provider) => provider,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum SourceTransferMode {
|
||||
RequiredContent,
|
||||
ExplicitSnapshotChunks,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceTransferPolicy {
|
||||
pub local_source_bytes_remain_node_local: bool,
|
||||
pub coordinator_receives_source_bytes_by_default: bool,
|
||||
pub default_full_repo_tarball: bool,
|
||||
pub allowed_remote_transfer: BTreeSet<SourceTransferMode>,
|
||||
}
|
||||
|
||||
impl SourceTransferPolicy {
|
||||
pub fn local_first_snapshot_chunks() -> Self {
|
||||
Self {
|
||||
local_source_bytes_remain_node_local: true,
|
||||
coordinator_receives_source_bytes_by_default: false,
|
||||
default_full_repo_tarball: false,
|
||||
allowed_remote_transfer: BTreeSet::from([
|
||||
SourceTransferMode::RequiredContent,
|
||||
SourceTransferMode::ExplicitSnapshotChunks,
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SourceTransferPolicy {
|
||||
fn default() -> Self {
|
||||
Self::local_first_snapshot_chunks()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceProviderManifest {
|
||||
pub kind: SourceProviderKind,
|
||||
pub digest: Digest,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub coordinator_requires_checkout_access: bool,
|
||||
#[serde(default)]
|
||||
pub transfer_policy: SourceTransferPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum SourceManifestError {
|
||||
#[error("source provider manifest digest is not a valid sha256 digest: {0}")]
|
||||
InvalidDigest(String),
|
||||
#[error("custom source provider id `{0}` is invalid")]
|
||||
InvalidProviderId(String),
|
||||
#[error("source provider manifest description must be non-empty")]
|
||||
EmptyDescription,
|
||||
#[error("source provider manifest description is too long")]
|
||||
DescriptionTooLong,
|
||||
#[error("source provider manifest description contains control characters")]
|
||||
DescriptionControlCharacter,
|
||||
#[error("source provider manifest would require coordinator checkout access")]
|
||||
CoordinatorCheckoutAccess,
|
||||
#[error("source provider manifest would send source bytes to the coordinator by default")]
|
||||
CoordinatorReceivesSourceBytes,
|
||||
#[error("source provider manifest would default to a full-repo tarball")]
|
||||
DefaultFullRepoTarball,
|
||||
#[error("source provider manifest has no allowed remote transfer mode")]
|
||||
MissingRemoteTransferMode,
|
||||
}
|
||||
|
||||
pub trait SourceProviderModule {
|
||||
fn kind(&self) -> SourceProviderKind;
|
||||
fn manifest(&self) -> SourceProviderManifest;
|
||||
}
|
||||
|
||||
impl SourceProviderManifest {
|
||||
pub fn local_first(kind: SourceProviderKind, description: impl Into<String>) -> Self {
|
||||
let transfer_policy = SourceTransferPolicy::local_first_snapshot_chunks();
|
||||
let digest = Self::digest_for(&kind, false, &transfer_policy);
|
||||
Self {
|
||||
kind,
|
||||
digest,
|
||||
description: description.into(),
|
||||
coordinator_requires_checkout_access: false,
|
||||
transfer_policy,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_public_mvp(&self) -> Result<(), SourceManifestError> {
|
||||
self.validate_shape()?;
|
||||
if self.coordinator_requires_checkout_access {
|
||||
return Err(SourceManifestError::CoordinatorCheckoutAccess);
|
||||
}
|
||||
if self
|
||||
.transfer_policy
|
||||
.coordinator_receives_source_bytes_by_default
|
||||
{
|
||||
return Err(SourceManifestError::CoordinatorReceivesSourceBytes);
|
||||
}
|
||||
if self.transfer_policy.default_full_repo_tarball {
|
||||
return Err(SourceManifestError::DefaultFullRepoTarball);
|
||||
}
|
||||
if self.transfer_policy.allowed_remote_transfer.is_empty() {
|
||||
return Err(SourceManifestError::MissingRemoteTransferMode);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_shape(&self) -> Result<(), SourceManifestError> {
|
||||
if !self.digest.is_valid_sha256() {
|
||||
return Err(SourceManifestError::InvalidDigest(
|
||||
self.digest.as_str().to_owned(),
|
||||
));
|
||||
}
|
||||
if let SourceProviderKind::Custom(provider) = &self.kind {
|
||||
if !valid_provider_id(provider) {
|
||||
return Err(SourceManifestError::InvalidProviderId(provider.clone()));
|
||||
}
|
||||
}
|
||||
if self.description.trim().is_empty() {
|
||||
return Err(SourceManifestError::EmptyDescription);
|
||||
}
|
||||
if self.description.len() > 256 {
|
||||
return Err(SourceManifestError::DescriptionTooLong);
|
||||
}
|
||||
if self.description.chars().any(char::is_control) {
|
||||
return Err(SourceManifestError::DescriptionControlCharacter);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digest_for(
|
||||
kind: &SourceProviderKind,
|
||||
coordinator_requires_checkout_access: bool,
|
||||
transfer_policy: &SourceTransferPolicy,
|
||||
) -> Digest {
|
||||
let mut modes = transfer_policy
|
||||
.allowed_remote_transfer
|
||||
.iter()
|
||||
.map(|mode| format!("{mode:?}"))
|
||||
.collect::<Vec<_>>();
|
||||
modes.sort();
|
||||
let mut parts = vec![
|
||||
b"source-provider-manifest:v2".to_vec(),
|
||||
kind.provider_id().as_bytes().to_vec(),
|
||||
coordinator_requires_checkout_access
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
transfer_policy
|
||||
.local_source_bytes_remain_node_local
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
transfer_policy
|
||||
.coordinator_receives_source_bytes_by_default
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
transfer_policy
|
||||
.default_full_repo_tarball
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
];
|
||||
parts.extend(modes.into_iter().map(String::into_bytes));
|
||||
Digest::from_parts(parts)
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_provider_id(provider: &str) -> bool {
|
||||
!provider.is_empty()
|
||||
&& provider.len() <= 64
|
||||
&& provider
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourcePreparation {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub provider: SourceProviderKind,
|
||||
pub required_capabilities: BTreeSet<Capability>,
|
||||
pub coordinator_requires_checkout_access: bool,
|
||||
}
|
||||
|
||||
impl SourcePreparation {
|
||||
pub fn node_task(tenant: TenantId, project: ProjectId, provider: SourceProviderKind) -> Self {
|
||||
let capability = match provider {
|
||||
SourceProviderKind::Filesystem => Capability::SourceFilesystem,
|
||||
SourceProviderKind::Git => Capability::SourceGit,
|
||||
SourceProviderKind::Custom(_) => Capability::SourceFilesystem,
|
||||
};
|
||||
|
||||
Self {
|
||||
tenant,
|
||||
project,
|
||||
provider,
|
||||
required_capabilities: BTreeSet::from([capability]),
|
||||
coordinator_requires_checkout_access: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_preparation_can_be_scheduled_as_node_task() {
|
||||
let prep = SourcePreparation::node_task(
|
||||
TenantId::from("tenant"),
|
||||
ProjectId::from("project"),
|
||||
SourceProviderKind::Git,
|
||||
);
|
||||
|
||||
assert!(!prep.coordinator_requires_checkout_access);
|
||||
assert!(prep.required_capabilities.contains(&Capability::SourceGit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_first_source_manifest_rejects_bulk_coordinator_paths() {
|
||||
let manifest = SourceProviderManifest::local_first(
|
||||
SourceProviderKind::Git,
|
||||
"node-side Git snapshot provider",
|
||||
);
|
||||
|
||||
assert!(manifest.validate_public_mvp().is_ok());
|
||||
assert!(!manifest.coordinator_requires_checkout_access);
|
||||
assert!(
|
||||
!manifest
|
||||
.transfer_policy
|
||||
.coordinator_receives_source_bytes_by_default
|
||||
);
|
||||
assert!(!manifest.transfer_policy.default_full_repo_tarball);
|
||||
assert!(manifest
|
||||
.transfer_policy
|
||||
.allowed_remote_transfer
|
||||
.contains(&SourceTransferMode::ExplicitSnapshotChunks));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_manifest_validation_treats_manifest_as_hostile_input() {
|
||||
let mut manifest = SourceProviderManifest::local_first(
|
||||
SourceProviderKind::Custom("gitlab-lfs".to_owned()),
|
||||
"custom provider",
|
||||
);
|
||||
assert!(manifest.validate_public_mvp().is_ok());
|
||||
|
||||
manifest.kind = SourceProviderKind::Custom("../checkout".to_owned());
|
||||
assert_eq!(
|
||||
manifest.validate_public_mvp(),
|
||||
Err(SourceManifestError::InvalidProviderId(
|
||||
"../checkout".to_owned()
|
||||
))
|
||||
);
|
||||
|
||||
manifest.kind = SourceProviderKind::Git;
|
||||
manifest.digest = Digest::sha256("valid");
|
||||
manifest.coordinator_requires_checkout_access = true;
|
||||
assert_eq!(
|
||||
manifest.validate_public_mvp(),
|
||||
Err(SourceManifestError::CoordinatorCheckoutAccess)
|
||||
);
|
||||
|
||||
manifest.coordinator_requires_checkout_access = false;
|
||||
manifest
|
||||
.transfer_policy
|
||||
.coordinator_receives_source_bytes_by_default = true;
|
||||
assert_eq!(
|
||||
manifest.validate_public_mvp(),
|
||||
Err(SourceManifestError::CoordinatorReceivesSourceBytes)
|
||||
);
|
||||
|
||||
manifest
|
||||
.transfer_policy
|
||||
.coordinator_receives_source_bytes_by_default = false;
|
||||
manifest.transfer_policy.default_full_repo_tarball = true;
|
||||
assert_eq!(
|
||||
manifest.validate_public_mvp(),
|
||||
Err(SourceManifestError::DefaultFullRepoTarball)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_manifest_rejects_malformed_digest_from_json() {
|
||||
let mut manifest = SourceProviderManifest::local_first(
|
||||
SourceProviderKind::Filesystem,
|
||||
"filesystem provider",
|
||||
);
|
||||
let value = serde_json::to_value(&manifest).unwrap();
|
||||
let mut object = value.as_object().unwrap().clone();
|
||||
object.insert(
|
||||
"digest".to_owned(),
|
||||
serde_json::Value::String("sha256:not-a-real-digest".to_owned()),
|
||||
);
|
||||
manifest = serde_json::from_value(serde_json::Value::Object(object)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
manifest.validate_public_mvp(),
|
||||
Err(SourceManifestError::InvalidDigest(
|
||||
"sha256:not-a-real-digest".to_owned()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
243
crates/disasmer-core/src/transport.rs
Normal file
243
crates/disasmer-core/src/transport.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ArtifactId, Digest, NodeId, ProcessId, ProjectId, TenantId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportKind {
|
||||
NativeQuic,
|
||||
}
|
||||
|
||||
pub trait Transport {
|
||||
fn kind(&self) -> TransportKind;
|
||||
fn authenticated_direct_connections(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NativeQuicTransport;
|
||||
|
||||
impl Transport for NativeQuicTransport {
|
||||
fn kind(&self) -> TransportKind {
|
||||
TransportKind::NativeQuic
|
||||
}
|
||||
|
||||
fn authenticated_direct_connections(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DataPlaneScope {
|
||||
pub tenant: TenantId,
|
||||
pub project: ProjectId,
|
||||
pub process: ProcessId,
|
||||
pub object: DataPlaneObject,
|
||||
pub authorization_subject: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataPlaneObject {
|
||||
Artifact(ArtifactId),
|
||||
Blob(Digest),
|
||||
SourceSnapshot(Digest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NodeEndpoint {
|
||||
pub node: NodeId,
|
||||
pub advertised_addr: String,
|
||||
pub public_key_fingerprint: Digest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RendezvousRequest {
|
||||
pub scope: DataPlaneScope,
|
||||
pub source: NodeEndpoint,
|
||||
pub destination: NodeEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DirectBulkTransferPlan {
|
||||
pub transport: TransportKind,
|
||||
pub scope: DataPlaneScope,
|
||||
pub source: NodeEndpoint,
|
||||
pub destination: NodeEndpoint,
|
||||
pub authorization_digest: Digest,
|
||||
pub coordinator_assisted_rendezvous: bool,
|
||||
pub coordinator_bulk_relay_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum TransportError {
|
||||
#[error(
|
||||
"direct node-to-node connectivity is unavailable for scoped data-plane transfer: {reason}; coordinator bulk relay is disabled"
|
||||
)]
|
||||
DirectConnectivityUnavailable { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BulkTransferDecision {
|
||||
DirectAuthenticated { scope: DataPlaneScope },
|
||||
FailClear { message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
#[error("bulk relay through coordinator is not allowed by default")]
|
||||
pub struct BulkRelayDenied;
|
||||
|
||||
impl NativeQuicTransport {
|
||||
pub fn plan_authenticated_direct_bulk_transfer(
|
||||
&self,
|
||||
request: RendezvousRequest,
|
||||
direct_connectivity: bool,
|
||||
failure_reason: impl Into<String>,
|
||||
) -> Result<DirectBulkTransferPlan, TransportError> {
|
||||
if !direct_connectivity {
|
||||
return Err(TransportError::DirectConnectivityUnavailable {
|
||||
reason: failure_reason.into(),
|
||||
});
|
||||
}
|
||||
|
||||
let authorization_digest = data_plane_authorization_digest(&request);
|
||||
Ok(DirectBulkTransferPlan {
|
||||
transport: self.kind(),
|
||||
scope: request.scope,
|
||||
source: request.source,
|
||||
destination: request.destination,
|
||||
authorization_digest,
|
||||
coordinator_assisted_rendezvous: true,
|
||||
coordinator_bulk_relay_allowed: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direct_bulk_transfer_or_error(
|
||||
scope: DataPlaneScope,
|
||||
direct_connectivity: bool,
|
||||
) -> BulkTransferDecision {
|
||||
if direct_connectivity {
|
||||
BulkTransferDecision::DirectAuthenticated { scope }
|
||||
} else {
|
||||
BulkTransferDecision::FailClear {
|
||||
message:
|
||||
"direct node-to-node connectivity is unavailable; coordinator bulk relay is disabled"
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn data_plane_authorization_digest(request: &RendezvousRequest) -> Digest {
|
||||
let object = match &request.scope.object {
|
||||
DataPlaneObject::Artifact(artifact) => format!("artifact:{artifact}"),
|
||||
DataPlaneObject::Blob(digest) => format!("blob:{}", digest.as_str()),
|
||||
DataPlaneObject::SourceSnapshot(digest) => format!("source:{}", digest.as_str()),
|
||||
};
|
||||
|
||||
Digest::from_parts([
|
||||
b"dataplane-auth:v1".as_slice(),
|
||||
request.scope.tenant.as_str().as_bytes(),
|
||||
request.scope.project.as_str().as_bytes(),
|
||||
request.scope.process.as_str().as_bytes(),
|
||||
object.as_bytes(),
|
||||
request.scope.authorization_subject.as_bytes(),
|
||||
request.source.node.as_str().as_bytes(),
|
||||
request.source.public_key_fingerprint.as_str().as_bytes(),
|
||||
request.destination.node.as_str().as_bytes(),
|
||||
request
|
||||
.destination
|
||||
.public_key_fingerprint
|
||||
.as_str()
|
||||
.as_bytes(),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn endpoint(name: &str) -> NodeEndpoint {
|
||||
NodeEndpoint {
|
||||
node: NodeId::from(name),
|
||||
advertised_addr: format!("{name}.mesh.invalid:4433"),
|
||||
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(project: &str) -> DataPlaneScope {
|
||||
DataPlaneScope {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from(project),
|
||||
process: ProcessId::from("process"),
|
||||
object: DataPlaneObject::Artifact(ArtifactId::from("artifact")),
|
||||
authorization_subject: "node-a-to-node-b".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_direct_transfer_does_not_silently_relay() {
|
||||
let decision = direct_bulk_transfer_or_error(scope("project"), false);
|
||||
|
||||
assert!(matches!(decision, BulkTransferDecision::FailClear { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_quic_rendezvous_plan_is_scoped_and_disallows_coordinator_bulk_relay() {
|
||||
let transport = NativeQuicTransport;
|
||||
let request = RendezvousRequest {
|
||||
scope: scope("project"),
|
||||
source: endpoint("node-a"),
|
||||
destination: endpoint("node-b"),
|
||||
};
|
||||
|
||||
let plan = transport
|
||||
.plan_authenticated_direct_bulk_transfer(request.clone(), true, "")
|
||||
.unwrap();
|
||||
let changed_scope_plan = transport
|
||||
.plan_authenticated_direct_bulk_transfer(
|
||||
RendezvousRequest {
|
||||
scope: scope("other-project"),
|
||||
..request
|
||||
},
|
||||
true,
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.transport, TransportKind::NativeQuic);
|
||||
assert_eq!(plan.scope.tenant, TenantId::from("tenant"));
|
||||
assert_eq!(plan.scope.project, ProjectId::from("project"));
|
||||
assert_eq!(plan.scope.process, ProcessId::from("process"));
|
||||
assert_eq!(
|
||||
plan.scope.object,
|
||||
DataPlaneObject::Artifact(ArtifactId::from("artifact"))
|
||||
);
|
||||
assert_eq!(plan.source.node, NodeId::from("node-a"));
|
||||
assert_eq!(plan.destination.node, NodeId::from("node-b"));
|
||||
assert!(plan.coordinator_assisted_rendezvous);
|
||||
assert!(!plan.coordinator_bulk_relay_allowed);
|
||||
assert_ne!(
|
||||
plan.authorization_digest,
|
||||
changed_scope_plan.authorization_digest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_direct_rendezvous_reports_clear_error_instead_of_relaying() {
|
||||
let error = NativeQuicTransport
|
||||
.plan_authenticated_direct_bulk_transfer(
|
||||
RendezvousRequest {
|
||||
scope: scope("project"),
|
||||
source: endpoint("node-a"),
|
||||
destination: endpoint("node-b"),
|
||||
},
|
||||
false,
|
||||
"nat traversal failed",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("nat traversal failed"));
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("coordinator bulk relay is disabled"));
|
||||
}
|
||||
}
|
||||
241
crates/disasmer-core/src/vfs.rs
Normal file
241
crates/disasmer-core/src/vfs.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Digest, NodeId, TaskId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct VfsPath(String);
|
||||
|
||||
impl VfsPath {
|
||||
pub fn new(path: impl Into<String>) -> Result<Self, VfsError> {
|
||||
let path = path.into();
|
||||
if !path.starts_with("/vfs/") {
|
||||
return Err(VfsError::InvalidPath(path));
|
||||
}
|
||||
Ok(Self(path))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VfsObject {
|
||||
pub path: VfsPath,
|
||||
pub digest: Digest,
|
||||
pub size: u64,
|
||||
pub producer: TaskId,
|
||||
pub node: NodeId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VfsManifest {
|
||||
pub epoch: u64,
|
||||
pub producer: TaskId,
|
||||
pub node: NodeId,
|
||||
pub objects: BTreeMap<VfsPath, VfsObject>,
|
||||
pub large_bytes_uploaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SyncPolicy {
|
||||
MetadataOnly,
|
||||
ExplicitNode(NodeId),
|
||||
ExplicitStore(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum VfsSyncDecision {
|
||||
NoBytesMoved,
|
||||
MoveBytesToNode(NodeId),
|
||||
MoveBytesToStore(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReuseDecision {
|
||||
SameNodeZeroCopy,
|
||||
NeedsTransfer { from: NodeId, to: NodeId },
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum VfsError {
|
||||
#[error("VFS path must start with /vfs/: {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("path is not visible in the published VFS manifest: {0}")]
|
||||
NotVisible(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VfsOverlay {
|
||||
task: TaskId,
|
||||
node: NodeId,
|
||||
epoch: u64,
|
||||
pending: BTreeMap<VfsPath, VfsObject>,
|
||||
published: BTreeMap<VfsPath, VfsObject>,
|
||||
}
|
||||
|
||||
impl VfsOverlay {
|
||||
pub fn new(task: TaskId, node: NodeId) -> Self {
|
||||
Self {
|
||||
task,
|
||||
node,
|
||||
epoch: 0,
|
||||
pending: BTreeMap::new(),
|
||||
published: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject {
|
||||
let object = VfsObject {
|
||||
path: path.clone(),
|
||||
digest,
|
||||
size,
|
||||
producer: self.task.clone(),
|
||||
node: self.node.clone(),
|
||||
};
|
||||
self.pending.insert(path, object.clone());
|
||||
object
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> VfsManifest {
|
||||
self.epoch += 1;
|
||||
self.published.append(&mut self.pending);
|
||||
VfsManifest {
|
||||
epoch: self.epoch,
|
||||
producer: self.task.clone(),
|
||||
node: self.node.clone(),
|
||||
objects: self.published.clone(),
|
||||
large_bytes_uploaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision {
|
||||
match policy {
|
||||
SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved,
|
||||
SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node),
|
||||
SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_published<'a>(
|
||||
manifest: &'a VfsManifest,
|
||||
path: &VfsPath,
|
||||
) -> Result<&'a VfsObject, VfsError> {
|
||||
manifest
|
||||
.objects
|
||||
.get(path)
|
||||
.ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned()))
|
||||
}
|
||||
|
||||
pub fn reuse_for_consumer(
|
||||
manifest: &VfsManifest,
|
||||
path: &VfsPath,
|
||||
consumer_node: &NodeId,
|
||||
) -> ReuseDecision {
|
||||
let Some(object) = manifest.objects.get(path) else {
|
||||
return ReuseDecision::Unavailable;
|
||||
};
|
||||
if &object.node == consumer_node {
|
||||
ReuseDecision::SameNodeZeroCopy
|
||||
} else {
|
||||
ReuseDecision::NeedsTransfer {
|
||||
from: object.node.clone(),
|
||||
to: consumer_node.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discard_unflushed(&mut self) {
|
||||
self.pending.clear();
|
||||
}
|
||||
|
||||
pub fn pending_len(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn path() -> VfsPath {
|
||||
VfsPath::new("/vfs/artifacts/app").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_publishes_manifest_without_large_byte_upload() {
|
||||
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||
|
||||
let manifest = overlay.flush();
|
||||
|
||||
assert_eq!(manifest.epoch, 1);
|
||||
assert!(!manifest.large_bytes_uploaded);
|
||||
assert!(manifest.objects.contains_key(&path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downstream_task_can_read_after_flush_but_not_before() {
|
||||
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||
|
||||
let empty = VfsManifest {
|
||||
epoch: 0,
|
||||
producer: TaskId::from("task"),
|
||||
node: NodeId::from("node-a"),
|
||||
objects: BTreeMap::new(),
|
||||
large_bytes_uploaded: false,
|
||||
};
|
||||
assert!(VfsOverlay::read_published(&empty, &path()).is_err());
|
||||
|
||||
let manifest = overlay.flush();
|
||||
assert!(VfsOverlay::read_published(&manifest, &path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_is_explicit_and_policy_driven() {
|
||||
let overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||
|
||||
assert_eq!(
|
||||
overlay.sync(SyncPolicy::MetadataOnly),
|
||||
VfsSyncDecision::NoBytesMoved
|
||||
);
|
||||
assert_eq!(
|
||||
overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())),
|
||||
VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_node_reuse_avoids_transfer() {
|
||||
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||
let manifest = overlay.flush();
|
||||
|
||||
assert_eq!(
|
||||
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")),
|
||||
ReuseDecision::SameNodeZeroCopy
|
||||
);
|
||||
assert_eq!(
|
||||
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")),
|
||||
ReuseDecision::NeedsTransfer {
|
||||
from: NodeId::from("node-a"),
|
||||
to: NodeId::from("node-b")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unflushed_task_local_changes_can_be_discarded() {
|
||||
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||
|
||||
overlay.discard_unflushed();
|
||||
|
||||
assert_eq!(overlay.pending_len(), 0);
|
||||
}
|
||||
}
|
||||
16
crates/disasmer-dap/Cargo.toml
Normal file
16
crates/disasmer-dap/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "disasmer-dap"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "disasmer-debug-dap"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
disasmer-node = { path = "../disasmer-node" }
|
||||
serde_json.workspace = true
|
||||
2572
crates/disasmer-dap/src/main.rs
Normal file
2572
crates/disasmer-dap/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
14
crates/disasmer-macros/Cargo.toml
Normal file
14
crates/disasmer-macros/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "disasmer-macros"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2.workspace = true
|
||||
quote.workspace = true
|
||||
syn.workspace = true
|
||||
77
crates/disasmer-macros/src/lib.rs
Normal file
77
crates/disasmer-macros/src/lib.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{parse::Parser, parse_macro_input, Expr, ItemFn, Lit, Meta, Token};
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let function = parse_macro_input!(item as ItemFn);
|
||||
let function_name = function.sig.ident.to_string();
|
||||
let entrypoint_name = descriptor_name(
|
||||
attr,
|
||||
function_name
|
||||
.strip_suffix("_main")
|
||||
.unwrap_or(&function_name),
|
||||
);
|
||||
let descriptor = format_ident!(
|
||||
"__DISASMER_ENTRYPOINT_{}",
|
||||
function_name.to_ascii_uppercase()
|
||||
);
|
||||
|
||||
quote! {
|
||||
#function
|
||||
|
||||
#[doc(hidden)]
|
||||
pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor {
|
||||
name: #entrypoint_name,
|
||||
function: #function_name,
|
||||
};
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let function = parse_macro_input!(item as ItemFn);
|
||||
let function_name = function.sig.ident.to_string();
|
||||
let task_name = descriptor_name(attr, &function_name);
|
||||
let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase());
|
||||
|
||||
quote! {
|
||||
#function
|
||||
|
||||
#[doc(hidden)]
|
||||
pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor {
|
||||
name: #task_name,
|
||||
function: #function_name,
|
||||
remotely_startable: true,
|
||||
};
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn descriptor_name(attr: TokenStream, default: &str) -> String {
|
||||
if attr.is_empty() {
|
||||
return default.to_owned();
|
||||
}
|
||||
|
||||
let parser = syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||
let Ok(args) = parser.parse(attr) else {
|
||||
return default.to_owned();
|
||||
};
|
||||
|
||||
for meta in args {
|
||||
let Meta::NameValue(name_value) = meta else {
|
||||
continue;
|
||||
};
|
||||
if !name_value.path.is_ident("name") {
|
||||
continue;
|
||||
}
|
||||
if let Expr::Lit(expr) = name_value.value {
|
||||
if let Lit::Str(name) = expr.lit {
|
||||
return name.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default.to_owned()
|
||||
}
|
||||
16
crates/disasmer-node/Cargo.toml
Normal file
16
crates/disasmer-node/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "disasmer-node"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
disasmer-core = { path = "../disasmer-core" }
|
||||
quinn.workspace = true
|
||||
rcgen.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
wasmtime.workspace = true
|
||||
84
crates/disasmer-node/src/bin/disasmer-podman-smoke.rs
Normal file
84
crates/disasmer-node/src/bin/disasmer-podman-smoke.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use disasmer_core::{
|
||||
CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource,
|
||||
NodeId, ProcessId, TaskId, VfsOverlay, VfsPath,
|
||||
};
|
||||
use disasmer_node::{LinuxRootlessPodmanBackend, LocalSourceCheckout, StdProcessRunner};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let workspace = create_workspace()?;
|
||||
let env_dir = workspace.join("envs/linux");
|
||||
fs::create_dir_all(&env_dir)?;
|
||||
fs::write(
|
||||
env_dir.join("Containerfile"),
|
||||
"FROM docker.io/library/alpine:3.20\nWORKDIR /workspace\n",
|
||||
)?;
|
||||
fs::write(workspace.join("input.txt"), "node-local source\n")?;
|
||||
|
||||
let env = EnvironmentResource {
|
||||
name: "linux".to_owned(),
|
||||
kind: EnvironmentKind::Containerfile,
|
||||
recipe_path: env_dir.join("Containerfile"),
|
||||
context_path: env_dir,
|
||||
digest: Digest::sha256("phase2-podman-smoke-linux-env"),
|
||||
requirements: EnvironmentRequirements::linux_container(),
|
||||
};
|
||||
let invocation = CommandInvocation {
|
||||
program: "sh".to_owned(),
|
||||
args: vec![
|
||||
"-c".to_owned(),
|
||||
"printf 'podman-ok:' && cat input.txt".to_owned(),
|
||||
],
|
||||
env: Some(env),
|
||||
};
|
||||
let checkout = LocalSourceCheckout {
|
||||
host_path: workspace.clone(),
|
||||
snapshot: Digest::sha256("phase2-podman-smoke-checkout"),
|
||||
};
|
||||
let task = TaskId::from("podman-smoke");
|
||||
let mut overlay = VfsOverlay::new(task.clone(), NodeId::from("node-podman-smoke"));
|
||||
let mut runner = StdProcessRunner;
|
||||
let output = LinuxRootlessPodmanBackend.execute_local_checkout_task(
|
||||
ProcessId::from("vp-podman-smoke"),
|
||||
task,
|
||||
&invocation,
|
||||
checkout,
|
||||
Some(VfsPath::new("/vfs/artifacts/podman-smoke.txt")?),
|
||||
&mut runner,
|
||||
&mut overlay,
|
||||
)?;
|
||||
let manifest = overlay.flush();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"podman_status": "completed",
|
||||
"status_code": output.status_code,
|
||||
"stdout": output.stdout,
|
||||
"stderr": output.stderr,
|
||||
"staged_artifact": output.staged_artifact,
|
||||
"large_bytes_uploaded": manifest.large_bytes_uploaded,
|
||||
"uses_full_repo_tarball": false,
|
||||
"coordinator_routed_file_reads": false,
|
||||
}))?
|
||||
);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace() -> Result<PathBuf, std::io::Error> {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or_default();
|
||||
let workspace = std::env::temp_dir().join(format!(
|
||||
"disasmer-podman-smoke-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&workspace)?;
|
||||
Ok(workspace)
|
||||
}
|
||||
138
crates/disasmer-node/src/bin/disasmer-quic-smoke.rs
Normal file
138
crates/disasmer-node/src/bin/disasmer-quic-smoke.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use disasmer_core::{
|
||||
ArtifactId, DataPlaneObject, DataPlaneScope, Digest, NativeQuicTransport, NodeEndpoint, NodeId,
|
||||
ProcessId, ProjectId, RendezvousRequest, TenantId, Transport,
|
||||
};
|
||||
use quinn::rustls::{
|
||||
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
RootCertStore,
|
||||
};
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct QuicTransferRequest {
|
||||
scope: DataPlaneScope,
|
||||
authorization_digest: Digest,
|
||||
requested_bytes: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let payload = b"artifact-bytes-over-rust-native-quic".to_vec();
|
||||
let (cert, key) = self_signed_localhost_cert()?;
|
||||
let server_endpoint = Endpoint::server(
|
||||
ServerConfig::with_single_cert(vec![cert.clone()], key)?,
|
||||
"127.0.0.1:0".parse()?,
|
||||
)?;
|
||||
let server_addr = server_endpoint.local_addr()?;
|
||||
|
||||
let scope = DataPlaneScope {
|
||||
tenant: TenantId::from("tenant"),
|
||||
project: ProjectId::from("project"),
|
||||
process: ProcessId::from("vp-quic"),
|
||||
object: DataPlaneObject::Artifact(ArtifactId::from("quic-artifact")),
|
||||
authorization_subject: "node-a-to-node-b".to_owned(),
|
||||
};
|
||||
let transport = NativeQuicTransport;
|
||||
let plan = transport.plan_authenticated_direct_bulk_transfer(
|
||||
RendezvousRequest {
|
||||
scope: scope.clone(),
|
||||
source: endpoint("node-a", server_addr),
|
||||
destination: endpoint("node-b", "127.0.0.1:0".parse()?),
|
||||
},
|
||||
true,
|
||||
"",
|
||||
)?;
|
||||
|
||||
let expected_scope = plan.scope.clone();
|
||||
let expected_digest = plan.authorization_digest.clone();
|
||||
let expected_payload = payload.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
let incoming = server_endpoint
|
||||
.accept()
|
||||
.await
|
||||
.ok_or("server endpoint closed before accepting a QUIC connection")?;
|
||||
let connection = incoming.await?;
|
||||
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||
let request_bytes = recv.read_to_end(64 * 1024).await?;
|
||||
let request: QuicTransferRequest = serde_json::from_slice(&request_bytes)?;
|
||||
if request.scope != expected_scope {
|
||||
return Err("QUIC request scope did not match the authorized data-plane scope".into());
|
||||
}
|
||||
if request.authorization_digest != expected_digest {
|
||||
return Err(
|
||||
"QUIC request authorization digest did not match the rendezvous plan".into(),
|
||||
);
|
||||
}
|
||||
send.write_all(&expected_payload).await?;
|
||||
send.finish()?;
|
||||
server_endpoint.wait_idle().await;
|
||||
Ok::<usize, Box<dyn std::error::Error + Send + Sync>>(request_bytes.len())
|
||||
});
|
||||
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(cert)?;
|
||||
let client_config = ClientConfig::with_root_certificates(Arc::new(roots))?;
|
||||
let mut client_endpoint = Endpoint::client("127.0.0.1:0".parse()?)?;
|
||||
client_endpoint.set_default_client_config(client_config);
|
||||
|
||||
let connection = client_endpoint.connect(server_addr, "localhost")?.await?;
|
||||
let (mut send, mut recv) = connection.open_bi().await?;
|
||||
let request = QuicTransferRequest {
|
||||
scope: plan.scope.clone(),
|
||||
authorization_digest: plan.authorization_digest.clone(),
|
||||
requested_bytes: payload.len() as u64,
|
||||
};
|
||||
let request_bytes = serde_json::to_vec(&request)?;
|
||||
send.write_all(&request_bytes).await?;
|
||||
send.finish()?;
|
||||
let received = recv.read_to_end(64 * 1024).await?;
|
||||
connection.close(0u32.into(), b"done");
|
||||
client_endpoint.wait_idle().await;
|
||||
let server_received_request_bytes = server.await??;
|
||||
|
||||
if received != payload {
|
||||
return Err("QUIC artifact payload did not round trip".into());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"kind": "disasmer_quic_smoke",
|
||||
"transport": format!("{:?}", transport.kind()),
|
||||
"rust_native_quic": true,
|
||||
"authenticated_direct_connection": transport.authenticated_direct_connections(),
|
||||
"coordinator_assisted_rendezvous": plan.coordinator_assisted_rendezvous,
|
||||
"coordinator_bulk_relay_allowed": plan.coordinator_bulk_relay_allowed,
|
||||
"source_node": plan.source.node,
|
||||
"destination_node": plan.destination.node,
|
||||
"scope": plan.scope,
|
||||
"request_bytes": request_bytes.len(),
|
||||
"server_received_request_bytes": server_received_request_bytes,
|
||||
"payload_bytes": received.len(),
|
||||
"authorization_digest": plan.authorization_digest,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn self_signed_localhost_cert() -> Result<
|
||||
(CertificateDer<'static>, PrivateKeyDer<'static>),
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
> {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?;
|
||||
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||
Ok((cert.cert.into(), key))
|
||||
}
|
||||
|
||||
fn endpoint(name: &str, addr: SocketAddr) -> NodeEndpoint {
|
||||
NodeEndpoint {
|
||||
node: NodeId::from(name),
|
||||
advertised_addr: addr.to_string(),
|
||||
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
|
||||
}
|
||||
}
|
||||
133
crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs
Normal file
133
crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use disasmer_core::{CommandInvocation, NodeId, TaskId, VfsPath};
|
||||
use disasmer_node::{LocalCommandExecutor, VirtualThreadCommand, WasmtimeTaskRuntime};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() == 2 && args[1] == "--host-command" {
|
||||
return run_host_command_smoke();
|
||||
}
|
||||
if args.len() == 6 && args[1] == "--debug-freeze-resume" {
|
||||
return run_debug_freeze_resume_smoke(&args[2], &args[3], &args[4], &args[5]);
|
||||
}
|
||||
if args.len() != 5 {
|
||||
return Err(
|
||||
"usage: disasmer-wasmtime-smoke <module.wasm> <export> <i32-arg> <expected-i32> | --host-command | --debug-freeze-resume <module.wasm> <export> <i32-arg> <expected-i32>".into(),
|
||||
);
|
||||
}
|
||||
|
||||
let module = PathBuf::from(&args[1]);
|
||||
let export = &args[2];
|
||||
let arg: i32 = args[3].parse()?;
|
||||
let expected: i32 = args[4].parse()?;
|
||||
|
||||
let wasm = fs::read(&module)?;
|
||||
let runtime = WasmtimeTaskRuntime::new()?;
|
||||
let result = runtime.run_i32_export(&wasm, export, arg)?;
|
||||
if result != expected {
|
||||
return Err(format!("expected {expected}, got {result} from export `{export}`").into());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"type": "wasmtime_task_smoke",
|
||||
"module": module,
|
||||
"export": export,
|
||||
"arg": arg,
|
||||
"result": result,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_debug_freeze_resume_smoke(
|
||||
module: &str,
|
||||
export: &str,
|
||||
arg: &str,
|
||||
expected: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let module = PathBuf::from(module);
|
||||
let arg: i32 = arg.parse()?;
|
||||
let expected: i32 = expected.parse()?;
|
||||
let wasm = fs::read(&module)?;
|
||||
let runtime = WasmtimeTaskRuntime::new()?;
|
||||
let probe = runtime.freeze_resume_i32_export_probe(&wasm, export, arg)?;
|
||||
if probe.result != expected {
|
||||
return Err(format!(
|
||||
"expected {expected}, got {} from export `{export}`",
|
||||
probe.result
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"type": "wasmtime_debug_freeze_resume_smoke",
|
||||
"module": module,
|
||||
"export": export,
|
||||
"task": probe.task,
|
||||
"frozen_state": probe.frozen_state,
|
||||
"resumed_state": probe.resumed_state,
|
||||
"stack_frames": probe.stack_frames,
|
||||
"local_values": probe.local_values,
|
||||
"wasm_function": probe.wasm_function,
|
||||
"wasm_pc": probe.wasm_pc,
|
||||
"arg": arg,
|
||||
"result": probe.result,
|
||||
"node_runtime_reached_wasm_task": true,
|
||||
"node_runtime_captured_wasm_locals": true,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_host_command_smoke() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = WasmtimeTaskRuntime::new()?;
|
||||
let result = runtime.run_i32_export_with_command_import(
|
||||
r#"
|
||||
(module
|
||||
(import "disasmer" "cmd_run" (func $cmd_run (result i32)))
|
||||
(func (export "compile-linux") (result i32)
|
||||
call $cmd_run))
|
||||
"#,
|
||||
"compile-linux",
|
||||
LocalCommandExecutor {
|
||||
node: NodeId::from("node-wasmtime"),
|
||||
hosted_control_plane: false,
|
||||
has_command_capability: true,
|
||||
},
|
||||
VirtualThreadCommand {
|
||||
virtual_thread: TaskId::from("compile-linux"),
|
||||
invocation: CommandInvocation {
|
||||
program: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), "printf linux-build-artifact".to_owned()],
|
||||
env: None,
|
||||
},
|
||||
stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/linux/app.tar.zst")?),
|
||||
},
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"type": "wasmtime_host_command_smoke",
|
||||
"export": "compile-linux",
|
||||
"export_result": result.export_result,
|
||||
"virtual_thread": result.command_output.virtual_thread,
|
||||
"stdout": result.command_output.stdout,
|
||||
"staged_artifact": result.command_output.staged_artifact,
|
||||
"large_bytes_uploaded": result.manifest.large_bytes_uploaded,
|
||||
"manifest_objects": result.manifest.objects.len(),
|
||||
"node_host_import": "disasmer.cmd_run",
|
||||
"flagship_linux_build_task": true,
|
||||
"node_executed_host_command": true,
|
||||
"hosted_control_plane_ran_command": false,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
1424
crates/disasmer-node/src/lib.rs
Normal file
1424
crates/disasmer-node/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
637
crates/disasmer-node/src/main.rs
Normal file
637
crates/disasmer-node/src/main.rs
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use disasmer_core::{
|
||||
Capability, CommandInvocation, NodeCapabilities, NodeId, TaskId, VfsOverlay, VfsPath,
|
||||
};
|
||||
use disasmer_node::{CommandOutput, LocalCommandExecutor, VirtualThreadCommand};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
coordinator: String,
|
||||
tenant: String,
|
||||
project: String,
|
||||
node: String,
|
||||
process: String,
|
||||
task: String,
|
||||
command: String,
|
||||
command_args: Vec<String>,
|
||||
artifact: String,
|
||||
enrollment_grant: Option<String>,
|
||||
public_key: Option<String>,
|
||||
control_poll_ms: u64,
|
||||
assignment_poll_ms: u64,
|
||||
emit_ready: bool,
|
||||
worker: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RuntimeTask {
|
||||
process: String,
|
||||
task: String,
|
||||
command: String,
|
||||
command_args: Vec<String>,
|
||||
artifact: String,
|
||||
epoch: Option<u64>,
|
||||
task_assignment_response: Value,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = parse_args()?;
|
||||
|
||||
let mut session = CoordinatorSession::connect(&args.coordinator)?;
|
||||
|
||||
let registration = register_node(&mut session, &args)?;
|
||||
let heartbeat = session.request(json!({
|
||||
"type": "node_heartbeat",
|
||||
"node": &args.node,
|
||||
}))?;
|
||||
let capability_report = session.request(json!({
|
||||
"type": "report_node_capabilities",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"node": &args.node,
|
||||
"capabilities": NodeCapabilities::detect_current(),
|
||||
"cached_environment_digests": [],
|
||||
"dependency_cache_digests": [],
|
||||
"source_snapshots": [],
|
||||
"artifact_locations": [],
|
||||
"direct_connectivity": true,
|
||||
"online": true,
|
||||
}))?;
|
||||
|
||||
if args.worker {
|
||||
return worker_loop(
|
||||
&args,
|
||||
&mut session,
|
||||
registration,
|
||||
heartbeat,
|
||||
capability_report,
|
||||
);
|
||||
}
|
||||
|
||||
let task_assignment = session.request(json!({
|
||||
"type": "schedule_task",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"environment": null,
|
||||
"environment_digest": null,
|
||||
"required_capabilities": [Capability::Command],
|
||||
"dependency_cache": null,
|
||||
"source_snapshot": null,
|
||||
"required_artifacts": [],
|
||||
"quota_available": true,
|
||||
"policy_allowed": true,
|
||||
"prefer_node": &args.node,
|
||||
}))?;
|
||||
let runtime_task = RuntimeTask {
|
||||
process: args.process.clone(),
|
||||
task: args.task.clone(),
|
||||
command: args.command.clone(),
|
||||
command_args: args.command_args.clone(),
|
||||
artifact: args.artifact.clone(),
|
||||
epoch: None,
|
||||
task_assignment_response: task_assignment,
|
||||
};
|
||||
let report = run_runtime_task(
|
||||
&args,
|
||||
&mut session,
|
||||
runtime_task,
|
||||
registration,
|
||||
heartbeat,
|
||||
capability_report,
|
||||
)?;
|
||||
println!("{}", serde_json::to_string(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_loop(
|
||||
args: &Args,
|
||||
session: &mut CoordinatorSession,
|
||||
registration: Value,
|
||||
heartbeat: Value,
|
||||
capability_report: Value,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if args.emit_ready {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"node_status": "ready",
|
||||
"mode": "worker",
|
||||
"node": &args.node,
|
||||
}))?
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let response = session.request(json!({
|
||||
"type": "poll_task_assignment",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"node": &args.node,
|
||||
}))?;
|
||||
let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else {
|
||||
std::thread::sleep(Duration::from_millis(args.assignment_poll_ms));
|
||||
continue;
|
||||
};
|
||||
let runtime_task = runtime_task_from_assignment(assignment)?;
|
||||
let report = run_runtime_task(
|
||||
args,
|
||||
session,
|
||||
runtime_task,
|
||||
registration.clone(),
|
||||
heartbeat.clone(),
|
||||
capability_report.clone(),
|
||||
)?;
|
||||
println!("{}", serde_json::to_string(&report)?);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_task_from_assignment(value: &Value) -> Result<RuntimeTask, Box<dyn std::error::Error>> {
|
||||
Ok(RuntimeTask {
|
||||
process: required_string(value, "process")?,
|
||||
task: required_string(value, "task")?,
|
||||
command: required_string(value, "command")?,
|
||||
command_args: value
|
||||
.get("command_args")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
artifact: required_string(value, "artifact_path")?,
|
||||
epoch: value.get("epoch").and_then(Value::as_u64),
|
||||
task_assignment_response: value.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn required_string(value: &Value, field: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("task assignment missing string field `{field}`").into())
|
||||
}
|
||||
|
||||
fn run_runtime_task(
|
||||
args: &Args,
|
||||
session: &mut CoordinatorSession,
|
||||
task: RuntimeTask,
|
||||
registration: Value,
|
||||
heartbeat: Value,
|
||||
capability_report: Value,
|
||||
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let epoch = match task.epoch {
|
||||
Some(epoch) => epoch,
|
||||
None => {
|
||||
let started = session.request(json!({
|
||||
"type": "start_process",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
}))?;
|
||||
started
|
||||
.get("epoch")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or("coordinator start_process response missing epoch")?
|
||||
}
|
||||
};
|
||||
session.request(json!({
|
||||
"type": "reconnect_node",
|
||||
"node": &args.node,
|
||||
"process": &task.process,
|
||||
"epoch": epoch,
|
||||
}))?;
|
||||
let debug_command = session.request(json!({
|
||||
"type": "poll_debug_command",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
}))?;
|
||||
if args.emit_ready && !args.worker {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"node_status": "ready",
|
||||
"node": &args.node,
|
||||
"process": &task.process,
|
||||
"task": &task.task,
|
||||
}))?
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
if args.control_poll_ms > 0 && wait_for_cancellation(session, args, &task)? {
|
||||
let recorded = session.request(json!({
|
||||
"type": "task_completed",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
"terminal_state": "cancelled",
|
||||
"status_code": null,
|
||||
"stdout_bytes": 0,
|
||||
"stderr_bytes": 0,
|
||||
"artifact_path": null,
|
||||
"artifact_digest": null,
|
||||
"artifact_size_bytes": null,
|
||||
}))?;
|
||||
return Ok(cancelled_node_report(
|
||||
args,
|
||||
&task,
|
||||
registration,
|
||||
heartbeat,
|
||||
capability_report,
|
||||
task.task_assignment_response.clone(),
|
||||
debug_command,
|
||||
recorded,
|
||||
session.requests(),
|
||||
));
|
||||
}
|
||||
|
||||
let executor = LocalCommandExecutor {
|
||||
node: NodeId::new(args.node.clone()),
|
||||
hosted_control_plane: false,
|
||||
has_command_capability: true,
|
||||
};
|
||||
let task_id = TaskId::new(task.task.clone());
|
||||
let mut overlay = VfsOverlay::new(task_id.clone(), NodeId::new(args.node.clone()));
|
||||
let output = executor.run(
|
||||
VirtualThreadCommand {
|
||||
virtual_thread: task_id,
|
||||
invocation: CommandInvocation {
|
||||
program: task.command.clone(),
|
||||
args: task.command_args.clone(),
|
||||
env: None,
|
||||
},
|
||||
stage_stdout_as: Some(VfsPath::new(task.artifact.clone())?),
|
||||
},
|
||||
&mut overlay,
|
||||
)?;
|
||||
let manifest = overlay.flush();
|
||||
let staged = output.staged_artifact.as_ref();
|
||||
let artifact_digest = staged.map(|artifact| artifact.digest.clone());
|
||||
let artifact_path = staged.map(|artifact| artifact.path.as_str().to_owned());
|
||||
let artifact_size_bytes = staged.map(|artifact| artifact.size);
|
||||
|
||||
let log_event = session.request(json!({
|
||||
"type": "report_task_log",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
"stdout_bytes": output.stdout.len(),
|
||||
"stderr_bytes": output.stderr.len(),
|
||||
"stdout_tail": &output.stdout,
|
||||
"stderr_tail": &output.stderr,
|
||||
"stdout_truncated": output.stdout_truncated,
|
||||
"stderr_truncated": output.stderr_truncated,
|
||||
"backpressured": output.log_backpressured,
|
||||
}))?;
|
||||
let vfs_metadata = session.request(json!({
|
||||
"type": "report_vfs_metadata",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
"artifact_path": artifact_path,
|
||||
"artifact_digest": artifact_digest,
|
||||
"artifact_size_bytes": artifact_size_bytes,
|
||||
"large_bytes_uploaded": manifest.large_bytes_uploaded,
|
||||
}))?;
|
||||
let recorded = session.request(json!({
|
||||
"type": "task_completed",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
"status_code": output.status_code,
|
||||
"stdout_bytes": output.stdout.len(),
|
||||
"stderr_bytes": output.stderr.len(),
|
||||
"stdout_tail": &output.stdout,
|
||||
"stderr_tail": &output.stderr,
|
||||
"stdout_truncated": output.stdout_truncated,
|
||||
"stderr_truncated": output.stderr_truncated,
|
||||
"artifact_path": staged.map(|artifact| artifact.path.as_str().to_owned()),
|
||||
"artifact_digest": staged.map(|artifact| artifact.digest.clone()),
|
||||
"artifact_size_bytes": artifact_size_bytes,
|
||||
}))?;
|
||||
|
||||
Ok(node_report(
|
||||
output,
|
||||
manifest.large_bytes_uploaded,
|
||||
registration,
|
||||
heartbeat,
|
||||
capability_report,
|
||||
task.task_assignment_response,
|
||||
debug_command,
|
||||
log_event,
|
||||
vfs_metadata,
|
||||
recorded,
|
||||
session.requests(),
|
||||
))
|
||||
}
|
||||
|
||||
fn node_report(
|
||||
output: CommandOutput,
|
||||
large_bytes_uploaded: bool,
|
||||
registration_response: Value,
|
||||
heartbeat_response: Value,
|
||||
capability_response: Value,
|
||||
task_assignment_response: Value,
|
||||
debug_command_response: Value,
|
||||
log_event_response: Value,
|
||||
vfs_metadata_response: Value,
|
||||
coordinator_response: Value,
|
||||
session_requests: usize,
|
||||
) -> Value {
|
||||
json!({
|
||||
"node_status": "completed",
|
||||
"virtual_thread": output.virtual_thread,
|
||||
"terminal_state": if output.status_code == Some(0) { "completed" } else { "failed" },
|
||||
"status_code": output.status_code,
|
||||
"stdout_bytes": output.stdout.len(),
|
||||
"stderr_bytes": output.stderr.len(),
|
||||
"stdout_tail": &output.stdout,
|
||||
"stderr_tail": &output.stderr,
|
||||
"stdout_truncated": output.stdout_truncated,
|
||||
"stderr_truncated": output.stderr_truncated,
|
||||
"log_backpressured": output.log_backpressured,
|
||||
"staged_artifact": output.staged_artifact,
|
||||
"large_bytes_uploaded": large_bytes_uploaded,
|
||||
"registration_response": registration_response,
|
||||
"heartbeat_response": heartbeat_response,
|
||||
"capability_response": capability_response,
|
||||
"task_assignment_response": task_assignment_response,
|
||||
"debug_command_response": debug_command_response,
|
||||
"log_event_response": log_event_response,
|
||||
"vfs_metadata_response": vfs_metadata_response,
|
||||
"session_requests": session_requests,
|
||||
"coordinator_response": coordinator_response,
|
||||
})
|
||||
}
|
||||
|
||||
fn cancelled_node_report(
|
||||
_args: &Args,
|
||||
task: &RuntimeTask,
|
||||
registration_response: Value,
|
||||
heartbeat_response: Value,
|
||||
capability_response: Value,
|
||||
task_assignment_response: Value,
|
||||
debug_command_response: Value,
|
||||
coordinator_response: Value,
|
||||
session_requests: usize,
|
||||
) -> Value {
|
||||
json!({
|
||||
"node_status": "cancelled",
|
||||
"virtual_thread": &task.task,
|
||||
"terminal_state": "cancelled",
|
||||
"status_code": null,
|
||||
"stdout_bytes": 0,
|
||||
"stderr_bytes": 0,
|
||||
"stdout_tail": "",
|
||||
"stderr_tail": "",
|
||||
"stdout_truncated": false,
|
||||
"stderr_truncated": false,
|
||||
"log_backpressured": false,
|
||||
"staged_artifact": null,
|
||||
"large_bytes_uploaded": false,
|
||||
"registration_response": registration_response,
|
||||
"heartbeat_response": heartbeat_response,
|
||||
"capability_response": capability_response,
|
||||
"task_assignment_response": task_assignment_response,
|
||||
"debug_command_response": debug_command_response,
|
||||
"log_event_response": null,
|
||||
"vfs_metadata_response": null,
|
||||
"session_requests": session_requests,
|
||||
"coordinator_response": coordinator_response,
|
||||
})
|
||||
}
|
||||
|
||||
fn register_node(
|
||||
session: &mut CoordinatorSession,
|
||||
args: &Args,
|
||||
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let public_key = args
|
||||
.public_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}-public-key", args.node));
|
||||
if let Some(grant) = &args.enrollment_grant {
|
||||
session.request(json!({
|
||||
"type": "exchange_node_enrollment_grant",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"node": &args.node,
|
||||
"public_key": public_key,
|
||||
"enrollment_grant": grant,
|
||||
"now_epoch_seconds": 0,
|
||||
}))
|
||||
} else {
|
||||
session.request(json!({
|
||||
"type": "attach_node",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"node": &args.node,
|
||||
"public_key": public_key,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_cancellation(
|
||||
session: &mut CoordinatorSession,
|
||||
args: &Args,
|
||||
task: &RuntimeTask,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + Duration::from_millis(args.control_poll_ms);
|
||||
loop {
|
||||
let control = session.request(json!({
|
||||
"type": "poll_task_control",
|
||||
"tenant": &args.tenant,
|
||||
"project": &args.project,
|
||||
"process": &task.process,
|
||||
"node": &args.node,
|
||||
"task": &task.task,
|
||||
}))?;
|
||||
if control
|
||||
.get("cancel_requested")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
std::thread::sleep((deadline - now).min(Duration::from_millis(100)));
|
||||
}
|
||||
}
|
||||
|
||||
struct CoordinatorSession {
|
||||
writer: TcpStream,
|
||||
reader: BufReader<TcpStream>,
|
||||
requests: usize,
|
||||
}
|
||||
|
||||
impl CoordinatorSession {
|
||||
fn connect(addr: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let transport_addr = json_line_transport_addr(addr);
|
||||
let writer = TcpStream::connect(&transport_addr)?;
|
||||
let reader = BufReader::new(writer.try_clone()?);
|
||||
Ok(Self {
|
||||
writer,
|
||||
reader,
|
||||
requests: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&mut self, value: Value) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
serde_json::to_writer(&mut self.writer, &value)?;
|
||||
self.writer.write_all(b"\n")?;
|
||||
self.writer.flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
if self.reader.read_line(&mut line)? == 0 {
|
||||
return Err("coordinator closed session without a response".into());
|
||||
}
|
||||
let response: Value = serde_json::from_str(&line)?;
|
||||
self.requests += 1;
|
||||
if response.get("type").and_then(Value::as_str) == Some("error") {
|
||||
return Err(format!("coordinator error: {response}").into());
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn requests(&self) -> usize {
|
||||
self.requests
|
||||
}
|
||||
}
|
||||
|
||||
fn json_line_transport_addr(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
for (scheme, default_port) in [("https://", 443), ("http://", 80)] {
|
||||
if let Some(rest) = endpoint.strip_prefix(scheme) {
|
||||
let authority = rest.split('/').next().unwrap_or(rest);
|
||||
if authority.contains(':') {
|
||||
return authority.to_owned();
|
||||
}
|
||||
return format!("{authority}:{default_port}");
|
||||
}
|
||||
}
|
||||
endpoint.to_owned()
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
|
||||
let mut coordinator = None;
|
||||
let mut tenant = "tenant".to_owned();
|
||||
let mut project = "project".to_owned();
|
||||
let mut node = "node".to_owned();
|
||||
let mut process = "process".to_owned();
|
||||
let mut task = "compile-linux".to_owned();
|
||||
let mut command = None;
|
||||
let mut command_args = Vec::new();
|
||||
let mut artifact = "/vfs/artifacts/node-output.txt".to_owned();
|
||||
let mut enrollment_grant = None;
|
||||
let mut public_key = None;
|
||||
let mut control_poll_ms = 0;
|
||||
let mut assignment_poll_ms = 500;
|
||||
let mut emit_ready = false;
|
||||
let mut worker = false;
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--coordinator" => coordinator = args.next(),
|
||||
"--tenant" => tenant = args.next().ok_or("--tenant requires a value")?,
|
||||
"--project-id" => project = args.next().ok_or("--project-id requires a value")?,
|
||||
"--node" => node = args.next().ok_or("--node requires a value")?,
|
||||
"--process" => process = args.next().ok_or("--process requires a value")?,
|
||||
"--task" => task = args.next().ok_or("--task requires a value")?,
|
||||
"--command" => command = args.next(),
|
||||
"--arg" => command_args.push(args.next().ok_or("--arg requires a value")?),
|
||||
"--artifact" => artifact = args.next().ok_or("--artifact requires a value")?,
|
||||
"--enrollment-grant" => enrollment_grant = args.next(),
|
||||
"--public-key" => public_key = args.next(),
|
||||
"--control-poll-ms" => {
|
||||
control_poll_ms = args
|
||||
.next()
|
||||
.ok_or("--control-poll-ms requires a value")?
|
||||
.parse()?
|
||||
}
|
||||
"--assignment-poll-ms" => {
|
||||
assignment_poll_ms = args
|
||||
.next()
|
||||
.ok_or("--assignment-poll-ms requires a value")?
|
||||
.parse()?
|
||||
}
|
||||
"--emit-ready" => emit_ready = true,
|
||||
"--worker" => worker = true,
|
||||
"--project" => {
|
||||
let project_path = PathBuf::from(args.next().ok_or("--project requires a path")?);
|
||||
command_args.extend([
|
||||
"test".to_owned(),
|
||||
"--manifest-path".to_owned(),
|
||||
project_path
|
||||
.join("Cargo.toml")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
]);
|
||||
}
|
||||
other => return Err(format!("unknown argument: {other}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Args {
|
||||
coordinator: coordinator.ok_or("--coordinator is required")?,
|
||||
tenant,
|
||||
project,
|
||||
node,
|
||||
process,
|
||||
task,
|
||||
command: command.unwrap_or_else(|| "cargo".to_owned()),
|
||||
command_args,
|
||||
artifact,
|
||||
enrollment_grant,
|
||||
public_key,
|
||||
control_poll_ms,
|
||||
assignment_poll_ms,
|
||||
emit_ready,
|
||||
worker,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::json_line_transport_addr;
|
||||
|
||||
#[test]
|
||||
fn hosted_operator_url_maps_to_json_line_transport_address() {
|
||||
assert_eq!(
|
||||
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443"),
|
||||
"disasmer.michelpaulissen.com:9443"
|
||||
);
|
||||
assert_eq!(
|
||||
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"),
|
||||
"disasmer.michelpaulissen.com:9443"
|
||||
);
|
||||
assert_eq!(
|
||||
json_line_transport_addr("http://operator.example.test"),
|
||||
"operator.example.test:80"
|
||||
);
|
||||
assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999");
|
||||
}
|
||||
}
|
||||
17
crates/disasmer-sdk/Cargo.toml
Normal file
17
crates/disasmer-sdk/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "disasmer-sdk"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "disasmer"
|
||||
|
||||
[dependencies]
|
||||
disasmer-macros = { path = "../disasmer-macros" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
futures-executor.workspace = true
|
||||
521
crates/disasmer-sdk/src/lib.rs
Normal file
521
crates/disasmer-sdk/src/lib.rs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use disasmer_macros::{main, task};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntrypointDescriptor {
|
||||
pub name: &'static str,
|
||||
pub function: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskDescriptor {
|
||||
pub name: &'static str,
|
||||
pub function: &'static str,
|
||||
pub remotely_startable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RegisteredProgram {
|
||||
pub entrypoints: &'static [EntrypointDescriptor],
|
||||
pub tasks: &'static [TaskDescriptor],
|
||||
}
|
||||
|
||||
impl RegisteredProgram {
|
||||
pub fn select_entrypoint(&self, name: &str) -> Option<EntrypointDescriptor> {
|
||||
self.entrypoints
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entrypoint| entrypoint.name == name)
|
||||
}
|
||||
|
||||
pub fn task(&self, name: &str) -> Option<TaskDescriptor> {
|
||||
self.tasks.iter().copied().find(|task| task.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceSnapshot {
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Blob {
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EnvRef {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
impl EnvRef {
|
||||
pub const fn new_static(name: &'static str) -> Self {
|
||||
Self { name }
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! env {
|
||||
($name:literal) => {
|
||||
$crate::EnvRef::new_static($name)
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TaskArgKind {
|
||||
SmallSerialized,
|
||||
Handle,
|
||||
}
|
||||
|
||||
pub trait TaskArg: Serialize {
|
||||
fn task_arg_kind(&self) -> TaskArgKind {
|
||||
TaskArgKind::SmallSerialized
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! small_task_arg {
|
||||
($($ty:ty),+ $(,)?) => {
|
||||
$(
|
||||
impl TaskArg for $ty {}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
small_task_arg!(
|
||||
(),
|
||||
bool,
|
||||
char,
|
||||
String,
|
||||
i8,
|
||||
i16,
|
||||
i32,
|
||||
i64,
|
||||
isize,
|
||||
u8,
|
||||
u16,
|
||||
u32,
|
||||
u64,
|
||||
usize,
|
||||
f32,
|
||||
f64,
|
||||
);
|
||||
|
||||
impl TaskArg for SourceSnapshot {
|
||||
fn task_arg_kind(&self) -> TaskArgKind {
|
||||
TaskArgKind::Handle
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskArg for Blob {
|
||||
fn task_arg_kind(&self) -> TaskArgKind {
|
||||
TaskArgKind::Handle
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskArg for Artifact {
|
||||
fn task_arg_kind(&self) -> TaskArgKind {
|
||||
TaskArgKind::Handle
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> TaskArg for Option<T> where T: TaskArg {}
|
||||
|
||||
impl<T> TaskArg for Vec<T> where T: TaskArg {}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TaskArgBudget {
|
||||
pub max_inline_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for TaskArgBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_inline_bytes: 64 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TaskArgValidation {
|
||||
pub inline_bytes: usize,
|
||||
pub kind: TaskArgKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TaskArgError {
|
||||
Serialization(String),
|
||||
TooLarge { size: usize, limit: usize },
|
||||
HostOnly { type_name: &'static str },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TaskArgError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"),
|
||||
Self::TooLarge { size, limit } => write!(
|
||||
f,
|
||||
"task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles"
|
||||
),
|
||||
Self::HostOnly { type_name } => write!(
|
||||
f,
|
||||
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TaskArgError {}
|
||||
|
||||
pub fn validate_task_arg<T>(
|
||||
value: &T,
|
||||
budget: TaskArgBudget,
|
||||
) -> Result<TaskArgValidation, TaskArgError>
|
||||
where
|
||||
T: TaskArg + ?Sized,
|
||||
{
|
||||
let bytes = serde_json::to_vec(value)
|
||||
.map_err(|error| TaskArgError::Serialization(error.to_string()))?;
|
||||
let kind = value.task_arg_kind();
|
||||
if kind == TaskArgKind::SmallSerialized && bytes.len() > budget.max_inline_bytes {
|
||||
return Err(TaskArgError::TooLarge {
|
||||
size: bytes.len(),
|
||||
limit: budget.max_inline_bytes,
|
||||
});
|
||||
}
|
||||
Ok(TaskArgValidation {
|
||||
inline_bytes: bytes.len(),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject_host_only_task_arg<T: ?Sized>() -> TaskArgError {
|
||||
TaskArgError::HostOnly {
|
||||
type_name: std::any::type_name::<T>(),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod spawn {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget, TaskArgError};
|
||||
|
||||
static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
|
||||
static RUNTIME_THREADS: OnceLock<Mutex<Vec<RuntimeSpawnEvent>>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeSpawnEvent {
|
||||
pub virtual_thread_id: u64,
|
||||
pub name: &'static str,
|
||||
pub env: Option<EnvRef>,
|
||||
pub debugger_visible: bool,
|
||||
}
|
||||
|
||||
fn runtime_threads() -> &'static Mutex<Vec<RuntimeSpawnEvent>> {
|
||||
RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn register_runtime_thread(
|
||||
virtual_thread_id: u64,
|
||||
name: &'static str,
|
||||
env: Option<EnvRef>,
|
||||
) -> RuntimeSpawnEvent {
|
||||
let event = RuntimeSpawnEvent {
|
||||
virtual_thread_id,
|
||||
name,
|
||||
env,
|
||||
debugger_visible: true,
|
||||
};
|
||||
runtime_threads().lock().unwrap().push(event.clone());
|
||||
event
|
||||
}
|
||||
|
||||
pub fn drain_runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
||||
runtime_threads().lock().unwrap().drain(..).collect()
|
||||
}
|
||||
|
||||
pub fn runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
||||
runtime_threads().lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn task<F, R>(entry: F) -> TaskBuilder<F, R>
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
TaskBuilder {
|
||||
entry: Some(entry),
|
||||
env: None,
|
||||
name: "task",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_with_arg<A, F, R>(arg: A, entry: F) -> TaskWithArgBuilder<A, F, R>
|
||||
where
|
||||
A: TaskArg,
|
||||
F: FnOnce(A) -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
TaskWithArgBuilder {
|
||||
arg: Some(arg),
|
||||
entry: Some(entry),
|
||||
env: None,
|
||||
name: "task",
|
||||
arg_budget: TaskArgBudget::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TaskBuilder<F, R>
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
entry: Option<F>,
|
||||
env: Option<EnvRef>,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl<F, R> TaskBuilder<F, R>
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
pub fn env(mut self, env: EnvRef) -> Self {
|
||||
self.env = Some(env);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: &'static str) -> Self {
|
||||
self.name = name;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn start(mut self) -> TaskHandle<R> {
|
||||
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
||||
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
||||
let entry = self.entry.take().expect("task entry used once");
|
||||
TaskHandle {
|
||||
virtual_thread_id: id,
|
||||
name: self.name,
|
||||
env: self.env,
|
||||
debugger_visible: runtime_event.debugger_visible,
|
||||
result: Some(entry()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TaskWithArgBuilder<A, F, R>
|
||||
where
|
||||
A: TaskArg,
|
||||
F: FnOnce(A) -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
arg: Option<A>,
|
||||
entry: Option<F>,
|
||||
env: Option<EnvRef>,
|
||||
name: &'static str,
|
||||
arg_budget: TaskArgBudget,
|
||||
}
|
||||
|
||||
impl<A, F, R> TaskWithArgBuilder<A, F, R>
|
||||
where
|
||||
A: TaskArg,
|
||||
F: FnOnce(A) -> R,
|
||||
R: TaskArg,
|
||||
{
|
||||
pub fn env(mut self, env: EnvRef) -> Self {
|
||||
self.env = Some(env);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: &'static str) -> Self {
|
||||
self.name = name;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
|
||||
self.arg_budget = arg_budget;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn start(mut self) -> Result<TaskHandle<R>, TaskArgError> {
|
||||
let arg = self.arg.take().expect("task argument used once");
|
||||
validate_task_arg(&arg, self.arg_budget)?;
|
||||
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
||||
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
||||
let entry = self.entry.take().expect("task entry used once");
|
||||
Ok(TaskHandle {
|
||||
virtual_thread_id: id,
|
||||
name: self.name,
|
||||
env: self.env,
|
||||
debugger_visible: runtime_event.debugger_visible,
|
||||
result: Some(entry(arg)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TaskHandle<R> {
|
||||
virtual_thread_id: u64,
|
||||
name: &'static str,
|
||||
env: Option<EnvRef>,
|
||||
debugger_visible: bool,
|
||||
result: Option<R>,
|
||||
}
|
||||
|
||||
impl<R> TaskHandle<R>
|
||||
where
|
||||
R: TaskArg,
|
||||
{
|
||||
pub fn virtual_thread_id(&self) -> u64 {
|
||||
self.virtual_thread_id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
pub fn env(&self) -> Option<EnvRef> {
|
||||
self.env
|
||||
}
|
||||
|
||||
pub fn debugger_visible(&self) -> bool {
|
||||
self.debugger_visible
|
||||
}
|
||||
|
||||
pub async fn join(mut self) -> R {
|
||||
self.result.take().expect("task joined once")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures_executor::block_on;
|
||||
|
||||
#[test]
|
||||
fn env_macro_creates_logical_environment_reference() {
|
||||
let env = crate::env!("linux");
|
||||
|
||||
assert_eq!(env.name, "linux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_task_start_join_returns_small_result() {
|
||||
let result = block_on(async {
|
||||
let handle = crate::spawn::task(|| 42)
|
||||
.name("compile linux")
|
||||
.env(crate::env!("linux"))
|
||||
.start()
|
||||
.await;
|
||||
assert_eq!(handle.name(), "compile linux");
|
||||
assert_eq!(handle.env().unwrap().name, "linux");
|
||||
assert!(handle.debugger_visible());
|
||||
handle.join().await
|
||||
});
|
||||
|
||||
assert_eq!(result, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_task_start_registers_debugger_visible_runtime_thread() {
|
||||
let handle = block_on(async {
|
||||
crate::spawn::task(|| 7_u32)
|
||||
.name("sdk-runtime-thread-test")
|
||||
.env(crate::env!("linux"))
|
||||
.start()
|
||||
.await
|
||||
});
|
||||
let events = crate::spawn::runtime_spawn_events();
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|event| event.virtual_thread_id == handle.virtual_thread_id())
|
||||
.expect("spawn runtime event should exist for task handle");
|
||||
|
||||
assert!(handle.debugger_visible());
|
||||
assert!(event.debugger_visible);
|
||||
assert_eq!(event.name, "sdk-runtime-thread-test");
|
||||
assert_eq!(event.env.unwrap().name, "linux");
|
||||
assert_eq!(block_on(handle.join()), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() {
|
||||
let artifact = crate::Artifact {
|
||||
id: "artifact://build/app".to_owned(),
|
||||
};
|
||||
let artifact_validation = crate::validate_task_arg(
|
||||
&artifact,
|
||||
crate::TaskArgBudget {
|
||||
max_inline_bytes: 4,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle);
|
||||
|
||||
let bytes = vec![1_u8, 2, 3, 4, 5];
|
||||
let error = crate::validate_task_arg(
|
||||
&bytes,
|
||||
crate::TaskArgBudget {
|
||||
max_inline_bytes: 4,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, crate::TaskArgError::TooLarge { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() {
|
||||
let dispatched = std::cell::Cell::new(false);
|
||||
let result = block_on(async {
|
||||
crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| {
|
||||
dispatched.set(true);
|
||||
42_u32
|
||||
})
|
||||
.arg_budget(crate::TaskArgBudget {
|
||||
max_inline_bytes: 4,
|
||||
})
|
||||
.start()
|
||||
.await
|
||||
});
|
||||
|
||||
assert!(matches!(result, Err(crate::TaskArgError::TooLarge { .. })));
|
||||
assert!(!dispatched.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() {
|
||||
let artifact = crate::Artifact {
|
||||
id: "artifact://build/app".to_owned(),
|
||||
};
|
||||
let result = block_on(async {
|
||||
let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id)
|
||||
.name("publish artifact")
|
||||
.env(crate::env!("linux"))
|
||||
.arg_budget(crate::TaskArgBudget {
|
||||
max_inline_bytes: 4,
|
||||
})
|
||||
.start()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(handle.name(), "publish artifact");
|
||||
handle.join().await
|
||||
});
|
||||
|
||||
assert_eq!(result, "artifact://build/app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_only_task_arg_error_names_rejected_type() {
|
||||
let error = crate::reject_host_only_task_arg::<*const u8>();
|
||||
|
||||
assert!(error.to_string().contains("*const u8"));
|
||||
assert!(error.to_string().contains("host-only"));
|
||||
}
|
||||
}
|
||||
80
crates/disasmer-sdk/tests/macros.rs
Normal file
80
crates/disasmer-sdk/tests/macros.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use futures_executor::block_on;
|
||||
|
||||
#[disasmer::main]
|
||||
fn build_main() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[disasmer::main(name = "release")]
|
||||
fn release_main() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
#[disasmer::task(name = "compile-linux")]
|
||||
fn compile_linux() -> u32 {
|
||||
41
|
||||
}
|
||||
|
||||
#[disasmer::task]
|
||||
fn package_release() -> disasmer::Artifact {
|
||||
disasmer::Artifact {
|
||||
id: "artifact://package/release.tar.zst".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() {
|
||||
let program = disasmer::RegisteredProgram {
|
||||
entrypoints: &[
|
||||
__DISASMER_ENTRYPOINT_BUILD_MAIN,
|
||||
__DISASMER_ENTRYPOINT_RELEASE_MAIN,
|
||||
],
|
||||
tasks: &[
|
||||
__DISASMER_TASK_COMPILE_LINUX,
|
||||
__DISASMER_TASK_PACKAGE_RELEASE,
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
program.select_entrypoint("build").unwrap().function,
|
||||
"build_main"
|
||||
);
|
||||
assert_eq!(
|
||||
program.select_entrypoint("release").unwrap().function,
|
||||
"release_main"
|
||||
);
|
||||
assert_eq!(
|
||||
program.task("compile-linux").unwrap().function,
|
||||
"compile_linux"
|
||||
);
|
||||
assert!(program.task("compile-linux").unwrap().remotely_startable);
|
||||
|
||||
let result = block_on(async {
|
||||
let handle = disasmer::spawn::task(|| compile_linux() + build_main())
|
||||
.name("compile linux")
|
||||
.env(disasmer::env!("linux"))
|
||||
.start()
|
||||
.await;
|
||||
|
||||
assert!(handle.virtual_thread_id() > 0);
|
||||
handle.join().await
|
||||
});
|
||||
|
||||
assert_eq!(result, 42);
|
||||
assert_eq!(release_main(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_boundaries_accept_small_values_and_runtime_handles() {
|
||||
let small = disasmer::validate_task_arg(&7_u32, disasmer::TaskArgBudget::default()).unwrap();
|
||||
assert_eq!(small.kind, disasmer::TaskArgKind::SmallSerialized);
|
||||
|
||||
let artifact = package_release();
|
||||
let artifact = disasmer::validate_task_arg(
|
||||
&artifact,
|
||||
disasmer::TaskArgBudget {
|
||||
max_inline_bytes: 4,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(artifact.kind, disasmer::TaskArgKind::Handle);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue