Public dry run dryrun-1714a9eedd5b
Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
This commit is contained in:
commit
20c72e6066
102 changed files with 32054 additions and 0 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue