Public dry run dryrun-1714a9eedd5b

Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c

Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db
This commit is contained in:
Disasmer release dry run 2026-07-03 17:31:04 +02:00
commit 20c72e6066
102 changed files with 32054 additions and 0 deletions

View 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);
}
}

View 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
);
}
}

View 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);
}
}

View 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()
))
);
}
}

View 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");
}
}

View 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"]);
}
}

View 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());
}
}

View 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));
}
}

View 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"));
}
}

View 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);

View 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,
};

View 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"));
}
}

View 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(_)));
}
}

View 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);
}
}

View 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 { .. }));
}
}

View 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"));
}
}

View 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()
))
);
}
}

View 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"));
}
}

View 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);
}
}