Public dry run dryrun-750f29790fe2
Source commit: 750f29790fe2046599fbe5b5d06fdb63d92fabff Public tree identity: sha256:02326e08850bb287585789733ea5f3f153e1f7f3fd88afcc24e249107df91506
This commit is contained in:
commit
de66658961
102 changed files with 31669 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/target/
|
||||||
|
/.disasmer/
|
||||||
|
**/.disasmer/
|
||||||
|
/vscode-extension/node_modules/
|
||||||
|
/private/*/Cargo.lock
|
||||||
|
/private/*/target/
|
||||||
2597
Cargo.lock
generated
Normal file
2597
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/disasmer-cli",
|
||||||
|
"crates/disasmer-coordinator",
|
||||||
|
"crates/disasmer-core",
|
||||||
|
"crates/disasmer-dap",
|
||||||
|
"crates/disasmer-macros",
|
||||||
|
"crates/disasmer-node",
|
||||||
|
"crates/disasmer-sdk",
|
||||||
|
"examples/launch-build-demo",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0 OR MIT"
|
||||||
|
repository = "https://example.invalid/disasmer"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
futures-executor = "0.3"
|
||||||
|
hex = "0.4"
|
||||||
|
proc-macro2 = "1.0"
|
||||||
|
postgres = { version = "0.19", features = ["with-serde_json-1"] }
|
||||||
|
quinn = { version = "0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
|
||||||
|
quote = "1.0"
|
||||||
|
rcgen = "0.14"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
sha2 = "0.10"
|
||||||
|
syn = { version = "2.0", features = ["full"] }
|
||||||
|
tempfile = "3.10"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tokio = { version = "1.52", features = ["io-util", "macros", "rt-multi-thread"] }
|
||||||
|
wasmtime = { version = "=43.0.2", default-features = false, features = ["async", "cranelift", "debug", "runtime", "std", "wat"] }
|
||||||
19
DISASMER_PUBLIC_TREE.json
Normal file
19
DISASMER_PUBLIC_TREE.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"kind": "disasmer-filtered-public-tree",
|
||||||
|
"source_commit": "750f29790fe2046599fbe5b5d06fdb63d92fabff",
|
||||||
|
"release_name": "dryrun-750f29790fe2",
|
||||||
|
"filtered_out": [
|
||||||
|
"private/**",
|
||||||
|
"experiments/**",
|
||||||
|
".git",
|
||||||
|
"target",
|
||||||
|
"/*.md",
|
||||||
|
".forgejo/**"
|
||||||
|
],
|
||||||
|
"public_export": {
|
||||||
|
"host_neutral": true,
|
||||||
|
"include_forgejo_workflows": false
|
||||||
|
},
|
||||||
|
"forgejo_host": "git.michelpaulissen.com",
|
||||||
|
"default_operator_endpoint": "https://disasmer.michelpaulissen.com:9443"
|
||||||
|
}
|
||||||
20
crates/disasmer-cli/Cargo.toml
Normal file
20
crates/disasmer-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "disasmer"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
disasmer-core = { path = "../disasmer-core" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
1631
crates/disasmer-cli/src/main.rs
Normal file
1631
crates/disasmer-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
13
crates/disasmer-coordinator/Cargo.toml
Normal file
13
crates/disasmer-coordinator/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-coordinator"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
disasmer-core = { path = "../disasmer-core" }
|
||||||
|
postgres.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
709
crates/disasmer-coordinator/src/lib.rs
Normal file
709
crates/disasmer-coordinator/src/lib.rs
Normal file
|
|
@ -0,0 +1,709 @@
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use disasmer_core::{
|
||||||
|
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
|
||||||
|
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod postgres_store;
|
||||||
|
pub mod service;
|
||||||
|
pub use postgres_store::{
|
||||||
|
PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES,
|
||||||
|
};
|
||||||
|
pub use service::{
|
||||||
|
CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
|
||||||
|
SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCompletionEvent,
|
||||||
|
TaskTerminalState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TenantRecord {
|
||||||
|
pub id: TenantId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UserRecord {
|
||||||
|
pub id: UserId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub credential_kind: CredentialKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectRecord {
|
||||||
|
pub id: ProjectId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeIdentityRecord {
|
||||||
|
pub id: NodeId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub public_key: String,
|
||||||
|
pub enrollment_scope: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialRecord {
|
||||||
|
pub subject: String,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: Option<ProjectId>,
|
||||||
|
pub kind: CredentialKind,
|
||||||
|
pub public_key_fingerprint: Option<Digest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceProviderConfigRecord {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub provider: SourceProviderKind,
|
||||||
|
pub manifest_digest: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ServicePolicyRecord {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub name: String,
|
||||||
|
pub digest: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectPermissionRecord {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub user: UserId,
|
||||||
|
pub can_debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct DurableState {
|
||||||
|
pub tenants: BTreeMap<TenantId, TenantRecord>,
|
||||||
|
pub users: BTreeMap<UserId, UserRecord>,
|
||||||
|
pub projects: BTreeMap<ProjectId, ProjectRecord>,
|
||||||
|
pub node_identities: BTreeMap<NodeId, NodeIdentityRecord>,
|
||||||
|
pub credentials: BTreeMap<String, CredentialRecord>,
|
||||||
|
pub source_provider_configs:
|
||||||
|
BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>,
|
||||||
|
pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>,
|
||||||
|
pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait DurableStore {
|
||||||
|
fn load(&self) -> DurableState;
|
||||||
|
fn save(&mut self, state: DurableState);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FallibleDurableStore {
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
fn load_state(&mut self) -> Result<DurableState, Self::Error>;
|
||||||
|
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct InMemoryDurableStore {
|
||||||
|
state: DurableState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurableStore for InMemoryDurableStore {
|
||||||
|
fn load(&self) -> DurableState {
|
||||||
|
self.state.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, state: DurableState) {
|
||||||
|
self.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ActiveProcess {
|
||||||
|
pub id: ProcessId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub connected_nodes: BTreeSet<NodeId>,
|
||||||
|
pub coordinator_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Coordinator {
|
||||||
|
durable: DurableState,
|
||||||
|
active_processes: BTreeMap<ProcessId, ActiveProcess>,
|
||||||
|
coordinator_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum CoordinatorError {
|
||||||
|
#[error("node identity is not enrolled")]
|
||||||
|
UnknownNode,
|
||||||
|
#[error("node enrollment failed: {0:?}")]
|
||||||
|
Enrollment(EnrollmentError),
|
||||||
|
#[error("stale virtual process state from coordinator epoch {stale_epoch}; current epoch is {current_epoch}")]
|
||||||
|
StaleProcessEpoch {
|
||||||
|
stale_epoch: u64,
|
||||||
|
current_epoch: u64,
|
||||||
|
},
|
||||||
|
#[error("unauthorized coordinator action: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coordinator {
|
||||||
|
pub fn boot(store: &impl DurableStore, coordinator_epoch: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
durable: store.load(),
|
||||||
|
active_processes: BTreeMap::new(),
|
||||||
|
coordinator_epoch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_boot<S: FallibleDurableStore>(
|
||||||
|
store: &mut S,
|
||||||
|
coordinator_epoch: u64,
|
||||||
|
) -> Result<Self, S::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
durable: store.load_state()?,
|
||||||
|
active_processes: BTreeMap::new(),
|
||||||
|
coordinator_epoch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn persist(&self, store: &mut impl DurableStore) {
|
||||||
|
store.save(self.durable.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_persist<S: FallibleDurableStore>(&self, store: &mut S) -> Result<(), S::Error> {
|
||||||
|
store.save_state(&self.durable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coordinator_epoch(&self) -> u64 {
|
||||||
|
self.coordinator_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_tenant(&mut self, id: TenantId) {
|
||||||
|
self.durable.tenants.insert(id.clone(), TenantRecord { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_user(&mut self, tenant: TenantId, id: UserId, credential_kind: CredentialKind) {
|
||||||
|
self.durable.users.insert(
|
||||||
|
id.clone(),
|
||||||
|
UserRecord {
|
||||||
|
id,
|
||||||
|
tenant,
|
||||||
|
credential_kind,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_project(&mut self, tenant: TenantId, id: ProjectId, name: impl Into<String>) {
|
||||||
|
self.durable.projects.insert(
|
||||||
|
id.clone(),
|
||||||
|
ProjectRecord {
|
||||||
|
id,
|
||||||
|
tenant,
|
||||||
|
name: name.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enroll_node(
|
||||||
|
&mut self,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
node: NodeId,
|
||||||
|
public_key: impl Into<String>,
|
||||||
|
enrollment_scope: impl Into<String>,
|
||||||
|
) {
|
||||||
|
self.durable.node_identities.insert(
|
||||||
|
node.clone(),
|
||||||
|
NodeIdentityRecord {
|
||||||
|
id: node,
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
public_key: public_key.into(),
|
||||||
|
enrollment_scope: enrollment_scope.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_node_enrollment_grant(
|
||||||
|
&self,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
grant_id: impl Into<String>,
|
||||||
|
scope: impl Into<String>,
|
||||||
|
expires_at_epoch_seconds: u64,
|
||||||
|
) -> EnrollmentGrant {
|
||||||
|
EnrollmentGrant {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
grant_id: grant_id.into(),
|
||||||
|
scope: scope.into(),
|
||||||
|
expires_at_epoch_seconds,
|
||||||
|
consumed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exchange_node_enrollment_grant(
|
||||||
|
&mut self,
|
||||||
|
grant: &mut EnrollmentGrant,
|
||||||
|
node: NodeId,
|
||||||
|
public_key: &str,
|
||||||
|
requested_scope: &str,
|
||||||
|
now_epoch_seconds: u64,
|
||||||
|
) -> Result<NodeCredential, CoordinatorError> {
|
||||||
|
let credential = grant
|
||||||
|
.exchange_for_node_identity(
|
||||||
|
node.clone(),
|
||||||
|
public_key,
|
||||||
|
requested_scope,
|
||||||
|
now_epoch_seconds,
|
||||||
|
)
|
||||||
|
.map_err(CoordinatorError::Enrollment)?;
|
||||||
|
self.enroll_node(
|
||||||
|
credential.tenant.clone(),
|
||||||
|
credential.project.clone(),
|
||||||
|
node.clone(),
|
||||||
|
public_key,
|
||||||
|
credential.scope.clone(),
|
||||||
|
);
|
||||||
|
self.durable.credentials.insert(
|
||||||
|
format!("node:{node}"),
|
||||||
|
CredentialRecord {
|
||||||
|
subject: format!("node:{node}"),
|
||||||
|
tenant: credential.tenant.clone(),
|
||||||
|
project: Some(credential.project.clone()),
|
||||||
|
kind: credential.credential_kind.clone(),
|
||||||
|
public_key_fingerprint: Some(credential.public_key_fingerprint.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_source_provider_config(
|
||||||
|
&mut self,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
provider: SourceProviderKind,
|
||||||
|
manifest_digest: Digest,
|
||||||
|
) {
|
||||||
|
let provider_key = format!("{provider:?}");
|
||||||
|
self.durable.source_provider_configs.insert(
|
||||||
|
(tenant.clone(), project.clone(), provider_key),
|
||||||
|
SourceProviderConfigRecord {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
manifest_digest,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_service_policy_record(
|
||||||
|
&mut self,
|
||||||
|
tenant: TenantId,
|
||||||
|
name: impl Into<String>,
|
||||||
|
digest: Digest,
|
||||||
|
) {
|
||||||
|
let name = name.into();
|
||||||
|
self.durable.service_policy_records.insert(
|
||||||
|
(tenant.clone(), name.clone()),
|
||||||
|
ServicePolicyRecord {
|
||||||
|
tenant,
|
||||||
|
name,
|
||||||
|
digest,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) {
|
||||||
|
self.durable.project_permissions.insert(
|
||||||
|
(tenant.clone(), project.clone(), user.clone()),
|
||||||
|
ProjectPermissionRecord {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
can_debug: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_process(
|
||||||
|
&mut self,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
id: ProcessId,
|
||||||
|
) -> ActiveProcess {
|
||||||
|
let process = ActiveProcess {
|
||||||
|
id: id.clone(),
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
connected_nodes: BTreeSet::new(),
|
||||||
|
coordinator_epoch: self.coordinator_epoch,
|
||||||
|
};
|
||||||
|
self.active_processes.insert(id, process.clone());
|
||||||
|
process
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorize_node_for_process(
|
||||||
|
&self,
|
||||||
|
node: &NodeId,
|
||||||
|
tenant: &TenantId,
|
||||||
|
project: &ProjectId,
|
||||||
|
process: &ProcessId,
|
||||||
|
) -> Result<(), CoordinatorError> {
|
||||||
|
let identity = self
|
||||||
|
.durable
|
||||||
|
.node_identities
|
||||||
|
.get(node)
|
||||||
|
.ok_or(CoordinatorError::UnknownNode)?;
|
||||||
|
if &identity.tenant != tenant || &identity.project != project {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"node identity is outside the requested tenant/project scope".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(active) = self.active_processes.get(process) else {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"virtual process is not active in coordinator memory".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if &active.tenant != tenant || &active.project != project {
|
||||||
|
return Err(CoordinatorError::Unauthorized(
|
||||||
|
"node cannot claim tasks or publish artifacts outside its process scope".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reconnect_node(
|
||||||
|
&mut self,
|
||||||
|
node: &NodeId,
|
||||||
|
process: Option<(&ProcessId, u64)>,
|
||||||
|
) -> Result<(), CoordinatorError> {
|
||||||
|
if !self.durable.node_identities.contains_key(node) {
|
||||||
|
return Err(CoordinatorError::UnknownNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((process_id, stale_epoch)) = process {
|
||||||
|
if stale_epoch != self.coordinator_epoch {
|
||||||
|
return Err(CoordinatorError::StaleProcessEpoch {
|
||||||
|
stale_epoch,
|
||||||
|
current_epoch: self.coordinator_epoch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(active) = self.active_processes.get_mut(process_id) {
|
||||||
|
active.connected_nodes.insert(node.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
|
||||||
|
self.durable
|
||||||
|
.projects
|
||||||
|
.values()
|
||||||
|
.filter(|project| project.tenant == context.tenant)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorize_debug_attach(
|
||||||
|
&self,
|
||||||
|
context: &AuthContext,
|
||||||
|
process: &ProcessId,
|
||||||
|
) -> Authorization {
|
||||||
|
let Some(active) = self.active_processes.get(process) else {
|
||||||
|
return Authorization::deny("virtual process is not active");
|
||||||
|
};
|
||||||
|
if active.tenant != context.tenant || active.project != context.project {
|
||||||
|
return Authorization::deny("tenant or project mismatch");
|
||||||
|
}
|
||||||
|
let Actor::User(user) = &context.actor else {
|
||||||
|
return Authorization::deny("debug attach requires a user identity");
|
||||||
|
};
|
||||||
|
let permission = self.durable.project_permissions.get(&(
|
||||||
|
active.tenant.clone(),
|
||||||
|
active.project.clone(),
|
||||||
|
user.clone(),
|
||||||
|
));
|
||||||
|
if !permission.is_some_and(|permission| permission.can_debug) {
|
||||||
|
return Authorization::deny("debug attach requires explicit project permission");
|
||||||
|
}
|
||||||
|
Authorization::allow("debug attach authorized for project")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project(&self, id: &ProjectId) -> Option<&ProjectRecord> {
|
||||||
|
self.durable.projects.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_process(&self, id: &ProcessId) -> Option<&ActiveProcess> {
|
||||||
|
self.active_processes.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_process_count(&self) -> usize {
|
||||||
|
self.active_processes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn node_identity(&self, id: &NodeId) -> Option<&NodeIdentityRecord> {
|
||||||
|
self.durable.node_identities.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source_provider_config(
|
||||||
|
&self,
|
||||||
|
tenant: &TenantId,
|
||||||
|
project: &ProjectId,
|
||||||
|
provider: &str,
|
||||||
|
) -> Option<&SourceProviderConfigRecord> {
|
||||||
|
self.durable.source_provider_configs.get(&(
|
||||||
|
tenant.clone(),
|
||||||
|
project.clone(),
|
||||||
|
provider.to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_policy_record(
|
||||||
|
&self,
|
||||||
|
tenant: &TenantId,
|
||||||
|
name: &str,
|
||||||
|
) -> Option<&ServicePolicyRecord> {
|
||||||
|
self.durable
|
||||||
|
.service_policy_records
|
||||||
|
.get(&(tenant.clone(), name.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coordinator_restart_preserves_project_but_not_live_processes() {
|
||||||
|
let mut store = InMemoryDurableStore::default();
|
||||||
|
let mut first = Coordinator::boot(&store, 1);
|
||||||
|
first.upsert_tenant(TenantId::from("tenant"));
|
||||||
|
first.upsert_user(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
UserId::from("user"),
|
||||||
|
CredentialKind::CliDeviceSession,
|
||||||
|
);
|
||||||
|
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||||
|
first.upsert_source_provider_config(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
SourceProviderKind::Git,
|
||||||
|
Digest::sha256("git-manifest"),
|
||||||
|
);
|
||||||
|
first.upsert_service_policy_record(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
"community tier",
|
||||||
|
Digest::sha256("policy"),
|
||||||
|
);
|
||||||
|
let mut grant = first.create_node_enrollment_grant(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
"grant",
|
||||||
|
"node:attach",
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
first
|
||||||
|
.exchange_node_enrollment_grant(
|
||||||
|
&mut grant,
|
||||||
|
NodeId::from("node"),
|
||||||
|
"public-key",
|
||||||
|
"node:attach",
|
||||||
|
99,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
first.start_process(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
ProcessId::from("process"),
|
||||||
|
);
|
||||||
|
first.persist(&mut store);
|
||||||
|
|
||||||
|
let mut restarted = Coordinator::boot(&store, 2);
|
||||||
|
|
||||||
|
assert!(restarted
|
||||||
|
.durable
|
||||||
|
.tenants
|
||||||
|
.contains_key(&TenantId::from("tenant")));
|
||||||
|
assert!(restarted.durable.users.contains_key(&UserId::from("user")));
|
||||||
|
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||||
|
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
restarted
|
||||||
|
.durable
|
||||||
|
.credentials
|
||||||
|
.get("node:node")
|
||||||
|
.map(|credential| &credential.kind),
|
||||||
|
Some(&CredentialKind::NodeCredential)
|
||||||
|
);
|
||||||
|
assert!(restarted
|
||||||
|
.source_provider_config(
|
||||||
|
&TenantId::from("tenant"),
|
||||||
|
&ProjectId::from("project"),
|
||||||
|
"Git"
|
||||||
|
)
|
||||||
|
.is_some());
|
||||||
|
assert!(restarted
|
||||||
|
.service_policy_record(&TenantId::from("tenant"), "community tier")
|
||||||
|
.is_some());
|
||||||
|
assert_eq!(restarted.active_process_count(), 0);
|
||||||
|
|
||||||
|
let process = ProcessId::from("process-rerun");
|
||||||
|
let rerun = restarted.start_process(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
process.clone(),
|
||||||
|
);
|
||||||
|
assert_eq!(rerun.coordinator_epoch, 2);
|
||||||
|
restarted
|
||||||
|
.reconnect_node(&NodeId::from("node"), Some((&process, 2)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(restarted
|
||||||
|
.active_process(&process)
|
||||||
|
.unwrap()
|
||||||
|
.connected_nodes
|
||||||
|
.contains(&NodeId::from("node")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_reconnect_rejects_stale_process_epoch_after_restart() {
|
||||||
|
let mut store = InMemoryDurableStore::default();
|
||||||
|
let mut first = Coordinator::boot(&store, 1);
|
||||||
|
first.upsert_tenant(TenantId::from("tenant"));
|
||||||
|
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||||
|
first.enroll_node(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
NodeId::from("node"),
|
||||||
|
"public-key",
|
||||||
|
"node",
|
||||||
|
);
|
||||||
|
first.persist(&mut store);
|
||||||
|
|
||||||
|
let mut restarted = Coordinator::boot(&store, 2);
|
||||||
|
restarted
|
||||||
|
.reconnect_node(&NodeId::from("node"), None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = restarted
|
||||||
|
.reconnect_node(
|
||||||
|
&NodeId::from("node"),
|
||||||
|
Some((&ProcessId::from("process"), 1)),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, CoordinatorError::StaleProcessEpoch { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_enrollment_grant_becomes_persistent_node_identity() {
|
||||||
|
let store = InMemoryDurableStore::default();
|
||||||
|
let mut coordinator = Coordinator::boot(&store, 1);
|
||||||
|
let mut grant = coordinator.create_node_enrollment_grant(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
"grant",
|
||||||
|
"node:attach",
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
let credential = coordinator
|
||||||
|
.exchange_node_enrollment_grant(
|
||||||
|
&mut grant,
|
||||||
|
NodeId::from("node"),
|
||||||
|
"public-key",
|
||||||
|
"node:attach",
|
||||||
|
99,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
||||||
|
assert!(coordinator.node_identity(&NodeId::from("node")).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_listing_is_filtered_by_tenant() {
|
||||||
|
let store = InMemoryDurableStore::default();
|
||||||
|
let mut coordinator = Coordinator::boot(&store, 1);
|
||||||
|
coordinator.upsert_project(
|
||||||
|
TenantId::from("tenant-a"),
|
||||||
|
ProjectId::from("project-a"),
|
||||||
|
"a",
|
||||||
|
);
|
||||||
|
coordinator.upsert_project(
|
||||||
|
TenantId::from("tenant-b"),
|
||||||
|
ProjectId::from("project-b"),
|
||||||
|
"b",
|
||||||
|
);
|
||||||
|
|
||||||
|
let projects = coordinator.list_projects(&AuthContext {
|
||||||
|
tenant: TenantId::from("tenant-a"),
|
||||||
|
project: ProjectId::from("project-a"),
|
||||||
|
actor: Actor::User(UserId::from("user-a")),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(projects.len(), 1);
|
||||||
|
assert_eq!(projects[0].id, ProjectId::from("project-a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_cannot_claim_process_outside_authorized_scope() {
|
||||||
|
let store = InMemoryDurableStore::default();
|
||||||
|
let mut coordinator = Coordinator::boot(&store, 1);
|
||||||
|
coordinator.enroll_node(
|
||||||
|
TenantId::from("tenant-a"),
|
||||||
|
ProjectId::from("project-a"),
|
||||||
|
NodeId::from("node-a"),
|
||||||
|
"public-key",
|
||||||
|
"node",
|
||||||
|
);
|
||||||
|
coordinator.start_process(
|
||||||
|
TenantId::from("tenant-b"),
|
||||||
|
ProjectId::from("project-b"),
|
||||||
|
ProcessId::from("process-b"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = coordinator
|
||||||
|
.authorize_node_for_process(
|
||||||
|
&NodeId::from("node-a"),
|
||||||
|
&TenantId::from("tenant-b"),
|
||||||
|
&ProjectId::from("project-b"),
|
||||||
|
&ProcessId::from("process-b"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, CoordinatorError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_attach_requires_explicit_project_permission() {
|
||||||
|
let store = InMemoryDurableStore::default();
|
||||||
|
let mut coordinator = Coordinator::boot(&store, 1);
|
||||||
|
coordinator.start_process(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
ProcessId::from("process"),
|
||||||
|
);
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let denied = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||||
|
coordinator.grant_project_debug(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
UserId::from("user"),
|
||||||
|
);
|
||||||
|
let allowed = coordinator.authorize_debug_attach(&context, &ProcessId::from("process"));
|
||||||
|
|
||||||
|
assert!(!denied.allowed);
|
||||||
|
assert!(denied.reason.contains("explicit project permission"));
|
||||||
|
assert!(allowed.allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/disasmer-coordinator/src/main.rs
Normal file
22
crates/disasmer-coordinator/src/main.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut listen = "127.0.0.1:0".to_owned();
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--listen" {
|
||||||
|
listen = args.next().ok_or("--listen requires an address")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (listener, addr) = bind_listener(&listen)?;
|
||||||
|
println!("{}", json!({ "listen": addr.to_string() }));
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
|
let service = CoordinatorService::new(1);
|
||||||
|
service.serve_tcp(listener)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
459
crates/disasmer-coordinator/src/postgres_store.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
use postgres::{Client, NoTls};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord,
|
||||||
|
ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord,
|
||||||
|
TenantRecord, UserRecord,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PostgresTable {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub durable_record: &'static str,
|
||||||
|
pub restart_surviving: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_tenants",
|
||||||
|
durable_record: "tenants",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_users",
|
||||||
|
durable_record: "users",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_projects",
|
||||||
|
durable_record: "projects",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_node_identities",
|
||||||
|
durable_record: "node identities",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_credentials",
|
||||||
|
durable_record: "credentials",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_source_provider_configs",
|
||||||
|
durable_record: "source-provider configuration",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_service_policy_records",
|
||||||
|
durable_record: "durable service policy records",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
PostgresTable {
|
||||||
|
name: "disasmer_project_permissions",
|
||||||
|
durable_record: "explicit project permissions",
|
||||||
|
restart_surviving: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PostgresStoreError {
|
||||||
|
#[error("postgres durable store error: {0}")]
|
||||||
|
Postgres(#[from] postgres::Error),
|
||||||
|
#[error("durable state serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresDurableStore {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresDurableStore {
|
||||||
|
pub fn connect(connection_string: &str) -> Result<Self, PostgresStoreError> {
|
||||||
|
let mut store = Self {
|
||||||
|
client: Client::connect(connection_string, NoTls)?,
|
||||||
|
};
|
||||||
|
store.migrate()?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_client(client: Client) -> Result<Self, PostgresStoreError> {
|
||||||
|
let mut store = Self { client };
|
||||||
|
store.migrate()?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn schema_sql() -> &'static str {
|
||||||
|
POSTGRES_SCHEMA_SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn durable_tables() -> &'static [PostgresTable] {
|
||||||
|
POSTGRES_DURABLE_TABLES
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate(&mut self) -> Result<(), PostgresStoreError> {
|
||||||
|
self.client.batch_execute(Self::schema_sql())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_records<T: DeserializeOwned>(
|
||||||
|
&mut self,
|
||||||
|
sql: &str,
|
||||||
|
) -> Result<Vec<T>, PostgresStoreError> {
|
||||||
|
self.client
|
||||||
|
.query(sql, &[])?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let value: serde_json::Value = row.get("record");
|
||||||
|
Ok(serde_json::from_value(value)?)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_value(record: &impl Serialize) -> Result<serde_json::Value, PostgresStoreError> {
|
||||||
|
Ok(serde_json::to_value(record)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FallibleDurableStore for PostgresDurableStore {
|
||||||
|
type Error = PostgresStoreError;
|
||||||
|
|
||||||
|
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||||
|
let mut state = DurableState::default();
|
||||||
|
|
||||||
|
for record in self.query_records::<TenantRecord>(
|
||||||
|
"SELECT record FROM disasmer_tenants ORDER BY tenant_id",
|
||||||
|
)? {
|
||||||
|
state.tenants.insert(record.id.clone(), record);
|
||||||
|
}
|
||||||
|
for record in
|
||||||
|
self.query_records::<UserRecord>("SELECT record FROM disasmer_users ORDER BY user_id")?
|
||||||
|
{
|
||||||
|
state.users.insert(record.id.clone(), record);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<ProjectRecord>(
|
||||||
|
"SELECT record FROM disasmer_projects ORDER BY project_id",
|
||||||
|
)? {
|
||||||
|
state.projects.insert(record.id.clone(), record);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<NodeIdentityRecord>(
|
||||||
|
"SELECT record FROM disasmer_node_identities ORDER BY node_id",
|
||||||
|
)? {
|
||||||
|
state.node_identities.insert(record.id.clone(), record);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<CredentialRecord>(
|
||||||
|
"SELECT record FROM disasmer_credentials ORDER BY subject",
|
||||||
|
)? {
|
||||||
|
state.credentials.insert(record.subject.clone(), record);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<SourceProviderConfigRecord>(
|
||||||
|
"SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key",
|
||||||
|
)? {
|
||||||
|
let provider_key = format!("{:?}", record.provider);
|
||||||
|
state.source_provider_configs.insert(
|
||||||
|
(record.tenant.clone(), record.project.clone(), provider_key),
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<ServicePolicyRecord>(
|
||||||
|
"SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name",
|
||||||
|
)? {
|
||||||
|
state
|
||||||
|
.service_policy_records
|
||||||
|
.insert((record.tenant.clone(), record.name.clone()), record);
|
||||||
|
}
|
||||||
|
for record in self.query_records::<ProjectPermissionRecord>(
|
||||||
|
"SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id",
|
||||||
|
)? {
|
||||||
|
state.project_permissions.insert(
|
||||||
|
(
|
||||||
|
record.tenant.clone(),
|
||||||
|
record.project.clone(),
|
||||||
|
record.user.clone(),
|
||||||
|
),
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||||
|
let mut tx = self.client.transaction()?;
|
||||||
|
tx.batch_execute(
|
||||||
|
"
|
||||||
|
DELETE FROM disasmer_project_permissions;
|
||||||
|
DELETE FROM disasmer_service_policy_records;
|
||||||
|
DELETE FROM disasmer_source_provider_configs;
|
||||||
|
DELETE FROM disasmer_credentials;
|
||||||
|
DELETE FROM disasmer_node_identities;
|
||||||
|
DELETE FROM disasmer_projects;
|
||||||
|
DELETE FROM disasmer_users;
|
||||||
|
DELETE FROM disasmer_tenants;
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for record in state.tenants.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)",
|
||||||
|
&[&record.id.as_str(), &value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.users.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||||
|
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.projects.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)",
|
||||||
|
&[&record.id.as_str(), &record.tenant.as_str(), &value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.node_identities.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||||
|
&[
|
||||||
|
&record.id.as_str(),
|
||||||
|
&record.tenant.as_str(),
|
||||||
|
&record.project.as_str(),
|
||||||
|
&value,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.credentials.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
let project_id = record.project.as_ref().map(|project| project.as_str());
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)",
|
||||||
|
&[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for ((_, _, provider_key), record) in &state.source_provider_configs {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)",
|
||||||
|
&[
|
||||||
|
&record.tenant.as_str(),
|
||||||
|
&record.project.as_str(),
|
||||||
|
&provider_key.as_str(),
|
||||||
|
&value,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.service_policy_records.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)",
|
||||||
|
&[&record.tenant.as_str(), &record.name.as_str(), &value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for record in state.project_permissions.values() {
|
||||||
|
let value = Self::record_value(record)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)",
|
||||||
|
&[
|
||||||
|
&record.tenant.as_str(),
|
||||||
|
&record.project.as_str(),
|
||||||
|
&record.user.as_str(),
|
||||||
|
&value,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSTGRES_SCHEMA_SQL: &str = r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_tenants (
|
||||||
|
tenant_id TEXT PRIMARY KEY,
|
||||||
|
record JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
record JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_projects (
|
||||||
|
project_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
record JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_node_identities (
|
||||||
|
node_id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
record JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_credentials (
|
||||||
|
subject TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
record JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs (
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
provider_key TEXT NOT NULL,
|
||||||
|
record JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, project_id, provider_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_service_policy_records (
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
record JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disasmer_project_permissions (
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE,
|
||||||
|
record JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, project_id, user_id)
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use disasmer_core::{
|
||||||
|
CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_schema_contains_only_restart_surviving_durable_tables() {
|
||||||
|
let names = PostgresDurableStore::durable_tables()
|
||||||
|
.iter()
|
||||||
|
.map(|table| table.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(names.len(), 8);
|
||||||
|
assert!(names.contains(&"disasmer_tenants"));
|
||||||
|
assert!(names.contains(&"disasmer_users"));
|
||||||
|
assert!(names.contains(&"disasmer_projects"));
|
||||||
|
assert!(names.contains(&"disasmer_node_identities"));
|
||||||
|
assert!(names.contains(&"disasmer_credentials"));
|
||||||
|
assert!(names.contains(&"disasmer_source_provider_configs"));
|
||||||
|
assert!(names.contains(&"disasmer_service_policy_records"));
|
||||||
|
assert!(names.contains(&"disasmer_project_permissions"));
|
||||||
|
assert!(PostgresDurableStore::durable_tables()
|
||||||
|
.iter()
|
||||||
|
.all(|table| table.restart_surviving));
|
||||||
|
|
||||||
|
for runtime_only in [
|
||||||
|
"active_process",
|
||||||
|
"virtual_thread",
|
||||||
|
"scheduler_state",
|
||||||
|
"debug_epoch",
|
||||||
|
"vfs_manifest",
|
||||||
|
"transient_artifact_location",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!PostgresDurableStore::schema_sql().contains(runtime_only),
|
||||||
|
"{runtime_only} must remain outside Postgres durable state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() {
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FallibleMemoryStore {
|
||||||
|
inner: InMemoryDurableStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FallibleDurableStore for FallibleMemoryStore {
|
||||||
|
type Error = std::convert::Infallible;
|
||||||
|
|
||||||
|
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
|
||||||
|
Ok(self.inner.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> {
|
||||||
|
self.inner.save(state.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut store = FallibleMemoryStore::default();
|
||||||
|
let mut first = Coordinator::try_boot(&mut store, 1).unwrap();
|
||||||
|
first.upsert_tenant(TenantId::from("tenant"));
|
||||||
|
first.upsert_user(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
UserId::from("user"),
|
||||||
|
CredentialKind::CliDeviceSession,
|
||||||
|
);
|
||||||
|
first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo");
|
||||||
|
first.enroll_node(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
NodeId::from("node"),
|
||||||
|
"public-key",
|
||||||
|
"node:attach",
|
||||||
|
);
|
||||||
|
first.upsert_source_provider_config(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
SourceProviderKind::Git,
|
||||||
|
Digest::sha256("git-manifest"),
|
||||||
|
);
|
||||||
|
first.start_process(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
disasmer_core::ProcessId::from("process"),
|
||||||
|
);
|
||||||
|
first.try_persist(&mut store).unwrap();
|
||||||
|
|
||||||
|
let restarted = Coordinator::try_boot(&mut store, 2).unwrap();
|
||||||
|
|
||||||
|
assert!(restarted.project(&ProjectId::from("project")).is_some());
|
||||||
|
assert!(restarted.node_identity(&NodeId::from("node")).is_some());
|
||||||
|
assert_eq!(restarted.active_process_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_round_trip_runs_when_dsn_is_configured() {
|
||||||
|
let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut store = PostgresDurableStore::connect(&dsn).unwrap();
|
||||||
|
let mut state = DurableState::default();
|
||||||
|
state.tenants.insert(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
TenantRecord {
|
||||||
|
id: TenantId::from("tenant"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.projects.insert(
|
||||||
|
ProjectId::from("project"),
|
||||||
|
ProjectRecord {
|
||||||
|
id: ProjectId::from("project"),
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
name: "demo".to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
store.save_state(&state).unwrap();
|
||||||
|
let loaded = store.load_state().unwrap();
|
||||||
|
|
||||||
|
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
|
||||||
|
}
|
||||||
|
}
|
||||||
3591
crates/disasmer-coordinator/src/service.rs
Normal file
3591
crates/disasmer-coordinator/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
16
crates/disasmer-core/Cargo.toml
Normal file
16
crates/disasmer-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
939
crates/disasmer-core/src/artifact.rs
Normal file
939
crates/disasmer-core/src/artifact.rs
Normal file
|
|
@ -0,0 +1,939 @@
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::same_tenant_project, Actor, ArtifactId, AuthContext, Digest, LimitError, LimitKind,
|
||||||
|
NodeId, ProcessId, ProjectId, ResourceLimits, ResourceMeter, Scope, TaskId, TenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum StorageLocation {
|
||||||
|
RetainedNode(NodeId),
|
||||||
|
ExplicitStore(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RetentionPolicy {
|
||||||
|
pub best_effort_node_retention: bool,
|
||||||
|
pub max_download_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RetentionPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
best_effort_node_retention: true,
|
||||||
|
max_download_bytes: 256 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadPolicy {
|
||||||
|
pub max_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadAction {
|
||||||
|
pub artifact: ArtifactId,
|
||||||
|
pub source: StorageLocation,
|
||||||
|
pub scoped_token_subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadLink {
|
||||||
|
pub artifact: ArtifactId,
|
||||||
|
pub source: StorageLocation,
|
||||||
|
pub url_path: String,
|
||||||
|
pub scoped_token_digest: Digest,
|
||||||
|
pub expires_at_epoch_seconds: u64,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub actor: Actor,
|
||||||
|
pub max_bytes: u64,
|
||||||
|
pub policy_context_digest: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct IssuedDownloadLink {
|
||||||
|
pub link: DownloadLink,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ArtifactDownloadStream {
|
||||||
|
pub link: DownloadLink,
|
||||||
|
pub streamed_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum DownloadError {
|
||||||
|
#[error("artifact does not exist")]
|
||||||
|
NotFound,
|
||||||
|
#[error("artifact is unavailable from current retention or explicit storage")]
|
||||||
|
Unavailable,
|
||||||
|
#[error("artifact download direct connectivity unavailable: {0}")]
|
||||||
|
DirectConnectivityUnavailable(String),
|
||||||
|
#[error("artifact download denied: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
|
#[error("artifact size {size} exceeds download limit {limit}")]
|
||||||
|
LimitExceeded { size: u64, limit: u64 },
|
||||||
|
#[error("download link token is invalid for this scoped artifact link")]
|
||||||
|
InvalidToken,
|
||||||
|
#[error("download link has expired")]
|
||||||
|
Expired,
|
||||||
|
#[error("download link has been revoked")]
|
||||||
|
Revoked,
|
||||||
|
#[error("download usage limit failed: {0}")]
|
||||||
|
Usage(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
#[error("artifact is unavailable because node-local unsynced bytes were lost")]
|
||||||
|
pub struct ArtifactUnavailable;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ArtifactMetadata {
|
||||||
|
pub id: ArtifactId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub producer_task: TaskId,
|
||||||
|
pub producer_node: NodeId,
|
||||||
|
pub digest: Digest,
|
||||||
|
pub size: u64,
|
||||||
|
pub flushed_epoch: u64,
|
||||||
|
pub retaining_nodes: BTreeSet<NodeId>,
|
||||||
|
pub explicit_locations: Vec<String>,
|
||||||
|
pub coordinator_has_large_bytes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ArtifactRegistry {
|
||||||
|
artifacts: BTreeMap<ArtifactId, ArtifactMetadata>,
|
||||||
|
issued_download_links: BTreeMap<Digest, IssuedDownloadLink>,
|
||||||
|
next_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtifactRegistry {
|
||||||
|
pub fn flush_metadata(
|
||||||
|
&mut self,
|
||||||
|
id: ArtifactId,
|
||||||
|
tenant: TenantId,
|
||||||
|
project: ProjectId,
|
||||||
|
process: ProcessId,
|
||||||
|
producer_task: TaskId,
|
||||||
|
retaining_node: NodeId,
|
||||||
|
digest: Digest,
|
||||||
|
size: u64,
|
||||||
|
) -> ArtifactMetadata {
|
||||||
|
self.next_epoch += 1;
|
||||||
|
let metadata = ArtifactMetadata {
|
||||||
|
id: id.clone(),
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
process,
|
||||||
|
producer_task,
|
||||||
|
producer_node: retaining_node.clone(),
|
||||||
|
digest,
|
||||||
|
size,
|
||||||
|
flushed_epoch: self.next_epoch,
|
||||||
|
retaining_nodes: BTreeSet::from([retaining_node]),
|
||||||
|
explicit_locations: Vec::new(),
|
||||||
|
coordinator_has_large_bytes: false,
|
||||||
|
};
|
||||||
|
self.artifacts.insert(id, metadata.clone());
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_to_explicit_store(
|
||||||
|
&mut self,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
location: impl Into<String>,
|
||||||
|
) -> Result<(), ArtifactUnavailable> {
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get_mut(artifact)
|
||||||
|
.ok_or(ArtifactUnavailable)?;
|
||||||
|
metadata.explicit_locations.push(location.into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn garbage_collect_node(&mut self, node: &NodeId) {
|
||||||
|
for metadata in self.artifacts.values_mut() {
|
||||||
|
metadata.retaining_nodes.remove(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata(&self, artifact: &ArtifactId) -> Option<&ArtifactMetadata> {
|
||||||
|
self.artifacts.get(artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_action(
|
||||||
|
&self,
|
||||||
|
context: &AuthContext,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
policy: &DownloadPolicy,
|
||||||
|
) -> Result<DownloadAction, DownloadError> {
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get(artifact)
|
||||||
|
.ok_or(DownloadError::NotFound)?;
|
||||||
|
let scope = Scope {
|
||||||
|
tenant: metadata.tenant.clone(),
|
||||||
|
project: metadata.project.clone(),
|
||||||
|
process: Some(metadata.process.clone()),
|
||||||
|
task: Some(metadata.producer_task.clone()),
|
||||||
|
node: None,
|
||||||
|
artifact: Some(metadata.id.clone()),
|
||||||
|
};
|
||||||
|
let authz = same_tenant_project(context, &scope);
|
||||||
|
if !authz.allowed {
|
||||||
|
return Err(DownloadError::Unauthorized(authz.reason));
|
||||||
|
}
|
||||||
|
if metadata.size > policy.max_bytes {
|
||||||
|
return Err(DownloadError::LimitExceeded {
|
||||||
|
size: metadata.size,
|
||||||
|
limit: policy.max_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let source = metadata
|
||||||
|
.retaining_nodes
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.cloned()
|
||||||
|
.map(StorageLocation::RetainedNode)
|
||||||
|
.or_else(|| {
|
||||||
|
metadata
|
||||||
|
.explicit_locations
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.map(StorageLocation::ExplicitStore)
|
||||||
|
})
|
||||||
|
.ok_or(DownloadError::Unavailable)?;
|
||||||
|
|
||||||
|
Ok(DownloadAction {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
source,
|
||||||
|
scoped_token_subject: format!(
|
||||||
|
"{}/{}/{}/{}",
|
||||||
|
metadata.tenant, metadata.project, metadata.process, metadata.id
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn downloadable_size(
|
||||||
|
&self,
|
||||||
|
context: &AuthContext,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
policy: &DownloadPolicy,
|
||||||
|
) -> Result<u64, DownloadError> {
|
||||||
|
self.download_action(context, artifact, policy)?;
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get(artifact)
|
||||||
|
.ok_or(DownloadError::NotFound)?;
|
||||||
|
Ok(metadata.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_download_link(
|
||||||
|
&mut self,
|
||||||
|
context: &AuthContext,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
policy: &DownloadPolicy,
|
||||||
|
token_nonce: &str,
|
||||||
|
now_epoch_seconds: u64,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> Result<DownloadLink, DownloadError> {
|
||||||
|
let action = self.download_action(context, artifact, policy)?;
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get(artifact)
|
||||||
|
.ok_or(DownloadError::NotFound)?;
|
||||||
|
let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds);
|
||||||
|
let policy_context_digest =
|
||||||
|
download_policy_context_digest(metadata, &action.source, policy);
|
||||||
|
let scoped_token_digest = Digest::from_parts([
|
||||||
|
b"artifact-download-token:v2".as_slice(),
|
||||||
|
action.scoped_token_subject.as_bytes(),
|
||||||
|
actor_subject(&context.actor).as_bytes(),
|
||||||
|
token_nonce.as_bytes(),
|
||||||
|
metadata.digest.as_str().as_bytes(),
|
||||||
|
metadata.size.to_string().as_bytes(),
|
||||||
|
policy_context_digest.as_str().as_bytes(),
|
||||||
|
expires_at_epoch_seconds.to_string().as_bytes(),
|
||||||
|
]);
|
||||||
|
let link = DownloadLink {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
source: action.source,
|
||||||
|
url_path: format!(
|
||||||
|
"/artifacts/{}/{}/{}/{}",
|
||||||
|
metadata.tenant, metadata.project, metadata.process, metadata.id
|
||||||
|
),
|
||||||
|
scoped_token_digest,
|
||||||
|
expires_at_epoch_seconds,
|
||||||
|
tenant: metadata.tenant.clone(),
|
||||||
|
project: metadata.project.clone(),
|
||||||
|
process: metadata.process.clone(),
|
||||||
|
actor: context.actor.clone(),
|
||||||
|
max_bytes: policy.max_bytes,
|
||||||
|
policy_context_digest,
|
||||||
|
};
|
||||||
|
self.issued_download_links.insert(
|
||||||
|
link.scoped_token_digest.clone(),
|
||||||
|
IssuedDownloadLink {
|
||||||
|
link: link.clone(),
|
||||||
|
revoked: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_download_link(
|
||||||
|
&mut self,
|
||||||
|
context: &AuthContext,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
presented_token_digest: &Digest,
|
||||||
|
) -> Result<DownloadLink, DownloadError> {
|
||||||
|
let issued = self
|
||||||
|
.issued_download_links
|
||||||
|
.get(presented_token_digest)
|
||||||
|
.ok_or(DownloadError::InvalidToken)?;
|
||||||
|
if issued.link.artifact != *artifact || issued.link.actor != context.actor {
|
||||||
|
return Err(DownloadError::InvalidToken);
|
||||||
|
}
|
||||||
|
self.download_action(
|
||||||
|
context,
|
||||||
|
artifact,
|
||||||
|
&DownloadPolicy {
|
||||||
|
max_bytes: issued.link.max_bytes,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let issued = self
|
||||||
|
.issued_download_links
|
||||||
|
.get_mut(presented_token_digest)
|
||||||
|
.ok_or(DownloadError::InvalidToken)?;
|
||||||
|
issued.revoked = true;
|
||||||
|
Ok(issued.link.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_download_stream(
|
||||||
|
&self,
|
||||||
|
context: &AuthContext,
|
||||||
|
artifact: &ArtifactId,
|
||||||
|
policy: &DownloadPolicy,
|
||||||
|
presented_token_digest: &Digest,
|
||||||
|
now_epoch_seconds: u64,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
meter: &mut ResourceMeter,
|
||||||
|
) -> Result<ArtifactDownloadStream, DownloadError> {
|
||||||
|
let issued = self
|
||||||
|
.issued_download_links
|
||||||
|
.get(presented_token_digest)
|
||||||
|
.ok_or(DownloadError::InvalidToken)?;
|
||||||
|
if issued.link.artifact != *artifact
|
||||||
|
|| issued.link.max_bytes != policy.max_bytes
|
||||||
|
|| issued.link.actor != context.actor
|
||||||
|
{
|
||||||
|
return Err(DownloadError::InvalidToken);
|
||||||
|
}
|
||||||
|
if issued.revoked {
|
||||||
|
return Err(DownloadError::Revoked);
|
||||||
|
}
|
||||||
|
if now_epoch_seconds > issued.link.expires_at_epoch_seconds {
|
||||||
|
return Err(DownloadError::Expired);
|
||||||
|
}
|
||||||
|
let action = self.download_action(context, artifact, policy)?;
|
||||||
|
if action.source != issued.link.source {
|
||||||
|
return Err(DownloadError::Unavailable);
|
||||||
|
}
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get(artifact)
|
||||||
|
.ok_or(DownloadError::NotFound)?;
|
||||||
|
if download_policy_context_digest(metadata, &action.source, policy)
|
||||||
|
!= issued.link.policy_context_digest
|
||||||
|
{
|
||||||
|
return Err(DownloadError::InvalidToken);
|
||||||
|
}
|
||||||
|
meter
|
||||||
|
.charge(limits, LimitKind::ArtifactDownloadBytes, 0)
|
||||||
|
.map_err(download_usage_error)?;
|
||||||
|
Ok(ArtifactDownloadStream {
|
||||||
|
link: issued.link.clone(),
|
||||||
|
streamed_bytes: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stream_download_chunk(
|
||||||
|
&self,
|
||||||
|
stream: &mut ArtifactDownloadStream,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
meter: &mut ResourceMeter,
|
||||||
|
bytes: u64,
|
||||||
|
) -> Result<(), DownloadError> {
|
||||||
|
let metadata = self
|
||||||
|
.artifacts
|
||||||
|
.get(&stream.link.artifact)
|
||||||
|
.ok_or(DownloadError::NotFound)?;
|
||||||
|
if !source_is_available(metadata, &stream.link.source) {
|
||||||
|
return Err(DownloadError::Unavailable);
|
||||||
|
}
|
||||||
|
stream.stream_chunk(limits, meter, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtifactDownloadStream {
|
||||||
|
pub fn stream_chunk(
|
||||||
|
&mut self,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
meter: &mut ResourceMeter,
|
||||||
|
bytes: u64,
|
||||||
|
) -> Result<(), DownloadError> {
|
||||||
|
if self.streamed_bytes.saturating_add(bytes) > self.link.max_bytes {
|
||||||
|
return Err(DownloadError::LimitExceeded {
|
||||||
|
size: self.streamed_bytes.saturating_add(bytes),
|
||||||
|
limit: self.link.max_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
meter
|
||||||
|
.charge(limits, LimitKind::ArtifactDownloadBytes, bytes)
|
||||||
|
.map_err(download_usage_error)?;
|
||||||
|
self.streamed_bytes += bytes;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_usage_error(error: LimitError) -> DownloadError {
|
||||||
|
DownloadError::Usage(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_policy_context_digest(
|
||||||
|
metadata: &ArtifactMetadata,
|
||||||
|
source: &StorageLocation,
|
||||||
|
policy: &DownloadPolicy,
|
||||||
|
) -> Digest {
|
||||||
|
Digest::from_parts([
|
||||||
|
b"artifact-download-policy-context:v1".as_slice(),
|
||||||
|
metadata.tenant.as_str().as_bytes(),
|
||||||
|
metadata.project.as_str().as_bytes(),
|
||||||
|
metadata.process.as_str().as_bytes(),
|
||||||
|
metadata.id.as_str().as_bytes(),
|
||||||
|
metadata.digest.as_str().as_bytes(),
|
||||||
|
metadata.size.to_string().as_bytes(),
|
||||||
|
storage_location_key(source).as_bytes(),
|
||||||
|
policy.max_bytes.to_string().as_bytes(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_subject(actor: &Actor) -> String {
|
||||||
|
match actor {
|
||||||
|
Actor::User(id) => format!("user:{id}"),
|
||||||
|
Actor::Agent(id) => format!("agent:{id}"),
|
||||||
|
Actor::Node(id) => format!("node:{id}"),
|
||||||
|
Actor::Task(id) => format!("task:{id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn storage_location_key(source: &StorageLocation) -> String {
|
||||||
|
match source {
|
||||||
|
StorageLocation::RetainedNode(node) => format!("retained-node:{node}"),
|
||||||
|
StorageLocation::ExplicitStore(location) => format!("explicit-store:{location}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_is_available(metadata: &ArtifactMetadata, source: &StorageLocation) -> bool {
|
||||||
|
match source {
|
||||||
|
StorageLocation::RetainedNode(node) => metadata.retaining_nodes.contains(node),
|
||||||
|
StorageLocation::ExplicitStore(location) => metadata.explicit_locations.contains(location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::{Actor, LimitKind, ResourceLimits, ResourceMeter, UserId};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn registry_with_artifact() -> ArtifactRegistry {
|
||||||
|
let mut registry = ArtifactRegistry::default();
|
||||||
|
registry.flush_metadata(
|
||||||
|
ArtifactId::from("artifact"),
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
ProcessId::from("process"),
|
||||||
|
TaskId::from("task"),
|
||||||
|
NodeId::from("node"),
|
||||||
|
Digest::sha256("bytes"),
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_publishes_metadata_without_coordinator_bytes() {
|
||||||
|
let registry = registry_with_artifact();
|
||||||
|
let metadata = registry.metadata(&ArtifactId::from("artifact")).unwrap();
|
||||||
|
|
||||||
|
assert!(!metadata.coordinator_has_large_bytes);
|
||||||
|
assert_eq!(metadata.id, ArtifactId::from("artifact"));
|
||||||
|
assert_eq!(metadata.tenant, TenantId::from("tenant"));
|
||||||
|
assert_eq!(metadata.project, ProjectId::from("project"));
|
||||||
|
assert_eq!(metadata.process, ProcessId::from("process"));
|
||||||
|
assert_eq!(metadata.producer_task, TaskId::from("task"));
|
||||||
|
assert_eq!(metadata.producer_node, NodeId::from("node"));
|
||||||
|
assert_eq!(metadata.digest, Digest::sha256("bytes"));
|
||||||
|
assert_eq!(metadata.size, 32);
|
||||||
|
assert_eq!(metadata.flushed_epoch, 1);
|
||||||
|
assert!(metadata.retaining_nodes.contains(&NodeId::from("node")));
|
||||||
|
assert!(metadata.explicit_locations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsynced_node_loss_surfaces_as_unavailable() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
registry.garbage_collect_node(&NodeId::from("node"));
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.download_action(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, DownloadError::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_user_storage_location_survives_node_retention_loss() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
registry
|
||||||
|
.sync_to_explicit_store(&ArtifactId::from("artifact"), "s3://bucket/app")
|
||||||
|
.unwrap();
|
||||||
|
registry.garbage_collect_node(&NodeId::from("node"));
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = registry
|
||||||
|
.download_action(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.source,
|
||||||
|
StorageLocation::ExplicitStore("s3://bucket/app".to_owned())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!registry
|
||||||
|
.metadata(&ArtifactId::from("artifact"))
|
||||||
|
.unwrap()
|
||||||
|
.coordinator_has_large_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_retention_policy_is_best_effort_node_retention() {
|
||||||
|
let policy = RetentionPolicy::default();
|
||||||
|
|
||||||
|
assert!(policy.best_effort_node_retention);
|
||||||
|
assert_eq!(policy.max_download_bytes, 256 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_tenant_download_is_denied_even_with_known_artifact_id() {
|
||||||
|
let registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("other"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.download_action(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, DownloadError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_project_download_is_denied_even_with_known_artifact_id() {
|
||||||
|
let registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("other-project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.download_action(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, DownloadError::Unauthorized(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_link_is_not_created_when_artifact_is_unavailable_or_too_large() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
registry.garbage_collect_node(&NodeId::from("node"));
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
"nonce",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
DownloadError::Unavailable
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
assert!(matches!(
|
||||||
|
registry.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 1 },
|
||||||
|
"nonce",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
Err(DownloadError::LimitExceeded { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_link_is_authenticated_scoped_and_not_guessable() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let link = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
"nonce-a",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let other = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
"nonce-b",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(link.tenant, TenantId::from("tenant"));
|
||||||
|
assert_eq!(link.project, ProjectId::from("project"));
|
||||||
|
assert_eq!(link.process, ProcessId::from("process"));
|
||||||
|
assert_eq!(link.actor, Actor::User(UserId::from("user")));
|
||||||
|
assert_eq!(link.max_bytes, 100);
|
||||||
|
assert!(link.policy_context_digest.is_valid_sha256());
|
||||||
|
assert_eq!(link.expires_at_epoch_seconds, 70);
|
||||||
|
assert!(link
|
||||||
|
.url_path
|
||||||
|
.contains("/artifacts/tenant/project/process/artifact"));
|
||||||
|
assert_ne!(link.scoped_token_digest, other.scoped_token_digest);
|
||||||
|
assert!(matches!(link.source, StorageLocation::RetainedNode(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_link_is_bound_to_actor_and_policy_context() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let other_actor = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("other-user")),
|
||||||
|
};
|
||||||
|
let policy = DownloadPolicy { max_bytes: 100 };
|
||||||
|
let link = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
"nonce",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.open_download_stream(
|
||||||
|
&other_actor,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
&link.scoped_token_digest,
|
||||||
|
11,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
DownloadError::InvalidToken
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 99 },
|
||||||
|
&link.scoped_token_digest,
|
||||||
|
11,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
DownloadError::InvalidToken
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.revoke_download_link(
|
||||||
|
&other_actor,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&link.scoped_token_digest,
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
DownloadError::InvalidToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_link_expires_and_can_be_revoked() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let policy = DownloadPolicy { max_bytes: 100 };
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
let expired = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
"expired",
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
&expired.scoped_token_digest,
|
||||||
|
16,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error, DownloadError::Expired);
|
||||||
|
|
||||||
|
let active = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
"active",
|
||||||
|
20,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let revoked = registry
|
||||||
|
.revoke_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&active.scoped_token_digest,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(revoked.scoped_token_digest, active.scoped_token_digest);
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
&active.scoped_token_digest,
|
||||||
|
21,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error, DownloadError::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_stream_accounts_usage_before_and_during_streaming() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let policy = DownloadPolicy { max_bytes: 100 };
|
||||||
|
let link = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
"nonce",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
let mut stream = registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
&link.scoped_token_digest,
|
||||||
|
11,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
stream.stream_chunk(&limits, &mut meter, 16).unwrap();
|
||||||
|
stream.stream_chunk(&limits, &mut meter, 16).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
stream.stream_chunk(&limits, &mut meter, 1),
|
||||||
|
Err(DownloadError::Usage(_))
|
||||||
|
));
|
||||||
|
assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_stream_fails_honestly_when_source_disappears_mid_stream() {
|
||||||
|
let mut registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let policy = DownloadPolicy { max_bytes: 100 };
|
||||||
|
let link = registry
|
||||||
|
.create_download_link(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
"nonce",
|
||||||
|
10,
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
let mut stream = registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&policy,
|
||||||
|
&link.scoped_token_digest,
|
||||||
|
11,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
registry
|
||||||
|
.stream_download_chunk(&mut stream, &limits, &mut meter, 16)
|
||||||
|
.unwrap();
|
||||||
|
registry.garbage_collect_node(&NodeId::from("node"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.stream_download_chunk(&mut stream, &limits, &mut meter, 1)
|
||||||
|
.unwrap_err(),
|
||||||
|
DownloadError::Unavailable
|
||||||
|
);
|
||||||
|
assert_eq!(stream.streamed_bytes, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guessed_download_token_is_rejected() {
|
||||||
|
let registry = registry_with_artifact();
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.open_download_stream(
|
||||||
|
&context,
|
||||||
|
&ArtifactId::from("artifact"),
|
||||||
|
&DownloadPolicy { max_bytes: 100 },
|
||||||
|
&Digest::sha256("guessed"),
|
||||||
|
11,
|
||||||
|
&limits,
|
||||||
|
&mut meter,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, DownloadError::InvalidToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
369
crates/disasmer-core/src/auth.rs
Normal file
369
crates/disasmer-core/src/auth.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Actor {
|
||||||
|
User(UserId),
|
||||||
|
Agent(AgentId),
|
||||||
|
Node(NodeId),
|
||||||
|
Task(TaskId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor {
|
||||||
|
pub fn kind(&self) -> IdentityKind {
|
||||||
|
match self {
|
||||||
|
Self::User(_) => IdentityKind::User,
|
||||||
|
Self::Agent(_) => IdentityKind::Agent,
|
||||||
|
Self::Node(_) => IdentityKind::Node,
|
||||||
|
Self::Task(_) => IdentityKind::Task,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum IdentityKind {
|
||||||
|
User,
|
||||||
|
Agent,
|
||||||
|
Node,
|
||||||
|
Project,
|
||||||
|
Task,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CredentialKind {
|
||||||
|
BrowserSession,
|
||||||
|
CliDeviceSession,
|
||||||
|
PublicKey,
|
||||||
|
NodeCredential,
|
||||||
|
TaskCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub actor: Actor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Action {
|
||||||
|
CreateProject,
|
||||||
|
AttachNode,
|
||||||
|
CreateNodeEnrollmentGrant,
|
||||||
|
ExchangeNodeEnrollmentGrant,
|
||||||
|
LoginBrowser,
|
||||||
|
LoginCli,
|
||||||
|
EnrollAgent,
|
||||||
|
List,
|
||||||
|
Inspect,
|
||||||
|
Mutate,
|
||||||
|
ClaimTask,
|
||||||
|
DebugAttach,
|
||||||
|
DebugRead,
|
||||||
|
DownloadArtifact,
|
||||||
|
PublishArtifact,
|
||||||
|
RunNativeCommand,
|
||||||
|
RunContainer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BrowserLoginFlow {
|
||||||
|
pub authorization_url: String,
|
||||||
|
pub callback_path: String,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CliLoginFlow {
|
||||||
|
pub verification_url: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub device_code: String,
|
||||||
|
pub expires_in_seconds: u64,
|
||||||
|
pub yields_long_lived_secret_directly: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PublicKeyIdentity {
|
||||||
|
pub subject: Actor,
|
||||||
|
pub public_key: String,
|
||||||
|
pub fingerprint: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnrollmentGrant {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub grant_id: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub expires_at_epoch_seconds: u64,
|
||||||
|
pub consumed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeCredential {
|
||||||
|
pub node: NodeId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub public_key_fingerprint: Digest,
|
||||||
|
pub scope: String,
|
||||||
|
pub capability_policy_digest: Digest,
|
||||||
|
pub credential_kind: CredentialKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum EnrollmentError {
|
||||||
|
Expired,
|
||||||
|
AlreadyConsumed,
|
||||||
|
WrongScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnrollmentGrant {
|
||||||
|
pub fn exchange_for_node_identity(
|
||||||
|
&mut self,
|
||||||
|
node: NodeId,
|
||||||
|
public_key: &str,
|
||||||
|
requested_scope: &str,
|
||||||
|
now_epoch_seconds: u64,
|
||||||
|
) -> Result<NodeCredential, EnrollmentError> {
|
||||||
|
if self.consumed {
|
||||||
|
return Err(EnrollmentError::AlreadyConsumed);
|
||||||
|
}
|
||||||
|
if now_epoch_seconds > self.expires_at_epoch_seconds {
|
||||||
|
return Err(EnrollmentError::Expired);
|
||||||
|
}
|
||||||
|
if requested_scope != self.scope {
|
||||||
|
return Err(EnrollmentError::WrongScope);
|
||||||
|
}
|
||||||
|
self.consumed = true;
|
||||||
|
let capability_policy_digest =
|
||||||
|
node_capability_policy_digest(&self.tenant, &self.project, &self.scope);
|
||||||
|
Ok(NodeCredential {
|
||||||
|
node,
|
||||||
|
tenant: self.tenant.clone(),
|
||||||
|
project: self.project.clone(),
|
||||||
|
public_key_fingerprint: Digest::sha256(public_key),
|
||||||
|
scope: self.scope.clone(),
|
||||||
|
capability_policy_digest,
|
||||||
|
credential_kind: CredentialKind::NodeCredential,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn node_capability_policy_digest(
|
||||||
|
tenant: &TenantId,
|
||||||
|
project: &ProjectId,
|
||||||
|
scope: &str,
|
||||||
|
) -> Digest {
|
||||||
|
Digest::from_parts([
|
||||||
|
b"node-capability-policy:v1".as_slice(),
|
||||||
|
tenant.as_str().as_bytes(),
|
||||||
|
project.as_str().as_bytes(),
|
||||||
|
scope.as_bytes(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Scope {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: Option<ProcessId>,
|
||||||
|
pub task: Option<TaskId>,
|
||||||
|
pub node: Option<NodeId>,
|
||||||
|
pub artifact: Option<ArtifactId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Authorization {
|
||||||
|
pub allowed: bool,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Authorization {
|
||||||
|
pub fn allow(reason: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
allowed: true,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deny(reason: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
allowed: false,
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization {
|
||||||
|
if context.tenant != scope.tenant {
|
||||||
|
return Authorization::deny("tenant mismatch");
|
||||||
|
}
|
||||||
|
if context.project != scope.project {
|
||||||
|
return Authorization::deny("project mismatch");
|
||||||
|
}
|
||||||
|
Authorization::allow("same tenant and project")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn task_credentials_do_not_contain_user_session(
|
||||||
|
task: &Actor,
|
||||||
|
credentials: &[CredentialKind],
|
||||||
|
) -> Authorization {
|
||||||
|
if !matches!(task, Actor::Task(_)) {
|
||||||
|
return Authorization::deny("credential check requires task actor");
|
||||||
|
}
|
||||||
|
if credentials.iter().any(|credential| {
|
||||||
|
matches!(
|
||||||
|
credential,
|
||||||
|
CredentialKind::BrowserSession | CredentialKind::CliDeviceSession
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Authorization::deny(
|
||||||
|
"user OAuth/session tokens must not be passed to nodes as task credentials",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Authorization::allow("task credentials are scoped runtime credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenant_project_scope_denies_cross_tenant_access() {
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant-a"),
|
||||||
|
project: ProjectId::from("project-a"),
|
||||||
|
actor: Actor::User(UserId::from("user-a")),
|
||||||
|
};
|
||||||
|
let scope = Scope {
|
||||||
|
tenant: TenantId::from("tenant-b"),
|
||||||
|
project: ProjectId::from("project-a"),
|
||||||
|
process: None,
|
||||||
|
task: None,
|
||||||
|
node: None,
|
||||||
|
artifact: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!same_tenant_project(&context, &scope).allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_enrollment_exchanges_short_lived_grant_once() {
|
||||||
|
let mut grant = EnrollmentGrant {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
grant_id: "grant".to_owned(),
|
||||||
|
scope: "node:attach".to_owned(),
|
||||||
|
expires_at_epoch_seconds: 100,
|
||||||
|
consumed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let credential = grant
|
||||||
|
.exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(credential.credential_kind, CredentialKind::NodeCredential);
|
||||||
|
assert_eq!(credential.tenant, TenantId::from("tenant"));
|
||||||
|
assert_eq!(credential.project, ProjectId::from("project"));
|
||||||
|
assert_eq!(credential.node, NodeId::from("node"));
|
||||||
|
assert_eq!(credential.scope, "node:attach");
|
||||||
|
assert_eq!(
|
||||||
|
credential.capability_policy_digest,
|
||||||
|
node_capability_policy_digest(
|
||||||
|
&TenantId::from("tenant"),
|
||||||
|
&ProjectId::from("project"),
|
||||||
|
"node:attach"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
grant.exchange_for_node_identity(
|
||||||
|
NodeId::from("node2"),
|
||||||
|
"public-key",
|
||||||
|
"node:attach",
|
||||||
|
99
|
||||||
|
),
|
||||||
|
Err(EnrollmentError::AlreadyConsumed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_capability_policy_digest_is_scoped() {
|
||||||
|
let base = node_capability_policy_digest(
|
||||||
|
&TenantId::from("tenant"),
|
||||||
|
&ProjectId::from("project"),
|
||||||
|
"node:attach",
|
||||||
|
);
|
||||||
|
let other_project = node_capability_policy_digest(
|
||||||
|
&TenantId::from("tenant"),
|
||||||
|
&ProjectId::from("other"),
|
||||||
|
"node:attach",
|
||||||
|
);
|
||||||
|
let other_scope = node_capability_policy_digest(
|
||||||
|
&TenantId::from("tenant"),
|
||||||
|
&ProjectId::from("project"),
|
||||||
|
"node:limited",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(base.is_valid_sha256());
|
||||||
|
assert_ne!(base, other_project);
|
||||||
|
assert_ne!(base, other_scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_credentials_reject_user_session_tokens() {
|
||||||
|
for credential in [
|
||||||
|
CredentialKind::BrowserSession,
|
||||||
|
CredentialKind::CliDeviceSession,
|
||||||
|
] {
|
||||||
|
let authz = task_credentials_do_not_contain_user_session(
|
||||||
|
&Actor::Task(TaskId::from("task")),
|
||||||
|
&[CredentialKind::TaskCredential, credential],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!authz.allowed);
|
||||||
|
assert!(authz.reason.contains("must not be passed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let scoped = task_credentials_do_not_contain_user_session(
|
||||||
|
&Actor::Task(TaskId::from("task")),
|
||||||
|
&[
|
||||||
|
CredentialKind::TaskCredential,
|
||||||
|
CredentialKind::NodeCredential,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(scoped.allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identities_remain_distinct_for_authorization() {
|
||||||
|
assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User);
|
||||||
|
assert_eq!(
|
||||||
|
Actor::Agent(AgentId::from("agent")).kind(),
|
||||||
|
IdentityKind::Agent
|
||||||
|
);
|
||||||
|
assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node);
|
||||||
|
assert_eq!(Actor::Task(TaskId::from("task")).kind(), IdentityKind::Task);
|
||||||
|
|
||||||
|
let scope = Scope {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: Some(ProcessId::from("process")),
|
||||||
|
task: Some(TaskId::from("task")),
|
||||||
|
node: Some(NodeId::from("node")),
|
||||||
|
artifact: Some(ArtifactId::from("artifact")),
|
||||||
|
};
|
||||||
|
assert_eq!(scope.process, Some(ProcessId::from("process")));
|
||||||
|
assert_eq!(scope.artifact, Some(ArtifactId::from("artifact")));
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
CredentialKind::BrowserSession,
|
||||||
|
CredentialKind::CliDeviceSession
|
||||||
|
);
|
||||||
|
assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential);
|
||||||
|
assert_ne!(
|
||||||
|
CredentialKind::NodeCredential,
|
||||||
|
CredentialKind::TaskCredential
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/disasmer-core/src/bundle.rs
Normal file
117
crates/disasmer-core/src/bundle.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Digest, EnvironmentResource};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SelectedInput {
|
||||||
|
pub path: String,
|
||||||
|
pub digest: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BundleIdentityInputs {
|
||||||
|
pub wasm_code: Digest,
|
||||||
|
pub task_abi: Digest,
|
||||||
|
pub environments: Vec<EnvironmentResource>,
|
||||||
|
pub source_provider_manifest: Digest,
|
||||||
|
pub selected_inputs: Vec<SelectedInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BundleMetadata {
|
||||||
|
pub identity: Digest,
|
||||||
|
pub environments: Vec<EnvironmentResource>,
|
||||||
|
pub selected_inputs: Vec<SelectedInput>,
|
||||||
|
pub embeds_full_container_images: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BundleIdentityInputs {
|
||||||
|
pub fn identity(&self) -> Digest {
|
||||||
|
let mut parts = vec![
|
||||||
|
b"bundle:v1".to_vec(),
|
||||||
|
self.wasm_code.as_str().as_bytes().to_vec(),
|
||||||
|
self.task_abi.as_str().as_bytes().to_vec(),
|
||||||
|
self.source_provider_manifest.as_str().as_bytes().to_vec(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut environments = self.environments.clone();
|
||||||
|
environments.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
for environment in environments {
|
||||||
|
parts.push(environment.name.as_bytes().to_vec());
|
||||||
|
parts.push(format!("{:?}", environment.kind).into_bytes());
|
||||||
|
parts.push(environment.digest.as_str().as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inputs = self.selected_inputs.clone();
|
||||||
|
inputs.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
for input in inputs {
|
||||||
|
parts.push(input.path.into_bytes());
|
||||||
|
parts.push(input.digest.as_str().as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
Digest::from_parts(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspectable_metadata(&self) -> BundleMetadata {
|
||||||
|
BundleMetadata {
|
||||||
|
identity: self.identity(),
|
||||||
|
environments: self.environments.clone(),
|
||||||
|
selected_inputs: self.selected_inputs.clone(),
|
||||||
|
embeds_full_container_images: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::{EnvironmentKind, EnvironmentRequirements};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn env(digest: &str) -> EnvironmentResource {
|
||||||
|
EnvironmentResource {
|
||||||
|
name: "linux".to_owned(),
|
||||||
|
kind: EnvironmentKind::Containerfile,
|
||||||
|
recipe_path: PathBuf::from("envs/linux/Containerfile"),
|
||||||
|
context_path: PathBuf::from("envs/linux"),
|
||||||
|
digest: Digest::sha256(digest),
|
||||||
|
requirements: EnvironmentRequirements::linux_container(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_identity_changes_when_environment_recipe_changes() {
|
||||||
|
let base = BundleIdentityInputs {
|
||||||
|
wasm_code: Digest::sha256("wasm"),
|
||||||
|
task_abi: Digest::sha256("abi"),
|
||||||
|
environments: vec![env("recipe-a")],
|
||||||
|
source_provider_manifest: Digest::sha256("source"),
|
||||||
|
selected_inputs: vec![],
|
||||||
|
};
|
||||||
|
let mut changed = base.clone();
|
||||||
|
changed.environments = vec![env("recipe-b")];
|
||||||
|
|
||||||
|
assert_ne!(base.identity(), changed.identity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_metadata_is_inspectable_and_does_not_vendor_images_by_default() {
|
||||||
|
let inputs = BundleIdentityInputs {
|
||||||
|
wasm_code: Digest::sha256("wasm"),
|
||||||
|
task_abi: Digest::sha256("abi"),
|
||||||
|
environments: vec![env("recipe")],
|
||||||
|
source_provider_manifest: Digest::sha256("source"),
|
||||||
|
selected_inputs: vec![SelectedInput {
|
||||||
|
path: "inputs/config.json".to_owned(),
|
||||||
|
digest: Digest::sha256("config"),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = inputs.inspectable_metadata();
|
||||||
|
|
||||||
|
assert_eq!(metadata.environments.len(), 1);
|
||||||
|
assert!(!metadata.embeds_full_container_images);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
crates/disasmer-core/src/capability.rs
Normal file
189
crates/disasmer-core/src/capability.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Capability {
|
||||||
|
Command,
|
||||||
|
Containers,
|
||||||
|
RootlessPodman,
|
||||||
|
SourceFilesystem,
|
||||||
|
SourceGit,
|
||||||
|
HostFilesystem,
|
||||||
|
Network,
|
||||||
|
Secrets,
|
||||||
|
InboundPorts,
|
||||||
|
ArbitrarySyscalls,
|
||||||
|
VfsArtifacts,
|
||||||
|
Wasmtime,
|
||||||
|
WindowsCommandDev,
|
||||||
|
QuicDirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum EnvironmentBackend {
|
||||||
|
Container,
|
||||||
|
NixFlake,
|
||||||
|
WindowsCommandDev,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Os {
|
||||||
|
Linux,
|
||||||
|
Windows,
|
||||||
|
Macos,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Os {
|
||||||
|
pub fn current() -> Self {
|
||||||
|
match std::env::consts::OS {
|
||||||
|
"linux" => Self::Linux,
|
||||||
|
"windows" => Self::Windows,
|
||||||
|
"macos" => Self::Macos,
|
||||||
|
other => Self::Other(other.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeCapabilities {
|
||||||
|
pub os: Os,
|
||||||
|
pub arch: String,
|
||||||
|
pub capabilities: BTreeSet<Capability>,
|
||||||
|
pub environment_backends: BTreeSet<EnvironmentBackend>,
|
||||||
|
pub source_providers: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum CapabilityReportError {
|
||||||
|
#[error("node architecture `{0}` is invalid")]
|
||||||
|
InvalidArchitecture(String),
|
||||||
|
#[error("node OS label `{0}` is invalid")]
|
||||||
|
InvalidOsLabel(String),
|
||||||
|
#[error("source provider id `{0}` is invalid")]
|
||||||
|
InvalidSourceProvider(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeCapabilities {
|
||||||
|
pub fn detect_current() -> Self {
|
||||||
|
let os = Os::current();
|
||||||
|
let mut capabilities = BTreeSet::from([
|
||||||
|
Capability::Command,
|
||||||
|
Capability::SourceFilesystem,
|
||||||
|
Capability::VfsArtifacts,
|
||||||
|
Capability::Wasmtime,
|
||||||
|
]);
|
||||||
|
let mut environment_backends = BTreeSet::new();
|
||||||
|
|
||||||
|
match os {
|
||||||
|
Os::Linux => {
|
||||||
|
capabilities.insert(Capability::Containers);
|
||||||
|
capabilities.insert(Capability::RootlessPodman);
|
||||||
|
environment_backends.insert(EnvironmentBackend::Container);
|
||||||
|
}
|
||||||
|
Os::Windows => {
|
||||||
|
capabilities.insert(Capability::WindowsCommandDev);
|
||||||
|
environment_backends.insert(EnvironmentBackend::WindowsCommandDev);
|
||||||
|
}
|
||||||
|
Os::Macos | Os::Other(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
os,
|
||||||
|
arch: std::env::consts::ARCH.to_owned(),
|
||||||
|
capabilities,
|
||||||
|
environment_backends,
|
||||||
|
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_capability(mut self, capability: Capability) -> Self {
|
||||||
|
self.capabilities.insert(capability);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_all(&self, required: &BTreeSet<Capability>) -> bool {
|
||||||
|
required
|
||||||
|
.iter()
|
||||||
|
.all(|capability| self.capabilities.contains(capability))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_public_report(&self) -> Result<(), CapabilityReportError> {
|
||||||
|
if !valid_capability_label(&self.arch) {
|
||||||
|
return Err(CapabilityReportError::InvalidArchitecture(
|
||||||
|
self.arch.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Os::Other(label) = &self.os {
|
||||||
|
if !valid_capability_label(label) {
|
||||||
|
return Err(CapabilityReportError::InvalidOsLabel(label.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for provider in &self.source_providers {
|
||||||
|
if !valid_source_provider_id(provider) {
|
||||||
|
return Err(CapabilityReportError::InvalidSourceProvider(
|
||||||
|
provider.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_capability_label(label: &str) -> bool {
|
||||||
|
!label.is_empty()
|
||||||
|
&& label.len() <= 64
|
||||||
|
&& label.bytes().all(
|
||||||
|
|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_source_provider_id(provider: &str) -> bool {
|
||||||
|
!provider.is_empty()
|
||||||
|
&& provider.len() <= 64
|
||||||
|
&& provider
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn capabilities() -> NodeCapabilities {
|
||||||
|
NodeCapabilities {
|
||||||
|
os: Os::Linux,
|
||||||
|
arch: "x86_64".to_owned(),
|
||||||
|
capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
environment_backends: BTreeSet::new(),
|
||||||
|
source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_reports_validate_hostile_strings() {
|
||||||
|
assert!(capabilities().validate_public_report().is_ok());
|
||||||
|
|
||||||
|
let mut invalid_arch = capabilities();
|
||||||
|
invalid_arch.arch = "x86_64\nmalicious".to_owned();
|
||||||
|
assert_eq!(
|
||||||
|
invalid_arch.validate_public_report(),
|
||||||
|
Err(CapabilityReportError::InvalidArchitecture(
|
||||||
|
"x86_64\nmalicious".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut invalid_provider = capabilities();
|
||||||
|
invalid_provider
|
||||||
|
.source_providers
|
||||||
|
.insert("../checkout".to_owned());
|
||||||
|
assert_eq!(
|
||||||
|
invalid_provider.validate_public_report(),
|
||||||
|
Err(CapabilityReportError::InvalidSourceProvider(
|
||||||
|
"../checkout".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
282
crates/disasmer-core/src/checkpoint.rs
Normal file
282
crates/disasmer-core/src/checkpoint.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{Digest, EnvironmentResource, TaskId, VfsManifest};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CheckpointBoundary {
|
||||||
|
pub task_entrypoint: String,
|
||||||
|
pub serialized_args: Digest,
|
||||||
|
pub environment_digest: Digest,
|
||||||
|
pub vfs_epoch: u64,
|
||||||
|
pub task_abi: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TaskCheckpoint {
|
||||||
|
pub task: TaskId,
|
||||||
|
pub boundary: CheckpointBoundary,
|
||||||
|
pub vfs_manifest: VfsManifest,
|
||||||
|
pub depends_on_live_stack: bool,
|
||||||
|
pub depends_on_live_socket: bool,
|
||||||
|
pub depends_on_ephemeral_artifact_durability: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RestartRequest {
|
||||||
|
pub task: TaskId,
|
||||||
|
pub entrypoint: String,
|
||||||
|
pub serialized_args: Digest,
|
||||||
|
pub environment: EnvironmentResource,
|
||||||
|
pub task_abi: Digest,
|
||||||
|
pub source_edited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum RestartDecision {
|
||||||
|
RestartTask {
|
||||||
|
task: TaskId,
|
||||||
|
from_vfs_epoch: u64,
|
||||||
|
discard_unflushed_changes: bool,
|
||||||
|
},
|
||||||
|
RestartWholeVirtualProcess {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct RestartPolicy;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum CompatibilityFailure {
|
||||||
|
#[error("task entrypoint changed")]
|
||||||
|
Entrypoint,
|
||||||
|
#[error("serialized task arguments changed")]
|
||||||
|
Args,
|
||||||
|
#[error("environment digest changed")]
|
||||||
|
Environment,
|
||||||
|
#[error("task ABI changed")]
|
||||||
|
TaskAbi,
|
||||||
|
#[error("checkpoint depends on unsupported live stack migration")]
|
||||||
|
LiveStack,
|
||||||
|
#[error("checkpoint depends on unsupported live socket checkpointing")]
|
||||||
|
LiveSocket,
|
||||||
|
#[error("checkpoint incorrectly treats ephemeral artifacts as durable")]
|
||||||
|
EphemeralArtifactDurability,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartPolicy {
|
||||||
|
pub fn decide(&self, checkpoint: &TaskCheckpoint, request: &RestartRequest) -> RestartDecision {
|
||||||
|
match compatibility_failure(checkpoint, request) {
|
||||||
|
None => RestartDecision::RestartTask {
|
||||||
|
task: checkpoint.task.clone(),
|
||||||
|
from_vfs_epoch: checkpoint.boundary.vfs_epoch,
|
||||||
|
discard_unflushed_changes: true,
|
||||||
|
},
|
||||||
|
Some(failure) => RestartDecision::RestartWholeVirtualProcess {
|
||||||
|
message: format!(
|
||||||
|
"cannot restart selected task `{}` from checkpoint: {failure}; restart the whole virtual process",
|
||||||
|
checkpoint.task
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatibility_failure(
|
||||||
|
checkpoint: &TaskCheckpoint,
|
||||||
|
request: &RestartRequest,
|
||||||
|
) -> Option<CompatibilityFailure> {
|
||||||
|
if checkpoint.depends_on_live_stack {
|
||||||
|
return Some(CompatibilityFailure::LiveStack);
|
||||||
|
}
|
||||||
|
if checkpoint.depends_on_live_socket {
|
||||||
|
return Some(CompatibilityFailure::LiveSocket);
|
||||||
|
}
|
||||||
|
if checkpoint.depends_on_ephemeral_artifact_durability {
|
||||||
|
return Some(CompatibilityFailure::EphemeralArtifactDurability);
|
||||||
|
}
|
||||||
|
if checkpoint.boundary.task_entrypoint != request.entrypoint {
|
||||||
|
return Some(CompatibilityFailure::Entrypoint);
|
||||||
|
}
|
||||||
|
if checkpoint.boundary.serialized_args != request.serialized_args {
|
||||||
|
return Some(CompatibilityFailure::Args);
|
||||||
|
}
|
||||||
|
if checkpoint.boundary.environment_digest != request.environment.digest {
|
||||||
|
return Some(CompatibilityFailure::Environment);
|
||||||
|
}
|
||||||
|
if checkpoint.boundary.task_abi != request.task_abi {
|
||||||
|
return Some(CompatibilityFailure::TaskAbi);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::{EnvironmentKind, EnvironmentRequirements, NodeId, VfsOverlay, VfsPath};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn env(digest_input: &str) -> EnvironmentResource {
|
||||||
|
EnvironmentResource {
|
||||||
|
name: "linux".to_owned(),
|
||||||
|
kind: EnvironmentKind::Containerfile,
|
||||||
|
recipe_path: PathBuf::from("envs/linux/Containerfile"),
|
||||||
|
context_path: PathBuf::from("envs/linux"),
|
||||||
|
digest: Digest::sha256(digest_input),
|
||||||
|
requirements: EnvironmentRequirements::linux_container(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkpoint() -> (TaskCheckpoint, EnvironmentResource) {
|
||||||
|
let environment = env("env");
|
||||||
|
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node"));
|
||||||
|
overlay.write(
|
||||||
|
VfsPath::new("/vfs/artifacts/app").unwrap(),
|
||||||
|
Digest::sha256("app"),
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
(
|
||||||
|
TaskCheckpoint {
|
||||||
|
task: TaskId::from("task"),
|
||||||
|
boundary: CheckpointBoundary {
|
||||||
|
task_entrypoint: "compile_linux".to_owned(),
|
||||||
|
serialized_args: Digest::sha256("args"),
|
||||||
|
environment_digest: environment.digest.clone(),
|
||||||
|
vfs_epoch: manifest.epoch,
|
||||||
|
task_abi: Digest::sha256("abi"),
|
||||||
|
},
|
||||||
|
vfs_manifest: manifest,
|
||||||
|
depends_on_live_stack: false,
|
||||||
|
depends_on_live_socket: false,
|
||||||
|
depends_on_ephemeral_artifact_durability: false,
|
||||||
|
},
|
||||||
|
environment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart_request(environment: EnvironmentResource) -> RestartRequest {
|
||||||
|
RestartRequest {
|
||||||
|
task: TaskId::from("task"),
|
||||||
|
entrypoint: "compile_linux".to_owned(),
|
||||||
|
serialized_args: Digest::sha256("args"),
|
||||||
|
environment,
|
||||||
|
task_abi: Digest::sha256("abi"),
|
||||||
|
source_edited: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_whole_process_restart(decision: RestartDecision, expected_reason: &str) {
|
||||||
|
match decision {
|
||||||
|
RestartDecision::RestartWholeVirtualProcess { message } => {
|
||||||
|
assert!(
|
||||||
|
message.contains(expected_reason),
|
||||||
|
"restart message `{message}` did not include `{expected_reason}`"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains("restart the whole virtual process"),
|
||||||
|
"restart message `{message}` did not direct a whole-process restart"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RestartDecision::RestartTask { .. } => {
|
||||||
|
panic!("incompatible checkpoint unexpectedly restarted selected task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatible_restart_uses_task_boundary_and_discards_unflushed_changes() {
|
||||||
|
let (checkpoint, environment) = checkpoint();
|
||||||
|
let request = restart_request(environment);
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
RestartDecision::RestartTask {
|
||||||
|
task: TaskId::from("task"),
|
||||||
|
from_vfs_epoch: 1,
|
||||||
|
discard_unflushed_changes: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_environment_requires_whole_process_restart() {
|
||||||
|
let (checkpoint, _) = checkpoint();
|
||||||
|
let request = restart_request(env("changed-env"));
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "environment digest changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_entrypoint_requires_whole_process_restart() {
|
||||||
|
let (checkpoint, environment) = checkpoint();
|
||||||
|
let mut request = restart_request(environment);
|
||||||
|
request.entrypoint = "package_linux".to_owned();
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "task entrypoint changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_serialized_args_require_whole_process_restart() {
|
||||||
|
let (checkpoint, environment) = checkpoint();
|
||||||
|
let mut request = restart_request(environment);
|
||||||
|
request.serialized_args = Digest::sha256("changed-args");
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "serialized task arguments changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incompatible_task_abi_requires_whole_process_restart() {
|
||||||
|
let (checkpoint, environment) = checkpoint();
|
||||||
|
let mut request = restart_request(environment);
|
||||||
|
request.task_abi = Digest::sha256("changed-abi");
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "task ABI changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_never_claims_live_stack_migration() {
|
||||||
|
let (mut checkpoint, environment) = checkpoint();
|
||||||
|
checkpoint.depends_on_live_stack = true;
|
||||||
|
let request = restart_request(environment);
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "live stack");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_never_claims_live_socket_checkpointing() {
|
||||||
|
let (mut checkpoint, environment) = checkpoint();
|
||||||
|
checkpoint.depends_on_live_socket = true;
|
||||||
|
let request = restart_request(environment);
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "live socket");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_never_depends_on_ephemeral_artifact_durability() {
|
||||||
|
let (mut checkpoint, environment) = checkpoint();
|
||||||
|
checkpoint.depends_on_ephemeral_artifact_durability = true;
|
||||||
|
let request = restart_request(environment);
|
||||||
|
|
||||||
|
let decision = RestartPolicy.decide(&checkpoint, &request);
|
||||||
|
|
||||||
|
assert_whole_process_restart(decision, "ephemeral artifacts");
|
||||||
|
}
|
||||||
|
}
|
||||||
276
crates/disasmer-core/src/debug.rs
Normal file
276
crates/disasmer-core/src/debug.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{ProcessId, TaskId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DebugParticipantKind {
|
||||||
|
WasmTask,
|
||||||
|
ControlledNativeCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DebugRuntimeState {
|
||||||
|
Running,
|
||||||
|
Frozen,
|
||||||
|
Completed,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DebugParticipant {
|
||||||
|
pub task: TaskId,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: DebugParticipantKind,
|
||||||
|
pub can_freeze: bool,
|
||||||
|
pub state: DebugRuntimeState,
|
||||||
|
pub stack_frames: Vec<String>,
|
||||||
|
pub local_values: Vec<(String, String)>,
|
||||||
|
pub task_args: Vec<(String, String)>,
|
||||||
|
pub handles: Vec<(String, String)>,
|
||||||
|
pub command_status: Option<String>,
|
||||||
|
pub recent_output: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DebugStopReason {
|
||||||
|
Breakpoint { task: TaskId, line: u32 },
|
||||||
|
PauseRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ThreadInspection {
|
||||||
|
pub task: TaskId,
|
||||||
|
pub name: String,
|
||||||
|
pub stack_frames: Vec<String>,
|
||||||
|
pub local_values: Vec<(String, String)>,
|
||||||
|
pub task_args: Vec<(String, String)>,
|
||||||
|
pub handles: Vec<(String, String)>,
|
||||||
|
pub command_status: Option<String>,
|
||||||
|
pub recent_output: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DebugEpoch {
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub epoch: u64,
|
||||||
|
pub reason: DebugStopReason,
|
||||||
|
participants: BTreeMap<TaskId, DebugParticipant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum DebugEpochError {
|
||||||
|
#[error("participant `{task}` cannot freeze, so all-stop failed")]
|
||||||
|
CannotFreeze { task: TaskId },
|
||||||
|
#[error("participant `{0}` is not part of this debug epoch")]
|
||||||
|
UnknownParticipant(TaskId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DebugEpoch {
|
||||||
|
pub fn all_stop(
|
||||||
|
process: ProcessId,
|
||||||
|
epoch: u64,
|
||||||
|
reason: DebugStopReason,
|
||||||
|
participants: Vec<DebugParticipant>,
|
||||||
|
) -> Result<Self, DebugEpochError> {
|
||||||
|
for participant in &participants {
|
||||||
|
if matches!(
|
||||||
|
participant.kind,
|
||||||
|
DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand
|
||||||
|
) && !participant.can_freeze
|
||||||
|
{
|
||||||
|
return Err(DebugEpochError::CannotFreeze {
|
||||||
|
task: participant.task.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let participants = participants
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut participant| {
|
||||||
|
if matches!(participant.state, DebugRuntimeState::Running) {
|
||||||
|
participant.state = DebugRuntimeState::Frozen;
|
||||||
|
}
|
||||||
|
(participant.task.clone(), participant)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
process,
|
||||||
|
epoch,
|
||||||
|
reason,
|
||||||
|
participants,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(
|
||||||
|
process: ProcessId,
|
||||||
|
epoch: u64,
|
||||||
|
participants: Vec<DebugParticipant>,
|
||||||
|
) -> Result<Self, DebugEpochError> {
|
||||||
|
Self::all_stop(process, epoch, DebugStopReason::PauseRequest, participants)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn continue_all(&mut self) {
|
||||||
|
for participant in self.participants.values_mut() {
|
||||||
|
if participant.state == DebugRuntimeState::Frozen {
|
||||||
|
participant.state = DebugRuntimeState::Running;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspection(&self, task: &TaskId) -> Result<ThreadInspection, DebugEpochError> {
|
||||||
|
let participant = self
|
||||||
|
.participants
|
||||||
|
.get(task)
|
||||||
|
.ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?;
|
||||||
|
Ok(ThreadInspection {
|
||||||
|
task: participant.task.clone(),
|
||||||
|
name: participant.name.clone(),
|
||||||
|
stack_frames: participant.stack_frames.clone(),
|
||||||
|
local_values: participant.local_values.clone(),
|
||||||
|
task_args: participant.task_args.clone(),
|
||||||
|
handles: participant.handles.clone(),
|
||||||
|
command_status: participant.command_status.clone(),
|
||||||
|
recent_output: participant.recent_output.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn participant_state(&self, task: &TaskId) -> Option<&DebugRuntimeState> {
|
||||||
|
self.participants
|
||||||
|
.get(task)
|
||||||
|
.map(|participant| &participant.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thread_names(&self) -> Vec<String> {
|
||||||
|
self.participants
|
||||||
|
.values()
|
||||||
|
.map(|participant| participant.name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn participant(task: &str, kind: DebugParticipantKind, can_freeze: bool) -> DebugParticipant {
|
||||||
|
DebugParticipant {
|
||||||
|
task: TaskId::from(task),
|
||||||
|
name: task.to_owned(),
|
||||||
|
kind,
|
||||||
|
can_freeze,
|
||||||
|
state: DebugRuntimeState::Running,
|
||||||
|
stack_frames: vec![format!("{task}::run")],
|
||||||
|
local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())],
|
||||||
|
task_args: vec![("target".to_owned(), "linux".to_owned())],
|
||||||
|
handles: vec![("artifact".to_owned(), "artifact-1".to_owned())],
|
||||||
|
command_status: Some("running".to_owned()),
|
||||||
|
recent_output: vec!["building".to_owned()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks() {
|
||||||
|
let epoch = DebugEpoch::all_stop(
|
||||||
|
ProcessId::from("process"),
|
||||||
|
1,
|
||||||
|
DebugStopReason::Breakpoint {
|
||||||
|
task: TaskId::from("compile-linux"),
|
||||||
|
line: 42,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
participant("main", DebugParticipantKind::WasmTask, true),
|
||||||
|
participant(
|
||||||
|
"compile-linux",
|
||||||
|
DebugParticipantKind::ControlledNativeCommand,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
epoch.participant_state(&TaskId::from("main")),
|
||||||
|
Some(&DebugRuntimeState::Frozen)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
epoch.participant_state(&TaskId::from("compile-linux")),
|
||||||
|
Some(&DebugRuntimeState::Frozen)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() {
|
||||||
|
let error = DebugEpoch::pause(
|
||||||
|
ProcessId::from("process"),
|
||||||
|
1,
|
||||||
|
vec![participant(
|
||||||
|
"compile-linux",
|
||||||
|
DebugParticipantKind::ControlledNativeCommand,
|
||||||
|
false,
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, DebugEpochError::CannotFreeze { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continue_resumes_every_frozen_participant() {
|
||||||
|
let mut epoch = DebugEpoch::pause(
|
||||||
|
ProcessId::from("process"),
|
||||||
|
1,
|
||||||
|
vec![
|
||||||
|
participant("main", DebugParticipantKind::WasmTask, true),
|
||||||
|
participant("task", DebugParticipantKind::WasmTask, true),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
epoch.continue_all();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
epoch.participant_state(&TaskId::from("main")),
|
||||||
|
Some(&DebugRuntimeState::Running)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
epoch.participant_state(&TaskId::from("task")),
|
||||||
|
Some(&DebugRuntimeState::Running)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspection_exposes_stack_args_handles_command_status_and_output() {
|
||||||
|
let epoch = DebugEpoch::pause(
|
||||||
|
ProcessId::from("process"),
|
||||||
|
1,
|
||||||
|
vec![participant(
|
||||||
|
"compile-linux",
|
||||||
|
DebugParticipantKind::ControlledNativeCommand,
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let inspection = epoch.inspection(&TaskId::from("compile-linux")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(inspection.stack_frames, vec!["compile-linux::run"]);
|
||||||
|
assert_eq!(
|
||||||
|
inspection.local_values[0],
|
||||||
|
("wasm_local_0".to_owned(), "I32(41)".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
inspection.task_args[0],
|
||||||
|
("target".to_owned(), "linux".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
inspection.handles[0],
|
||||||
|
("artifact".to_owned(), "artifact-1".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(inspection.command_status, Some("running".to_owned()));
|
||||||
|
assert_eq!(inspection.recent_output, vec!["building"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
crates/disasmer-core/src/digest.rs
Normal file
56
crates/disasmer-core/src/digest.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest as ShaDigest, Sha256};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Digest(String);
|
||||||
|
|
||||||
|
impl Digest {
|
||||||
|
pub fn sha256(bytes: impl AsRef<[u8]>) -> Self {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes.as_ref());
|
||||||
|
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<[u8]>>) -> Self {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for part in parts {
|
||||||
|
let part = part.as_ref();
|
||||||
|
hasher.update((part.len() as u64).to_be_bytes());
|
||||||
|
hasher.update(part);
|
||||||
|
}
|
||||||
|
Self(format!("sha256:{}", hex::encode(hasher.finalize())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_sha256(&self) -> bool {
|
||||||
|
let Some(hex) = self.0.strip_prefix("sha256:") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
hex.len() == 64
|
||||||
|
&& hex
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Digest {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn digest_validates_strict_sha256_syntax() {
|
||||||
|
assert!(Digest::sha256("bytes").is_valid_sha256());
|
||||||
|
assert!(!Digest("sha1:abc".to_owned()).is_valid_sha256());
|
||||||
|
assert!(!Digest("sha256:ABCDEF".to_owned()).is_valid_sha256());
|
||||||
|
assert!(!Digest("sha256:not-hex".to_owned()).is_valid_sha256());
|
||||||
|
}
|
||||||
|
}
|
||||||
288
crates/disasmer-core/src/environment.rs
Normal file
288
crates/disasmer-core/src/environment.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{Capability, Digest, Os};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum EnvironmentKind {
|
||||||
|
Containerfile,
|
||||||
|
Dockerfile,
|
||||||
|
NixFlake,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvironmentRequirements {
|
||||||
|
pub os: Option<Os>,
|
||||||
|
pub arch: Option<String>,
|
||||||
|
pub capabilities: BTreeSet<Capability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvironmentRequirements {
|
||||||
|
pub fn linux_container() -> Self {
|
||||||
|
Self {
|
||||||
|
os: Some(Os::Linux),
|
||||||
|
arch: None,
|
||||||
|
capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn windows_command_dev() -> Self {
|
||||||
|
Self {
|
||||||
|
os: Some(Os::Windows),
|
||||||
|
arch: None,
|
||||||
|
capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unconstrained() -> Self {
|
||||||
|
Self {
|
||||||
|
os: None,
|
||||||
|
arch: None,
|
||||||
|
capabilities: BTreeSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvironmentResource {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: EnvironmentKind,
|
||||||
|
pub recipe_path: PathBuf,
|
||||||
|
pub context_path: PathBuf,
|
||||||
|
pub digest: Digest,
|
||||||
|
pub requirements: EnvironmentRequirements,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvironmentReference {
|
||||||
|
pub name: String,
|
||||||
|
pub byte_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvironmentDiagnostic {
|
||||||
|
pub reference: EnvironmentReference,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum EnvironmentError {
|
||||||
|
#[error("failed to read environment resources under {path}: {source}")]
|
||||||
|
Read {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_environments(
|
||||||
|
project_root: &Path,
|
||||||
|
) -> Result<Vec<EnvironmentResource>, EnvironmentError> {
|
||||||
|
let envs_dir = project_root.join("envs");
|
||||||
|
if !envs_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resources = Vec::new();
|
||||||
|
let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read {
|
||||||
|
path: envs_dir.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|source| EnvironmentError::Read {
|
||||||
|
path: envs_dir.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(resource) = discover_one(project_root, name, &path)? {
|
||||||
|
resources.push(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resources.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
Ok(resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diagnose_environment_references(
|
||||||
|
source: &str,
|
||||||
|
environments: &[EnvironmentResource],
|
||||||
|
) -> Vec<EnvironmentDiagnostic> {
|
||||||
|
let known = environments
|
||||||
|
.iter()
|
||||||
|
.map(|environment| environment.name.as_str())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
|
||||||
|
find_env_macro_references(source)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|reference| !known.contains(reference.name.as_str()))
|
||||||
|
.map(|reference| EnvironmentDiagnostic {
|
||||||
|
message: format!(
|
||||||
|
"missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile",
|
||||||
|
reference.name, reference.name, reference.name
|
||||||
|
),
|
||||||
|
reference,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_one(
|
||||||
|
project_root: &Path,
|
||||||
|
name: &str,
|
||||||
|
env_dir: &Path,
|
||||||
|
) -> Result<Option<EnvironmentResource>, EnvironmentError> {
|
||||||
|
let candidates = [
|
||||||
|
("Containerfile", EnvironmentKind::Containerfile),
|
||||||
|
("Dockerfile", EnvironmentKind::Dockerfile),
|
||||||
|
("flake.nix", EnvironmentKind::NixFlake),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (file_name, kind) in candidates {
|
||||||
|
let recipe_path = env_dir.join(file_name);
|
||||||
|
if !recipe_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read {
|
||||||
|
path: recipe_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let relative_recipe = recipe_path
|
||||||
|
.strip_prefix(project_root)
|
||||||
|
.unwrap_or(&recipe_path)
|
||||||
|
.to_string_lossy();
|
||||||
|
let digest = Digest::from_parts([
|
||||||
|
b"environment:v1".as_slice(),
|
||||||
|
name.as_bytes(),
|
||||||
|
format!("{kind:?}").as_bytes(),
|
||||||
|
relative_recipe.as_bytes(),
|
||||||
|
recipe_bytes.as_slice(),
|
||||||
|
]);
|
||||||
|
let requirements = match kind {
|
||||||
|
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile
|
||||||
|
if name.eq_ignore_ascii_case("windows") =>
|
||||||
|
{
|
||||||
|
EnvironmentRequirements::windows_command_dev()
|
||||||
|
}
|
||||||
|
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {
|
||||||
|
EnvironmentRequirements::linux_container()
|
||||||
|
}
|
||||||
|
EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(Some(EnvironmentResource {
|
||||||
|
name: name.to_owned(),
|
||||||
|
kind,
|
||||||
|
recipe_path,
|
||||||
|
context_path: env_dir.to_path_buf(),
|
||||||
|
digest,
|
||||||
|
requirements,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_env_macro_references(source: &str) -> Vec<EnvironmentReference> {
|
||||||
|
let mut references = Vec::new();
|
||||||
|
let mut cursor = 0;
|
||||||
|
|
||||||
|
while let Some(index) = source[cursor..].find("env!(") {
|
||||||
|
let start = cursor + index;
|
||||||
|
let mut pos = start + "env!(".len();
|
||||||
|
while source[pos..].starts_with(char::is_whitespace) {
|
||||||
|
pos += source[pos..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(char::len_utf8)
|
||||||
|
.unwrap_or(1);
|
||||||
|
}
|
||||||
|
if !source[pos..].starts_with('"') {
|
||||||
|
cursor = pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
let name_start = pos;
|
||||||
|
while pos < source.len() && !source[pos..].starts_with('"') {
|
||||||
|
pos += source[pos..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(char::len_utf8)
|
||||||
|
.unwrap_or(1);
|
||||||
|
}
|
||||||
|
if pos < source.len() {
|
||||||
|
references.push(EnvironmentReference {
|
||||||
|
name: source[name_start..pos].to_owned(),
|
||||||
|
byte_offset: start,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursor = pos.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
references
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_containerfile_environments_by_logical_name() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let linux = temp.path().join("envs/linux");
|
||||||
|
fs::create_dir_all(&linux).unwrap();
|
||||||
|
fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap();
|
||||||
|
|
||||||
|
let envs = discover_environments(temp.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(envs.len(), 1);
|
||||||
|
assert_eq!(envs[0].name, "linux");
|
||||||
|
assert_eq!(envs[0].kind, EnvironmentKind::Containerfile);
|
||||||
|
assert!(!envs[0].digest.as_str().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_env_macro_reference_reports_clear_diagnostic() {
|
||||||
|
let source = r#"fn main() { let _ = env!("windows"); }"#;
|
||||||
|
let diagnostics = diagnose_environment_references(source, &[]);
|
||||||
|
|
||||||
|
assert_eq!(diagnostics.len(), 1);
|
||||||
|
assert!(diagnostics[0]
|
||||||
|
.message
|
||||||
|
.contains("envs/windows/Containerfile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_environment_name_uses_windows_development_requirements() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let windows = temp.path().join("envs/windows");
|
||||||
|
fs::create_dir_all(&windows).unwrap();
|
||||||
|
fs::write(
|
||||||
|
windows.join("Dockerfile"),
|
||||||
|
"# user-attached windows dev contract\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let envs = discover_environments(temp.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(envs[0].name, "windows");
|
||||||
|
assert_eq!(envs[0].requirements.os, Some(Os::Windows));
|
||||||
|
assert!(envs[0]
|
||||||
|
.requirements
|
||||||
|
.capabilities
|
||||||
|
.contains(&Capability::WindowsCommandDev));
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/disasmer-core/src/execution.rs
Normal file
90
crates/disasmer-core/src/execution.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Capability, EnvironmentResource};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum GuestRuntimeKind {
|
||||||
|
Wasmtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CommandBackendKind {
|
||||||
|
LinuxRootlessPodman,
|
||||||
|
WindowsCommandDev,
|
||||||
|
StubbedWindowsSandbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CommandInvocation {
|
||||||
|
pub program: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub env: Option<EnvironmentResource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CommandPlan {
|
||||||
|
pub guest_runtime: GuestRuntimeKind,
|
||||||
|
pub backend: CommandBackendKind,
|
||||||
|
pub required_capability: Capability,
|
||||||
|
pub user_attached_development_execution: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NativeCommandPolicy {
|
||||||
|
pub hosted_control_plane: bool,
|
||||||
|
pub node_has_command_capability: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeCommandPolicy {
|
||||||
|
pub fn authorize(&self) -> Result<(), String> {
|
||||||
|
if self.hosted_control_plane {
|
||||||
|
return Err("hosted coordinator control plane cannot run native commands".to_owned());
|
||||||
|
}
|
||||||
|
if !self.node_has_command_capability {
|
||||||
|
return Err("selected node or task lacks native command capability".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum TaskBoundaryValue {
|
||||||
|
SmallJson(serde_json::Value),
|
||||||
|
SourceSnapshot(crate::Digest),
|
||||||
|
Blob(crate::Digest),
|
||||||
|
Artifact(crate::ArtifactId),
|
||||||
|
VfsManifest(crate::Digest),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskBoundaryValue {
|
||||||
|
pub fn reject_host_only(type_name: &str) -> Result<Self, String> {
|
||||||
|
Err(format!(
|
||||||
|
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_control_plane_cannot_authorize_native_command() {
|
||||||
|
let policy = NativeCommandPolicy {
|
||||||
|
hosted_control_plane: true,
|
||||||
|
node_has_command_capability: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(policy
|
||||||
|
.authorize()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("hosted coordinator"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_pointer_style_task_argument_is_rejected() {
|
||||||
|
let error = TaskBoundaryValue::reject_host_only("*const u8").unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.contains("host-only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/disasmer-core/src/ids.rs
Normal file
44
crates/disasmer-core/src/ids.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
macro_rules! id_type {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
let value = value.into();
|
||||||
|
assert!(
|
||||||
|
!value.trim().is_empty(),
|
||||||
|
concat!(stringify!($name), " cannot be empty")
|
||||||
|
);
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for $name {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
id_type!(AgentId);
|
||||||
|
id_type!(ArtifactId);
|
||||||
|
id_type!(NodeId);
|
||||||
|
id_type!(ProcessId);
|
||||||
|
id_type!(ProjectId);
|
||||||
|
id_type!(TaskId);
|
||||||
|
id_type!(TenantId);
|
||||||
|
id_type!(UserId);
|
||||||
75
crates/disasmer-core/src/lib.rs
Normal file
75
crates/disasmer-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
pub mod artifact;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod bundle;
|
||||||
|
pub mod capability;
|
||||||
|
pub mod checkpoint;
|
||||||
|
pub mod debug;
|
||||||
|
pub mod digest;
|
||||||
|
pub mod environment;
|
||||||
|
pub mod execution;
|
||||||
|
pub mod ids;
|
||||||
|
pub mod limits;
|
||||||
|
pub mod operator_panel;
|
||||||
|
pub mod policy;
|
||||||
|
pub mod project;
|
||||||
|
pub mod scheduler;
|
||||||
|
pub mod source;
|
||||||
|
pub mod transport;
|
||||||
|
pub mod vfs;
|
||||||
|
|
||||||
|
pub use artifact::{
|
||||||
|
ArtifactDownloadStream, ArtifactMetadata, ArtifactRegistry, ArtifactUnavailable,
|
||||||
|
DownloadAction, DownloadError, DownloadLink, DownloadPolicy, RetentionPolicy, StorageLocation,
|
||||||
|
};
|
||||||
|
pub use auth::{
|
||||||
|
node_capability_policy_digest, Action, Actor, AuthContext, Authorization, BrowserLoginFlow,
|
||||||
|
CliLoginFlow, CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential,
|
||||||
|
PublicKeyIdentity, Scope,
|
||||||
|
};
|
||||||
|
pub use bundle::{BundleIdentityInputs, BundleMetadata, SelectedInput};
|
||||||
|
pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os};
|
||||||
|
pub use checkpoint::{
|
||||||
|
CheckpointBoundary, CompatibilityFailure, RestartDecision, RestartPolicy, RestartRequest,
|
||||||
|
TaskCheckpoint,
|
||||||
|
};
|
||||||
|
pub use debug::{
|
||||||
|
DebugEpoch, DebugEpochError, DebugParticipant, DebugParticipantKind, DebugRuntimeState,
|
||||||
|
DebugStopReason, ThreadInspection,
|
||||||
|
};
|
||||||
|
pub use digest::Digest;
|
||||||
|
pub use environment::{
|
||||||
|
diagnose_environment_references, discover_environments, EnvironmentDiagnostic, EnvironmentKind,
|
||||||
|
EnvironmentReference, EnvironmentRequirements, EnvironmentResource,
|
||||||
|
};
|
||||||
|
pub use execution::{
|
||||||
|
CommandBackendKind, CommandInvocation, CommandPlan, GuestRuntimeKind, NativeCommandPolicy,
|
||||||
|
TaskBoundaryValue,
|
||||||
|
};
|
||||||
|
pub use ids::{AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
|
||||||
|
pub use limits::{
|
||||||
|
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
|
||||||
|
ResourceMeter, TaskArgumentBudget,
|
||||||
|
};
|
||||||
|
pub use operator_panel::{
|
||||||
|
ControlPlaneAction, PanelError, PanelEvent, PanelEventKind, PanelState, PanelWidget,
|
||||||
|
PanelWidgetKind, RateLimit,
|
||||||
|
};
|
||||||
|
pub use policy::{
|
||||||
|
CapabilityPolicy, Decision, LocalTrustedPolicy, PolicyReason, ResourceRequest, ServicePolicy,
|
||||||
|
};
|
||||||
|
pub use project::{Entrypoint, ProjectModel, ProjectModelError};
|
||||||
|
pub use scheduler::{
|
||||||
|
DefaultScheduler, NodeDescriptor, Placement, PlacementError, PlacementRequest, Scheduler,
|
||||||
|
};
|
||||||
|
pub use source::{
|
||||||
|
SourceManifestError, SourcePreparation, SourceProviderKind, SourceProviderManifest,
|
||||||
|
SourceProviderModule, SourceTransferMode, SourceTransferPolicy,
|
||||||
|
};
|
||||||
|
pub use transport::{
|
||||||
|
BulkTransferDecision, DataPlaneObject, DataPlaneScope, DirectBulkTransferPlan,
|
||||||
|
NativeQuicTransport, NodeEndpoint, RendezvousRequest, Transport, TransportError, TransportKind,
|
||||||
|
};
|
||||||
|
pub use vfs::{
|
||||||
|
ReuseDecision, SyncPolicy, VfsError, VfsManifest, VfsObject, VfsOverlay, VfsPath,
|
||||||
|
VfsSyncDecision,
|
||||||
|
};
|
||||||
255
crates/disasmer-core/src/limits.rs
Normal file
255
crates/disasmer-core/src/limits.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::TaskId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum LimitKind {
|
||||||
|
ApiCall,
|
||||||
|
Spawn,
|
||||||
|
LogBytes,
|
||||||
|
MetadataBytes,
|
||||||
|
DebugReadBytes,
|
||||||
|
UiEvent,
|
||||||
|
RendezvousAttempt,
|
||||||
|
ArtifactDownloadBytes,
|
||||||
|
HostedFuel,
|
||||||
|
HostedMemoryBytes,
|
||||||
|
HostedWallClockMs,
|
||||||
|
HostedStateBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceLimits {
|
||||||
|
pub limits: BTreeMap<LimitKind, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceLimits {
|
||||||
|
pub fn community_tier_defaults() -> Self {
|
||||||
|
Self {
|
||||||
|
limits: BTreeMap::from([
|
||||||
|
(LimitKind::ApiCall, 10_000),
|
||||||
|
(LimitKind::Spawn, 64),
|
||||||
|
(LimitKind::LogBytes, 256 * 1024),
|
||||||
|
(LimitKind::MetadataBytes, 512 * 1024),
|
||||||
|
(LimitKind::DebugReadBytes, 128 * 1024),
|
||||||
|
(LimitKind::UiEvent, 1_000),
|
||||||
|
(LimitKind::RendezvousAttempt, 128),
|
||||||
|
(LimitKind::ArtifactDownloadBytes, 256 * 1024 * 1024),
|
||||||
|
(LimitKind::HostedFuel, 10_000_000),
|
||||||
|
(LimitKind::HostedMemoryBytes, 64 * 1024 * 1024),
|
||||||
|
(LimitKind::HostedWallClockMs, 30_000),
|
||||||
|
(LimitKind::HostedStateBytes, 1024 * 1024),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn limit(&self, kind: &LimitKind) -> u64 {
|
||||||
|
*self.limits.get(kind).unwrap_or(&0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceMeter {
|
||||||
|
used: BTreeMap<LimitKind, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum LimitError {
|
||||||
|
#[error(
|
||||||
|
"resource limit exceeded for {kind:?}: requested {requested}, used {used}, limit {limit}"
|
||||||
|
)]
|
||||||
|
Exceeded {
|
||||||
|
kind: LimitKind,
|
||||||
|
requested: u64,
|
||||||
|
used: u64,
|
||||||
|
limit: u64,
|
||||||
|
},
|
||||||
|
#[error("task argument is too large: {size} bytes exceeds {limit} bytes")]
|
||||||
|
LargeTaskArgument { size: u64, limit: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceMeter {
|
||||||
|
pub fn can_charge(
|
||||||
|
&self,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
kind: LimitKind,
|
||||||
|
amount: u64,
|
||||||
|
) -> Result<(), LimitError> {
|
||||||
|
let used = self.used.get(&kind).copied().unwrap_or(0);
|
||||||
|
let limit = limits.limit(&kind);
|
||||||
|
if used.saturating_add(amount) > limit {
|
||||||
|
return Err(LimitError::Exceeded {
|
||||||
|
kind,
|
||||||
|
requested: amount,
|
||||||
|
used,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn charge(
|
||||||
|
&mut self,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
kind: LimitKind,
|
||||||
|
amount: u64,
|
||||||
|
) -> Result<(), LimitError> {
|
||||||
|
self.can_charge(limits, kind.clone(), amount)?;
|
||||||
|
let used = self.used.get(&kind).copied().unwrap_or(0);
|
||||||
|
self.used.insert(kind, used + amount);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn used(&self, kind: &LimitKind) -> u64 {
|
||||||
|
self.used.get(kind).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LogRecord {
|
||||||
|
pub task: TaskId,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LogBuffer {
|
||||||
|
max_bytes: usize,
|
||||||
|
used_bytes: usize,
|
||||||
|
records: Vec<LogRecord>,
|
||||||
|
backpressured: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogBuffer {
|
||||||
|
pub fn new(max_bytes: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
max_bytes,
|
||||||
|
used_bytes: 0,
|
||||||
|
records: Vec::new(),
|
||||||
|
backpressured: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, task: TaskId, bytes: impl AsRef<[u8]>) {
|
||||||
|
let bytes = bytes.as_ref();
|
||||||
|
let remaining = self.max_bytes.saturating_sub(self.used_bytes);
|
||||||
|
let truncated = bytes.len() > remaining;
|
||||||
|
let stored = bytes[..bytes.len().min(remaining)].to_vec();
|
||||||
|
self.used_bytes += stored.len();
|
||||||
|
if truncated {
|
||||||
|
self.backpressured = true;
|
||||||
|
}
|
||||||
|
self.records.push(LogRecord {
|
||||||
|
task,
|
||||||
|
bytes: stored,
|
||||||
|
truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn records(&self) -> &[LogRecord] {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backpressured(&self) -> bool {
|
||||||
|
self.backpressured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum LargeArgumentPolicy {
|
||||||
|
Allow,
|
||||||
|
Warn,
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TaskArgumentBudget {
|
||||||
|
pub max_inline_bytes: u64,
|
||||||
|
pub policy: LargeArgumentPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskArgumentBudget {
|
||||||
|
pub fn validate(&self, size: u64) -> Result<Option<String>, LimitError> {
|
||||||
|
if size <= self.max_inline_bytes {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.policy {
|
||||||
|
LargeArgumentPolicy::Allow => Ok(None),
|
||||||
|
LargeArgumentPolicy::Warn => Ok(Some(format!(
|
||||||
|
"task argument is {size} bytes; prefer SourceSnapshot, Blob, Artifact, or VFS handles"
|
||||||
|
))),
|
||||||
|
LargeArgumentPolicy::Reject => Err(LimitError::LargeTaskArgument {
|
||||||
|
size,
|
||||||
|
limit: self.max_inline_bytes,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_meter_rejects_usage_before_work_starts() {
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::Spawn, 1)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
|
||||||
|
meter.charge(&limits, LimitKind::Spawn, 1).unwrap();
|
||||||
|
let error = meter.charge(&limits, LimitKind::Spawn, 1).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, LimitError::Exceeded { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_meter_can_check_limits_without_consuming() {
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 4)]),
|
||||||
|
};
|
||||||
|
let mut meter = ResourceMeter::default();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 4)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 0);
|
||||||
|
meter
|
||||||
|
.charge(&limits, LimitKind::ArtifactDownloadBytes, 3)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
meter.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 2),
|
||||||
|
Err(LimitError::Exceeded { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_buffer_caps_backpressures_and_keeps_task_association() {
|
||||||
|
let mut logs = LogBuffer::new(4);
|
||||||
|
logs.push(TaskId::from("task-a"), b"abcdef");
|
||||||
|
|
||||||
|
assert!(logs.backpressured());
|
||||||
|
assert_eq!(logs.records()[0].task, TaskId::from("task-a"));
|
||||||
|
assert_eq!(logs.records()[0].bytes, b"abcd");
|
||||||
|
assert!(logs.records()[0].truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_task_arguments_are_rejected_or_warned() {
|
||||||
|
let reject = TaskArgumentBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
policy: LargeArgumentPolicy::Reject,
|
||||||
|
};
|
||||||
|
assert!(reject.validate(5).is_err());
|
||||||
|
|
||||||
|
let warn = TaskArgumentBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
policy: LargeArgumentPolicy::Warn,
|
||||||
|
};
|
||||||
|
assert!(warn.validate(5).unwrap().unwrap().contains("Artifact"));
|
||||||
|
}
|
||||||
|
}
|
||||||
375
crates/disasmer-core/src/operator_panel.rs
Normal file
375
crates/disasmer-core/src/operator_panel.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskId, TenantId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PanelWidgetKind {
|
||||||
|
Text {
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
Progress {
|
||||||
|
current: u64,
|
||||||
|
total: u64,
|
||||||
|
},
|
||||||
|
Button {
|
||||||
|
action: String,
|
||||||
|
},
|
||||||
|
Toggle {
|
||||||
|
value: bool,
|
||||||
|
},
|
||||||
|
Select {
|
||||||
|
options: Vec<String>,
|
||||||
|
selected: String,
|
||||||
|
},
|
||||||
|
ArtifactDownload {
|
||||||
|
artifact: ArtifactId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PanelWidget {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: PanelWidgetKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ControlPlaneAction {
|
||||||
|
RestartTask(TaskId),
|
||||||
|
CancelProcess,
|
||||||
|
DebugProcess,
|
||||||
|
DownloadArtifact(ArtifactId),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PanelState {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub widgets: BTreeMap<String, PanelWidget>,
|
||||||
|
pub program_ui_events_enabled: bool,
|
||||||
|
pub control_plane_actions: Vec<ControlPlaneAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PanelEventKind {
|
||||||
|
ButtonClicked,
|
||||||
|
ToggleChanged(bool),
|
||||||
|
SelectChanged(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PanelEvent {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub widget_id: String,
|
||||||
|
pub kind: PanelEventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RateLimit {
|
||||||
|
pub max_events: u64,
|
||||||
|
pub used_events: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum PanelError {
|
||||||
|
#[error("custom HTML or JavaScript is not supported in operator panels")]
|
||||||
|
CustomContentDenied,
|
||||||
|
#[error(
|
||||||
|
"operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials"
|
||||||
|
)]
|
||||||
|
CredentialCollectionDenied(String),
|
||||||
|
#[error("panel event scope does not match tenant/project/process")]
|
||||||
|
ScopeMismatch,
|
||||||
|
#[error("program UI events are disabled while debug process is stopped")]
|
||||||
|
ProgramEventsDisabled,
|
||||||
|
#[error("panel event rate limit exceeded")]
|
||||||
|
RateLimited,
|
||||||
|
#[error("unknown panel widget `{0}`")]
|
||||||
|
UnknownWidget(String),
|
||||||
|
#[error("artifact download action is unavailable: {0}")]
|
||||||
|
DownloadUnavailable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelState {
|
||||||
|
pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self {
|
||||||
|
Self {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
process,
|
||||||
|
widgets: BTreeMap::new(),
|
||||||
|
program_ui_events_enabled: true,
|
||||||
|
control_plane_actions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> {
|
||||||
|
validate_widget(&widget)?;
|
||||||
|
self.widgets.insert(widget.id.clone(), widget);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_download_widget_from_action(
|
||||||
|
&mut self,
|
||||||
|
widget_id: impl Into<String>,
|
||||||
|
label: impl Into<String>,
|
||||||
|
action: Result<DownloadAction, DownloadError>,
|
||||||
|
) -> Result<(), PanelError> {
|
||||||
|
let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?;
|
||||||
|
let artifact = action.artifact;
|
||||||
|
self.add_widget(PanelWidget {
|
||||||
|
id: widget_id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
kind: PanelWidgetKind::ArtifactDownload {
|
||||||
|
artifact: artifact.clone(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
self.control_plane_actions
|
||||||
|
.push(ControlPlaneAction::DownloadArtifact(artifact));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> {
|
||||||
|
Err(PanelError::CustomContentDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn freeze_program_ui_events(&mut self) {
|
||||||
|
self.program_ui_events_enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_control_plane_actions(&mut self, actions: Vec<ControlPlaneAction>) {
|
||||||
|
self.control_plane_actions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accept_event(
|
||||||
|
&self,
|
||||||
|
event: &PanelEvent,
|
||||||
|
limit: &mut RateLimit,
|
||||||
|
) -> Result<(), PanelError> {
|
||||||
|
if !self.program_ui_events_enabled {
|
||||||
|
return Err(PanelError::ProgramEventsDisabled);
|
||||||
|
}
|
||||||
|
if self.tenant != event.tenant
|
||||||
|
|| self.project != event.project
|
||||||
|
|| self.process != event.process
|
||||||
|
{
|
||||||
|
return Err(PanelError::ScopeMismatch);
|
||||||
|
}
|
||||||
|
if !self.widgets.contains_key(&event.widget_id) {
|
||||||
|
return Err(PanelError::UnknownWidget(event.widget_id.clone()));
|
||||||
|
}
|
||||||
|
if limit.used_events >= limit.max_events {
|
||||||
|
return Err(PanelError::RateLimited);
|
||||||
|
}
|
||||||
|
limit.used_events += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] {
|
||||||
|
&self.control_plane_actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> {
|
||||||
|
let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()];
|
||||||
|
match &widget.kind {
|
||||||
|
PanelWidgetKind::Button { action } => checked_text.push(action),
|
||||||
|
PanelWidgetKind::Select { options, selected } => {
|
||||||
|
checked_text.push(selected);
|
||||||
|
checked_text.extend(options.iter().map(String::as_str));
|
||||||
|
}
|
||||||
|
PanelWidgetKind::Text { .. }
|
||||||
|
| PanelWidgetKind::Progress { .. }
|
||||||
|
| PanelWidgetKind::Toggle { .. }
|
||||||
|
| PanelWidgetKind::ArtifactDownload { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let combined = checked_text.join(" ").to_ascii_lowercase();
|
||||||
|
if combined.contains("password")
|
||||||
|
|| combined.contains("token")
|
||||||
|
|| combined.contains("oauth")
|
||||||
|
|| combined.contains("secret")
|
||||||
|
{
|
||||||
|
return Err(PanelError::CredentialCollectionDenied(widget.id.clone()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn panel() -> PanelState {
|
||||||
|
PanelState::new(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
ProcessId::from("process"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_uses_typed_widgets_and_rejects_custom_content() {
|
||||||
|
let mut panel = panel();
|
||||||
|
panel
|
||||||
|
.add_widget(PanelWidget {
|
||||||
|
id: "progress".to_owned(),
|
||||||
|
label: "Build".to_owned(),
|
||||||
|
kind: PanelWidgetKind::Progress {
|
||||||
|
current: 1,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(PanelState::reject_custom_content("<script>alert(1)</script>").is_err());
|
||||||
|
assert!(panel.widgets.contains_key("progress"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_rejects_password_or_oauth_collection_widgets() {
|
||||||
|
let mut panel = panel();
|
||||||
|
let error = panel
|
||||||
|
.add_widget(PanelWidget {
|
||||||
|
id: "oauth_token".to_owned(),
|
||||||
|
label: "OAuth Token".to_owned(),
|
||||||
|
kind: PanelWidgetKind::Text {
|
||||||
|
value: String::new(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, PanelError::CredentialCollectionDenied(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_rejects_credential_collection_in_interactive_fields() {
|
||||||
|
let mut panel = panel();
|
||||||
|
let button_error = panel
|
||||||
|
.add_widget(PanelWidget {
|
||||||
|
id: "continue".to_owned(),
|
||||||
|
label: "Continue".to_owned(),
|
||||||
|
kind: PanelWidgetKind::Button {
|
||||||
|
action: "collect-secret".to_owned(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
button_error,
|
||||||
|
PanelError::CredentialCollectionDenied(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
let select_error = panel
|
||||||
|
.add_widget(PanelWidget {
|
||||||
|
id: "auth-mode".to_owned(),
|
||||||
|
label: "Auth Mode".to_owned(),
|
||||||
|
kind: PanelWidgetKind::Select {
|
||||||
|
options: vec!["password".to_owned(), "public key".to_owned()],
|
||||||
|
selected: "public key".to_owned(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
select_error,
|
||||||
|
PanelError::CredentialCollectionDenied(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_events_are_scoped_and_rate_limited() {
|
||||||
|
let mut panel = panel();
|
||||||
|
panel
|
||||||
|
.add_widget(PanelWidget {
|
||||||
|
id: "restart".to_owned(),
|
||||||
|
label: "Restart".to_owned(),
|
||||||
|
kind: PanelWidgetKind::Button {
|
||||||
|
action: "restart".to_owned(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = PanelEvent {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: ProcessId::from("process"),
|
||||||
|
widget_id: "restart".to_owned(),
|
||||||
|
kind: PanelEventKind::ButtonClicked,
|
||||||
|
};
|
||||||
|
let mut limit = RateLimit {
|
||||||
|
max_events: 1,
|
||||||
|
used_events: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.accept_event(&event, &mut limit).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
panel.accept_event(&event, &mut limit),
|
||||||
|
Err(PanelError::RateLimited)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopped_debug_process_keeps_control_plane_actions_available() {
|
||||||
|
let mut panel = panel();
|
||||||
|
panel.freeze_program_ui_events();
|
||||||
|
panel.set_control_plane_actions(vec![
|
||||||
|
ControlPlaneAction::RestartTask(TaskId::from("task")),
|
||||||
|
ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let event = PanelEvent {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: ProcessId::from("process"),
|
||||||
|
widget_id: "missing".to_owned(),
|
||||||
|
kind: PanelEventKind::ButtonClicked,
|
||||||
|
};
|
||||||
|
let mut limit = RateLimit {
|
||||||
|
max_events: 1,
|
||||||
|
used_events: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
panel.accept_event(&event, &mut limit),
|
||||||
|
Err(PanelError::ProgramEventsDisabled)
|
||||||
|
);
|
||||||
|
assert_eq!(panel.control_plane_actions_available().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_widget_is_only_created_from_available_action() {
|
||||||
|
let mut panel = panel();
|
||||||
|
let action = Ok(DownloadAction {
|
||||||
|
artifact: ArtifactId::from("artifact"),
|
||||||
|
source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")),
|
||||||
|
scoped_token_subject: "tenant/project/process/artifact".to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
panel
|
||||||
|
.add_download_widget_from_action("download-artifact", "Download", action)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
panel.widgets["download-artifact"].kind,
|
||||||
|
PanelWidgetKind::ArtifactDownload { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
panel.control_plane_actions_available()[0],
|
||||||
|
ControlPlaneAction::DownloadArtifact(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
let before = panel.widgets.len();
|
||||||
|
let error = panel
|
||||||
|
.add_download_widget_from_action(
|
||||||
|
"missing-download",
|
||||||
|
"Download",
|
||||||
|
Err(DownloadError::Unavailable),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(panel.widgets.len(), before);
|
||||||
|
assert!(matches!(error, PanelError::DownloadUnavailable(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
134
crates/disasmer-core/src/policy.rs
Normal file
134
crates/disasmer-core/src/policy.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{Action, AuthContext, Capability, Scope};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum PolicyReason {
|
||||||
|
Allowed,
|
||||||
|
MissingCapability(Capability),
|
||||||
|
HostedNativeComputeDenied,
|
||||||
|
HostedContainerDenied,
|
||||||
|
QuotaExceeded(String),
|
||||||
|
Unauthorized(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Decision {
|
||||||
|
pub allowed: bool,
|
||||||
|
pub reason: PolicyReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
pub fn allow() -> Self {
|
||||||
|
Self {
|
||||||
|
allowed: true,
|
||||||
|
reason: PolicyReason::Allowed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deny(reason: PolicyReason) -> Self {
|
||||||
|
Self {
|
||||||
|
allowed: false,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceRequest {
|
||||||
|
pub action: Action,
|
||||||
|
pub required_capabilities: BTreeSet<Capability>,
|
||||||
|
pub hosted_control_plane: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait CapabilityPolicy {
|
||||||
|
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ServicePolicy: CapabilityPolicy + Send + Sync {}
|
||||||
|
|
||||||
|
impl<T> ServicePolicy for T where T: CapabilityPolicy + Send + Sync {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct LocalTrustedPolicy;
|
||||||
|
|
||||||
|
impl CapabilityPolicy for LocalTrustedPolicy {
|
||||||
|
fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision {
|
||||||
|
let authz = crate::auth::same_tenant_project(context, scope);
|
||||||
|
if !authz.allowed {
|
||||||
|
return Decision::deny(PolicyReason::Unauthorized(authz.reason));
|
||||||
|
}
|
||||||
|
if request.hosted_control_plane && request.action == Action::RunNativeCommand {
|
||||||
|
return Decision::deny(PolicyReason::HostedNativeComputeDenied);
|
||||||
|
}
|
||||||
|
if request.hosted_control_plane && request.action == Action::RunContainer {
|
||||||
|
return Decision::deny(PolicyReason::HostedContainerDenied);
|
||||||
|
}
|
||||||
|
Decision::allow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{Actor, ProjectId, TenantId, UserId};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_policy_interface_denies_hosted_native_compute() {
|
||||||
|
let policy = LocalTrustedPolicy;
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("user")),
|
||||||
|
};
|
||||||
|
let scope = Scope {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: None,
|
||||||
|
task: None,
|
||||||
|
node: None,
|
||||||
|
artifact: None,
|
||||||
|
};
|
||||||
|
let request = ResourceRequest {
|
||||||
|
action: Action::RunNativeCommand,
|
||||||
|
required_capabilities: BTreeSet::new(),
|
||||||
|
hosted_control_plane: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = policy.decide(&context, &scope, &request);
|
||||||
|
|
||||||
|
assert!(!decision.allowed);
|
||||||
|
assert_eq!(decision.reason, PolicyReason::HostedNativeComputeDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_trusted_policy_allows_owner_controlled_native_capability_request() {
|
||||||
|
let policy = LocalTrustedPolicy;
|
||||||
|
let context = AuthContext {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
actor: Actor::User(UserId::from("owner")),
|
||||||
|
};
|
||||||
|
let scope = Scope {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: None,
|
||||||
|
task: None,
|
||||||
|
node: None,
|
||||||
|
artifact: None,
|
||||||
|
};
|
||||||
|
let request = ResourceRequest {
|
||||||
|
action: Action::RunNativeCommand,
|
||||||
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
hosted_control_plane: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = policy.decide(&context, &scope, &request);
|
||||||
|
|
||||||
|
assert!(decision.allowed);
|
||||||
|
assert_eq!(decision.reason, PolicyReason::Allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
crates/disasmer-core/src/project.rs
Normal file
125
crates/disasmer-core/src/project.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Entrypoint {
|
||||||
|
pub name: String,
|
||||||
|
pub function: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectModel {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub environments: Vec<EnvironmentResource>,
|
||||||
|
pub entrypoints: BTreeMap<String, Entrypoint>,
|
||||||
|
pub default_entrypoint: String,
|
||||||
|
pub required_config_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum ProjectModelError {
|
||||||
|
#[error("environment discovery failed: {0}")]
|
||||||
|
Environment(String),
|
||||||
|
#[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")]
|
||||||
|
UnknownEntrypoint {
|
||||||
|
name: String,
|
||||||
|
available: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectModel {
|
||||||
|
pub fn discover_without_config(root: &Path) -> Result<Self, ProjectModelError> {
|
||||||
|
let environments = discover_environments(root).map_err(|err| {
|
||||||
|
ProjectModelError::Environment(match err {
|
||||||
|
EnvironmentError::Read { path, source } => {
|
||||||
|
format!("failed to read {}: {source}", path.display())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
environments,
|
||||||
|
entrypoints: default_entrypoints(),
|
||||||
|
default_entrypoint: "build".to_owned(),
|
||||||
|
required_config_file: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> {
|
||||||
|
let name = name.unwrap_or(&self.default_entrypoint);
|
||||||
|
self.entrypoints
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| ProjectModelError::UnknownEntrypoint {
|
||||||
|
name: name.to_owned(),
|
||||||
|
available: self.entrypoints.keys().cloned().collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_entrypoints() -> BTreeMap<String, Entrypoint> {
|
||||||
|
["build", "test", "package", "release", "watch"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
(
|
||||||
|
name.to_owned(),
|
||||||
|
Entrypoint {
|
||||||
|
name: name.to_owned(),
|
||||||
|
function: format!("{name}_main"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_works_without_hand_written_configuration_file() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir_all(temp.path().join("envs/linux")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
temp.path().join("envs/linux/Containerfile"),
|
||||||
|
"FROM alpine\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(model.required_config_file, None);
|
||||||
|
assert_eq!(model.environments[0].name, "linux");
|
||||||
|
assert_eq!(model.select_entrypoint(None).unwrap().name, "build");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_can_define_multiple_default_entrypoints() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
model.select_entrypoint(Some("test")).unwrap().function,
|
||||||
|
"test_main"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
model.select_entrypoint(Some("release")).unwrap().function,
|
||||||
|
"release_main"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_entrypoint_lists_available_choices() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
|
||||||
|
let error = model.select_entrypoint(Some("deploy")).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
429
crates/disasmer-core/src/scheduler.rs
Normal file
429
crates/disasmer-core/src/scheduler.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ArtifactId, Capability, Digest, EnvironmentRequirements, NodeCapabilities, NodeId, ProjectId,
|
||||||
|
TenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeDescriptor {
|
||||||
|
pub id: NodeId,
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub capabilities: NodeCapabilities,
|
||||||
|
pub cached_environments: BTreeSet<Digest>,
|
||||||
|
pub dependency_caches: BTreeSet<Digest>,
|
||||||
|
pub source_snapshots: BTreeSet<Digest>,
|
||||||
|
pub artifact_locations: BTreeSet<ArtifactId>,
|
||||||
|
pub direct_connectivity: bool,
|
||||||
|
pub online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlacementRequest {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub environment: Option<EnvironmentRequirements>,
|
||||||
|
pub environment_digest: Option<Digest>,
|
||||||
|
pub required_capabilities: BTreeSet<Capability>,
|
||||||
|
pub dependency_cache: Option<Digest>,
|
||||||
|
pub source_snapshot: Option<Digest>,
|
||||||
|
pub required_artifacts: BTreeSet<ArtifactId>,
|
||||||
|
pub quota_available: bool,
|
||||||
|
pub policy_allowed: bool,
|
||||||
|
pub prefer_node: Option<NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Placement {
|
||||||
|
pub node: NodeId,
|
||||||
|
pub score: i64,
|
||||||
|
pub reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
#[error("no capable node for placement: {message}")]
|
||||||
|
pub struct PlacementError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Scheduler {
|
||||||
|
fn place(
|
||||||
|
&self,
|
||||||
|
nodes: &[NodeDescriptor],
|
||||||
|
request: &PlacementRequest,
|
||||||
|
) -> Result<Placement, PlacementError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct DefaultScheduler;
|
||||||
|
|
||||||
|
impl Scheduler for DefaultScheduler {
|
||||||
|
fn place(
|
||||||
|
&self,
|
||||||
|
nodes: &[NodeDescriptor],
|
||||||
|
request: &PlacementRequest,
|
||||||
|
) -> Result<Placement, PlacementError> {
|
||||||
|
let mut scored = Vec::new();
|
||||||
|
let mut rejection_counts = BTreeMap::<String, usize>::new();
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
match compatibility(node, request) {
|
||||||
|
Ok(mut placement) => {
|
||||||
|
locality_score(node, request, &mut placement);
|
||||||
|
scored.push(placement);
|
||||||
|
}
|
||||||
|
Err(reasons) => {
|
||||||
|
for reason in reasons {
|
||||||
|
*rejection_counts.entry(reason).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|placement| placement.score)
|
||||||
|
.ok_or_else(|| PlacementError {
|
||||||
|
message: rejection_counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(reason, count)| format!("{reason} ({count} node(s))"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatibility(
|
||||||
|
node: &NodeDescriptor,
|
||||||
|
request: &PlacementRequest,
|
||||||
|
) -> Result<Placement, Vec<String>> {
|
||||||
|
let mut reasons = Vec::new();
|
||||||
|
if !node.online {
|
||||||
|
reasons.push("node offline".to_owned());
|
||||||
|
}
|
||||||
|
if node.tenant != request.tenant {
|
||||||
|
reasons.push("tenant mismatch".to_owned());
|
||||||
|
}
|
||||||
|
if node.project != request.project {
|
||||||
|
reasons.push("project mismatch".to_owned());
|
||||||
|
}
|
||||||
|
if !request.quota_available {
|
||||||
|
reasons.push("quota unavailable for placement".to_owned());
|
||||||
|
}
|
||||||
|
if !request.policy_allowed {
|
||||||
|
reasons.push("policy denied placement".to_owned());
|
||||||
|
}
|
||||||
|
for capability in &request.required_capabilities {
|
||||||
|
if !node.capabilities.capabilities.contains(capability) {
|
||||||
|
reasons.push(format!("missing capability {capability:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(environment) = &request.environment {
|
||||||
|
if let Some(required_os) = &environment.os {
|
||||||
|
if &node.capabilities.os != required_os {
|
||||||
|
reasons.push(format!("environment requires os {required_os:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(required_arch) = &environment.arch {
|
||||||
|
if &node.capabilities.arch != required_arch {
|
||||||
|
reasons.push(format!("environment requires arch {required_arch}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for capability in &environment.capabilities {
|
||||||
|
if !node.capabilities.capabilities.contains(capability) {
|
||||||
|
reasons.push(format!("environment requires capability {capability:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source_transfer_required = request
|
||||||
|
.source_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|digest| !node.source_snapshots.contains(digest));
|
||||||
|
if source_transfer_required && !node.direct_connectivity {
|
||||||
|
reasons.push("source snapshot unavailable and direct connectivity unavailable".to_owned());
|
||||||
|
}
|
||||||
|
let missing_artifacts = request
|
||||||
|
.required_artifacts
|
||||||
|
.iter()
|
||||||
|
.filter(|artifact| !node.artifact_locations.contains(*artifact))
|
||||||
|
.count();
|
||||||
|
if missing_artifacts > 0 && !node.direct_connectivity {
|
||||||
|
reasons.push(format!(
|
||||||
|
"{missing_artifacts} required artifact(s) unavailable and direct connectivity unavailable"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasons.is_empty() {
|
||||||
|
Ok(Placement {
|
||||||
|
node: node.id.clone(),
|
||||||
|
score: 0,
|
||||||
|
reasons: Vec::new(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locality_score(node: &NodeDescriptor, request: &PlacementRequest, placement: &mut Placement) {
|
||||||
|
if request.prefer_node.as_ref() == Some(&node.id) {
|
||||||
|
placement.score += 100;
|
||||||
|
placement.reasons.push("preferred node".to_owned());
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.environment_digest
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|digest| node.cached_environments.contains(digest))
|
||||||
|
{
|
||||||
|
placement.score += 50;
|
||||||
|
placement.reasons.push("warm environment cache".to_owned());
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.source_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|digest| node.source_snapshots.contains(digest))
|
||||||
|
{
|
||||||
|
placement.score += 40;
|
||||||
|
placement
|
||||||
|
.reasons
|
||||||
|
.push("source snapshot already local".to_owned());
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.dependency_cache
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|digest| node.dependency_caches.contains(digest))
|
||||||
|
{
|
||||||
|
placement.score += 30;
|
||||||
|
placement.reasons.push("warm dependency cache".to_owned());
|
||||||
|
}
|
||||||
|
let artifact_hits = request
|
||||||
|
.required_artifacts
|
||||||
|
.iter()
|
||||||
|
.filter(|artifact| node.artifact_locations.contains(*artifact))
|
||||||
|
.count() as i64;
|
||||||
|
if artifact_hits > 0 {
|
||||||
|
placement.score += 10 * artifact_hits;
|
||||||
|
placement.reasons.push(format!(
|
||||||
|
"{artifact_hits} required artifact(s) already local"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{EnvironmentBackend, Os};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn node(id: &str, cached_source: bool) -> NodeDescriptor {
|
||||||
|
let source = Digest::sha256("source");
|
||||||
|
NodeDescriptor {
|
||||||
|
id: NodeId::from(id),
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
capabilities: NodeCapabilities {
|
||||||
|
os: Os::Linux,
|
||||||
|
arch: "x86_64".to_owned(),
|
||||||
|
capabilities: BTreeSet::from([
|
||||||
|
Capability::Command,
|
||||||
|
Capability::Containers,
|
||||||
|
Capability::RootlessPodman,
|
||||||
|
]),
|
||||||
|
environment_backends: BTreeSet::from([EnvironmentBackend::Container]),
|
||||||
|
source_providers: BTreeSet::from(["filesystem".to_owned()]),
|
||||||
|
},
|
||||||
|
cached_environments: BTreeSet::from([Digest::sha256("env")]),
|
||||||
|
dependency_caches: if cached_source {
|
||||||
|
BTreeSet::from([Digest::sha256("deps")])
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
},
|
||||||
|
source_snapshots: if cached_source {
|
||||||
|
BTreeSet::from([source])
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
},
|
||||||
|
artifact_locations: BTreeSet::new(),
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_prefers_warm_source_and_environment() {
|
||||||
|
let request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: Some(EnvironmentRequirements::linux_container()),
|
||||||
|
environment_digest: Some(Digest::sha256("env")),
|
||||||
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
dependency_cache: Some(Digest::sha256("deps")),
|
||||||
|
source_snapshot: Some(Digest::sha256("source")),
|
||||||
|
required_artifacts: BTreeSet::new(),
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let placement = DefaultScheduler
|
||||||
|
.place(&[node("cold", false), node("warm", true)], &request)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(placement.node, NodeId::from("warm"));
|
||||||
|
assert!(placement
|
||||||
|
.reasons
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason.contains("source")));
|
||||||
|
assert!(placement
|
||||||
|
.reasons
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason.contains("dependency")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_failure_names_missing_constraint() {
|
||||||
|
let mut request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: None,
|
||||||
|
environment_digest: None,
|
||||||
|
required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
|
||||||
|
dependency_cache: None,
|
||||||
|
source_snapshot: None,
|
||||||
|
required_artifacts: BTreeSet::new(),
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
request.required_capabilities.insert(Capability::Command);
|
||||||
|
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[node("linux", false)], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.message.contains("WindowsCommandDev"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_failure_names_environment_constraint() {
|
||||||
|
let request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: Some(EnvironmentRequirements::windows_command_dev()),
|
||||||
|
environment_digest: None,
|
||||||
|
required_capabilities: BTreeSet::new(),
|
||||||
|
dependency_cache: None,
|
||||||
|
source_snapshot: None,
|
||||||
|
required_artifacts: BTreeSet::new(),
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[node("linux", false)], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.message.contains("environment requires os Windows"));
|
||||||
|
assert!(error
|
||||||
|
.message
|
||||||
|
.contains("environment requires capability WindowsCommandDev"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_requires_direct_connectivity_when_transfer_is_needed() {
|
||||||
|
let mut disconnected = node("disconnected", false);
|
||||||
|
disconnected.direct_connectivity = false;
|
||||||
|
let mut local = node("local", true);
|
||||||
|
local.direct_connectivity = false;
|
||||||
|
let request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: None,
|
||||||
|
environment_digest: None,
|
||||||
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
dependency_cache: None,
|
||||||
|
source_snapshot: Some(Digest::sha256("source")),
|
||||||
|
required_artifacts: BTreeSet::new(),
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let placement = DefaultScheduler
|
||||||
|
.place(&[disconnected, local], &request)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(placement.node, NodeId::from("local"));
|
||||||
|
|
||||||
|
let mut disconnected = node("disconnected", false);
|
||||||
|
disconnected.direct_connectivity = false;
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[disconnected], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error
|
||||||
|
.message
|
||||||
|
.contains("source snapshot unavailable and direct connectivity unavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_failure_names_required_artifact_transfer_constraint() {
|
||||||
|
let mut disconnected = node("disconnected", true);
|
||||||
|
disconnected.direct_connectivity = false;
|
||||||
|
let request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: None,
|
||||||
|
environment_digest: None,
|
||||||
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
dependency_cache: None,
|
||||||
|
source_snapshot: None,
|
||||||
|
required_artifacts: BTreeSet::from([ArtifactId::from("cache")]),
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[disconnected], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error
|
||||||
|
.message
|
||||||
|
.contains("1 required artifact(s) unavailable and direct connectivity unavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_failure_names_quota_and_policy_constraints() {
|
||||||
|
let mut request = PlacementRequest {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
environment: None,
|
||||||
|
environment_digest: None,
|
||||||
|
required_capabilities: BTreeSet::from([Capability::Command]),
|
||||||
|
dependency_cache: None,
|
||||||
|
source_snapshot: None,
|
||||||
|
required_artifacts: BTreeSet::new(),
|
||||||
|
quota_available: false,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[node("linux", false)], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.message.contains("quota unavailable for placement"));
|
||||||
|
|
||||||
|
request.quota_available = true;
|
||||||
|
request.policy_allowed = false;
|
||||||
|
let error = DefaultScheduler
|
||||||
|
.place(&[node("linux", false)], &request)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.message.contains("policy denied placement"));
|
||||||
|
}
|
||||||
|
}
|
||||||
324
crates/disasmer-core/src/source.rs
Normal file
324
crates/disasmer-core/src/source.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{Capability, Digest, ProjectId, TenantId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum SourceProviderKind {
|
||||||
|
Filesystem,
|
||||||
|
Git,
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceProviderKind {
|
||||||
|
pub fn provider_id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
SourceProviderKind::Filesystem => "filesystem",
|
||||||
|
SourceProviderKind::Git => "git",
|
||||||
|
SourceProviderKind::Custom(provider) => provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum SourceTransferMode {
|
||||||
|
RequiredContent,
|
||||||
|
ExplicitSnapshotChunks,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceTransferPolicy {
|
||||||
|
pub local_source_bytes_remain_node_local: bool,
|
||||||
|
pub coordinator_receives_source_bytes_by_default: bool,
|
||||||
|
pub default_full_repo_tarball: bool,
|
||||||
|
pub allowed_remote_transfer: BTreeSet<SourceTransferMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceTransferPolicy {
|
||||||
|
pub fn local_first_snapshot_chunks() -> Self {
|
||||||
|
Self {
|
||||||
|
local_source_bytes_remain_node_local: true,
|
||||||
|
coordinator_receives_source_bytes_by_default: false,
|
||||||
|
default_full_repo_tarball: false,
|
||||||
|
allowed_remote_transfer: BTreeSet::from([
|
||||||
|
SourceTransferMode::RequiredContent,
|
||||||
|
SourceTransferMode::ExplicitSnapshotChunks,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SourceTransferPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::local_first_snapshot_chunks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceProviderManifest {
|
||||||
|
pub kind: SourceProviderKind,
|
||||||
|
pub digest: Digest,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub coordinator_requires_checkout_access: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub transfer_policy: SourceTransferPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum SourceManifestError {
|
||||||
|
#[error("source provider manifest digest is not a valid sha256 digest: {0}")]
|
||||||
|
InvalidDigest(String),
|
||||||
|
#[error("custom source provider id `{0}` is invalid")]
|
||||||
|
InvalidProviderId(String),
|
||||||
|
#[error("source provider manifest description must be non-empty")]
|
||||||
|
EmptyDescription,
|
||||||
|
#[error("source provider manifest description is too long")]
|
||||||
|
DescriptionTooLong,
|
||||||
|
#[error("source provider manifest description contains control characters")]
|
||||||
|
DescriptionControlCharacter,
|
||||||
|
#[error("source provider manifest would require coordinator checkout access")]
|
||||||
|
CoordinatorCheckoutAccess,
|
||||||
|
#[error("source provider manifest would send source bytes to the coordinator by default")]
|
||||||
|
CoordinatorReceivesSourceBytes,
|
||||||
|
#[error("source provider manifest would default to a full-repo tarball")]
|
||||||
|
DefaultFullRepoTarball,
|
||||||
|
#[error("source provider manifest has no allowed remote transfer mode")]
|
||||||
|
MissingRemoteTransferMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SourceProviderModule {
|
||||||
|
fn kind(&self) -> SourceProviderKind;
|
||||||
|
fn manifest(&self) -> SourceProviderManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceProviderManifest {
|
||||||
|
pub fn local_first(kind: SourceProviderKind, description: impl Into<String>) -> Self {
|
||||||
|
let transfer_policy = SourceTransferPolicy::local_first_snapshot_chunks();
|
||||||
|
let digest = Self::digest_for(&kind, false, &transfer_policy);
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
digest,
|
||||||
|
description: description.into(),
|
||||||
|
coordinator_requires_checkout_access: false,
|
||||||
|
transfer_policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_public_mvp(&self) -> Result<(), SourceManifestError> {
|
||||||
|
self.validate_shape()?;
|
||||||
|
if self.coordinator_requires_checkout_access {
|
||||||
|
return Err(SourceManifestError::CoordinatorCheckoutAccess);
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.transfer_policy
|
||||||
|
.coordinator_receives_source_bytes_by_default
|
||||||
|
{
|
||||||
|
return Err(SourceManifestError::CoordinatorReceivesSourceBytes);
|
||||||
|
}
|
||||||
|
if self.transfer_policy.default_full_repo_tarball {
|
||||||
|
return Err(SourceManifestError::DefaultFullRepoTarball);
|
||||||
|
}
|
||||||
|
if self.transfer_policy.allowed_remote_transfer.is_empty() {
|
||||||
|
return Err(SourceManifestError::MissingRemoteTransferMode);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_shape(&self) -> Result<(), SourceManifestError> {
|
||||||
|
if !self.digest.is_valid_sha256() {
|
||||||
|
return Err(SourceManifestError::InvalidDigest(
|
||||||
|
self.digest.as_str().to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let SourceProviderKind::Custom(provider) = &self.kind {
|
||||||
|
if !valid_provider_id(provider) {
|
||||||
|
return Err(SourceManifestError::InvalidProviderId(provider.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.description.trim().is_empty() {
|
||||||
|
return Err(SourceManifestError::EmptyDescription);
|
||||||
|
}
|
||||||
|
if self.description.len() > 256 {
|
||||||
|
return Err(SourceManifestError::DescriptionTooLong);
|
||||||
|
}
|
||||||
|
if self.description.chars().any(char::is_control) {
|
||||||
|
return Err(SourceManifestError::DescriptionControlCharacter);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_for(
|
||||||
|
kind: &SourceProviderKind,
|
||||||
|
coordinator_requires_checkout_access: bool,
|
||||||
|
transfer_policy: &SourceTransferPolicy,
|
||||||
|
) -> Digest {
|
||||||
|
let mut modes = transfer_policy
|
||||||
|
.allowed_remote_transfer
|
||||||
|
.iter()
|
||||||
|
.map(|mode| format!("{mode:?}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
modes.sort();
|
||||||
|
let mut parts = vec![
|
||||||
|
b"source-provider-manifest:v2".to_vec(),
|
||||||
|
kind.provider_id().as_bytes().to_vec(),
|
||||||
|
coordinator_requires_checkout_access
|
||||||
|
.to_string()
|
||||||
|
.into_bytes(),
|
||||||
|
transfer_policy
|
||||||
|
.local_source_bytes_remain_node_local
|
||||||
|
.to_string()
|
||||||
|
.into_bytes(),
|
||||||
|
transfer_policy
|
||||||
|
.coordinator_receives_source_bytes_by_default
|
||||||
|
.to_string()
|
||||||
|
.into_bytes(),
|
||||||
|
transfer_policy
|
||||||
|
.default_full_repo_tarball
|
||||||
|
.to_string()
|
||||||
|
.into_bytes(),
|
||||||
|
];
|
||||||
|
parts.extend(modes.into_iter().map(String::into_bytes));
|
||||||
|
Digest::from_parts(parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_provider_id(provider: &str) -> bool {
|
||||||
|
!provider.is_empty()
|
||||||
|
&& provider.len() <= 64
|
||||||
|
&& provider
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourcePreparation {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub provider: SourceProviderKind,
|
||||||
|
pub required_capabilities: BTreeSet<Capability>,
|
||||||
|
pub coordinator_requires_checkout_access: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourcePreparation {
|
||||||
|
pub fn node_task(tenant: TenantId, project: ProjectId, provider: SourceProviderKind) -> Self {
|
||||||
|
let capability = match provider {
|
||||||
|
SourceProviderKind::Filesystem => Capability::SourceFilesystem,
|
||||||
|
SourceProviderKind::Git => Capability::SourceGit,
|
||||||
|
SourceProviderKind::Custom(_) => Capability::SourceFilesystem,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
required_capabilities: BTreeSet::from([capability]),
|
||||||
|
coordinator_requires_checkout_access: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_preparation_can_be_scheduled_as_node_task() {
|
||||||
|
let prep = SourcePreparation::node_task(
|
||||||
|
TenantId::from("tenant"),
|
||||||
|
ProjectId::from("project"),
|
||||||
|
SourceProviderKind::Git,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!prep.coordinator_requires_checkout_access);
|
||||||
|
assert!(prep.required_capabilities.contains(&Capability::SourceGit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_first_source_manifest_rejects_bulk_coordinator_paths() {
|
||||||
|
let manifest = SourceProviderManifest::local_first(
|
||||||
|
SourceProviderKind::Git,
|
||||||
|
"node-side Git snapshot provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(manifest.validate_public_mvp().is_ok());
|
||||||
|
assert!(!manifest.coordinator_requires_checkout_access);
|
||||||
|
assert!(
|
||||||
|
!manifest
|
||||||
|
.transfer_policy
|
||||||
|
.coordinator_receives_source_bytes_by_default
|
||||||
|
);
|
||||||
|
assert!(!manifest.transfer_policy.default_full_repo_tarball);
|
||||||
|
assert!(manifest
|
||||||
|
.transfer_policy
|
||||||
|
.allowed_remote_transfer
|
||||||
|
.contains(&SourceTransferMode::ExplicitSnapshotChunks));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_manifest_validation_treats_manifest_as_hostile_input() {
|
||||||
|
let mut manifest = SourceProviderManifest::local_first(
|
||||||
|
SourceProviderKind::Custom("gitlab-lfs".to_owned()),
|
||||||
|
"custom provider",
|
||||||
|
);
|
||||||
|
assert!(manifest.validate_public_mvp().is_ok());
|
||||||
|
|
||||||
|
manifest.kind = SourceProviderKind::Custom("../checkout".to_owned());
|
||||||
|
assert_eq!(
|
||||||
|
manifest.validate_public_mvp(),
|
||||||
|
Err(SourceManifestError::InvalidProviderId(
|
||||||
|
"../checkout".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest.kind = SourceProviderKind::Git;
|
||||||
|
manifest.digest = Digest::sha256("valid");
|
||||||
|
manifest.coordinator_requires_checkout_access = true;
|
||||||
|
assert_eq!(
|
||||||
|
manifest.validate_public_mvp(),
|
||||||
|
Err(SourceManifestError::CoordinatorCheckoutAccess)
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest.coordinator_requires_checkout_access = false;
|
||||||
|
manifest
|
||||||
|
.transfer_policy
|
||||||
|
.coordinator_receives_source_bytes_by_default = true;
|
||||||
|
assert_eq!(
|
||||||
|
manifest.validate_public_mvp(),
|
||||||
|
Err(SourceManifestError::CoordinatorReceivesSourceBytes)
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest
|
||||||
|
.transfer_policy
|
||||||
|
.coordinator_receives_source_bytes_by_default = false;
|
||||||
|
manifest.transfer_policy.default_full_repo_tarball = true;
|
||||||
|
assert_eq!(
|
||||||
|
manifest.validate_public_mvp(),
|
||||||
|
Err(SourceManifestError::DefaultFullRepoTarball)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_manifest_rejects_malformed_digest_from_json() {
|
||||||
|
let mut manifest = SourceProviderManifest::local_first(
|
||||||
|
SourceProviderKind::Filesystem,
|
||||||
|
"filesystem provider",
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&manifest).unwrap();
|
||||||
|
let mut object = value.as_object().unwrap().clone();
|
||||||
|
object.insert(
|
||||||
|
"digest".to_owned(),
|
||||||
|
serde_json::Value::String("sha256:not-a-real-digest".to_owned()),
|
||||||
|
);
|
||||||
|
manifest = serde_json::from_value(serde_json::Value::Object(object)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
manifest.validate_public_mvp(),
|
||||||
|
Err(SourceManifestError::InvalidDigest(
|
||||||
|
"sha256:not-a-real-digest".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
243
crates/disasmer-core/src/transport.rs
Normal file
243
crates/disasmer-core/src/transport.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{ArtifactId, Digest, NodeId, ProcessId, ProjectId, TenantId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum TransportKind {
|
||||||
|
NativeQuic,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Transport {
|
||||||
|
fn kind(&self) -> TransportKind;
|
||||||
|
fn authenticated_direct_connections(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct NativeQuicTransport;
|
||||||
|
|
||||||
|
impl Transport for NativeQuicTransport {
|
||||||
|
fn kind(&self) -> TransportKind {
|
||||||
|
TransportKind::NativeQuic
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authenticated_direct_connections(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DataPlaneScope {
|
||||||
|
pub tenant: TenantId,
|
||||||
|
pub project: ProjectId,
|
||||||
|
pub process: ProcessId,
|
||||||
|
pub object: DataPlaneObject,
|
||||||
|
pub authorization_subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DataPlaneObject {
|
||||||
|
Artifact(ArtifactId),
|
||||||
|
Blob(Digest),
|
||||||
|
SourceSnapshot(Digest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NodeEndpoint {
|
||||||
|
pub node: NodeId,
|
||||||
|
pub advertised_addr: String,
|
||||||
|
pub public_key_fingerprint: Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RendezvousRequest {
|
||||||
|
pub scope: DataPlaneScope,
|
||||||
|
pub source: NodeEndpoint,
|
||||||
|
pub destination: NodeEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DirectBulkTransferPlan {
|
||||||
|
pub transport: TransportKind,
|
||||||
|
pub scope: DataPlaneScope,
|
||||||
|
pub source: NodeEndpoint,
|
||||||
|
pub destination: NodeEndpoint,
|
||||||
|
pub authorization_digest: Digest,
|
||||||
|
pub coordinator_assisted_rendezvous: bool,
|
||||||
|
pub coordinator_bulk_relay_allowed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum TransportError {
|
||||||
|
#[error(
|
||||||
|
"direct node-to-node connectivity is unavailable for scoped data-plane transfer: {reason}; coordinator bulk relay is disabled"
|
||||||
|
)]
|
||||||
|
DirectConnectivityUnavailable { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum BulkTransferDecision {
|
||||||
|
DirectAuthenticated { scope: DataPlaneScope },
|
||||||
|
FailClear { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
#[error("bulk relay through coordinator is not allowed by default")]
|
||||||
|
pub struct BulkRelayDenied;
|
||||||
|
|
||||||
|
impl NativeQuicTransport {
|
||||||
|
pub fn plan_authenticated_direct_bulk_transfer(
|
||||||
|
&self,
|
||||||
|
request: RendezvousRequest,
|
||||||
|
direct_connectivity: bool,
|
||||||
|
failure_reason: impl Into<String>,
|
||||||
|
) -> Result<DirectBulkTransferPlan, TransportError> {
|
||||||
|
if !direct_connectivity {
|
||||||
|
return Err(TransportError::DirectConnectivityUnavailable {
|
||||||
|
reason: failure_reason.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorization_digest = data_plane_authorization_digest(&request);
|
||||||
|
Ok(DirectBulkTransferPlan {
|
||||||
|
transport: self.kind(),
|
||||||
|
scope: request.scope,
|
||||||
|
source: request.source,
|
||||||
|
destination: request.destination,
|
||||||
|
authorization_digest,
|
||||||
|
coordinator_assisted_rendezvous: true,
|
||||||
|
coordinator_bulk_relay_allowed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn direct_bulk_transfer_or_error(
|
||||||
|
scope: DataPlaneScope,
|
||||||
|
direct_connectivity: bool,
|
||||||
|
) -> BulkTransferDecision {
|
||||||
|
if direct_connectivity {
|
||||||
|
BulkTransferDecision::DirectAuthenticated { scope }
|
||||||
|
} else {
|
||||||
|
BulkTransferDecision::FailClear {
|
||||||
|
message:
|
||||||
|
"direct node-to-node connectivity is unavailable; coordinator bulk relay is disabled"
|
||||||
|
.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_plane_authorization_digest(request: &RendezvousRequest) -> Digest {
|
||||||
|
let object = match &request.scope.object {
|
||||||
|
DataPlaneObject::Artifact(artifact) => format!("artifact:{artifact}"),
|
||||||
|
DataPlaneObject::Blob(digest) => format!("blob:{}", digest.as_str()),
|
||||||
|
DataPlaneObject::SourceSnapshot(digest) => format!("source:{}", digest.as_str()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Digest::from_parts([
|
||||||
|
b"dataplane-auth:v1".as_slice(),
|
||||||
|
request.scope.tenant.as_str().as_bytes(),
|
||||||
|
request.scope.project.as_str().as_bytes(),
|
||||||
|
request.scope.process.as_str().as_bytes(),
|
||||||
|
object.as_bytes(),
|
||||||
|
request.scope.authorization_subject.as_bytes(),
|
||||||
|
request.source.node.as_str().as_bytes(),
|
||||||
|
request.source.public_key_fingerprint.as_str().as_bytes(),
|
||||||
|
request.destination.node.as_str().as_bytes(),
|
||||||
|
request
|
||||||
|
.destination
|
||||||
|
.public_key_fingerprint
|
||||||
|
.as_str()
|
||||||
|
.as_bytes(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn endpoint(name: &str) -> NodeEndpoint {
|
||||||
|
NodeEndpoint {
|
||||||
|
node: NodeId::from(name),
|
||||||
|
advertised_addr: format!("{name}.mesh.invalid:4433"),
|
||||||
|
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope(project: &str) -> DataPlaneScope {
|
||||||
|
DataPlaneScope {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from(project),
|
||||||
|
process: ProcessId::from("process"),
|
||||||
|
object: DataPlaneObject::Artifact(ArtifactId::from("artifact")),
|
||||||
|
authorization_subject: "node-a-to-node-b".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_direct_transfer_does_not_silently_relay() {
|
||||||
|
let decision = direct_bulk_transfer_or_error(scope("project"), false);
|
||||||
|
|
||||||
|
assert!(matches!(decision, BulkTransferDecision::FailClear { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_quic_rendezvous_plan_is_scoped_and_disallows_coordinator_bulk_relay() {
|
||||||
|
let transport = NativeQuicTransport;
|
||||||
|
let request = RendezvousRequest {
|
||||||
|
scope: scope("project"),
|
||||||
|
source: endpoint("node-a"),
|
||||||
|
destination: endpoint("node-b"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let plan = transport
|
||||||
|
.plan_authenticated_direct_bulk_transfer(request.clone(), true, "")
|
||||||
|
.unwrap();
|
||||||
|
let changed_scope_plan = transport
|
||||||
|
.plan_authenticated_direct_bulk_transfer(
|
||||||
|
RendezvousRequest {
|
||||||
|
scope: scope("other-project"),
|
||||||
|
..request
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(plan.transport, TransportKind::NativeQuic);
|
||||||
|
assert_eq!(plan.scope.tenant, TenantId::from("tenant"));
|
||||||
|
assert_eq!(plan.scope.project, ProjectId::from("project"));
|
||||||
|
assert_eq!(plan.scope.process, ProcessId::from("process"));
|
||||||
|
assert_eq!(
|
||||||
|
plan.scope.object,
|
||||||
|
DataPlaneObject::Artifact(ArtifactId::from("artifact"))
|
||||||
|
);
|
||||||
|
assert_eq!(plan.source.node, NodeId::from("node-a"));
|
||||||
|
assert_eq!(plan.destination.node, NodeId::from("node-b"));
|
||||||
|
assert!(plan.coordinator_assisted_rendezvous);
|
||||||
|
assert!(!plan.coordinator_bulk_relay_allowed);
|
||||||
|
assert_ne!(
|
||||||
|
plan.authorization_digest,
|
||||||
|
changed_scope_plan.authorization_digest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_direct_rendezvous_reports_clear_error_instead_of_relaying() {
|
||||||
|
let error = NativeQuicTransport
|
||||||
|
.plan_authenticated_direct_bulk_transfer(
|
||||||
|
RendezvousRequest {
|
||||||
|
scope: scope("project"),
|
||||||
|
source: endpoint("node-a"),
|
||||||
|
destination: endpoint("node-b"),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
"nat traversal failed",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("nat traversal failed"));
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("coordinator bulk relay is disabled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/disasmer-core/src/vfs.rs
Normal file
241
crates/disasmer-core/src/vfs.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{Digest, NodeId, TaskId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct VfsPath(String);
|
||||||
|
|
||||||
|
impl VfsPath {
|
||||||
|
pub fn new(path: impl Into<String>) -> Result<Self, VfsError> {
|
||||||
|
let path = path.into();
|
||||||
|
if !path.starts_with("/vfs/") {
|
||||||
|
return Err(VfsError::InvalidPath(path));
|
||||||
|
}
|
||||||
|
Ok(Self(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct VfsObject {
|
||||||
|
pub path: VfsPath,
|
||||||
|
pub digest: Digest,
|
||||||
|
pub size: u64,
|
||||||
|
pub producer: TaskId,
|
||||||
|
pub node: NodeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct VfsManifest {
|
||||||
|
pub epoch: u64,
|
||||||
|
pub producer: TaskId,
|
||||||
|
pub node: NodeId,
|
||||||
|
pub objects: BTreeMap<VfsPath, VfsObject>,
|
||||||
|
pub large_bytes_uploaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SyncPolicy {
|
||||||
|
MetadataOnly,
|
||||||
|
ExplicitNode(NodeId),
|
||||||
|
ExplicitStore(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum VfsSyncDecision {
|
||||||
|
NoBytesMoved,
|
||||||
|
MoveBytesToNode(NodeId),
|
||||||
|
MoveBytesToStore(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ReuseDecision {
|
||||||
|
SameNodeZeroCopy,
|
||||||
|
NeedsTransfer { from: NodeId, to: NodeId },
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum VfsError {
|
||||||
|
#[error("VFS path must start with /vfs/: {0}")]
|
||||||
|
InvalidPath(String),
|
||||||
|
#[error("path is not visible in the published VFS manifest: {0}")]
|
||||||
|
NotVisible(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VfsOverlay {
|
||||||
|
task: TaskId,
|
||||||
|
node: NodeId,
|
||||||
|
epoch: u64,
|
||||||
|
pending: BTreeMap<VfsPath, VfsObject>,
|
||||||
|
published: BTreeMap<VfsPath, VfsObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VfsOverlay {
|
||||||
|
pub fn new(task: TaskId, node: NodeId) -> Self {
|
||||||
|
Self {
|
||||||
|
task,
|
||||||
|
node,
|
||||||
|
epoch: 0,
|
||||||
|
pending: BTreeMap::new(),
|
||||||
|
published: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject {
|
||||||
|
let object = VfsObject {
|
||||||
|
path: path.clone(),
|
||||||
|
digest,
|
||||||
|
size,
|
||||||
|
producer: self.task.clone(),
|
||||||
|
node: self.node.clone(),
|
||||||
|
};
|
||||||
|
self.pending.insert(path, object.clone());
|
||||||
|
object
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&mut self) -> VfsManifest {
|
||||||
|
self.epoch += 1;
|
||||||
|
self.published.append(&mut self.pending);
|
||||||
|
VfsManifest {
|
||||||
|
epoch: self.epoch,
|
||||||
|
producer: self.task.clone(),
|
||||||
|
node: self.node.clone(),
|
||||||
|
objects: self.published.clone(),
|
||||||
|
large_bytes_uploaded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision {
|
||||||
|
match policy {
|
||||||
|
SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved,
|
||||||
|
SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node),
|
||||||
|
SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_published<'a>(
|
||||||
|
manifest: &'a VfsManifest,
|
||||||
|
path: &VfsPath,
|
||||||
|
) -> Result<&'a VfsObject, VfsError> {
|
||||||
|
manifest
|
||||||
|
.objects
|
||||||
|
.get(path)
|
||||||
|
.ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reuse_for_consumer(
|
||||||
|
manifest: &VfsManifest,
|
||||||
|
path: &VfsPath,
|
||||||
|
consumer_node: &NodeId,
|
||||||
|
) -> ReuseDecision {
|
||||||
|
let Some(object) = manifest.objects.get(path) else {
|
||||||
|
return ReuseDecision::Unavailable;
|
||||||
|
};
|
||||||
|
if &object.node == consumer_node {
|
||||||
|
ReuseDecision::SameNodeZeroCopy
|
||||||
|
} else {
|
||||||
|
ReuseDecision::NeedsTransfer {
|
||||||
|
from: object.node.clone(),
|
||||||
|
to: consumer_node.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discard_unflushed(&mut self) {
|
||||||
|
self.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_len(&self) -> usize {
|
||||||
|
self.pending.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn path() -> VfsPath {
|
||||||
|
VfsPath::new("/vfs/artifacts/app").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_publishes_manifest_without_large_byte_upload() {
|
||||||
|
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||||
|
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||||
|
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
|
||||||
|
assert_eq!(manifest.epoch, 1);
|
||||||
|
assert!(!manifest.large_bytes_uploaded);
|
||||||
|
assert!(manifest.objects.contains_key(&path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downstream_task_can_read_after_flush_but_not_before() {
|
||||||
|
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||||
|
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||||
|
|
||||||
|
let empty = VfsManifest {
|
||||||
|
epoch: 0,
|
||||||
|
producer: TaskId::from("task"),
|
||||||
|
node: NodeId::from("node-a"),
|
||||||
|
objects: BTreeMap::new(),
|
||||||
|
large_bytes_uploaded: false,
|
||||||
|
};
|
||||||
|
assert!(VfsOverlay::read_published(&empty, &path()).is_err());
|
||||||
|
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
assert!(VfsOverlay::read_published(&manifest, &path()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_is_explicit_and_policy_driven() {
|
||||||
|
let overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
overlay.sync(SyncPolicy::MetadataOnly),
|
||||||
|
VfsSyncDecision::NoBytesMoved
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())),
|
||||||
|
VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_node_reuse_avoids_transfer() {
|
||||||
|
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||||
|
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")),
|
||||||
|
ReuseDecision::SameNodeZeroCopy
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")),
|
||||||
|
ReuseDecision::NeedsTransfer {
|
||||||
|
from: NodeId::from("node-a"),
|
||||||
|
to: NodeId::from("node-b")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unflushed_task_local_changes_can_be_discarded() {
|
||||||
|
let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a"));
|
||||||
|
overlay.write(path(), Digest::sha256("binary"), 6);
|
||||||
|
|
||||||
|
overlay.discard_unflushed();
|
||||||
|
|
||||||
|
assert_eq!(overlay.pending_len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/disasmer-dap/Cargo.toml
Normal file
16
crates/disasmer-dap/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-dap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "disasmer-debug-dap"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
disasmer-core = { path = "../disasmer-core" }
|
||||||
|
disasmer-node = { path = "../disasmer-node" }
|
||||||
|
serde_json.workspace = true
|
||||||
2572
crates/disasmer-dap/src/main.rs
Normal file
2572
crates/disasmer-dap/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
14
crates/disasmer-macros/Cargo.toml
Normal file
14
crates/disasmer-macros/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proc-macro2.workspace = true
|
||||||
|
quote.workspace = true
|
||||||
|
syn.workspace = true
|
||||||
77
crates/disasmer-macros/src/lib.rs
Normal file
77
crates/disasmer-macros/src/lib.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{parse::Parser, parse_macro_input, Expr, ItemFn, Lit, Meta, Token};
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let function = parse_macro_input!(item as ItemFn);
|
||||||
|
let function_name = function.sig.ident.to_string();
|
||||||
|
let entrypoint_name = descriptor_name(
|
||||||
|
attr,
|
||||||
|
function_name
|
||||||
|
.strip_suffix("_main")
|
||||||
|
.unwrap_or(&function_name),
|
||||||
|
);
|
||||||
|
let descriptor = format_ident!(
|
||||||
|
"__DISASMER_ENTRYPOINT_{}",
|
||||||
|
function_name.to_ascii_uppercase()
|
||||||
|
);
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#function
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor {
|
||||||
|
name: #entrypoint_name,
|
||||||
|
function: #function_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let function = parse_macro_input!(item as ItemFn);
|
||||||
|
let function_name = function.sig.ident.to_string();
|
||||||
|
let task_name = descriptor_name(attr, &function_name);
|
||||||
|
let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase());
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#function
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor {
|
||||||
|
name: #task_name,
|
||||||
|
function: #function_name,
|
||||||
|
remotely_startable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor_name(attr: TokenStream, default: &str) -> String {
|
||||||
|
if attr.is_empty() {
|
||||||
|
return default.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let parser = syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated;
|
||||||
|
let Ok(args) = parser.parse(attr) else {
|
||||||
|
return default.to_owned();
|
||||||
|
};
|
||||||
|
|
||||||
|
for meta in args {
|
||||||
|
let Meta::NameValue(name_value) = meta else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name_value.path.is_ident("name") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Expr::Lit(expr) = name_value.value {
|
||||||
|
if let Lit::Str(name) = expr.lit {
|
||||||
|
return name.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default.to_owned()
|
||||||
|
}
|
||||||
16
crates/disasmer-node/Cargo.toml
Normal file
16
crates/disasmer-node/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-node"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
disasmer-core = { path = "../disasmer-core" }
|
||||||
|
quinn.workspace = true
|
||||||
|
rcgen.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
wasmtime.workspace = true
|
||||||
84
crates/disasmer-node/src/bin/disasmer-podman-smoke.rs
Normal file
84
crates/disasmer-node/src/bin/disasmer-podman-smoke.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use disasmer_core::{
|
||||||
|
CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource,
|
||||||
|
NodeId, ProcessId, TaskId, VfsOverlay, VfsPath,
|
||||||
|
};
|
||||||
|
use disasmer_node::{LinuxRootlessPodmanBackend, LocalSourceCheckout, StdProcessRunner};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let workspace = create_workspace()?;
|
||||||
|
let env_dir = workspace.join("envs/linux");
|
||||||
|
fs::create_dir_all(&env_dir)?;
|
||||||
|
fs::write(
|
||||||
|
env_dir.join("Containerfile"),
|
||||||
|
"FROM docker.io/library/alpine:3.20\nWORKDIR /workspace\n",
|
||||||
|
)?;
|
||||||
|
fs::write(workspace.join("input.txt"), "node-local source\n")?;
|
||||||
|
|
||||||
|
let env = EnvironmentResource {
|
||||||
|
name: "linux".to_owned(),
|
||||||
|
kind: EnvironmentKind::Containerfile,
|
||||||
|
recipe_path: env_dir.join("Containerfile"),
|
||||||
|
context_path: env_dir,
|
||||||
|
digest: Digest::sha256("phase2-podman-smoke-linux-env"),
|
||||||
|
requirements: EnvironmentRequirements::linux_container(),
|
||||||
|
};
|
||||||
|
let invocation = CommandInvocation {
|
||||||
|
program: "sh".to_owned(),
|
||||||
|
args: vec![
|
||||||
|
"-c".to_owned(),
|
||||||
|
"printf 'podman-ok:' && cat input.txt".to_owned(),
|
||||||
|
],
|
||||||
|
env: Some(env),
|
||||||
|
};
|
||||||
|
let checkout = LocalSourceCheckout {
|
||||||
|
host_path: workspace.clone(),
|
||||||
|
snapshot: Digest::sha256("phase2-podman-smoke-checkout"),
|
||||||
|
};
|
||||||
|
let task = TaskId::from("podman-smoke");
|
||||||
|
let mut overlay = VfsOverlay::new(task.clone(), NodeId::from("node-podman-smoke"));
|
||||||
|
let mut runner = StdProcessRunner;
|
||||||
|
let output = LinuxRootlessPodmanBackend.execute_local_checkout_task(
|
||||||
|
ProcessId::from("vp-podman-smoke"),
|
||||||
|
task,
|
||||||
|
&invocation,
|
||||||
|
checkout,
|
||||||
|
Some(VfsPath::new("/vfs/artifacts/podman-smoke.txt")?),
|
||||||
|
&mut runner,
|
||||||
|
&mut overlay,
|
||||||
|
)?;
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"podman_status": "completed",
|
||||||
|
"status_code": output.status_code,
|
||||||
|
"stdout": output.stdout,
|
||||||
|
"stderr": output.stderr,
|
||||||
|
"staged_artifact": output.staged_artifact,
|
||||||
|
"large_bytes_uploaded": manifest.large_bytes_uploaded,
|
||||||
|
"uses_full_repo_tarball": false,
|
||||||
|
"coordinator_routed_file_reads": false,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_workspace() -> Result<PathBuf, std::io::Error> {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let workspace = std::env::temp_dir().join(format!(
|
||||||
|
"disasmer-podman-smoke-{}-{nanos}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&workspace)?;
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
138
crates/disasmer-node/src/bin/disasmer-quic-smoke.rs
Normal file
138
crates/disasmer-node/src/bin/disasmer-quic-smoke.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use disasmer_core::{
|
||||||
|
ArtifactId, DataPlaneObject, DataPlaneScope, Digest, NativeQuicTransport, NodeEndpoint, NodeId,
|
||||||
|
ProcessId, ProjectId, RendezvousRequest, TenantId, Transport,
|
||||||
|
};
|
||||||
|
use quinn::rustls::{
|
||||||
|
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||||
|
RootCertStore,
|
||||||
|
};
|
||||||
|
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct QuicTransferRequest {
|
||||||
|
scope: DataPlaneScope,
|
||||||
|
authorization_digest: Digest,
|
||||||
|
requested_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let payload = b"artifact-bytes-over-rust-native-quic".to_vec();
|
||||||
|
let (cert, key) = self_signed_localhost_cert()?;
|
||||||
|
let server_endpoint = Endpoint::server(
|
||||||
|
ServerConfig::with_single_cert(vec![cert.clone()], key)?,
|
||||||
|
"127.0.0.1:0".parse()?,
|
||||||
|
)?;
|
||||||
|
let server_addr = server_endpoint.local_addr()?;
|
||||||
|
|
||||||
|
let scope = DataPlaneScope {
|
||||||
|
tenant: TenantId::from("tenant"),
|
||||||
|
project: ProjectId::from("project"),
|
||||||
|
process: ProcessId::from("vp-quic"),
|
||||||
|
object: DataPlaneObject::Artifact(ArtifactId::from("quic-artifact")),
|
||||||
|
authorization_subject: "node-a-to-node-b".to_owned(),
|
||||||
|
};
|
||||||
|
let transport = NativeQuicTransport;
|
||||||
|
let plan = transport.plan_authenticated_direct_bulk_transfer(
|
||||||
|
RendezvousRequest {
|
||||||
|
scope: scope.clone(),
|
||||||
|
source: endpoint("node-a", server_addr),
|
||||||
|
destination: endpoint("node-b", "127.0.0.1:0".parse()?),
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
"",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let expected_scope = plan.scope.clone();
|
||||||
|
let expected_digest = plan.authorization_digest.clone();
|
||||||
|
let expected_payload = payload.clone();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let incoming = server_endpoint
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.ok_or("server endpoint closed before accepting a QUIC connection")?;
|
||||||
|
let connection = incoming.await?;
|
||||||
|
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||||
|
let request_bytes = recv.read_to_end(64 * 1024).await?;
|
||||||
|
let request: QuicTransferRequest = serde_json::from_slice(&request_bytes)?;
|
||||||
|
if request.scope != expected_scope {
|
||||||
|
return Err("QUIC request scope did not match the authorized data-plane scope".into());
|
||||||
|
}
|
||||||
|
if request.authorization_digest != expected_digest {
|
||||||
|
return Err(
|
||||||
|
"QUIC request authorization digest did not match the rendezvous plan".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
send.write_all(&expected_payload).await?;
|
||||||
|
send.finish()?;
|
||||||
|
server_endpoint.wait_idle().await;
|
||||||
|
Ok::<usize, Box<dyn std::error::Error + Send + Sync>>(request_bytes.len())
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut roots = RootCertStore::empty();
|
||||||
|
roots.add(cert)?;
|
||||||
|
let client_config = ClientConfig::with_root_certificates(Arc::new(roots))?;
|
||||||
|
let mut client_endpoint = Endpoint::client("127.0.0.1:0".parse()?)?;
|
||||||
|
client_endpoint.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
let connection = client_endpoint.connect(server_addr, "localhost")?.await?;
|
||||||
|
let (mut send, mut recv) = connection.open_bi().await?;
|
||||||
|
let request = QuicTransferRequest {
|
||||||
|
scope: plan.scope.clone(),
|
||||||
|
authorization_digest: plan.authorization_digest.clone(),
|
||||||
|
requested_bytes: payload.len() as u64,
|
||||||
|
};
|
||||||
|
let request_bytes = serde_json::to_vec(&request)?;
|
||||||
|
send.write_all(&request_bytes).await?;
|
||||||
|
send.finish()?;
|
||||||
|
let received = recv.read_to_end(64 * 1024).await?;
|
||||||
|
connection.close(0u32.into(), b"done");
|
||||||
|
client_endpoint.wait_idle().await;
|
||||||
|
let server_received_request_bytes = server.await??;
|
||||||
|
|
||||||
|
if received != payload {
|
||||||
|
return Err("QUIC artifact payload did not round trip".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
json!({
|
||||||
|
"kind": "disasmer_quic_smoke",
|
||||||
|
"transport": format!("{:?}", transport.kind()),
|
||||||
|
"rust_native_quic": true,
|
||||||
|
"authenticated_direct_connection": transport.authenticated_direct_connections(),
|
||||||
|
"coordinator_assisted_rendezvous": plan.coordinator_assisted_rendezvous,
|
||||||
|
"coordinator_bulk_relay_allowed": plan.coordinator_bulk_relay_allowed,
|
||||||
|
"source_node": plan.source.node,
|
||||||
|
"destination_node": plan.destination.node,
|
||||||
|
"scope": plan.scope,
|
||||||
|
"request_bytes": request_bytes.len(),
|
||||||
|
"server_received_request_bytes": server_received_request_bytes,
|
||||||
|
"payload_bytes": received.len(),
|
||||||
|
"authorization_digest": plan.authorization_digest,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_signed_localhost_cert() -> Result<
|
||||||
|
(CertificateDer<'static>, PrivateKeyDer<'static>),
|
||||||
|
Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?;
|
||||||
|
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
|
||||||
|
Ok((cert.cert.into(), key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(name: &str, addr: SocketAddr) -> NodeEndpoint {
|
||||||
|
NodeEndpoint {
|
||||||
|
node: NodeId::from(name),
|
||||||
|
advertised_addr: addr.to_string(),
|
||||||
|
public_key_fingerprint: Digest::sha256(format!("{name}-public-key")),
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs
Normal file
133
crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use disasmer_core::{CommandInvocation, NodeId, TaskId, VfsPath};
|
||||||
|
use disasmer_node::{LocalCommandExecutor, VirtualThreadCommand, WasmtimeTaskRuntime};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() == 2 && args[1] == "--host-command" {
|
||||||
|
return run_host_command_smoke();
|
||||||
|
}
|
||||||
|
if args.len() == 6 && args[1] == "--debug-freeze-resume" {
|
||||||
|
return run_debug_freeze_resume_smoke(&args[2], &args[3], &args[4], &args[5]);
|
||||||
|
}
|
||||||
|
if args.len() != 5 {
|
||||||
|
return Err(
|
||||||
|
"usage: disasmer-wasmtime-smoke <module.wasm> <export> <i32-arg> <expected-i32> | --host-command | --debug-freeze-resume <module.wasm> <export> <i32-arg> <expected-i32>".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let module = PathBuf::from(&args[1]);
|
||||||
|
let export = &args[2];
|
||||||
|
let arg: i32 = args[3].parse()?;
|
||||||
|
let expected: i32 = args[4].parse()?;
|
||||||
|
|
||||||
|
let wasm = fs::read(&module)?;
|
||||||
|
let runtime = WasmtimeTaskRuntime::new()?;
|
||||||
|
let result = runtime.run_i32_export(&wasm, export, arg)?;
|
||||||
|
if result != expected {
|
||||||
|
return Err(format!("expected {expected}, got {result} from export `{export}`").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"type": "wasmtime_task_smoke",
|
||||||
|
"module": module,
|
||||||
|
"export": export,
|
||||||
|
"arg": arg,
|
||||||
|
"result": result,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_debug_freeze_resume_smoke(
|
||||||
|
module: &str,
|
||||||
|
export: &str,
|
||||||
|
arg: &str,
|
||||||
|
expected: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let module = PathBuf::from(module);
|
||||||
|
let arg: i32 = arg.parse()?;
|
||||||
|
let expected: i32 = expected.parse()?;
|
||||||
|
let wasm = fs::read(&module)?;
|
||||||
|
let runtime = WasmtimeTaskRuntime::new()?;
|
||||||
|
let probe = runtime.freeze_resume_i32_export_probe(&wasm, export, arg)?;
|
||||||
|
if probe.result != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"expected {expected}, got {} from export `{export}`",
|
||||||
|
probe.result
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"type": "wasmtime_debug_freeze_resume_smoke",
|
||||||
|
"module": module,
|
||||||
|
"export": export,
|
||||||
|
"task": probe.task,
|
||||||
|
"frozen_state": probe.frozen_state,
|
||||||
|
"resumed_state": probe.resumed_state,
|
||||||
|
"stack_frames": probe.stack_frames,
|
||||||
|
"local_values": probe.local_values,
|
||||||
|
"wasm_function": probe.wasm_function,
|
||||||
|
"wasm_pc": probe.wasm_pc,
|
||||||
|
"arg": arg,
|
||||||
|
"result": probe.result,
|
||||||
|
"node_runtime_reached_wasm_task": true,
|
||||||
|
"node_runtime_captured_wasm_locals": true,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_host_command_smoke() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let runtime = WasmtimeTaskRuntime::new()?;
|
||||||
|
let result = runtime.run_i32_export_with_command_import(
|
||||||
|
r#"
|
||||||
|
(module
|
||||||
|
(import "disasmer" "cmd_run" (func $cmd_run (result i32)))
|
||||||
|
(func (export "compile-linux") (result i32)
|
||||||
|
call $cmd_run))
|
||||||
|
"#,
|
||||||
|
"compile-linux",
|
||||||
|
LocalCommandExecutor {
|
||||||
|
node: NodeId::from("node-wasmtime"),
|
||||||
|
hosted_control_plane: false,
|
||||||
|
has_command_capability: true,
|
||||||
|
},
|
||||||
|
VirtualThreadCommand {
|
||||||
|
virtual_thread: TaskId::from("compile-linux"),
|
||||||
|
invocation: CommandInvocation {
|
||||||
|
program: "sh".to_owned(),
|
||||||
|
args: vec!["-c".to_owned(), "printf linux-build-artifact".to_owned()],
|
||||||
|
env: None,
|
||||||
|
},
|
||||||
|
stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/linux/app.tar.zst")?),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"type": "wasmtime_host_command_smoke",
|
||||||
|
"export": "compile-linux",
|
||||||
|
"export_result": result.export_result,
|
||||||
|
"virtual_thread": result.command_output.virtual_thread,
|
||||||
|
"stdout": result.command_output.stdout,
|
||||||
|
"staged_artifact": result.command_output.staged_artifact,
|
||||||
|
"large_bytes_uploaded": result.manifest.large_bytes_uploaded,
|
||||||
|
"manifest_objects": result.manifest.objects.len(),
|
||||||
|
"node_host_import": "disasmer.cmd_run",
|
||||||
|
"flagship_linux_build_task": true,
|
||||||
|
"node_executed_host_command": true,
|
||||||
|
"hosted_control_plane_ran_command": false,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1424
crates/disasmer-node/src/lib.rs
Normal file
1424
crates/disasmer-node/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
637
crates/disasmer-node/src/main.rs
Normal file
637
crates/disasmer-node/src/main.rs
Normal file
|
|
@ -0,0 +1,637 @@
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use disasmer_core::{
|
||||||
|
Capability, CommandInvocation, NodeCapabilities, NodeId, TaskId, VfsOverlay, VfsPath,
|
||||||
|
};
|
||||||
|
use disasmer_node::{CommandOutput, LocalCommandExecutor, VirtualThreadCommand};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Args {
|
||||||
|
coordinator: String,
|
||||||
|
tenant: String,
|
||||||
|
project: String,
|
||||||
|
node: String,
|
||||||
|
process: String,
|
||||||
|
task: String,
|
||||||
|
command: String,
|
||||||
|
command_args: Vec<String>,
|
||||||
|
artifact: String,
|
||||||
|
enrollment_grant: Option<String>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
control_poll_ms: u64,
|
||||||
|
assignment_poll_ms: u64,
|
||||||
|
emit_ready: bool,
|
||||||
|
worker: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct RuntimeTask {
|
||||||
|
process: String,
|
||||||
|
task: String,
|
||||||
|
command: String,
|
||||||
|
command_args: Vec<String>,
|
||||||
|
artifact: String,
|
||||||
|
epoch: Option<u64>,
|
||||||
|
task_assignment_response: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let args = parse_args()?;
|
||||||
|
|
||||||
|
let mut session = CoordinatorSession::connect(&args.coordinator)?;
|
||||||
|
|
||||||
|
let registration = register_node(&mut session, &args)?;
|
||||||
|
let heartbeat = session.request(json!({
|
||||||
|
"type": "node_heartbeat",
|
||||||
|
"node": &args.node,
|
||||||
|
}))?;
|
||||||
|
let capability_report = session.request(json!({
|
||||||
|
"type": "report_node_capabilities",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"node": &args.node,
|
||||||
|
"capabilities": NodeCapabilities::detect_current(),
|
||||||
|
"cached_environment_digests": [],
|
||||||
|
"dependency_cache_digests": [],
|
||||||
|
"source_snapshots": [],
|
||||||
|
"artifact_locations": [],
|
||||||
|
"direct_connectivity": true,
|
||||||
|
"online": true,
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
if args.worker {
|
||||||
|
return worker_loop(
|
||||||
|
&args,
|
||||||
|
&mut session,
|
||||||
|
registration,
|
||||||
|
heartbeat,
|
||||||
|
capability_report,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_assignment = session.request(json!({
|
||||||
|
"type": "schedule_task",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"environment": null,
|
||||||
|
"environment_digest": null,
|
||||||
|
"required_capabilities": [Capability::Command],
|
||||||
|
"dependency_cache": null,
|
||||||
|
"source_snapshot": null,
|
||||||
|
"required_artifacts": [],
|
||||||
|
"quota_available": true,
|
||||||
|
"policy_allowed": true,
|
||||||
|
"prefer_node": &args.node,
|
||||||
|
}))?;
|
||||||
|
let runtime_task = RuntimeTask {
|
||||||
|
process: args.process.clone(),
|
||||||
|
task: args.task.clone(),
|
||||||
|
command: args.command.clone(),
|
||||||
|
command_args: args.command_args.clone(),
|
||||||
|
artifact: args.artifact.clone(),
|
||||||
|
epoch: None,
|
||||||
|
task_assignment_response: task_assignment,
|
||||||
|
};
|
||||||
|
let report = run_runtime_task(
|
||||||
|
&args,
|
||||||
|
&mut session,
|
||||||
|
runtime_task,
|
||||||
|
registration,
|
||||||
|
heartbeat,
|
||||||
|
capability_report,
|
||||||
|
)?;
|
||||||
|
println!("{}", serde_json::to_string(&report)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_loop(
|
||||||
|
args: &Args,
|
||||||
|
session: &mut CoordinatorSession,
|
||||||
|
registration: Value,
|
||||||
|
heartbeat: Value,
|
||||||
|
capability_report: Value,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if args.emit_ready {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"node_status": "ready",
|
||||||
|
"mode": "worker",
|
||||||
|
"node": &args.node,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let response = session.request(json!({
|
||||||
|
"type": "poll_task_assignment",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"node": &args.node,
|
||||||
|
}))?;
|
||||||
|
let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else {
|
||||||
|
std::thread::sleep(Duration::from_millis(args.assignment_poll_ms));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let runtime_task = runtime_task_from_assignment(assignment)?;
|
||||||
|
let report = run_runtime_task(
|
||||||
|
args,
|
||||||
|
session,
|
||||||
|
runtime_task,
|
||||||
|
registration.clone(),
|
||||||
|
heartbeat.clone(),
|
||||||
|
capability_report.clone(),
|
||||||
|
)?;
|
||||||
|
println!("{}", serde_json::to_string(&report)?);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_task_from_assignment(value: &Value) -> Result<RuntimeTask, Box<dyn std::error::Error>> {
|
||||||
|
Ok(RuntimeTask {
|
||||||
|
process: required_string(value, "process")?,
|
||||||
|
task: required_string(value, "task")?,
|
||||||
|
command: required_string(value, "command")?,
|
||||||
|
command_args: value
|
||||||
|
.get("command_args")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
artifact: required_string(value, "artifact_path")?,
|
||||||
|
epoch: value.get("epoch").and_then(Value::as_u64),
|
||||||
|
task_assignment_response: value.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_string(value: &Value, field: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
value
|
||||||
|
.get(field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| format!("task assignment missing string field `{field}`").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_runtime_task(
|
||||||
|
args: &Args,
|
||||||
|
session: &mut CoordinatorSession,
|
||||||
|
task: RuntimeTask,
|
||||||
|
registration: Value,
|
||||||
|
heartbeat: Value,
|
||||||
|
capability_report: Value,
|
||||||
|
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||||
|
let epoch = match task.epoch {
|
||||||
|
Some(epoch) => epoch,
|
||||||
|
None => {
|
||||||
|
let started = session.request(json!({
|
||||||
|
"type": "start_process",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
}))?;
|
||||||
|
started
|
||||||
|
.get("epoch")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or("coordinator start_process response missing epoch")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
session.request(json!({
|
||||||
|
"type": "reconnect_node",
|
||||||
|
"node": &args.node,
|
||||||
|
"process": &task.process,
|
||||||
|
"epoch": epoch,
|
||||||
|
}))?;
|
||||||
|
let debug_command = session.request(json!({
|
||||||
|
"type": "poll_debug_command",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
}))?;
|
||||||
|
if args.emit_ready && !args.worker {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"node_status": "ready",
|
||||||
|
"node": &args.node,
|
||||||
|
"process": &task.process,
|
||||||
|
"task": &task.task,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
if args.control_poll_ms > 0 && wait_for_cancellation(session, args, &task)? {
|
||||||
|
let recorded = session.request(json!({
|
||||||
|
"type": "task_completed",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
"terminal_state": "cancelled",
|
||||||
|
"status_code": null,
|
||||||
|
"stdout_bytes": 0,
|
||||||
|
"stderr_bytes": 0,
|
||||||
|
"artifact_path": null,
|
||||||
|
"artifact_digest": null,
|
||||||
|
"artifact_size_bytes": null,
|
||||||
|
}))?;
|
||||||
|
return Ok(cancelled_node_report(
|
||||||
|
args,
|
||||||
|
&task,
|
||||||
|
registration,
|
||||||
|
heartbeat,
|
||||||
|
capability_report,
|
||||||
|
task.task_assignment_response.clone(),
|
||||||
|
debug_command,
|
||||||
|
recorded,
|
||||||
|
session.requests(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let executor = LocalCommandExecutor {
|
||||||
|
node: NodeId::new(args.node.clone()),
|
||||||
|
hosted_control_plane: false,
|
||||||
|
has_command_capability: true,
|
||||||
|
};
|
||||||
|
let task_id = TaskId::new(task.task.clone());
|
||||||
|
let mut overlay = VfsOverlay::new(task_id.clone(), NodeId::new(args.node.clone()));
|
||||||
|
let output = executor.run(
|
||||||
|
VirtualThreadCommand {
|
||||||
|
virtual_thread: task_id,
|
||||||
|
invocation: CommandInvocation {
|
||||||
|
program: task.command.clone(),
|
||||||
|
args: task.command_args.clone(),
|
||||||
|
env: None,
|
||||||
|
},
|
||||||
|
stage_stdout_as: Some(VfsPath::new(task.artifact.clone())?),
|
||||||
|
},
|
||||||
|
&mut overlay,
|
||||||
|
)?;
|
||||||
|
let manifest = overlay.flush();
|
||||||
|
let staged = output.staged_artifact.as_ref();
|
||||||
|
let artifact_digest = staged.map(|artifact| artifact.digest.clone());
|
||||||
|
let artifact_path = staged.map(|artifact| artifact.path.as_str().to_owned());
|
||||||
|
let artifact_size_bytes = staged.map(|artifact| artifact.size);
|
||||||
|
|
||||||
|
let log_event = session.request(json!({
|
||||||
|
"type": "report_task_log",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
"stdout_bytes": output.stdout.len(),
|
||||||
|
"stderr_bytes": output.stderr.len(),
|
||||||
|
"stdout_tail": &output.stdout,
|
||||||
|
"stderr_tail": &output.stderr,
|
||||||
|
"stdout_truncated": output.stdout_truncated,
|
||||||
|
"stderr_truncated": output.stderr_truncated,
|
||||||
|
"backpressured": output.log_backpressured,
|
||||||
|
}))?;
|
||||||
|
let vfs_metadata = session.request(json!({
|
||||||
|
"type": "report_vfs_metadata",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
"artifact_path": artifact_path,
|
||||||
|
"artifact_digest": artifact_digest,
|
||||||
|
"artifact_size_bytes": artifact_size_bytes,
|
||||||
|
"large_bytes_uploaded": manifest.large_bytes_uploaded,
|
||||||
|
}))?;
|
||||||
|
let recorded = session.request(json!({
|
||||||
|
"type": "task_completed",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
"status_code": output.status_code,
|
||||||
|
"stdout_bytes": output.stdout.len(),
|
||||||
|
"stderr_bytes": output.stderr.len(),
|
||||||
|
"stdout_tail": &output.stdout,
|
||||||
|
"stderr_tail": &output.stderr,
|
||||||
|
"stdout_truncated": output.stdout_truncated,
|
||||||
|
"stderr_truncated": output.stderr_truncated,
|
||||||
|
"artifact_path": staged.map(|artifact| artifact.path.as_str().to_owned()),
|
||||||
|
"artifact_digest": staged.map(|artifact| artifact.digest.clone()),
|
||||||
|
"artifact_size_bytes": artifact_size_bytes,
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
Ok(node_report(
|
||||||
|
output,
|
||||||
|
manifest.large_bytes_uploaded,
|
||||||
|
registration,
|
||||||
|
heartbeat,
|
||||||
|
capability_report,
|
||||||
|
task.task_assignment_response,
|
||||||
|
debug_command,
|
||||||
|
log_event,
|
||||||
|
vfs_metadata,
|
||||||
|
recorded,
|
||||||
|
session.requests(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_report(
|
||||||
|
output: CommandOutput,
|
||||||
|
large_bytes_uploaded: bool,
|
||||||
|
registration_response: Value,
|
||||||
|
heartbeat_response: Value,
|
||||||
|
capability_response: Value,
|
||||||
|
task_assignment_response: Value,
|
||||||
|
debug_command_response: Value,
|
||||||
|
log_event_response: Value,
|
||||||
|
vfs_metadata_response: Value,
|
||||||
|
coordinator_response: Value,
|
||||||
|
session_requests: usize,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"node_status": "completed",
|
||||||
|
"virtual_thread": output.virtual_thread,
|
||||||
|
"terminal_state": if output.status_code == Some(0) { "completed" } else { "failed" },
|
||||||
|
"status_code": output.status_code,
|
||||||
|
"stdout_bytes": output.stdout.len(),
|
||||||
|
"stderr_bytes": output.stderr.len(),
|
||||||
|
"stdout_tail": &output.stdout,
|
||||||
|
"stderr_tail": &output.stderr,
|
||||||
|
"stdout_truncated": output.stdout_truncated,
|
||||||
|
"stderr_truncated": output.stderr_truncated,
|
||||||
|
"log_backpressured": output.log_backpressured,
|
||||||
|
"staged_artifact": output.staged_artifact,
|
||||||
|
"large_bytes_uploaded": large_bytes_uploaded,
|
||||||
|
"registration_response": registration_response,
|
||||||
|
"heartbeat_response": heartbeat_response,
|
||||||
|
"capability_response": capability_response,
|
||||||
|
"task_assignment_response": task_assignment_response,
|
||||||
|
"debug_command_response": debug_command_response,
|
||||||
|
"log_event_response": log_event_response,
|
||||||
|
"vfs_metadata_response": vfs_metadata_response,
|
||||||
|
"session_requests": session_requests,
|
||||||
|
"coordinator_response": coordinator_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancelled_node_report(
|
||||||
|
_args: &Args,
|
||||||
|
task: &RuntimeTask,
|
||||||
|
registration_response: Value,
|
||||||
|
heartbeat_response: Value,
|
||||||
|
capability_response: Value,
|
||||||
|
task_assignment_response: Value,
|
||||||
|
debug_command_response: Value,
|
||||||
|
coordinator_response: Value,
|
||||||
|
session_requests: usize,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"node_status": "cancelled",
|
||||||
|
"virtual_thread": &task.task,
|
||||||
|
"terminal_state": "cancelled",
|
||||||
|
"status_code": null,
|
||||||
|
"stdout_bytes": 0,
|
||||||
|
"stderr_bytes": 0,
|
||||||
|
"stdout_tail": "",
|
||||||
|
"stderr_tail": "",
|
||||||
|
"stdout_truncated": false,
|
||||||
|
"stderr_truncated": false,
|
||||||
|
"log_backpressured": false,
|
||||||
|
"staged_artifact": null,
|
||||||
|
"large_bytes_uploaded": false,
|
||||||
|
"registration_response": registration_response,
|
||||||
|
"heartbeat_response": heartbeat_response,
|
||||||
|
"capability_response": capability_response,
|
||||||
|
"task_assignment_response": task_assignment_response,
|
||||||
|
"debug_command_response": debug_command_response,
|
||||||
|
"log_event_response": null,
|
||||||
|
"vfs_metadata_response": null,
|
||||||
|
"session_requests": session_requests,
|
||||||
|
"coordinator_response": coordinator_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_node(
|
||||||
|
session: &mut CoordinatorSession,
|
||||||
|
args: &Args,
|
||||||
|
) -> Result<Value, Box<dyn std::error::Error>> {
|
||||||
|
let public_key = args
|
||||||
|
.public_key
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}-public-key", args.node));
|
||||||
|
if let Some(grant) = &args.enrollment_grant {
|
||||||
|
session.request(json!({
|
||||||
|
"type": "exchange_node_enrollment_grant",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"node": &args.node,
|
||||||
|
"public_key": public_key,
|
||||||
|
"enrollment_grant": grant,
|
||||||
|
"now_epoch_seconds": 0,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
session.request(json!({
|
||||||
|
"type": "attach_node",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"node": &args.node,
|
||||||
|
"public_key": public_key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_cancellation(
|
||||||
|
session: &mut CoordinatorSession,
|
||||||
|
args: &Args,
|
||||||
|
task: &RuntimeTask,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(args.control_poll_ms);
|
||||||
|
loop {
|
||||||
|
let control = session.request(json!({
|
||||||
|
"type": "poll_task_control",
|
||||||
|
"tenant": &args.tenant,
|
||||||
|
"project": &args.project,
|
||||||
|
"process": &task.process,
|
||||||
|
"node": &args.node,
|
||||||
|
"task": &task.task,
|
||||||
|
}))?;
|
||||||
|
if control
|
||||||
|
.get("cancel_requested")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
std::thread::sleep((deadline - now).min(Duration::from_millis(100)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CoordinatorSession {
|
||||||
|
writer: TcpStream,
|
||||||
|
reader: BufReader<TcpStream>,
|
||||||
|
requests: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoordinatorSession {
|
||||||
|
fn connect(addr: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let transport_addr = json_line_transport_addr(addr);
|
||||||
|
let writer = TcpStream::connect(&transport_addr)?;
|
||||||
|
let reader = BufReader::new(writer.try_clone()?);
|
||||||
|
Ok(Self {
|
||||||
|
writer,
|
||||||
|
reader,
|
||||||
|
requests: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&mut self, value: Value) -> Result<Value, Box<dyn std::error::Error>> {
|
||||||
|
serde_json::to_writer(&mut self.writer, &value)?;
|
||||||
|
self.writer.write_all(b"\n")?;
|
||||||
|
self.writer.flush()?;
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
if self.reader.read_line(&mut line)? == 0 {
|
||||||
|
return Err("coordinator closed session without a response".into());
|
||||||
|
}
|
||||||
|
let response: Value = serde_json::from_str(&line)?;
|
||||||
|
self.requests += 1;
|
||||||
|
if response.get("type").and_then(Value::as_str) == Some("error") {
|
||||||
|
return Err(format!("coordinator error: {response}").into());
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> usize {
|
||||||
|
self.requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_line_transport_addr(endpoint: &str) -> String {
|
||||||
|
let endpoint = endpoint.trim();
|
||||||
|
for (scheme, default_port) in [("https://", 443), ("http://", 80)] {
|
||||||
|
if let Some(rest) = endpoint.strip_prefix(scheme) {
|
||||||
|
let authority = rest.split('/').next().unwrap_or(rest);
|
||||||
|
if authority.contains(':') {
|
||||||
|
return authority.to_owned();
|
||||||
|
}
|
||||||
|
return format!("{authority}:{default_port}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endpoint.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
|
||||||
|
let mut coordinator = None;
|
||||||
|
let mut tenant = "tenant".to_owned();
|
||||||
|
let mut project = "project".to_owned();
|
||||||
|
let mut node = "node".to_owned();
|
||||||
|
let mut process = "process".to_owned();
|
||||||
|
let mut task = "compile-linux".to_owned();
|
||||||
|
let mut command = None;
|
||||||
|
let mut command_args = Vec::new();
|
||||||
|
let mut artifact = "/vfs/artifacts/node-output.txt".to_owned();
|
||||||
|
let mut enrollment_grant = None;
|
||||||
|
let mut public_key = None;
|
||||||
|
let mut control_poll_ms = 0;
|
||||||
|
let mut assignment_poll_ms = 500;
|
||||||
|
let mut emit_ready = false;
|
||||||
|
let mut worker = false;
|
||||||
|
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--coordinator" => coordinator = args.next(),
|
||||||
|
"--tenant" => tenant = args.next().ok_or("--tenant requires a value")?,
|
||||||
|
"--project-id" => project = args.next().ok_or("--project-id requires a value")?,
|
||||||
|
"--node" => node = args.next().ok_or("--node requires a value")?,
|
||||||
|
"--process" => process = args.next().ok_or("--process requires a value")?,
|
||||||
|
"--task" => task = args.next().ok_or("--task requires a value")?,
|
||||||
|
"--command" => command = args.next(),
|
||||||
|
"--arg" => command_args.push(args.next().ok_or("--arg requires a value")?),
|
||||||
|
"--artifact" => artifact = args.next().ok_or("--artifact requires a value")?,
|
||||||
|
"--enrollment-grant" => enrollment_grant = args.next(),
|
||||||
|
"--public-key" => public_key = args.next(),
|
||||||
|
"--control-poll-ms" => {
|
||||||
|
control_poll_ms = args
|
||||||
|
.next()
|
||||||
|
.ok_or("--control-poll-ms requires a value")?
|
||||||
|
.parse()?
|
||||||
|
}
|
||||||
|
"--assignment-poll-ms" => {
|
||||||
|
assignment_poll_ms = args
|
||||||
|
.next()
|
||||||
|
.ok_or("--assignment-poll-ms requires a value")?
|
||||||
|
.parse()?
|
||||||
|
}
|
||||||
|
"--emit-ready" => emit_ready = true,
|
||||||
|
"--worker" => worker = true,
|
||||||
|
"--project" => {
|
||||||
|
let project_path = PathBuf::from(args.next().ok_or("--project requires a path")?);
|
||||||
|
command_args.extend([
|
||||||
|
"test".to_owned(),
|
||||||
|
"--manifest-path".to_owned(),
|
||||||
|
project_path
|
||||||
|
.join("Cargo.toml")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
other => return Err(format!("unknown argument: {other}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Args {
|
||||||
|
coordinator: coordinator.ok_or("--coordinator is required")?,
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
node,
|
||||||
|
process,
|
||||||
|
task,
|
||||||
|
command: command.unwrap_or_else(|| "cargo".to_owned()),
|
||||||
|
command_args,
|
||||||
|
artifact,
|
||||||
|
enrollment_grant,
|
||||||
|
public_key,
|
||||||
|
control_poll_ms,
|
||||||
|
assignment_poll_ms,
|
||||||
|
emit_ready,
|
||||||
|
worker,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::json_line_transport_addr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosted_operator_url_maps_to_json_line_transport_address() {
|
||||||
|
assert_eq!(
|
||||||
|
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443"),
|
||||||
|
"disasmer.michelpaulissen.com:9443"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"),
|
||||||
|
"disasmer.michelpaulissen.com:9443"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
json_line_transport_addr("http://operator.example.test"),
|
||||||
|
"operator.example.test:80"
|
||||||
|
);
|
||||||
|
assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/disasmer-sdk/Cargo.toml
Normal file
17
crates/disasmer-sdk/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "disasmer-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "disasmer"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
disasmer-macros = { path = "../disasmer-macros" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
futures-executor.workspace = true
|
||||||
521
crates/disasmer-sdk/src/lib.rs
Normal file
521
crates/disasmer-sdk/src/lib.rs
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use disasmer_macros::{main, task};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EntrypointDescriptor {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub function: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TaskDescriptor {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub function: &'static str,
|
||||||
|
pub remotely_startable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RegisteredProgram {
|
||||||
|
pub entrypoints: &'static [EntrypointDescriptor],
|
||||||
|
pub tasks: &'static [TaskDescriptor],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisteredProgram {
|
||||||
|
pub fn select_entrypoint(&self, name: &str) -> Option<EntrypointDescriptor> {
|
||||||
|
self.entrypoints
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|entrypoint| entrypoint.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn task(&self, name: &str) -> Option<TaskDescriptor> {
|
||||||
|
self.tasks.iter().copied().find(|task| task.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceSnapshot {
|
||||||
|
pub digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Blob {
|
||||||
|
pub digest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Artifact {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EnvRef {
|
||||||
|
pub name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvRef {
|
||||||
|
pub const fn new_static(name: &'static str) -> Self {
|
||||||
|
Self { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! env {
|
||||||
|
($name:literal) => {
|
||||||
|
$crate::EnvRef::new_static($name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TaskArgKind {
|
||||||
|
SmallSerialized,
|
||||||
|
Handle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TaskArg: Serialize {
|
||||||
|
fn task_arg_kind(&self) -> TaskArgKind {
|
||||||
|
TaskArgKind::SmallSerialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! small_task_arg {
|
||||||
|
($($ty:ty),+ $(,)?) => {
|
||||||
|
$(
|
||||||
|
impl TaskArg for $ty {}
|
||||||
|
)+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
small_task_arg!(
|
||||||
|
(),
|
||||||
|
bool,
|
||||||
|
char,
|
||||||
|
String,
|
||||||
|
i8,
|
||||||
|
i16,
|
||||||
|
i32,
|
||||||
|
i64,
|
||||||
|
isize,
|
||||||
|
u8,
|
||||||
|
u16,
|
||||||
|
u32,
|
||||||
|
u64,
|
||||||
|
usize,
|
||||||
|
f32,
|
||||||
|
f64,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl TaskArg for SourceSnapshot {
|
||||||
|
fn task_arg_kind(&self) -> TaskArgKind {
|
||||||
|
TaskArgKind::Handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskArg for Blob {
|
||||||
|
fn task_arg_kind(&self) -> TaskArgKind {
|
||||||
|
TaskArgKind::Handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskArg for Artifact {
|
||||||
|
fn task_arg_kind(&self) -> TaskArgKind {
|
||||||
|
TaskArgKind::Handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> TaskArg for Option<T> where T: TaskArg {}
|
||||||
|
|
||||||
|
impl<T> TaskArg for Vec<T> where T: TaskArg {}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TaskArgBudget {
|
||||||
|
pub max_inline_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TaskArgBudget {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_inline_bytes: 64 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct TaskArgValidation {
|
||||||
|
pub inline_bytes: usize,
|
||||||
|
pub kind: TaskArgKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TaskArgError {
|
||||||
|
Serialization(String),
|
||||||
|
TooLarge { size: usize, limit: usize },
|
||||||
|
HostOnly { type_name: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TaskArgError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"),
|
||||||
|
Self::TooLarge { size, limit } => write!(
|
||||||
|
f,
|
||||||
|
"task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles"
|
||||||
|
),
|
||||||
|
Self::HostOnly { type_name } => write!(
|
||||||
|
f,
|
||||||
|
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TaskArgError {}
|
||||||
|
|
||||||
|
pub fn validate_task_arg<T>(
|
||||||
|
value: &T,
|
||||||
|
budget: TaskArgBudget,
|
||||||
|
) -> Result<TaskArgValidation, TaskArgError>
|
||||||
|
where
|
||||||
|
T: TaskArg + ?Sized,
|
||||||
|
{
|
||||||
|
let bytes = serde_json::to_vec(value)
|
||||||
|
.map_err(|error| TaskArgError::Serialization(error.to_string()))?;
|
||||||
|
let kind = value.task_arg_kind();
|
||||||
|
if kind == TaskArgKind::SmallSerialized && bytes.len() > budget.max_inline_bytes {
|
||||||
|
return Err(TaskArgError::TooLarge {
|
||||||
|
size: bytes.len(),
|
||||||
|
limit: budget.max_inline_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(TaskArgValidation {
|
||||||
|
inline_bytes: bytes.len(),
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reject_host_only_task_arg<T: ?Sized>() -> TaskArgError {
|
||||||
|
TaskArgError::HostOnly {
|
||||||
|
type_name: std::any::type_name::<T>(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod spawn {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget, TaskArgError};
|
||||||
|
|
||||||
|
static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
static RUNTIME_THREADS: OnceLock<Mutex<Vec<RuntimeSpawnEvent>>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RuntimeSpawnEvent {
|
||||||
|
pub virtual_thread_id: u64,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub env: Option<EnvRef>,
|
||||||
|
pub debugger_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_threads() -> &'static Mutex<Vec<RuntimeSpawnEvent>> {
|
||||||
|
RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_runtime_thread(
|
||||||
|
virtual_thread_id: u64,
|
||||||
|
name: &'static str,
|
||||||
|
env: Option<EnvRef>,
|
||||||
|
) -> RuntimeSpawnEvent {
|
||||||
|
let event = RuntimeSpawnEvent {
|
||||||
|
virtual_thread_id,
|
||||||
|
name,
|
||||||
|
env,
|
||||||
|
debugger_visible: true,
|
||||||
|
};
|
||||||
|
runtime_threads().lock().unwrap().push(event.clone());
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
||||||
|
runtime_threads().lock().unwrap().drain(..).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
||||||
|
runtime_threads().lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn task<F, R>(entry: F) -> TaskBuilder<F, R>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
TaskBuilder {
|
||||||
|
entry: Some(entry),
|
||||||
|
env: None,
|
||||||
|
name: "task",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn task_with_arg<A, F, R>(arg: A, entry: F) -> TaskWithArgBuilder<A, F, R>
|
||||||
|
where
|
||||||
|
A: TaskArg,
|
||||||
|
F: FnOnce(A) -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
TaskWithArgBuilder {
|
||||||
|
arg: Some(arg),
|
||||||
|
entry: Some(entry),
|
||||||
|
env: None,
|
||||||
|
name: "task",
|
||||||
|
arg_budget: TaskArgBudget::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TaskBuilder<F, R>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
entry: Option<F>,
|
||||||
|
env: Option<EnvRef>,
|
||||||
|
name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F, R> TaskBuilder<F, R>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
pub fn env(mut self, env: EnvRef) -> Self {
|
||||||
|
self.env = Some(env);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(mut self, name: &'static str) -> Self {
|
||||||
|
self.name = name;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(mut self) -> TaskHandle<R> {
|
||||||
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
||||||
|
let entry = self.entry.take().expect("task entry used once");
|
||||||
|
TaskHandle {
|
||||||
|
virtual_thread_id: id,
|
||||||
|
name: self.name,
|
||||||
|
env: self.env,
|
||||||
|
debugger_visible: runtime_event.debugger_visible,
|
||||||
|
result: Some(entry()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TaskWithArgBuilder<A, F, R>
|
||||||
|
where
|
||||||
|
A: TaskArg,
|
||||||
|
F: FnOnce(A) -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
arg: Option<A>,
|
||||||
|
entry: Option<F>,
|
||||||
|
env: Option<EnvRef>,
|
||||||
|
name: &'static str,
|
||||||
|
arg_budget: TaskArgBudget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A, F, R> TaskWithArgBuilder<A, F, R>
|
||||||
|
where
|
||||||
|
A: TaskArg,
|
||||||
|
F: FnOnce(A) -> R,
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
pub fn env(mut self, env: EnvRef) -> Self {
|
||||||
|
self.env = Some(env);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(mut self, name: &'static str) -> Self {
|
||||||
|
self.name = name;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
|
||||||
|
self.arg_budget = arg_budget;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(mut self) -> Result<TaskHandle<R>, TaskArgError> {
|
||||||
|
let arg = self.arg.take().expect("task argument used once");
|
||||||
|
validate_task_arg(&arg, self.arg_budget)?;
|
||||||
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
||||||
|
let entry = self.entry.take().expect("task entry used once");
|
||||||
|
Ok(TaskHandle {
|
||||||
|
virtual_thread_id: id,
|
||||||
|
name: self.name,
|
||||||
|
env: self.env,
|
||||||
|
debugger_visible: runtime_event.debugger_visible,
|
||||||
|
result: Some(entry(arg)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TaskHandle<R> {
|
||||||
|
virtual_thread_id: u64,
|
||||||
|
name: &'static str,
|
||||||
|
env: Option<EnvRef>,
|
||||||
|
debugger_visible: bool,
|
||||||
|
result: Option<R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> TaskHandle<R>
|
||||||
|
where
|
||||||
|
R: TaskArg,
|
||||||
|
{
|
||||||
|
pub fn virtual_thread_id(&self) -> u64 {
|
||||||
|
self.virtual_thread_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn env(&self) -> Option<EnvRef> {
|
||||||
|
self.env
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn debugger_visible(&self) -> bool {
|
||||||
|
self.debugger_visible
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join(mut self) -> R {
|
||||||
|
self.result.take().expect("task joined once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use futures_executor::block_on;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_macro_creates_logical_environment_reference() {
|
||||||
|
let env = crate::env!("linux");
|
||||||
|
|
||||||
|
assert_eq!(env.name, "linux");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_task_start_join_returns_small_result() {
|
||||||
|
let result = block_on(async {
|
||||||
|
let handle = crate::spawn::task(|| 42)
|
||||||
|
.name("compile linux")
|
||||||
|
.env(crate::env!("linux"))
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
assert_eq!(handle.name(), "compile linux");
|
||||||
|
assert_eq!(handle.env().unwrap().name, "linux");
|
||||||
|
assert!(handle.debugger_visible());
|
||||||
|
handle.join().await
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(result, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_task_start_registers_debugger_visible_runtime_thread() {
|
||||||
|
let handle = block_on(async {
|
||||||
|
crate::spawn::task(|| 7_u32)
|
||||||
|
.name("sdk-runtime-thread-test")
|
||||||
|
.env(crate::env!("linux"))
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
let events = crate::spawn::runtime_spawn_events();
|
||||||
|
let event = events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.virtual_thread_id == handle.virtual_thread_id())
|
||||||
|
.expect("spawn runtime event should exist for task handle");
|
||||||
|
|
||||||
|
assert!(handle.debugger_visible());
|
||||||
|
assert!(event.debugger_visible);
|
||||||
|
assert_eq!(event.name, "sdk-runtime-thread-test");
|
||||||
|
assert_eq!(event.env.unwrap().name, "linux");
|
||||||
|
assert_eq!(block_on(handle.join()), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() {
|
||||||
|
let artifact = crate::Artifact {
|
||||||
|
id: "artifact://build/app".to_owned(),
|
||||||
|
};
|
||||||
|
let artifact_validation = crate::validate_task_arg(
|
||||||
|
&artifact,
|
||||||
|
crate::TaskArgBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle);
|
||||||
|
|
||||||
|
let bytes = vec![1_u8, 2, 3, 4, 5];
|
||||||
|
let error = crate::validate_task_arg(
|
||||||
|
&bytes,
|
||||||
|
crate::TaskArgBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, crate::TaskArgError::TooLarge { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() {
|
||||||
|
let dispatched = std::cell::Cell::new(false);
|
||||||
|
let result = block_on(async {
|
||||||
|
crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| {
|
||||||
|
dispatched.set(true);
|
||||||
|
42_u32
|
||||||
|
})
|
||||||
|
.arg_budget(crate::TaskArgBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
})
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(crate::TaskArgError::TooLarge { .. })));
|
||||||
|
assert!(!dispatched.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() {
|
||||||
|
let artifact = crate::Artifact {
|
||||||
|
id: "artifact://build/app".to_owned(),
|
||||||
|
};
|
||||||
|
let result = block_on(async {
|
||||||
|
let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id)
|
||||||
|
.name("publish artifact")
|
||||||
|
.env(crate::env!("linux"))
|
||||||
|
.arg_budget(crate::TaskArgBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
})
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(handle.name(), "publish artifact");
|
||||||
|
handle.join().await
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(result, "artifact://build/app");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_only_task_arg_error_names_rejected_type() {
|
||||||
|
let error = crate::reject_host_only_task_arg::<*const u8>();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("*const u8"));
|
||||||
|
assert!(error.to_string().contains("host-only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
80
crates/disasmer-sdk/tests/macros.rs
Normal file
80
crates/disasmer-sdk/tests/macros.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use futures_executor::block_on;
|
||||||
|
|
||||||
|
#[disasmer::main]
|
||||||
|
fn build_main() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::main(name = "release")]
|
||||||
|
fn release_main() -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::task(name = "compile-linux")]
|
||||||
|
fn compile_linux() -> u32 {
|
||||||
|
41
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::task]
|
||||||
|
fn package_release() -> disasmer::Artifact {
|
||||||
|
disasmer::Artifact {
|
||||||
|
id: "artifact://package/release.tar.zst".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() {
|
||||||
|
let program = disasmer::RegisteredProgram {
|
||||||
|
entrypoints: &[
|
||||||
|
__DISASMER_ENTRYPOINT_BUILD_MAIN,
|
||||||
|
__DISASMER_ENTRYPOINT_RELEASE_MAIN,
|
||||||
|
],
|
||||||
|
tasks: &[
|
||||||
|
__DISASMER_TASK_COMPILE_LINUX,
|
||||||
|
__DISASMER_TASK_PACKAGE_RELEASE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
program.select_entrypoint("build").unwrap().function,
|
||||||
|
"build_main"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
program.select_entrypoint("release").unwrap().function,
|
||||||
|
"release_main"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
program.task("compile-linux").unwrap().function,
|
||||||
|
"compile_linux"
|
||||||
|
);
|
||||||
|
assert!(program.task("compile-linux").unwrap().remotely_startable);
|
||||||
|
|
||||||
|
let result = block_on(async {
|
||||||
|
let handle = disasmer::spawn::task(|| compile_linux() + build_main())
|
||||||
|
.name("compile linux")
|
||||||
|
.env(disasmer::env!("linux"))
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(handle.virtual_thread_id() > 0);
|
||||||
|
handle.join().await
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(result, 42);
|
||||||
|
assert_eq!(release_main(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_boundaries_accept_small_values_and_runtime_handles() {
|
||||||
|
let small = disasmer::validate_task_arg(&7_u32, disasmer::TaskArgBudget::default()).unwrap();
|
||||||
|
assert_eq!(small.kind, disasmer::TaskArgKind::SmallSerialized);
|
||||||
|
|
||||||
|
let artifact = package_release();
|
||||||
|
let artifact = disasmer::validate_task_arg(
|
||||||
|
&artifact,
|
||||||
|
disasmer::TaskArgBudget {
|
||||||
|
max_inline_bytes: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(artifact.kind, disasmer::TaskArgKind::Handle);
|
||||||
|
}
|
||||||
17
examples/launch-build-demo/Cargo.toml
Normal file
17
examples/launch-build-demo/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "launch-build-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/build.rs"
|
||||||
|
crate-type = ["rlib", "cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
disasmer = { package = "disasmer-sdk", path = "../../crates/disasmer-sdk" }
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
futures-executor.workspace = true
|
||||||
11
examples/launch-build-demo/README.md
Normal file
11
examples/launch-build-demo/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Launch Build Demo
|
||||||
|
|
||||||
|
This demo is the MVP build workflow expressed as Rust source code. It uses `env!("linux")`, recognizes `env!("windows")` when a Windows development node is attached, spawns debugger-visible virtual tasks, and returns artifact/source handles instead of moving large bytes through task arguments.
|
||||||
|
|
||||||
|
The Linux environment is defined by `envs/linux/Containerfile`. The Windows environment is a user-attached development contract and does not claim secure managed Windows sandboxing.
|
||||||
|
|
||||||
|
Inspect the bundle metadata:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
disasmer bundle inspect --project examples/launch-build-demo
|
||||||
|
```
|
||||||
3
examples/launch-build-demo/envs/linux/Containerfile
Normal file
3
examples/launch-build-demo/envs/linux/Containerfile
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
FROM docker.io/library/alpine:3.20
|
||||||
|
RUN apk add --no-cache build-base tar zstd
|
||||||
|
WORKDIR /workspace
|
||||||
2
examples/launch-build-demo/envs/windows/Dockerfile
Normal file
2
examples/launch-build-demo/envs/windows/Dockerfile
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# User-attached Windows development execution contract for the MVP.
|
||||||
|
# This is not a managed untrusted Windows sandbox.
|
||||||
97
examples/launch-build-demo/src/build.rs
Normal file
97
examples/launch-build-demo/src/build.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use disasmer::{Artifact, EnvRef, SourceSnapshot};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BuildReport {
|
||||||
|
pub linux_thread: u64,
|
||||||
|
pub package_thread: u64,
|
||||||
|
pub linux_artifact: Artifact,
|
||||||
|
pub package_artifact: Artifact,
|
||||||
|
pub source: SourceSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn linux_env() -> EnvRef {
|
||||||
|
disasmer::env!("linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn windows_env() -> EnvRef {
|
||||||
|
disasmer::env!("windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::task]
|
||||||
|
pub fn compile_linux() -> Artifact {
|
||||||
|
Artifact {
|
||||||
|
id: "artifact://linux/app.tar.zst".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::task]
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn task_add_one(input: i32) -> i32 {
|
||||||
|
input + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::task]
|
||||||
|
pub fn package_release() -> Artifact {
|
||||||
|
Artifact {
|
||||||
|
id: "artifact://package/release.tar.zst".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[disasmer::main]
|
||||||
|
pub fn build_main() -> &'static str {
|
||||||
|
"launch-build-demo"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_build_workflow() -> BuildReport {
|
||||||
|
let linux = disasmer::spawn::task(compile_linux)
|
||||||
|
.name("compile linux")
|
||||||
|
.env(linux_env())
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
let package = disasmer::spawn::task(package_release)
|
||||||
|
.name("package artifacts")
|
||||||
|
.env(linux_env())
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let linux_thread = linux.virtual_thread_id();
|
||||||
|
let package_thread = package.virtual_thread_id();
|
||||||
|
let linux_artifact = linux.join().await;
|
||||||
|
let package_artifact = package.join().await;
|
||||||
|
|
||||||
|
BuildReport {
|
||||||
|
linux_thread,
|
||||||
|
package_thread,
|
||||||
|
linux_artifact,
|
||||||
|
package_artifact,
|
||||||
|
source: SourceSnapshot {
|
||||||
|
digest: "source://local-checkout".to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use futures_executor::block_on;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flagship_workflow_is_rust_source_with_spawned_virtual_tasks() {
|
||||||
|
assert_eq!(build_main(), "launch-build-demo");
|
||||||
|
assert_eq!(task_add_one(41), 42);
|
||||||
|
assert_eq!(linux_env().name, "linux");
|
||||||
|
assert_eq!(windows_env().name, "windows");
|
||||||
|
|
||||||
|
let report = block_on(run_build_workflow());
|
||||||
|
|
||||||
|
assert_ne!(report.linux_thread, report.package_thread);
|
||||||
|
assert_eq!(report.linux_artifact.id, "artifact://linux/app.tar.zst");
|
||||||
|
assert_eq!(
|
||||||
|
report.package_artifact.id,
|
||||||
|
"artifact://package/release.tar.zst"
|
||||||
|
);
|
||||||
|
assert_eq!(report.source.digest, "source://local-checkout");
|
||||||
|
}
|
||||||
|
}
|
||||||
111
scripts/acceptance-doc-contract-smoke.js
Executable file
111
scripts/acceptance-doc-contract-smoke.js
Executable file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function criterionLines(source) {
|
||||||
|
return source
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => /^- \[[ x]\] \*\*/.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEveryCriterionHasStatus(source, name) {
|
||||||
|
const lines = criterionLines(source);
|
||||||
|
assert(lines.length > 0, `${name} must contain acceptance criteria`);
|
||||||
|
for (const line of lines) {
|
||||||
|
assert.match(
|
||||||
|
line,
|
||||||
|
/^- \[[ x]\] \*\*(Passed|Partial|Open)(?: \([^)]+\))?:\*\*/,
|
||||||
|
`${name} criterion lacks an explicit status prefix: ${line}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoOpenCriteria(source, name) {
|
||||||
|
const open = criterionLines(source).filter((line) => /\*\*Open(?::| \()/.test(line));
|
||||||
|
assert.deepStrictEqual(open, [], `${name} still has Open criteria`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase2 = read("acceptance_criteria_phase2.md");
|
||||||
|
const base = read("acceptance_criteria.md");
|
||||||
|
const docsSmoke = read("scripts/docs-smoke.js");
|
||||||
|
const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
phase2,
|
||||||
|
/phase 2 superset of `acceptance_criteria\.md`/,
|
||||||
|
"phase 2 criteria must declare that they are a superset of the base criteria"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
phase2,
|
||||||
|
/Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts/,
|
||||||
|
"phase 2 criteria must keep base acceptance criteria required unless stricter phase 2 criteria conflict"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
phase2,
|
||||||
|
/- \[x\] \*\*Passed:\*\* Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts\./,
|
||||||
|
"phase 2 cross-document requirement must be marked passed only when this guard is wired"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [source, name] of [
|
||||||
|
[base, "acceptance_criteria.md"],
|
||||||
|
[phase2, "acceptance_criteria_phase2.md"],
|
||||||
|
]) {
|
||||||
|
assertEveryCriterionHasStatus(source, name);
|
||||||
|
assertNoOpenCriteria(source, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of ["MVP.md", "acceptance_criteria.md", "acceptance_criteria_phase2.md"]) {
|
||||||
|
assert(
|
||||||
|
docsSmoke.includes(`"${file}"`),
|
||||||
|
`docs smoke must include ${file} as user-facing acceptance context`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
releaseBlockerSmoke.includes('const phase2 = read("acceptance_criteria_phase2.md")'),
|
||||||
|
"release-blocker smoke must read phase 2 acceptance criteria"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
releaseBlockerSmoke.includes('const base = read("acceptance_criteria.md")'),
|
||||||
|
"release-blocker smoke must read base acceptance criteria"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [source, name] of [
|
||||||
|
[base, "acceptance_criteria.md"],
|
||||||
|
[phase2, "acceptance_criteria_phase2.md"],
|
||||||
|
]) {
|
||||||
|
for (const [label, pattern] of [
|
||||||
|
["MVP selected locals", /selected (?:top-level )?locals|selected real source locals/],
|
||||||
|
["MVP task args", /task arguments|task args/],
|
||||||
|
["MVP handle inspection", /Artifact.*SourceSnapshot.*Blob|Disasmer handles/],
|
||||||
|
["MVP stdout stderr", /stdout\/stderr/],
|
||||||
|
["MVP unavailable locals", /cannot be inspected|unavailable-local/],
|
||||||
|
["MVP required DAP surface", /initialize[\s\S]*launch.*attach[\s\S]*setBreakpoints[\s\S]*configurationDone[\s\S]*threads[\s\S]*stackTrace[\s\S]*scopes[\s\S]*variables[\s\S]*continue[\s\S]*pause/],
|
||||||
|
]) {
|
||||||
|
assert.match(source, pattern, `${name} must include MVP debugging criterion: ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["private acceptance", privateAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||||
|
`${scriptName} must run acceptance-doc-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Acceptance doc contract smoke passed");
|
||||||
248
scripts/acceptance-environment-contract-smoke.js
Executable file
248
scripts/acceptance-environment-contract-smoke.js
Executable file
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(relativePath) {
|
||||||
|
const absolute = path.join(repo, relativePath);
|
||||||
|
if (!fs.existsSync(absolute)) return null;
|
||||||
|
return fs.readFileSync(absolute, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing acceptance environment evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectIncludes(source, name, text) {
|
||||||
|
assert(source.includes(text), `missing acceptance environment evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const acceptanceReport = read("scripts/acceptance-report.js");
|
||||||
|
const acceptanceReportSmoke = read("scripts/acceptance-report-smoke.js");
|
||||||
|
const readme = read("README.md");
|
||||||
|
const windowsWorkflow = read(".forgejo/workflows/windows-validation.yml");
|
||||||
|
const publicDryrunServiceSmoke = maybeRead("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js");
|
||||||
|
const publicDryrunDeployPrep = maybeRead("private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js");
|
||||||
|
const publicDryrunSystemd = maybeRead("private/hosted-policy/deploy/disasmer-public-release-dryrun.service");
|
||||||
|
const publicDryrunRunbook = maybeRead("private/hosted-policy/deploy/README.md");
|
||||||
|
const publicOperatorCompatSmoke = maybeRead("private/hosted-policy/scripts/public-operator-compat-smoke.js");
|
||||||
|
const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs");
|
||||||
|
const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js");
|
||||||
|
const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js");
|
||||||
|
|
||||||
|
for (const [name, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["private acceptance", privateAcceptance],
|
||||||
|
]) {
|
||||||
|
expect(script, `${name} writes acceptance environment report first`, /node scripts\/acceptance-report\.js (public|private)[\s\S]*node scripts\/acceptance-report-smoke\.js/);
|
||||||
|
expectIncludes(
|
||||||
|
script,
|
||||||
|
`${name} runs acceptance environment contract`,
|
||||||
|
"node scripts/acceptance-environment-contract-smoke.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
for (const smoke of [
|
||||||
|
"scripts/local-services-smoke.js",
|
||||||
|
"scripts/node-attach-smoke.js",
|
||||||
|
"scripts/cli-local-run-smoke.js",
|
||||||
|
"scripts/vscode-f5-smoke.js",
|
||||||
|
"scripts/dap-smoke.js",
|
||||||
|
"scripts/artifact-download-smoke.js",
|
||||||
|
"scripts/artifact-export-smoke.js",
|
||||||
|
"scripts/public-local-demo-matrix-smoke.js",
|
||||||
|
]) {
|
||||||
|
assert(script.includes(`node ${smoke}`), `${name} must run ${smoke}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectIncludes(publicAcceptance, "public gate runs rootless Podman backend smoke", "node scripts/podman-backend-smoke.js");
|
||||||
|
expectIncludes(publicAcceptance, "public gate runs Wasmtime node smoke", "node scripts/wasmtime-node-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs hosted deployment smoke", "node private/hosted-policy/scripts/hosted-deployment-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate prepares public dry-run deployment bundle", "node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs hosted community smoke", "node private/hosted-policy/scripts/hosted-community-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs public operator compatibility smoke", "node private/hosted-policy/scripts/public-operator-compat-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs standalone public coordinator smoke", "node scripts/self-hosted-coordinator-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs Postgres durable smoke", "node private/hosted-policy/scripts/postgres-durable-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "private gate can run public release dry-run service smoke", "node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js");
|
||||||
|
expectIncludes(privateAcceptance, "public release dry-run service smoke is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR");
|
||||||
|
expectIncludes(privateAcceptance, "private gate runs hosted policy cargo tests", "cargo test --manifest-path private/hosted-policy/Cargo.toml");
|
||||||
|
expectIncludes(publicAcceptance, "public gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js");
|
||||||
|
expectIncludes(publicAcceptance, "public gate can run public release dry-run e2e", "node scripts/public-release-dryrun-e2e.js");
|
||||||
|
expectIncludes(publicAcceptance, "public release dry-run e2e is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E");
|
||||||
|
expectIncludes(privateAcceptance, "private gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js");
|
||||||
|
expectIncludes(publicAcceptance, "public final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL");
|
||||||
|
expectIncludes(privateAcceptance, "private final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL");
|
||||||
|
|
||||||
|
expect(publicSplit, "public split excludes private modules", /--exclude='\.\/private'/);
|
||||||
|
expect(publicSplit, "public split excludes experiments", /--exclude='\.\/experiments'/);
|
||||||
|
expect(publicSplit, "public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||||
|
expect(publicSplit, "public split builds copied workspace binaries", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||||
|
expectIncludes(
|
||||||
|
publicSplit,
|
||||||
|
"public split runs acceptance environment contract from copied tree",
|
||||||
|
'(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["commit SHA fallback", /process\.env\.DISASMER_ACCEPTANCE_COMMIT \|\| commandOutput\("git", \["rev-parse", "HEAD"\]\)/],
|
||||||
|
["tree status", /tree_status: \(commandOutput\("git", \["status", "--short"\]\) \|\| ""\)[\s\S]*\.filter\(Boolean\)/],
|
||||||
|
["OS report", /platform: os\.platform\(\)[\s\S]*kernel: os\.release\(\)/],
|
||||||
|
["Rust report", /rustc: commandOutput\("rustc", \["--version"\]\)/],
|
||||||
|
["Node report", /version: process\.version/],
|
||||||
|
["Podman report", /function podmanReport\(\)/],
|
||||||
|
["Postgres report", /postgres: \{[\s\S]*commandOutput\("postgres", \["--version"\]\) \|\|[\s\S]*commandOutput\("psql", \["--version"\]\)/],
|
||||||
|
["browser harness report", /browser_harness:/],
|
||||||
|
["VS Code harness report", /vscode_harness:/],
|
||||||
|
["Windows validation report", /windows_validation: process\.env\.DISASMER_WINDOWS_VALIDATION \|\| "not-run"/],
|
||||||
|
]) {
|
||||||
|
expect(acceptanceReport, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["acceptance report validates Podman incomplete state", /assertPodmanReport/],
|
||||||
|
["acceptance report validates Windows not-run", /assertReport\(runReport\(mode\), mode, "not-run"\)/],
|
||||||
|
["acceptance report validates Windows runner mode", /DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner"/],
|
||||||
|
]) {
|
||||||
|
expect(acceptanceReportSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(readme, "README documents public acceptance script", /scripts\/acceptance-public\.sh/);
|
||||||
|
expect(readme, "README documents private acceptance script", /scripts\/acceptance-private\.sh/);
|
||||||
|
expect(readme, "README documents rootless Podman incomplete handling", /Podman backend behavior is marked `incomplete`/);
|
||||||
|
expect(readme, "README documents Postgres discovery in environment report", /Podman\/Postgres discovery/);
|
||||||
|
expect(readme, "README documents manual Windows validation", /manual `Windows validation`\s+workflow/);
|
||||||
|
|
||||||
|
expect(windowsWorkflow, "Windows workflow is manual", /workflow_dispatch/);
|
||||||
|
expect(windowsWorkflow, "Windows workflow uses intermittent Windows runner", /runs-on:\s*windows/);
|
||||||
|
expect(windowsWorkflow, "Windows workflow writes acceptance report", /node scripts\/acceptance-report\.js windows/);
|
||||||
|
|
||||||
|
if (publicDryrunServiceSmoke && publicDryrunDeployPrep && publicDryrunSystemd && publicDryrunRunbook) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["service smoke requires external service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR is required/],
|
||||||
|
["service smoke requires OIDC test issuer", /DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL/],
|
||||||
|
["service smoke loads release manifest", /public-release-manifest\.json/],
|
||||||
|
["service smoke rejects stale release manifest", /manifest\.source_commit[\s\S]*expectedCommit/],
|
||||||
|
["service smoke records source commit", /source_commit: release\.sourceCommit/],
|
||||||
|
["service smoke records release name", /release_name: release\.releaseName/],
|
||||||
|
["service smoke connects through public domain", /addr\.host[\s\S]*serviceHost/],
|
||||||
|
["service smoke verifies DNS state", /\["not-published", "published"\]\.includes\(dnsPublicationState\)/],
|
||||||
|
["service smoke performs OIDC login", /type: "oidc_browser_login"/],
|
||||||
|
["service smoke creates project", /type: "create_project"/],
|
||||||
|
["service smoke enrolls node", /type: "create_node_enrollment_token"[\s\S]*exchange_node_enrollment_token/],
|
||||||
|
["service smoke starts user node process", /type: "start_user_node_process"/],
|
||||||
|
["service smoke records task", /type: "record_user_node_task_completion"/],
|
||||||
|
["service smoke reads debug state", /type: "debug_process"/],
|
||||||
|
["service smoke reads artifact metadata", /type: "artifact_metadata"/],
|
||||||
|
["service smoke creates download link", /type: "create_artifact_download_link"/],
|
||||||
|
["service smoke records observability", /type: "observability_snapshot"/],
|
||||||
|
["service smoke records private hosted coordinator", /operator_implementation:[\s\S]*"private-hosted-coordinator"/],
|
||||||
|
["service smoke writes evidence report", /public-release-dryrun-service\.json/],
|
||||||
|
]) {
|
||||||
|
expect(publicDryrunServiceSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, source, pattern] of [
|
||||||
|
["deployment prep builds hosted service release", publicDryrunDeployPrep, /cargo"[\s\S]*"build"[\s\S]*"--release"[\s\S]*"private\/hosted-policy\/Cargo\.toml"[\s\S]*"disasmer-hosted-service"/],
|
||||||
|
["deployment prep stages systemd unit", publicDryrunDeployPrep, /disasmer-public-release-dryrun\.service/],
|
||||||
|
["deployment prep writes manifest", publicDryrunDeployPrep, /deployment-manifest\.json/],
|
||||||
|
["deployment prep records private hosted coordinator", publicDryrunDeployPrep, /operator_implementation:[\s\S]*"private-hosted-coordinator"/],
|
||||||
|
["deployment prep records service address", publicDryrunDeployPrep, /service_addr:[\s\S]*`\$\{serviceHost\}:\$\{servicePort\}`/],
|
||||||
|
["deployment prep records DNS state", publicDryrunDeployPrep, /dns_publication_state: dnsPublicationState/],
|
||||||
|
["deployment prep records service smoke command", publicDryrunDeployPrep, /public-release-dryrun-service-smoke\.js/],
|
||||||
|
["systemd binds public TCP 9443", publicDryrunSystemd, /--listen 0\.0\.0\.0:9443/],
|
||||||
|
["systemd avoids privileged port capability", publicDryrunSystemd, /NoNewPrivileges=true/],
|
||||||
|
["systemd uses dedicated user", publicDryrunSystemd, /User=disasmer[\s\S]*Group=disasmer/],
|
||||||
|
["runbook says externally reachable", publicDryrunRunbook, /externally reachable host/],
|
||||||
|
["runbook documents DNS pending fallback", publicDryrunRunbook, /Until the `disasmer\.michelpaulissen\.com` DNS record is deployed/],
|
||||||
|
["runbook gives hosts entry", publicDryrunRunbook, /<deployment-ip> disasmer\.michelpaulissen\.com/],
|
||||||
|
]) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicOperatorCompatSmoke && hostedService) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["hosted service embeds private coordinator runtime", /private_coordinator: CoordinatorService/],
|
||||||
|
["hosted service parses public client protocol requests", /serde_json::from_str::<CoordinatorRequest>/],
|
||||||
|
["hosted service delegates public requests", /handle_public_request/],
|
||||||
|
["hosted service marks public protocol sessions", /ConnectionProtocol::Public/],
|
||||||
|
["hosted service seeds public projects from hosted auth", /seed_public_project/],
|
||||||
|
["hosted service seeds public enrollment grants", /seed_public_node_enrollment_grant/],
|
||||||
|
]) {
|
||||||
|
expect(hostedService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["compat smoke starts hosted service", /disasmer-hosted-service/],
|
||||||
|
["compat smoke creates hosted project", /type: "create_project"/],
|
||||||
|
["compat smoke creates hosted enrollment grant", /type: "create_node_enrollment_token"/],
|
||||||
|
["compat smoke runs public CLI attach", /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/],
|
||||||
|
["compat smoke verifies public enrollment exchange", /node_enrollment_exchanged/],
|
||||||
|
["compat smoke runs public node runtime", /"disasmer-node"/],
|
||||||
|
["compat smoke launches through coordinator assignment", /type: "launch_task"/],
|
||||||
|
["compat smoke verifies assignment polling", /poll_task_assignment/],
|
||||||
|
["compat smoke verifies public node metadata", /vfs_metadata_recorded/],
|
||||||
|
["compat smoke writes evidence report", /public-operator-compat\.json/],
|
||||||
|
]) {
|
||||||
|
expect(publicOperatorCompatSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/],
|
||||||
|
["e2e runner requires public domain service address", /serviceAddr[\s\S]*serviceHost/],
|
||||||
|
["e2e runner downloads release assets", /downloadReleaseAssets/],
|
||||||
|
["e2e runner verifies release checksums", /verifyChecksums/],
|
||||||
|
["e2e runner clones public repo", /git"[\s\S]*"clone"[\s\S]*publicRepositoryUrl/],
|
||||||
|
["e2e runner uses default operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
|
||||||
|
["e2e runner completes browser login", /--complete-browser-code/],
|
||||||
|
["e2e runner attaches user node", /node"[\s\S]*"attach"/],
|
||||||
|
["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/],
|
||||||
|
["e2e runner launches through coordinator assignment", /type: "launch_task"/],
|
||||||
|
["e2e runner verifies public assignment polling", /worker_assignment_poll_protocol/],
|
||||||
|
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
|
||||||
|
["e2e runner records standalone public coordinator", /public_coordinator_operator_implementation/],
|
||||||
|
["e2e runner verifies task events", /list_task_events/],
|
||||||
|
["e2e runner creates artifact download link", /create_artifact_download_link/],
|
||||||
|
["e2e runner verifies VS Code debugger", /vscode-f5-smoke\.js/],
|
||||||
|
["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/],
|
||||||
|
]) {
|
||||||
|
expect(publicDryrunE2e, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["final verifier requires public release manifest", /public-release-manifest\.json/],
|
||||||
|
["final verifier requires Forgejo release evidence", /public-release-dryrun-forgejo-release\.json/],
|
||||||
|
["final verifier requires deployment manifest", /deployment-manifest\.json/],
|
||||||
|
["final verifier requires service smoke evidence", /public-release-dryrun-service\.json/],
|
||||||
|
["final verifier requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final verifier requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final verifier requires public operator compatibility evidence", /public-operator-compat\.json/],
|
||||||
|
["final verifier requires current public operator source", /compat\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final verifier requires current public operator release", /compat\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final verifier requires public coordinator compatibility evidence", /public-coordinator-compat\.json/],
|
||||||
|
["final verifier requires current public coordinator source", /publicCoordinator\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final verifier requires current public coordinator release", /publicCoordinator\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final verifier requires public e2e evidence", /public-release-dryrun-e2e\.json/],
|
||||||
|
["final verifier records both coordinator validations", /coordinator_validation/],
|
||||||
|
["final verifier writes final evidence", /public-release-dryrun-final\.json/],
|
||||||
|
]) {
|
||||||
|
expect(finalDryrunEvidence, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Acceptance environment contract smoke passed");
|
||||||
236
scripts/acceptance-evidence-contract-smoke.js
Executable file
236
scripts/acceptance-evidence-contract-smoke.js
Executable file
|
|
@ -0,0 +1,236 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing acceptance evidence guard: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectGate(script, gateName) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||||
|
`${gateName} must run acceptance-evidence-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase2 = read("acceptance_criteria_phase2.md");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const localServicesSmoke = read("scripts/local-services-smoke.js");
|
||||||
|
const nodeAttachSmoke = read("scripts/node-attach-smoke.js");
|
||||||
|
const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js");
|
||||||
|
const vscodeF5Smoke = read("scripts/vscode-f5-smoke.js");
|
||||||
|
const dapSmoke = read("scripts/dap-smoke.js");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const artifactExportSmoke = read("scripts/artifact-export-smoke.js");
|
||||||
|
const selfHostedCoordinatorSmoke = read("scripts/self-hosted-coordinator-smoke.js");
|
||||||
|
const publicLocalDemoMatrix = read("scripts/public-local-demo-matrix-smoke.js");
|
||||||
|
const publicOperatorCompatSmoke = fs.existsSync(
|
||||||
|
path.join(repo, "private/hosted-policy/scripts/public-operator-compat-smoke.js")
|
||||||
|
)
|
||||||
|
? read("private/hosted-policy/scripts/public-operator-compat-smoke.js")
|
||||||
|
: null;
|
||||||
|
const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js");
|
||||||
|
const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
phase2,
|
||||||
|
"phase 2 rejects type-only acceptance",
|
||||||
|
/- \[x\] \*\*Passed:\*\* A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists\./
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [gateName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["private acceptance", privateAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
expectGate(script, gateName);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(publicAcceptance, "public gate includes unit coverage", /cargo test --workspace/);
|
||||||
|
expect(publicAcceptance, "public gate includes binary build coverage", /cargo build --workspace --bins/);
|
||||||
|
expect(privateAcceptance, "private gate includes hosted unit coverage", /cargo test --manifest-path private\/hosted-policy\/Cargo\.toml/);
|
||||||
|
expect(publicSplit, "public split includes copied-tree unit coverage", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||||
|
expect(publicSplit, "public split includes copied-tree binary build coverage", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||||
|
|
||||||
|
for (const [gateName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
for (const smoke of [
|
||||||
|
"scripts/local-services-smoke.js",
|
||||||
|
"scripts/node-attach-smoke.js",
|
||||||
|
"scripts/cli-local-run-smoke.js",
|
||||||
|
"scripts/vscode-f5-smoke.js",
|
||||||
|
"scripts/dap-smoke.js",
|
||||||
|
"scripts/artifact-download-smoke.js",
|
||||||
|
"scripts/artifact-export-smoke.js",
|
||||||
|
"scripts/public-local-demo-matrix-smoke.js",
|
||||||
|
]) {
|
||||||
|
assert(script.includes(`node ${smoke}`), `${gateName} must run boundary smoke ${smoke}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const smoke of [
|
||||||
|
"private/hosted-policy/scripts/hosted-deployment-smoke.js",
|
||||||
|
"private/hosted-policy/scripts/hosted-community-smoke.js",
|
||||||
|
"private/hosted-policy/scripts/public-operator-compat-smoke.js",
|
||||||
|
"private/hosted-policy/scripts/postgres-durable-smoke.js",
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes(`node ${smoke}`),
|
||||||
|
`private acceptance must run hosted boundary smoke ${smoke}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||||
|
"private acceptance must run standalone public coordinator smoke"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, source, patterns] of [
|
||||||
|
[
|
||||||
|
"local services",
|
||||||
|
localServicesSmoke,
|
||||||
|
[/cp\.spawn/, /disasmer-coordinator/, /type: "create_node_enrollment_grant"/, /disasmer-node/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"node attach",
|
||||||
|
nodeAttachSmoke,
|
||||||
|
[/cp\.spawn/, /"node"[\s\S]*"attach"/, /used_enrollment_exchange/, /runAttachedNodeWork/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"CLI local run",
|
||||||
|
cliLocalRunSmoke,
|
||||||
|
[/cp\.spawn/, /disasmer[\s\S]*run/, /cli_process_started_node_process[\s\S]*true/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"VS Code F5",
|
||||||
|
vscodeF5Smoke,
|
||||||
|
[
|
||||||
|
/runtimeBackend[\s\S]*local-services/,
|
||||||
|
/coordinator_task_events[\s\S]*value === 1/,
|
||||||
|
/Source Locals/,
|
||||||
|
/Task Args and Handles/,
|
||||||
|
/unavailable-local-diagnostic/,
|
||||||
|
/TaskHandle/,
|
||||||
|
/linux_thread/,
|
||||||
|
/linux_artifact/,
|
||||||
|
/command_spec/,
|
||||||
|
/stdout_tail/,
|
||||||
|
/stderr_tail/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"DAP",
|
||||||
|
dapSmoke,
|
||||||
|
[
|
||||||
|
/runtimeBackend: "local-services"/,
|
||||||
|
/threads[\s\S]*compile linux/,
|
||||||
|
/send\("attach"/,
|
||||||
|
/Source Locals/,
|
||||||
|
/return_value/,
|
||||||
|
/TaskHandle/,
|
||||||
|
/linux_thread/,
|
||||||
|
/linux_artifact/,
|
||||||
|
/vfs_mounts/,
|
||||||
|
/command_spec/,
|
||||||
|
/stdout_tail/,
|
||||||
|
/stderr_tail/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"artifact download",
|
||||||
|
artifactDownloadSmoke,
|
||||||
|
[/create_artifact_download_link/, /open_artifact_download_stream/, /crossTenantOpen/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"artifact export",
|
||||||
|
artifactExportSmoke,
|
||||||
|
[/export_artifact_to_node/, /node-export-receiver/, /coordinator_bulk_relay_allowed[\s\S]*false/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"standalone public coordinator",
|
||||||
|
selfHostedCoordinatorSmoke,
|
||||||
|
[/disasmer-coordinator/, /standalone-public-coordinator/, /public-coordinator-compat\.json/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"public local demo matrix",
|
||||||
|
publicLocalDemoMatrix,
|
||||||
|
[/scripts\/cli-install-smoke\.js/, /scripts\/node-attach-smoke\.js/, /scripts\/vscode-f5-smoke\.js/],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicOperatorCompatSmoke) {
|
||||||
|
for (const pattern of [
|
||||||
|
/disasmer-hosted-service/,
|
||||||
|
/"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/,
|
||||||
|
/node_enrollment_exchanged/,
|
||||||
|
/"disasmer-node"/,
|
||||||
|
/node_capabilities_recorded/,
|
||||||
|
/task_launched/,
|
||||||
|
/poll_task_assignment/,
|
||||||
|
/debug_command/,
|
||||||
|
/task_log_recorded/,
|
||||||
|
/vfs_metadata_recorded/,
|
||||||
|
/public-operator-compat\.json/,
|
||||||
|
]) {
|
||||||
|
expect(publicOperatorCompatSmoke, "public operator compatibility", pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of [
|
||||||
|
/DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/,
|
||||||
|
/downloadReleaseAssets/,
|
||||||
|
/verifyChecksums/,
|
||||||
|
/git"[\s\S]*"clone"/,
|
||||||
|
/defaultLoginPlan\.coordinator/,
|
||||||
|
/--complete-browser-code/,
|
||||||
|
/node_enrollment_exchanged/,
|
||||||
|
/disasmerNode/,
|
||||||
|
/launch_task/,
|
||||||
|
/worker_assignment_poll_verified/,
|
||||||
|
/validateStandalonePublicCoordinator/,
|
||||||
|
/public_coordinator_validated/,
|
||||||
|
/standalone-public-coordinator/,
|
||||||
|
/task_recorded/,
|
||||||
|
/list_task_events/,
|
||||||
|
/create_artifact_download_link/,
|
||||||
|
/vscode-f5-smoke\.js/,
|
||||||
|
/downloaded_release_assets: true/,
|
||||||
|
/vscode_debugger_verified: true/,
|
||||||
|
/artifact_download_or_export_verified: true/,
|
||||||
|
/public-release-dryrun-e2e\.json/,
|
||||||
|
]) {
|
||||||
|
expect(publicDryrunE2e, "public release e2e evidence", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of [
|
||||||
|
/public-release-dryrun-forgejo-release\.json/,
|
||||||
|
/deployment-manifest\.json/,
|
||||||
|
/public-release-dryrun-service\.json/,
|
||||||
|
/public-operator-compat\.json/,
|
||||||
|
/public-coordinator-compat\.json/,
|
||||||
|
/public-release-dryrun-e2e\.json/,
|
||||||
|
/coordinator_validation/,
|
||||||
|
/downloaded_release_assets/,
|
||||||
|
/vscode_debugger_verified/,
|
||||||
|
/artifact_download_or_export_verified/,
|
||||||
|
/public-release-dryrun-final\.json/,
|
||||||
|
]) {
|
||||||
|
expect(finalDryrunEvidence, "public release final evidence", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Acceptance evidence contract smoke passed");
|
||||||
30
scripts/acceptance-private.sh
Executable file
30
scripts/acceptance-private.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo"
|
||||||
|
|
||||||
|
node scripts/acceptance-report.js private
|
||||||
|
node scripts/acceptance-report-smoke.js
|
||||||
|
node scripts/acceptance-doc-contract-smoke.js
|
||||||
|
node scripts/acceptance-environment-contract-smoke.js
|
||||||
|
node scripts/acceptance-evidence-contract-smoke.js
|
||||||
|
node scripts/public-private-boundary-smoke.js
|
||||||
|
node scripts/release-blocker-smoke.js
|
||||||
|
node scripts/resource-metering-contract-smoke.js
|
||||||
|
node scripts/hostile-input-contract-smoke.js
|
||||||
|
node scripts/tenant-isolation-contract-smoke.js
|
||||||
|
scripts/release-source-scan.sh
|
||||||
|
cargo test --manifest-path private/hosted-policy/Cargo.toml
|
||||||
|
node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js
|
||||||
|
node private/hosted-policy/scripts/hosted-deployment-smoke.js
|
||||||
|
node private/hosted-policy/scripts/hosted-community-smoke.js
|
||||||
|
node private/hosted-policy/scripts/public-operator-compat-smoke.js
|
||||||
|
node scripts/self-hosted-coordinator-smoke.js
|
||||||
|
node private/hosted-policy/scripts/postgres-durable-smoke.js
|
||||||
|
if [[ -n "${DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR:-}" ]]; then
|
||||||
|
node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js
|
||||||
|
fi
|
||||||
|
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then
|
||||||
|
node scripts/public-release-dryrun-final-evidence.js
|
||||||
|
fi
|
||||||
64
scripts/acceptance-public.sh
Executable file
64
scripts/acceptance-public.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo"
|
||||||
|
|
||||||
|
node scripts/acceptance-report.js public
|
||||||
|
node scripts/acceptance-report-smoke.js
|
||||||
|
node scripts/acceptance-doc-contract-smoke.js
|
||||||
|
node scripts/acceptance-environment-contract-smoke.js
|
||||||
|
node scripts/acceptance-evidence-contract-smoke.js
|
||||||
|
node scripts/public-private-boundary-smoke.js
|
||||||
|
node scripts/release-blocker-smoke.js
|
||||||
|
node scripts/resource-metering-contract-smoke.js
|
||||||
|
node scripts/hostile-input-contract-smoke.js
|
||||||
|
node scripts/tenant-isolation-contract-smoke.js
|
||||||
|
node scripts/public-story-contract-smoke.js
|
||||||
|
node scripts/public-release-dryrun-contract-smoke.js
|
||||||
|
node scripts/public-browser-login-contract-smoke.js
|
||||||
|
node scripts/self-hosted-coordinator-smoke.js
|
||||||
|
node scripts/public-local-demo-matrix-smoke.js
|
||||||
|
scripts/release-source-scan.sh
|
||||||
|
node scripts/prepare-public-release-dryrun.js
|
||||||
|
if [[ "${DISASMER_PUBLIC_RELEASE_PREFLIGHT:-}" == "1" ]]; then
|
||||||
|
node scripts/public-release-dryrun-preflight.js
|
||||||
|
fi
|
||||||
|
if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then
|
||||||
|
node scripts/publish-public-release-dryrun.js
|
||||||
|
fi
|
||||||
|
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then
|
||||||
|
node scripts/public-release-dryrun-e2e.js
|
||||||
|
fi
|
||||||
|
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then
|
||||||
|
node scripts/public-release-dryrun-final-evidence.js
|
||||||
|
fi
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo test --workspace
|
||||||
|
cargo build --workspace --bins
|
||||||
|
node scripts/docs-smoke.js
|
||||||
|
node scripts/cli-login-smoke.js
|
||||||
|
node scripts/cli-browser-login-flow-smoke.js
|
||||||
|
node scripts/cli-install-smoke.js
|
||||||
|
node scripts/user-session-token-boundary-smoke.js
|
||||||
|
node scripts/sdk-spawn-runtime-smoke.js
|
||||||
|
node scripts/node-lifecycle-contract-smoke.js
|
||||||
|
node scripts/wasmtime-node-smoke.js
|
||||||
|
node scripts/podman-backend-smoke.js
|
||||||
|
node scripts/vscode-extension-smoke.js
|
||||||
|
node scripts/vscode-f5-smoke.js
|
||||||
|
node scripts/node-attach-smoke.js
|
||||||
|
node scripts/local-services-smoke.js
|
||||||
|
node scripts/cancellation-smoke.js
|
||||||
|
node scripts/cli-local-run-smoke.js
|
||||||
|
node scripts/artifact-download-smoke.js
|
||||||
|
node scripts/artifact-export-smoke.js
|
||||||
|
node scripts/operator-panel-smoke.js
|
||||||
|
node scripts/source-preparation-smoke.js
|
||||||
|
node scripts/scheduler-placement-smoke.js
|
||||||
|
node scripts/windows-best-effort-smoke.js
|
||||||
|
node scripts/windows-validation-contract-smoke.js
|
||||||
|
node scripts/quic-smoke.js
|
||||||
|
node scripts/dap-smoke.js
|
||||||
|
node scripts/flagship-demo-smoke.js
|
||||||
|
scripts/verify-public-split.sh
|
||||||
113
scripts/acceptance-report-smoke.js
Normal file
113
scripts/acceptance-report-smoke.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function runReport(mode, env = {}) {
|
||||||
|
const output = cp.execFileSync(
|
||||||
|
"node",
|
||||||
|
["scripts/acceptance-report.js", mode],
|
||||||
|
{
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const report = JSON.parse(output);
|
||||||
|
const persistedPath = path.join(
|
||||||
|
repo,
|
||||||
|
"target",
|
||||||
|
"acceptance",
|
||||||
|
`${mode}-environment.json`
|
||||||
|
);
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8"));
|
||||||
|
assert.deepStrictEqual(persisted, report, `${mode} report was not persisted`);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertString(value, name) {
|
||||||
|
assert.strictEqual(typeof value, "string", `${name} must be a string`);
|
||||||
|
assert(value.length > 0, `${name} must not be empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNullableString(value, name) {
|
||||||
|
if (value === null) return;
|
||||||
|
assertString(value, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPodmanReport(podman) {
|
||||||
|
assert(podman && typeof podman === "object", "podman report must be an object");
|
||||||
|
assert(
|
||||||
|
["available", "incomplete"].includes(podman.status),
|
||||||
|
"podman.status must be available or incomplete"
|
||||||
|
);
|
||||||
|
assertNullableString(podman.version, "podman.version");
|
||||||
|
assertNullableString(podman.rootless, "podman.rootless");
|
||||||
|
assertNullableString(podman.incomplete_reason, "podman.incomplete_reason");
|
||||||
|
if (podman.status === "available") {
|
||||||
|
assertString(podman.version, "podman.version");
|
||||||
|
assert.strictEqual(podman.rootless, "true");
|
||||||
|
assert.strictEqual(podman.incomplete_reason, null);
|
||||||
|
} else {
|
||||||
|
assertString(podman.incomplete_reason, "podman.incomplete_reason");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReport(report, mode, expectedWindowsValidation) {
|
||||||
|
assert.strictEqual(report.kind, "disasmer_acceptance_environment");
|
||||||
|
assert.strictEqual(report.mode, mode);
|
||||||
|
assert.match(report.generated_at, /^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
assert.match(report.commit, /^[0-9a-f]{40}$/);
|
||||||
|
assert(Array.isArray(report.tree_status), "tree_status must be an array");
|
||||||
|
|
||||||
|
assertString(report.os.platform, "os.platform");
|
||||||
|
assertString(report.os.release, "os.release");
|
||||||
|
assertString(report.os.kernel, "os.kernel");
|
||||||
|
assertString(report.os.arch, "os.arch");
|
||||||
|
|
||||||
|
assertString(report.rust.rustc, "rust.rustc");
|
||||||
|
assertString(report.rust.cargo, "rust.cargo");
|
||||||
|
assertString(report.node.version, "node.version");
|
||||||
|
assert(report.node.version.startsWith("v"), "node.version must be Node.js style");
|
||||||
|
|
||||||
|
assertPodmanReport(report.podman);
|
||||||
|
assertNullableString(report.postgres.version, "postgres.version");
|
||||||
|
assertNullableString(report.browser_harness.version, "browser_harness.version");
|
||||||
|
assertNullableString(report.browser_harness.command, "browser_harness.command");
|
||||||
|
assertString(report.browser_harness.configured, "browser_harness.configured");
|
||||||
|
|
||||||
|
assert(Array.isArray(report.vscode_harness.smokes), "vscode smokes must be listed");
|
||||||
|
assert(
|
||||||
|
report.vscode_harness.smokes.includes("scripts/vscode-extension-smoke.js"),
|
||||||
|
"VS Code extension smoke must be recorded"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
report.vscode_harness.smokes.includes("scripts/vscode-f5-smoke.js"),
|
||||||
|
"VS Code F5 smoke must be recorded"
|
||||||
|
);
|
||||||
|
assertString(report.vscode_harness.engine, "vscode_harness.engine");
|
||||||
|
assertString(
|
||||||
|
report.vscode_harness.extension_version,
|
||||||
|
"vscode_harness.extension_version"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(report.windows_validation, expectedWindowsValidation);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mode of ["public", "private"]) {
|
||||||
|
assertReport(runReport(mode), mode, "not-run");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertReport(
|
||||||
|
runReport("windows", {
|
||||||
|
DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner",
|
||||||
|
}),
|
||||||
|
"windows",
|
||||||
|
"forgejo-windows-runner"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Acceptance report smoke passed");
|
||||||
139
scripts/acceptance-report.js
Executable file
139
scripts/acceptance-report.js
Executable file
|
|
@ -0,0 +1,139 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const mode = process.argv[2] || "public";
|
||||||
|
|
||||||
|
function commandOutput(command, args = []) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageJson() {
|
||||||
|
return JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repo, "vscode-extension/package.json"), "utf8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCommandOutput(candidates) {
|
||||||
|
for (const [command, args] of candidates) {
|
||||||
|
const output = commandOutput(command, args);
|
||||||
|
if (output) {
|
||||||
|
return { command, version: output };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function podmanReport() {
|
||||||
|
const version = commandOutput("podman", ["--version"]);
|
||||||
|
if (!version) {
|
||||||
|
return {
|
||||||
|
status: "incomplete",
|
||||||
|
version: null,
|
||||||
|
rootless: null,
|
||||||
|
incomplete_reason: "podman command is unavailable"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rootless = commandOutput("podman", [
|
||||||
|
"info",
|
||||||
|
"--format",
|
||||||
|
"{{.Host.Security.Rootless}}"
|
||||||
|
]);
|
||||||
|
if (!rootless) {
|
||||||
|
return {
|
||||||
|
status: "incomplete",
|
||||||
|
version,
|
||||||
|
rootless: null,
|
||||||
|
incomplete_reason: "podman info did not report rootless status"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rootless !== "true") {
|
||||||
|
return {
|
||||||
|
status: "incomplete",
|
||||||
|
version,
|
||||||
|
rootless,
|
||||||
|
incomplete_reason: "podman is not running in rootless mode"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "available",
|
||||||
|
version,
|
||||||
|
rootless,
|
||||||
|
incomplete_reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionPackage = packageJson();
|
||||||
|
const sourceCommit =
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]);
|
||||||
|
const browserVersion = firstCommandOutput([
|
||||||
|
["chromium", ["--version"]],
|
||||||
|
["chromium-browser", ["--version"]],
|
||||||
|
["google-chrome", ["--version"]],
|
||||||
|
["firefox", ["--version"]]
|
||||||
|
]);
|
||||||
|
const report = {
|
||||||
|
kind: "disasmer_acceptance_environment",
|
||||||
|
mode,
|
||||||
|
commit: sourceCommit,
|
||||||
|
tree_status: (commandOutput("git", ["status", "--short"]) || "")
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean),
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
os: {
|
||||||
|
platform: os.platform(),
|
||||||
|
release: os.release(),
|
||||||
|
kernel: os.release(),
|
||||||
|
arch: os.arch()
|
||||||
|
},
|
||||||
|
rust: {
|
||||||
|
rustc: commandOutput("rustc", ["--version"]),
|
||||||
|
cargo: commandOutput("cargo", ["--version"])
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
version: process.version
|
||||||
|
},
|
||||||
|
podman: podmanReport(),
|
||||||
|
postgres: {
|
||||||
|
version:
|
||||||
|
commandOutput("postgres", ["--version"]) ||
|
||||||
|
commandOutput("psql", ["--version"])
|
||||||
|
},
|
||||||
|
browser_harness: {
|
||||||
|
version: browserVersion && browserVersion.version,
|
||||||
|
command: browserVersion && browserVersion.command,
|
||||||
|
configured: process.env.DISASMER_BROWSER_HARNESS || "not-configured"
|
||||||
|
},
|
||||||
|
vscode_harness: {
|
||||||
|
smokes: [
|
||||||
|
"scripts/vscode-extension-smoke.js",
|
||||||
|
"scripts/vscode-f5-smoke.js"
|
||||||
|
],
|
||||||
|
engine: extensionPackage.engines && extensionPackage.engines.vscode,
|
||||||
|
extension_version: extensionPackage.version
|
||||||
|
},
|
||||||
|
windows_validation: process.env.DISASMER_WINDOWS_VALIDATION || "not-run"
|
||||||
|
};
|
||||||
|
|
||||||
|
const outDir = path.join(repo, "target/acceptance");
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, `${mode}-environment.json`),
|
||||||
|
`${JSON.stringify(report, null, 2)}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
414
scripts/artifact-download-smoke.js
Executable file
414
scripts/artifact-download-smoke.js
Executable file
|
|
@ -0,0 +1,414 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNode(addr) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-download",
|
||||||
|
"--process",
|
||||||
|
"vp-download",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--project",
|
||||||
|
project,
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/download-output.txt",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadNodeCapabilities() {
|
||||||
|
return {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "VfsArtifacts"],
|
||||||
|
environment_backends: [],
|
||||||
|
source_providers: ["filesystem"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const report = await runNode(addr);
|
||||||
|
assert.strictEqual(report.node_status, "completed");
|
||||||
|
assert.strictEqual(report.status_code, 0);
|
||||||
|
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/download-output.txt");
|
||||||
|
|
||||||
|
const disconnectedReport = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "node-download",
|
||||||
|
capabilities: downloadNodeCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: false,
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(disconnectedReport.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const disconnectedLink = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "disconnected",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60,
|
||||||
|
});
|
||||||
|
assert.strictEqual(disconnectedLink.type, "error");
|
||||||
|
assert.match(disconnectedLink.message, /direct connectivity unavailable/);
|
||||||
|
|
||||||
|
const connectedReport = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "node-download",
|
||||||
|
capabilities: downloadNodeCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(connectedReport.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const link = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60,
|
||||||
|
});
|
||||||
|
assert.strictEqual(link.type, "artifact_download_link");
|
||||||
|
assert.strictEqual(link.link.tenant, "tenant");
|
||||||
|
assert.strictEqual(link.link.project, "project");
|
||||||
|
assert.strictEqual(link.link.process, "vp-download");
|
||||||
|
assert.deepStrictEqual(link.link.actor, { User: "user" });
|
||||||
|
assert.match(link.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
|
||||||
|
assert.strictEqual(link.link.expires_at_epoch_seconds, 70);
|
||||||
|
assert.match(link.link.url_path, /\/artifacts\/tenant\/project\/vp-download\/download-output\.txt$/);
|
||||||
|
assert.deepStrictEqual(link.link.source, { RetainedNode: "node-download" });
|
||||||
|
|
||||||
|
const crossTenant = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "other",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenant.type, "error");
|
||||||
|
assert.match(crossTenant.message, /tenant mismatch/);
|
||||||
|
|
||||||
|
const crossProject = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "other-project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossProject.type, "error");
|
||||||
|
assert.match(crossProject.message, /project mismatch/);
|
||||||
|
|
||||||
|
const crossTenantOpen = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "other",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenantOpen.type, "error");
|
||||||
|
assert.match(crossTenantOpen.message, /tenant mismatch/);
|
||||||
|
|
||||||
|
const crossProjectOpen = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "other-project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossProjectOpen.type, "error");
|
||||||
|
assert.match(crossProjectOpen.message, /project mismatch/);
|
||||||
|
|
||||||
|
const guessed = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: "sha256:guessed",
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(guessed.type, "error");
|
||||||
|
assert.match(guessed.message, /token is invalid/);
|
||||||
|
|
||||||
|
const crossActorOpen = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "other-user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossActorOpen.type, "error");
|
||||||
|
assert.match(crossActorOpen.message, /token is invalid/);
|
||||||
|
|
||||||
|
const expired = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 71,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(expired.type, "error");
|
||||||
|
assert.match(expired.message, /expired/);
|
||||||
|
|
||||||
|
await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "node-download",
|
||||||
|
capabilities: downloadNodeCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: false,
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
const disconnectedOpen = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(disconnectedOpen.type, "error");
|
||||||
|
assert.match(disconnectedOpen.message, /direct connectivity unavailable/);
|
||||||
|
|
||||||
|
await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "node-download",
|
||||||
|
capabilities: downloadNodeCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 16,
|
||||||
|
});
|
||||||
|
assert.strictEqual(stream.type, "artifact_download_stream");
|
||||||
|
assert.strictEqual(stream.streamed_bytes, 16);
|
||||||
|
assert.strictEqual(stream.charged_download_bytes, 16);
|
||||||
|
assert.strictEqual(stream.link.artifact, "download-output.txt");
|
||||||
|
|
||||||
|
const crossActorRevoke = await send(addr, {
|
||||||
|
type: "revoke_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "other-user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossActorRevoke.type, "error");
|
||||||
|
assert.match(crossActorRevoke.message, /token is invalid/);
|
||||||
|
|
||||||
|
const revoked = await send(addr, {
|
||||||
|
type: "revoke_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
});
|
||||||
|
assert.strictEqual(revoked.type, "artifact_download_link_revoked");
|
||||||
|
assert.strictEqual(revoked.link.scoped_token_digest, link.link.scoped_token_digest);
|
||||||
|
|
||||||
|
const revokedOpen = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "download-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "nonce",
|
||||||
|
token_digest: link.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 12,
|
||||||
|
chunk_bytes: 1,
|
||||||
|
});
|
||||||
|
assert.strictEqual(revokedOpen.type, "error");
|
||||||
|
assert.match(revokedOpen.message, /revoked/);
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Artifact download smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
244
scripts/artifact-export-smoke.js
Normal file
244
scripts/artifact-export-smoke.js
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProducerNode(addr) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-export-source",
|
||||||
|
"--process",
|
||||||
|
"vp-export",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--project",
|
||||||
|
project,
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/export-output.txt",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`producer node failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`producer node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCapabilities() {
|
||||||
|
return {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "VfsArtifacts"],
|
||||||
|
environment_backends: [],
|
||||||
|
source_providers: ["filesystem"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportNode(addr, node, { directConnectivity = true, online = true } = {}) {
|
||||||
|
const response = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node,
|
||||||
|
capabilities: nodeCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: directConnectivity,
|
||||||
|
online,
|
||||||
|
});
|
||||||
|
assert.strictEqual(response.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(response.node, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const produced = await runProducerNode(addr);
|
||||||
|
assert.strictEqual(produced.node_status, "completed");
|
||||||
|
assert.strictEqual(produced.coordinator_response.type, "task_recorded");
|
||||||
|
assert.strictEqual(produced.staged_artifact.path, "/vfs/artifacts/export-output.txt");
|
||||||
|
|
||||||
|
await reportNode(addr, "node-export-source");
|
||||||
|
|
||||||
|
const attachedReceiver = await send(addr, {
|
||||||
|
type: "attach_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "node-export-receiver",
|
||||||
|
public_key: "node-export-receiver-public-key",
|
||||||
|
});
|
||||||
|
assert.strictEqual(attachedReceiver.type, "node_attached");
|
||||||
|
await reportNode(addr, "node-export-receiver");
|
||||||
|
|
||||||
|
const exportPlan = await send(addr, {
|
||||||
|
type: "export_artifact_to_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "export-output.txt",
|
||||||
|
receiver_node: "node-export-receiver",
|
||||||
|
direct_connectivity: true,
|
||||||
|
failure_reason: "",
|
||||||
|
});
|
||||||
|
assert.strictEqual(exportPlan.type, "artifact_export_plan");
|
||||||
|
assert.strictEqual(exportPlan.source_node, "node-export-source");
|
||||||
|
assert.strictEqual(exportPlan.receiver_node, "node-export-receiver");
|
||||||
|
assert.strictEqual(exportPlan.plan.transport, "NativeQuic");
|
||||||
|
assert.strictEqual(exportPlan.plan.scope.tenant, "tenant");
|
||||||
|
assert.strictEqual(exportPlan.plan.scope.project, "project");
|
||||||
|
assert.strictEqual(exportPlan.plan.scope.process, "vp-export");
|
||||||
|
assert.deepStrictEqual(exportPlan.plan.scope.object, { Artifact: "export-output.txt" });
|
||||||
|
assert.strictEqual(exportPlan.plan.source.node, "node-export-source");
|
||||||
|
assert.strictEqual(exportPlan.plan.destination.node, "node-export-receiver");
|
||||||
|
assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true);
|
||||||
|
assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false);
|
||||||
|
assert.match(exportPlan.plan.authorization_digest, /^sha256:[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
const crossTenant = await send(addr, {
|
||||||
|
type: "export_artifact_to_node",
|
||||||
|
tenant: "other",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "export-output.txt",
|
||||||
|
receiver_node: "node-export-receiver",
|
||||||
|
direct_connectivity: true,
|
||||||
|
failure_reason: "",
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenant.type, "error");
|
||||||
|
assert.match(crossTenant.message, /tenant mismatch/);
|
||||||
|
|
||||||
|
const failedDirect = await send(addr, {
|
||||||
|
type: "export_artifact_to_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "export-output.txt",
|
||||||
|
receiver_node: "node-export-receiver",
|
||||||
|
direct_connectivity: false,
|
||||||
|
failure_reason: "nat traversal failed",
|
||||||
|
});
|
||||||
|
assert.strictEqual(failedDirect.type, "error");
|
||||||
|
assert.match(failedDirect.message, /nat traversal failed/);
|
||||||
|
assert.match(failedDirect.message, /coordinator bulk relay is disabled/);
|
||||||
|
|
||||||
|
await reportNode(addr, "node-export-receiver", { online: false });
|
||||||
|
const offlineReceiver = await send(addr, {
|
||||||
|
type: "export_artifact_to_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "export-output.txt",
|
||||||
|
receiver_node: "node-export-receiver",
|
||||||
|
direct_connectivity: true,
|
||||||
|
failure_reason: "",
|
||||||
|
});
|
||||||
|
assert.strictEqual(offlineReceiver.type, "error");
|
||||||
|
assert.match(offlineReceiver.message, /offline/);
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Artifact export smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
263
scripts/cancellation-smoke.js
Normal file
263
scripts/cancellation-smoke.js
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnNode(addr) {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-cancel",
|
||||||
|
"--process",
|
||||||
|
"vp-cancel",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/cancelled-output.txt",
|
||||||
|
"--emit-ready",
|
||||||
|
"--control-poll-ms",
|
||||||
|
"5000",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let rawStdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let exitCode = null;
|
||||||
|
let exitSignal = null;
|
||||||
|
const messages = [];
|
||||||
|
const waiters = [];
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
rawStdout += chunk.toString();
|
||||||
|
buffer += chunk.toString();
|
||||||
|
while (true) {
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
const line = buffer.slice(0, newline).trim();
|
||||||
|
buffer = buffer.slice(newline + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line);
|
||||||
|
} catch (error) {
|
||||||
|
rejectWaiters(new Error(`node emitted non-JSON line: ${line}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages.push(message);
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
exitCode = code;
|
||||||
|
exitSignal = signal;
|
||||||
|
flush();
|
||||||
|
rejectWaiters(
|
||||||
|
new Error(
|
||||||
|
`node process exited before expected message with code ${code} signal ${signal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
for (const waiter of [...waiters]) {
|
||||||
|
const message = messages.find(waiter.predicate);
|
||||||
|
if (!message) continue;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiters.splice(waiters.indexOf(waiter), 1);
|
||||||
|
waiter.resolve(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(predicate, timeoutMs = 120000) {
|
||||||
|
const existing = messages.find(predicate);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
if (exitCode !== null) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
`node process already exited with code ${exitCode} signal ${exitSignal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
reject(new Error(`timed out waiting for node message\n${stderr}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
waiters.push({ predicate, resolve, reject, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectWaiters(error) {
|
||||||
|
for (const waiter of [...waiters]) {
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
waiters.splice(waiters.indexOf(waiter), 1);
|
||||||
|
waiter.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit() {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
if (child.exitCode === 0) return Promise.resolve();
|
||||||
|
return Promise.reject(new Error(`node process failed with code ${child.exitCode}\n${stderr}`));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { child, waitForMessage, waitForExit };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
let node;
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
node = spawnNode(addr);
|
||||||
|
const nodeReady = await node.waitForMessage((message) => message.node_status === "ready");
|
||||||
|
assert.strictEqual(nodeReady.process, "vp-cancel");
|
||||||
|
assert.strictEqual(nodeReady.task, "compile-linux");
|
||||||
|
|
||||||
|
const cancel = await send(addr, {
|
||||||
|
type: "cancel_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-cancel",
|
||||||
|
node: "node-cancel",
|
||||||
|
task: "compile-linux",
|
||||||
|
});
|
||||||
|
assert.strictEqual(cancel.type, "task_cancellation_requested");
|
||||||
|
assert.strictEqual(cancel.process, "vp-cancel");
|
||||||
|
assert.strictEqual(cancel.task, "compile-linux");
|
||||||
|
assert.strictEqual(cancel.node, "node-cancel");
|
||||||
|
|
||||||
|
const report = await node.waitForMessage((message) => message.node_status === "cancelled");
|
||||||
|
assert.strictEqual(report.terminal_state, "cancelled");
|
||||||
|
assert.strictEqual(report.status_code, null);
|
||||||
|
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||||
|
await node.waitForExit();
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
process: "vp-cancel"
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "node-cancel");
|
||||||
|
assert.strictEqual(events.events[0].process, "vp-cancel");
|
||||||
|
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||||
|
assert.strictEqual(events.events[0].terminal_state, "cancelled");
|
||||||
|
assert.strictEqual(events.events[0].status_code, null);
|
||||||
|
|
||||||
|
const control = await send(addr, {
|
||||||
|
type: "poll_task_control",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-cancel",
|
||||||
|
node: "node-cancel",
|
||||||
|
task: "compile-linux",
|
||||||
|
});
|
||||||
|
assert.strictEqual(control.type, "task_control");
|
||||||
|
assert.strictEqual(control.cancel_requested, false);
|
||||||
|
} finally {
|
||||||
|
if (node && node.child.exitCode === null) node.child.kill("SIGKILL");
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Cancellation smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
206
scripts/cli-browser-login-flow-smoke.js
Normal file
206
scripts/cli-browser-login-flow-smoke.js
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const tmp = path.join(repo, "target", "acceptance", "tmp", "cli-browser-login-flow");
|
||||||
|
fs.mkdirSync(tmp, { recursive: true });
|
||||||
|
|
||||||
|
function writeOpener() {
|
||||||
|
const opener = path.join(tmp, "browser-opener.js");
|
||||||
|
const trace = path.join(tmp, "browser-opener.log");
|
||||||
|
fs.writeFileSync(
|
||||||
|
opener,
|
||||||
|
`#!/usr/bin/env node
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const trace = ${JSON.stringify(trace)};
|
||||||
|
fs.appendFileSync(trace, "started " + process.argv.slice(2).join(" ") + "\\n");
|
||||||
|
const loginUrl = new URL(process.argv[2]);
|
||||||
|
const state = loginUrl.searchParams.get("state");
|
||||||
|
const redirect = new URL(loginUrl.searchParams.get("redirect_uri"));
|
||||||
|
if (!state || !redirect) {
|
||||||
|
fs.appendFileSync(trace, "missing state or redirect\\n");
|
||||||
|
console.error("browser opener did not receive state and redirect_uri");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
redirect.searchParams.set("code", "browser-smoke-code");
|
||||||
|
redirect.searchParams.set("state", state);
|
||||||
|
fs.appendFileSync(trace, "callback " + redirect.toString() + "\\n");
|
||||||
|
|
||||||
|
http.get(redirect, (response) => {
|
||||||
|
response.resume();
|
||||||
|
response.on("end", () => {
|
||||||
|
fs.appendFileSync(trace, "status " + response.statusCode + "\\n");
|
||||||
|
process.exit(response.statusCode === 200 ? 0 : 1);
|
||||||
|
});
|
||||||
|
}).on("error", (error) => {
|
||||||
|
fs.appendFileSync(trace, "error " + (error.stack || error.message) + "\\n");
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
`
|
||||||
|
);
|
||||||
|
fs.chmodSync(opener, 0o755);
|
||||||
|
return opener;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server, host = "127.0.0.1") {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, host, () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolve(server.address());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCoordinator() {
|
||||||
|
const requests = [];
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
let buffered = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffered += chunk.toString("utf8");
|
||||||
|
if (!buffered.includes("\n")) return;
|
||||||
|
const [line] = buffered.split(/\r?\n/);
|
||||||
|
const request = JSON.parse(line);
|
||||||
|
requests.push(request);
|
||||||
|
assert.strictEqual(request.type, "oidc_browser_login");
|
||||||
|
assert.strictEqual(request.authorization_code, "browser-smoke-code");
|
||||||
|
assert.strictEqual(request.issuer_url, "http://127.0.0.1:1");
|
||||||
|
assert.strictEqual(request.client_id, "disasmer-smoke");
|
||||||
|
assert.match(request.redirect_path, /^http:\/\/127\.0\.0\.1:45173\/callback$/);
|
||||||
|
assert.match(request.state, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
socket.end(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "oidc_browser_session",
|
||||||
|
session: {
|
||||||
|
tenant: request.tenant,
|
||||||
|
project: request.project,
|
||||||
|
user: request.user,
|
||||||
|
browser_credential_kind: "BrowserSession",
|
||||||
|
cli_session_credential_kind: "CliDeviceSession",
|
||||||
|
provider_tokens_sent_to_nodes: false,
|
||||||
|
flow: {
|
||||||
|
authorization_url: "http://127.0.0.1:1/application/o/authorize/",
|
||||||
|
callback_path: request.redirect_path,
|
||||||
|
state: request.state,
|
||||||
|
},
|
||||||
|
oidc_token_exchange: {
|
||||||
|
token_endpoint: "http://127.0.0.1:1/application/o/token/",
|
||||||
|
token_type: "Bearer",
|
||||||
|
received_access_token: true,
|
||||||
|
received_id_token: true,
|
||||||
|
retained_provider_tokens: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const address = await listen(server);
|
||||||
|
return {
|
||||||
|
url: `${address.address}:${address.port}`,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise((resolve) => server.close(() => resolve())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDisasmer(args, env) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn("cargo", args, {
|
||||||
|
cwd: repo,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
reject(new Error(`disasmer command timed out\n${stderr}`));
|
||||||
|
}, 30000);
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`disasmer command failed with ${signal || code}\nSTDERR:\n${stderr}\nSTDOUT:\n${stdout}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const opener = writeOpener();
|
||||||
|
const coordinator = await startCoordinator();
|
||||||
|
try {
|
||||||
|
const stdout = await runDisasmer(
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"login",
|
||||||
|
"--browser",
|
||||||
|
"--json",
|
||||||
|
"--coordinator",
|
||||||
|
coordinator.url,
|
||||||
|
"--oidc-issuer-url",
|
||||||
|
"http://127.0.0.1:1",
|
||||||
|
"--oidc-client-id",
|
||||||
|
"disasmer-smoke",
|
||||||
|
"--tenant",
|
||||||
|
"tenant-smoke",
|
||||||
|
"--project-id",
|
||||||
|
"project-smoke",
|
||||||
|
"--user",
|
||||||
|
"user-smoke",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...process.env,
|
||||||
|
DISASMER_BROWSER_OPEN_COMMAND: opener,
|
||||||
|
DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS: "5",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const report = JSON.parse(stdout);
|
||||||
|
assert.strictEqual(report.plan.coordinator, coordinator.url);
|
||||||
|
assert.strictEqual(report.boundary.cli_contacted_coordinator, true);
|
||||||
|
assert.strictEqual(report.boundary.scoped_cli_session_received, true);
|
||||||
|
assert.strictEqual(report.boundary.provider_tokens_exposed_to_cli, false);
|
||||||
|
assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false);
|
||||||
|
assert.strictEqual(report.boundary.coordinator_session_requests, 1);
|
||||||
|
assert.strictEqual(coordinator.requests.length, 1);
|
||||||
|
} finally {
|
||||||
|
if (coordinator.requests.length === 0) {
|
||||||
|
await coordinator.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("CLI browser login flow smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
58
scripts/cli-install-smoke.js
Executable file
58
scripts/cli-install-smoke.js
Executable file
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-install-"));
|
||||||
|
const installRoot = path.join(temp, "install");
|
||||||
|
const targetDir = path.join(temp, "target");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
const binName = process.platform === "win32" ? "disasmer.exe" : "disasmer";
|
||||||
|
const installedBin = path.join(installRoot, "bin", binName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--path",
|
||||||
|
"crates/disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--root",
|
||||||
|
installRoot,
|
||||||
|
"--debug"
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repo,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CARGO_TARGET_DIR: targetDir
|
||||||
|
},
|
||||||
|
stdio: "inherit"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(fs.existsSync(installedBin), "installed disasmer binary must exist");
|
||||||
|
|
||||||
|
const inspection = JSON.parse(
|
||||||
|
cp.execFileSync(
|
||||||
|
installedBin,
|
||||||
|
["bundle", "inspect", "--project", project],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(inspection.project, project);
|
||||||
|
assert.strictEqual(inspection.metadata.embeds_full_container_images, false);
|
||||||
|
assert(inspection.metadata.environments.some((env) => env.name === "linux"));
|
||||||
|
assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs"));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("CLI install smoke passed");
|
||||||
202
scripts/cli-local-run-smoke.js
Executable file
202
scripts/cli-local-run-smoke.js
Executable file
|
|
@ -0,0 +1,202 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
const agentPublicKey = "agent-cli-smoke-public-key";
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args, env = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
|
||||||
|
{
|
||||||
|
cwd: repo,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const cliPid = child.pid;
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`CLI run failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve({ pid: cliPid, report: JSON.parse(stdout) });
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`CLI output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
assert(Number.isInteger(coordinator.pid));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const { pid: cliPid, report } = await runCli(
|
||||||
|
["run", "--coordinator", `${addr.host}:${addr.port}`, "--project", project],
|
||||||
|
{ DISASMER_AGENT_PUBLIC_KEY: agentPublicKey }
|
||||||
|
);
|
||||||
|
assert(Number.isInteger(cliPid));
|
||||||
|
assert.notStrictEqual(cliPid, coordinator.pid);
|
||||||
|
assert.strictEqual(report.plan.entry, "build");
|
||||||
|
assert.deepStrictEqual(report.plan.session, {
|
||||||
|
AgentPublicKey: {
|
||||||
|
public_key_fingerprint: sha256(agentPublicKey),
|
||||||
|
browser_interaction_required: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(report.boundary.cli_process_started_node_process, true);
|
||||||
|
assert.strictEqual(report.boundary.cli_process_started_coordinator_process, false);
|
||||||
|
assert(Number.isInteger(report.boundary.spawned_node_process_id));
|
||||||
|
assert.notStrictEqual(report.boundary.spawned_node_process_id, cliPid);
|
||||||
|
assert.notStrictEqual(report.boundary.spawned_node_process_id, coordinator.pid);
|
||||||
|
assert.strictEqual(report.boundary.node_session_requests, 10);
|
||||||
|
assert.strictEqual(report.node_report.node_status, "completed");
|
||||||
|
assert.strictEqual(report.node_report.status_code, 0);
|
||||||
|
assert.strictEqual(report.node_report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(report.node_report.capability_response.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(report.node_report.task_assignment_response.type, "task_placement");
|
||||||
|
assert.strictEqual(report.node_report.debug_command_response.type, "debug_command");
|
||||||
|
assert.strictEqual(report.node_report.log_event_response.type, "task_log_recorded");
|
||||||
|
assert.strictEqual(report.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||||
|
assert.strictEqual(report.node_report.staged_artifact.path, "/vfs/artifacts/cli-run-output.txt");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
process: "vp-cli-local"
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "node-cli-local");
|
||||||
|
assert.strictEqual(events.events[0].process, "vp-cli-local");
|
||||||
|
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/cli-run-output.txt");
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pid: autoCliPid, report: autoReport } = await runCli([
|
||||||
|
"run",
|
||||||
|
"--local",
|
||||||
|
"--project",
|
||||||
|
project
|
||||||
|
]);
|
||||||
|
assert(Number.isInteger(autoCliPid));
|
||||||
|
assert.strictEqual(autoReport.plan.entry, "build");
|
||||||
|
assert.deepStrictEqual(autoReport.plan.coordinator, "LocalOnly");
|
||||||
|
assert.deepStrictEqual(autoReport.plan.session, "Anonymous");
|
||||||
|
assert.strictEqual(autoReport.boundary.cli_process_started_node_process, true);
|
||||||
|
assert.strictEqual(autoReport.boundary.cli_process_started_coordinator_process, true);
|
||||||
|
assert.match(autoReport.boundary.coordinator_address, /^127\.0\.0\.1:\d+$/);
|
||||||
|
assert(Number.isInteger(autoReport.boundary.coordinator_process_id));
|
||||||
|
assert(Number.isInteger(autoReport.boundary.spawned_node_process_id));
|
||||||
|
assert.notStrictEqual(autoReport.boundary.coordinator_process_id, autoCliPid);
|
||||||
|
assert.notStrictEqual(autoReport.boundary.spawned_node_process_id, autoCliPid);
|
||||||
|
assert.notStrictEqual(
|
||||||
|
autoReport.boundary.spawned_node_process_id,
|
||||||
|
autoReport.boundary.coordinator_process_id
|
||||||
|
);
|
||||||
|
assert.strictEqual(autoReport.boundary.node_session_requests, 10);
|
||||||
|
assert.strictEqual(autoReport.node_report.node_status, "completed");
|
||||||
|
assert.strictEqual(autoReport.node_report.status_code, 0);
|
||||||
|
assert.strictEqual(autoReport.node_report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(autoReport.node_report.capability_response.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(autoReport.node_report.task_assignment_response.type, "task_placement");
|
||||||
|
assert.strictEqual(autoReport.node_report.debug_command_response.type, "debug_command");
|
||||||
|
assert.strictEqual(autoReport.node_report.log_event_response.type, "task_log_recorded");
|
||||||
|
assert.strictEqual(autoReport.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||||
|
assert.strictEqual(
|
||||||
|
autoReport.node_report.staged_artifact.path,
|
||||||
|
"/vfs/artifacts/cli-run-output.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("CLI local run smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
47
scripts/cli-login-smoke.js
Normal file
47
scripts/cli-login-smoke.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const coordinator = "https://coord.example.test";
|
||||||
|
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
|
||||||
|
function disasmer(args) {
|
||||||
|
return JSON.parse(
|
||||||
|
cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = disasmer(["login", "--coordinator", coordinator]);
|
||||||
|
assert.strictEqual(device.coordinator, coordinator);
|
||||||
|
assert(device.human_flow.Device, "default human login should use device flow");
|
||||||
|
assert.strictEqual(device.human_flow.Device.verification_url, `${coordinator}/auth/device`);
|
||||||
|
assert.match(device.human_flow.Device.user_code, /^DISASMER-[A-F0-9]{4}-[A-F0-9]{4}$/);
|
||||||
|
assert.match(device.human_flow.Device.device_code, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
assert.strictEqual(device.human_flow.Device.expires_in_seconds, 900);
|
||||||
|
assert.strictEqual(device.human_flow.Device.yields_long_lived_secret_directly, false);
|
||||||
|
|
||||||
|
const defaultDevice = disasmer(["login"]);
|
||||||
|
assert.strictEqual(defaultDevice.coordinator, defaultOperatorEndpoint);
|
||||||
|
assert.strictEqual(
|
||||||
|
defaultDevice.human_flow.Device.verification_url,
|
||||||
|
`${defaultOperatorEndpoint}/auth/device`
|
||||||
|
);
|
||||||
|
|
||||||
|
const browser = disasmer(["login", "--browser", "--plan", "--coordinator", coordinator]);
|
||||||
|
assert.strictEqual(browser.coordinator, coordinator);
|
||||||
|
assert(browser.human_flow.Browser, "browser login should be available for human users");
|
||||||
|
assert.match(
|
||||||
|
browser.human_flow.Browser.authorization_url,
|
||||||
|
new RegExp(`^${coordinator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/auth/browser/start\\?`)
|
||||||
|
);
|
||||||
|
assert.match(browser.human_flow.Browser.callback_path, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
||||||
|
assert.match(browser.human_flow.Browser.state, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
console.log("CLI login smoke passed");
|
||||||
948
scripts/dap-smoke.js
Executable file
948
scripts/dap-smoke.js
Executable file
|
|
@ -0,0 +1,948 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const cp = require("child_process");
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
class DapClient {
|
||||||
|
constructor() {
|
||||||
|
this.child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
|
||||||
|
{ cwd: process.cwd() }
|
||||||
|
);
|
||||||
|
this.seq = 1;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.messages = [];
|
||||||
|
this.waiters = [];
|
||||||
|
this.stderr = "";
|
||||||
|
|
||||||
|
this.child.stdout.on("data", (chunk) => {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
this.parse();
|
||||||
|
});
|
||||||
|
this.child.stderr.on("data", (chunk) => {
|
||||||
|
this.stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
this.child.on("exit", () => this.flushWaiters());
|
||||||
|
}
|
||||||
|
|
||||||
|
send(command, args = {}) {
|
||||||
|
const seq = this.seq++;
|
||||||
|
const message = { seq, type: "request", command, arguments: args };
|
||||||
|
const payload = Buffer.from(JSON.stringify(message));
|
||||||
|
this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
||||||
|
this.child.stdin.write(payload);
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async response(seq, command) {
|
||||||
|
const message = await this.waitFor(
|
||||||
|
(item) =>
|
||||||
|
item.type === "response" &&
|
||||||
|
item.request_seq === seq &&
|
||||||
|
item.command === command
|
||||||
|
);
|
||||||
|
if (!message.success) {
|
||||||
|
throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async failure(seq, command) {
|
||||||
|
const message = await this.waitFor(
|
||||||
|
(item) =>
|
||||||
|
item.type === "response" &&
|
||||||
|
item.request_seq === seq &&
|
||||||
|
item.command === command
|
||||||
|
);
|
||||||
|
if (message.success) {
|
||||||
|
throw new Error(`DAP ${command} unexpectedly succeeded`);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor(predicate, timeoutMs = 120000) {
|
||||||
|
const existing = this.messages.find(predicate);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
reject(new Error(`timed out waiting for DAP message\n${this.stderr}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.waiters.push({ predicate, resolve, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
while (true) {
|
||||||
|
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) return;
|
||||||
|
const header = this.buffer.slice(0, headerEnd).toString();
|
||||||
|
const match = header.match(/Content-Length: (\d+)/i);
|
||||||
|
if (!match) throw new Error(`bad DAP header: ${header}`);
|
||||||
|
const length = Number(match[1]);
|
||||||
|
const start = headerEnd + 4;
|
||||||
|
const end = start + length;
|
||||||
|
if (this.buffer.length < end) return;
|
||||||
|
const payload = this.buffer.slice(start, end).toString();
|
||||||
|
this.buffer = this.buffer.slice(end);
|
||||||
|
this.messages.push(JSON.parse(payload));
|
||||||
|
this.flushWaiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushWaiters() {
|
||||||
|
for (const waiter of [...this.waiters]) {
|
||||||
|
const message = this.messages.find(waiter.predicate);
|
||||||
|
if (!message) continue;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||||
|
waiter.resolve(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
const seq = this.send("disconnect");
|
||||||
|
await this.response(seq, "disconnect");
|
||||||
|
this.child.stdin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFailingProject() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-dap-failing-"));
|
||||||
|
fs.mkdirSync(path.join(root, "src"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "Cargo.toml"),
|
||||||
|
`[package]
|
||||||
|
name = "dap-failing-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "src/lib.rs"),
|
||||||
|
`#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn fails_for_restart_smoke() {
|
||||||
|
assert_eq!(1, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForJsonLine(child, description) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`${description} did not emit JSON: ${buffer}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
reject(new Error(`${description} exited before JSON line with code ${code} signal ${signal}\n${stderr}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCoordinator() {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: process.cwd() }
|
||||||
|
);
|
||||||
|
const ready = await waitForJsonLine(child, "coordinator");
|
||||||
|
return { child, listen: ready.listen };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startExplicitWorker(listen) {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
listen,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"dap-live-worker",
|
||||||
|
"--worker",
|
||||||
|
"--assignment-poll-ms",
|
||||||
|
"50",
|
||||||
|
"--emit-ready"
|
||||||
|
],
|
||||||
|
{ cwd: process.cwd() }
|
||||||
|
);
|
||||||
|
const ready = await waitForJsonLine(child, "explicit worker");
|
||||||
|
assert.strictEqual(ready.node_status, "ready");
|
||||||
|
assert.strictEqual(ready.mode, "worker");
|
||||||
|
assert.strictEqual(ready.node, "dap-live-worker");
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killChild(child) {
|
||||||
|
if (child && child.exitCode === null) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchToBreakpoint({
|
||||||
|
runtimeBackend = "simulated",
|
||||||
|
breakpointLine,
|
||||||
|
breakpointLines,
|
||||||
|
project = path.join(process.cwd(), "examples/launch-build-demo"),
|
||||||
|
operatorEndpoint,
|
||||||
|
tenant = "tenant",
|
||||||
|
projectId = "project",
|
||||||
|
actorUser = "dap"
|
||||||
|
}) {
|
||||||
|
const client = new DapClient();
|
||||||
|
|
||||||
|
const initialize = client.send("initialize", {
|
||||||
|
adapterID: "disasmer",
|
||||||
|
linesStartAt1: true,
|
||||||
|
columnsStartAt1: true
|
||||||
|
});
|
||||||
|
await client.response(initialize, "initialize");
|
||||||
|
|
||||||
|
const launchArgs = {
|
||||||
|
entry: "build",
|
||||||
|
project,
|
||||||
|
runtimeBackend,
|
||||||
|
tenant,
|
||||||
|
projectId,
|
||||||
|
actorUser
|
||||||
|
};
|
||||||
|
if (operatorEndpoint) {
|
||||||
|
launchArgs.operatorEndpoint = operatorEndpoint;
|
||||||
|
}
|
||||||
|
const launch = client.send("launch", launchArgs);
|
||||||
|
await client.response(launch, "launch");
|
||||||
|
await client.waitFor((message) => message.type === "event" && message.event === "initialized");
|
||||||
|
|
||||||
|
const breakpoints = client.send("setBreakpoints", {
|
||||||
|
source: { path: path.join(project, "src/build.rs") },
|
||||||
|
breakpoints: (breakpointLines || [breakpointLine]).map((line) => ({ line }))
|
||||||
|
});
|
||||||
|
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified),
|
||||||
|
(breakpointLines || [breakpointLine]).map(() => true)
|
||||||
|
);
|
||||||
|
|
||||||
|
const exceptions = client.send("setExceptionBreakpoints", { filters: [] });
|
||||||
|
await client.response(exceptions, "setExceptionBreakpoints");
|
||||||
|
|
||||||
|
const configurationDone = client.send("configurationDone");
|
||||||
|
await client.response(configurationDone, "configurationDone");
|
||||||
|
const stopped = await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "stopped"
|
||||||
|
);
|
||||||
|
assert.strictEqual(stopped.body.allThreadsStopped, true);
|
||||||
|
assert.strictEqual(stopped.body.reason, "breakpoint");
|
||||||
|
|
||||||
|
return { client, stopped };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const launchProject = path.join(process.cwd(), "examples/launch-build-demo");
|
||||||
|
const launchSource = fs.realpathSync(path.join(launchProject, "src/build.rs"));
|
||||||
|
const dapSource = fs.readFileSync(
|
||||||
|
path.join(process.cwd(), "crates/disasmer-dap/src/main.rs"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const liveStart = dapSource.indexOf("fn run_live_services_runtime");
|
||||||
|
const liveEnd = dapSource.indexOf("fn run_with_coordinator", liveStart);
|
||||||
|
const liveRuntimeSource = dapSource.slice(liveStart, liveEnd);
|
||||||
|
assert.doesNotMatch(liveRuntimeSource, /run_node_against_coordinator|Command::new|project_binary/);
|
||||||
|
|
||||||
|
const { client, stopped } = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "local-services",
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLine: 22
|
||||||
|
});
|
||||||
|
assert.strictEqual(stopped.body.threadId, 2);
|
||||||
|
|
||||||
|
const threadsRequest = client.send("threads");
|
||||||
|
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
threads.map((thread) => thread.id),
|
||||||
|
[1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
assert(threads.some((thread) => thread.name.includes("compile linux")));
|
||||||
|
assert(threads.some((thread) => thread.name.includes("compile windows")));
|
||||||
|
assert(threads.some((thread) => thread.name.includes("package artifacts")));
|
||||||
|
|
||||||
|
const stackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
|
||||||
|
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
|
||||||
|
assert.strictEqual(stack.length, 1);
|
||||||
|
assert.match(stack[0].name, /compile linux::run/);
|
||||||
|
assert.strictEqual(stack[0].line, 22);
|
||||||
|
assert.strictEqual(stack[0].source.path, launchSource);
|
||||||
|
assert.strictEqual(stack[0].source.sourceReference || 0, 0);
|
||||||
|
assert.doesNotMatch(stack[0].name, /podman|cmd\.exe|powershell|pid|native child/i);
|
||||||
|
|
||||||
|
const sourceRequest = client.send("source", { source: stack[0].source });
|
||||||
|
const source = (await client.response(sourceRequest, "source")).body;
|
||||||
|
assert.match(source.content, /compile_linux/);
|
||||||
|
assert.match(source.mimeType, /rust/);
|
||||||
|
|
||||||
|
const nextRequest = client.send("next", { threadId: 2 });
|
||||||
|
await client.response(nextRequest, "next");
|
||||||
|
const stepped = await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "step"
|
||||||
|
);
|
||||||
|
assert.strictEqual(stepped.body.threadId, 2);
|
||||||
|
assert.strictEqual(stepped.body.allThreadsStopped, true);
|
||||||
|
|
||||||
|
const steppedStackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
|
||||||
|
const steppedStack = (await client.response(steppedStackRequest, "stackTrace")).body.stackFrames;
|
||||||
|
assert.strictEqual(steppedStack[0].line, 23);
|
||||||
|
const steppedSourceRequest = client.send("source", { source: steppedStack[0].source });
|
||||||
|
const steppedSource = (await client.response(steppedSourceRequest, "source")).body;
|
||||||
|
assert.match(steppedSource.content, /compile_linux/);
|
||||||
|
|
||||||
|
const scopesRequest = client.send("scopes", { frameId: steppedStack[0].id });
|
||||||
|
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
|
||||||
|
const localsRef = scopes.find((scope) => scope.name === "Source Locals").variablesReference;
|
||||||
|
const argsRef = scopes.find((scope) => scope.name === "Task Args and Handles").variablesReference;
|
||||||
|
const runtimeRef = scopes.find((scope) => scope.name === "Disasmer Runtime").variablesReference;
|
||||||
|
const outputRef = scopes.find((scope) => scope.name === "Recent Output").variablesReference;
|
||||||
|
|
||||||
|
const localsRequest = client.send("variables", { variablesReference: localsRef });
|
||||||
|
const locals = (await client.response(localsRequest, "variables")).body.variables;
|
||||||
|
assert(
|
||||||
|
locals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "unavailable-local-diagnostic" &&
|
||||||
|
String(variable.value).includes("cannot be inspected")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const argsRequest = client.send("variables", { variablesReference: argsRef });
|
||||||
|
const args = (await client.response(argsRequest, "variables")).body.variables;
|
||||||
|
const target = args.find((variable) => variable.name === "target");
|
||||||
|
const artifact = args.find((variable) => variable.name === "artifact");
|
||||||
|
const sourceSnapshot = args.find((variable) => variable.name === "source_snapshot");
|
||||||
|
const returnValue = args.find((variable) => variable.name === "return_value");
|
||||||
|
const blob = args.find((variable) => variable.name === "blob");
|
||||||
|
const vfsMounts = args.find((variable) => variable.name === "vfs_mounts");
|
||||||
|
assert(target);
|
||||||
|
assert(target.variablesReference > 0);
|
||||||
|
assert(artifact);
|
||||||
|
assert.strictEqual(artifact.value, 'Artifact { id = "/vfs/artifacts/dap-output.txt" }');
|
||||||
|
assert(sourceSnapshot);
|
||||||
|
assert.match(sourceSnapshot.value, /^SourceSnapshot \{ digest = "source:\/\/local-checkout\/[a-f0-9]{12}" \}$/);
|
||||||
|
assert(returnValue);
|
||||||
|
assert.match(returnValue.value, /Artifact/);
|
||||||
|
assert(blob);
|
||||||
|
assert.match(blob.value, /^Blob \{ digest = "sha256:[a-f0-9]{64}" \}$/);
|
||||||
|
assert(vfsMounts);
|
||||||
|
assert(vfsMounts.variablesReference > 0);
|
||||||
|
|
||||||
|
const targetRequest = client.send("variables", { variablesReference: target.variablesReference });
|
||||||
|
const targetFields = (await client.response(targetRequest, "variables")).body.variables;
|
||||||
|
assert(targetFields.some((variable) => variable.name === "environment" && variable.value === "linux"));
|
||||||
|
assert(
|
||||||
|
targetFields.some(
|
||||||
|
(variable) => variable.name === "required_capability" && variable.value === "Command"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const vfsRequest = client.send("variables", { variablesReference: vfsMounts.variablesReference });
|
||||||
|
const vfs = (await client.response(vfsRequest, "variables")).body.variables;
|
||||||
|
assert(vfs.some((variable) => variable.name === "/vfs/artifacts"));
|
||||||
|
assert(vfs.some((variable) => variable.name === "/vfs/sources"));
|
||||||
|
assert(vfs.some((variable) => variable.name === "/vfs/blobs"));
|
||||||
|
|
||||||
|
const runtimeRequest = client.send("variables", { variablesReference: runtimeRef });
|
||||||
|
const runtime = (await client.response(runtimeRequest, "variables")).body.variables;
|
||||||
|
const processId = runtime.find((variable) => variable.name === "virtual_process_id");
|
||||||
|
const commandSpec = runtime.find((variable) => variable.name === "command_spec");
|
||||||
|
assert(processId);
|
||||||
|
assert.match(processId.value, /^vp-[a-f0-9]{12}$/);
|
||||||
|
assert(runtime.some((variable) => variable.name === "debug_epoch" && variable.value === 2));
|
||||||
|
assert(runtime.some((variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"));
|
||||||
|
assert(runtime.some((variable) => variable.name === "coordinator_task_events" && variable.value === 1));
|
||||||
|
assert(runtime.some((variable) => variable.name === "command_status" && String(variable.value).includes("completed through local services")));
|
||||||
|
assert(commandSpec);
|
||||||
|
assert(commandSpec.variablesReference > 0);
|
||||||
|
assert(runtime.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(runtime.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
|
||||||
|
const commandRequest = client.send("variables", {
|
||||||
|
variablesReference: commandSpec.variablesReference
|
||||||
|
});
|
||||||
|
const commandVariables = (await client.response(commandRequest, "variables")).body.variables;
|
||||||
|
assert(commandVariables.some((variable) => variable.name === "program" && variable.value === "cargo"));
|
||||||
|
assert(
|
||||||
|
commandVariables.some(
|
||||||
|
(variable) => variable.name === "required_capability" && variable.value === "Command"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputRequest = client.send("variables", { variablesReference: outputRef });
|
||||||
|
const output = (await client.response(outputRequest, "variables")).body.variables;
|
||||||
|
assert(output.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(output.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
assert(output.some((variable) => String(variable.value).includes("all-stop")));
|
||||||
|
assert(output.some((variable) => String(variable.value).includes("attached node completed task")));
|
||||||
|
assert(output.some((variable) => String(variable.value).includes("coordinator recorded 1 task event")));
|
||||||
|
|
||||||
|
const continueRequest = client.send("continue", { threadId: 2 });
|
||||||
|
await client.response(continueRequest, "continue");
|
||||||
|
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||||
|
|
||||||
|
const pauseRequest = client.send("pause", { threadId: 2 });
|
||||||
|
await client.response(pauseRequest, "pause");
|
||||||
|
const paused = await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "pause"
|
||||||
|
);
|
||||||
|
assert.strictEqual(paused.body.allThreadsStopped, true);
|
||||||
|
|
||||||
|
const restartRequest = client.send("restartFrame", { frameId: stack[0].id });
|
||||||
|
await client.response(restartRequest, "restartFrame");
|
||||||
|
await client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "output" &&
|
||||||
|
String(message.body.output).includes("Restarted selected task")
|
||||||
|
);
|
||||||
|
|
||||||
|
const incompatibleRestartRequest = client.send("restartFrame", {
|
||||||
|
frameId: stack[0].id,
|
||||||
|
sourceCompatibility: "incompatible"
|
||||||
|
});
|
||||||
|
const incompatibleRestart = await client.failure(incompatibleRestartRequest, "restartFrame");
|
||||||
|
assert.match(incompatibleRestart.message, /incompatible source edit/i);
|
||||||
|
assert.match(incompatibleRestart.message, /whole virtual-process restart/i);
|
||||||
|
|
||||||
|
const continueAfterRestartRequest = client.send("continue", { threadId: 2 });
|
||||||
|
await client.response(continueAfterRestartRequest, "continue");
|
||||||
|
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||||
|
|
||||||
|
const freezeFailureRequest = client.send("pause", {
|
||||||
|
threadId: 2,
|
||||||
|
simulateFreezeFailure: true
|
||||||
|
});
|
||||||
|
const freezeFailure = await client.failure(freezeFailureRequest, "pause");
|
||||||
|
assert.match(freezeFailure.message, /all-stop failed/i);
|
||||||
|
assert.match(freezeFailure.message, /could not freeze/i);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
const attachClient = new DapClient();
|
||||||
|
try {
|
||||||
|
const attachInitialize = attachClient.send("initialize", {
|
||||||
|
adapterID: "disasmer",
|
||||||
|
linesStartAt1: true,
|
||||||
|
columnsStartAt1: true
|
||||||
|
});
|
||||||
|
await attachClient.response(attachInitialize, "initialize");
|
||||||
|
|
||||||
|
const attachRequest = attachClient.send("attach", {
|
||||||
|
entry: "build",
|
||||||
|
project: launchProject,
|
||||||
|
runtimeBackend: "live-services",
|
||||||
|
operatorEndpoint: "127.0.0.1:1"
|
||||||
|
});
|
||||||
|
await attachClient.response(attachRequest, "attach");
|
||||||
|
await attachClient.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "initialized"
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachBreakpoints = attachClient.send("setBreakpoints", {
|
||||||
|
source: { path: path.join(launchProject, "src/build.rs") },
|
||||||
|
breakpoints: [{ line: 12 }]
|
||||||
|
});
|
||||||
|
await attachClient.response(attachBreakpoints, "setBreakpoints");
|
||||||
|
|
||||||
|
const attachDone = attachClient.send("configurationDone");
|
||||||
|
await attachClient.response(attachDone, "configurationDone");
|
||||||
|
const attachStopped = await attachClient.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "stopped"
|
||||||
|
);
|
||||||
|
assert.strictEqual(attachStopped.body.allThreadsStopped, true);
|
||||||
|
|
||||||
|
const attachStackRequest = attachClient.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const attachStack = (await attachClient.response(attachStackRequest, "stackTrace")).body
|
||||||
|
.stackFrames;
|
||||||
|
const attachScopesRequest = attachClient.send("scopes", { frameId: attachStack[0].id });
|
||||||
|
const attachScopes = (await attachClient.response(attachScopesRequest, "scopes")).body.scopes;
|
||||||
|
const attachRuntimeRef = attachScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||||
|
.variablesReference;
|
||||||
|
const attachRuntimeRequest = attachClient.send("variables", {
|
||||||
|
variablesReference: attachRuntimeRef
|
||||||
|
});
|
||||||
|
const attachRuntime = (await attachClient.response(attachRuntimeRequest, "variables")).body
|
||||||
|
.variables;
|
||||||
|
assert(
|
||||||
|
attachRuntime.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("attached to existing virtual process")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await attachClient.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceLocalsSession;
|
||||||
|
try {
|
||||||
|
sourceLocalsSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "simulated",
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLine: 60
|
||||||
|
});
|
||||||
|
assert.strictEqual(sourceLocalsSession.stopped.body.threadId, 1);
|
||||||
|
|
||||||
|
const localsStackRequest = sourceLocalsSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const localsStack = (await sourceLocalsSession.client.response(localsStackRequest, "stackTrace"))
|
||||||
|
.body.stackFrames;
|
||||||
|
assert.strictEqual(localsStack[0].line, 60);
|
||||||
|
const localsScopesRequest = sourceLocalsSession.client.send("scopes", {
|
||||||
|
frameId: localsStack[0].id
|
||||||
|
});
|
||||||
|
const localsScopes = (await sourceLocalsSession.client.response(localsScopesRequest, "scopes"))
|
||||||
|
.body.scopes;
|
||||||
|
const sourceLocalsRef = localsScopes.find((scope) => scope.name === "Source Locals")
|
||||||
|
.variablesReference;
|
||||||
|
const sourceLocalsRequest = sourceLocalsSession.client.send("variables", {
|
||||||
|
variablesReference: sourceLocalsRef
|
||||||
|
});
|
||||||
|
const sourceLocals = (
|
||||||
|
await sourceLocalsSession.client.response(sourceLocalsRequest, "variables")
|
||||||
|
).body.variables;
|
||||||
|
assert(
|
||||||
|
sourceLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "linux" &&
|
||||||
|
String(variable.value).includes("TaskHandle") &&
|
||||||
|
String(variable.value).includes("compile-linux") &&
|
||||||
|
String(variable.value).includes("virtual_thread_id = 2")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sourceLocals.some((variable) => variable.name === "linux_thread" && variable.value === "2")
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sourceLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "linux_artifact" && String(variable.value).includes("Artifact")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (sourceLocalsSession) {
|
||||||
|
await sourceLocalsSession.client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasmLocalsSession;
|
||||||
|
try {
|
||||||
|
wasmLocalsSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "simulated",
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLine: 31
|
||||||
|
});
|
||||||
|
assert.strictEqual(wasmLocalsSession.stopped.body.threadId, 1);
|
||||||
|
|
||||||
|
const wasmStackRequest = wasmLocalsSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const wasmStack = (await wasmLocalsSession.client.response(wasmStackRequest, "stackTrace"))
|
||||||
|
.body.stackFrames;
|
||||||
|
assert.strictEqual(wasmStack[0].line, 31);
|
||||||
|
const wasmScopesRequest = wasmLocalsSession.client.send("scopes", {
|
||||||
|
frameId: wasmStack[0].id
|
||||||
|
});
|
||||||
|
const wasmScopes = (await wasmLocalsSession.client.response(wasmScopesRequest, "scopes")).body
|
||||||
|
.scopes;
|
||||||
|
const wasmLocalsRef = wasmScopes.find((scope) => scope.name === "Wasm Frame Locals")
|
||||||
|
.variablesReference;
|
||||||
|
const wasmLocalsRequest = wasmLocalsSession.client.send("variables", {
|
||||||
|
variablesReference: wasmLocalsRef
|
||||||
|
});
|
||||||
|
const wasmLocals = (await wasmLocalsSession.client.response(wasmLocalsRequest, "variables"))
|
||||||
|
.body.variables;
|
||||||
|
assert(
|
||||||
|
wasmLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "wasm_local_0" &&
|
||||||
|
String(variable.value).includes("41") &&
|
||||||
|
variable.type === "wasm-frame-local"
|
||||||
|
),
|
||||||
|
"DAP variables must expose Wasmtime frame-local values from the product node runtime"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (wasmLocalsSession) {
|
||||||
|
await wasmLocalsSession.client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let liveCoordinator;
|
||||||
|
let liveWorker;
|
||||||
|
let liveSession;
|
||||||
|
try {
|
||||||
|
liveCoordinator = await startCoordinator();
|
||||||
|
liveSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "live-services",
|
||||||
|
operatorEndpoint: liveCoordinator.listen,
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLines: [12, 22]
|
||||||
|
});
|
||||||
|
assert.strictEqual(liveSession.stopped.body.threadId, 1);
|
||||||
|
|
||||||
|
const liveStackRequest = liveSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const liveStack = (await liveSession.client.response(liveStackRequest, "stackTrace")).body
|
||||||
|
.stackFrames;
|
||||||
|
assert.strictEqual(liveStack[0].line, 12);
|
||||||
|
assert.strictEqual(liveStack[0].source.path, launchSource);
|
||||||
|
|
||||||
|
const liveScopesRequest = liveSession.client.send("scopes", { frameId: liveStack[0].id });
|
||||||
|
const liveScopes = (await liveSession.client.response(liveScopesRequest, "scopes")).body.scopes;
|
||||||
|
const liveRuntimeRef = liveScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||||
|
.variablesReference;
|
||||||
|
const liveOutputRef = liveScopes.find((scope) => scope.name === "Recent Output")
|
||||||
|
.variablesReference;
|
||||||
|
|
||||||
|
const liveRuntimeRequest = liveSession.client.send("variables", {
|
||||||
|
variablesReference: liveRuntimeRef
|
||||||
|
});
|
||||||
|
const liveRuntime = (await liveSession.client.response(liveRuntimeRequest, "variables")).body
|
||||||
|
.variables;
|
||||||
|
assert(
|
||||||
|
liveRuntime.some(
|
||||||
|
(variable) => variable.name === "runtime_backend" && variable.value === "LiveServices"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
liveRuntime.some(
|
||||||
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
liveRuntime.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("coordinator-side virtual process started")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveOutputRequest = liveSession.client.send("variables", {
|
||||||
|
variablesReference: liveOutputRef
|
||||||
|
});
|
||||||
|
const liveOutput = (await liveSession.client.response(liveOutputRequest, "variables")).body
|
||||||
|
.variables;
|
||||||
|
assert(
|
||||||
|
liveOutput.some((variable) =>
|
||||||
|
String(variable.value).includes("Command-capability virtual task")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
liveWorker = await startExplicitWorker(liveCoordinator.listen);
|
||||||
|
const liveContinueRequest = liveSession.client.send("continue", { threadId: 1 });
|
||||||
|
await liveSession.client.response(liveContinueRequest, "continue");
|
||||||
|
await liveSession.client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "continued"
|
||||||
|
);
|
||||||
|
const liveTaskStopped = await liveSession.client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "stopped" &&
|
||||||
|
message.body.reason === "breakpoint" &&
|
||||||
|
message.body.threadId === 2
|
||||||
|
);
|
||||||
|
assert.strictEqual(liveTaskStopped.body.threadId, 2);
|
||||||
|
|
||||||
|
const liveTaskStackRequest = liveSession.client.send("stackTrace", {
|
||||||
|
threadId: 2,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const liveTaskStack = (await liveSession.client.response(liveTaskStackRequest, "stackTrace"))
|
||||||
|
.body.stackFrames;
|
||||||
|
assert.strictEqual(liveTaskStack[0].line, 22);
|
||||||
|
|
||||||
|
const liveTaskScopesRequest = liveSession.client.send("scopes", {
|
||||||
|
frameId: liveTaskStack[0].id
|
||||||
|
});
|
||||||
|
const liveTaskScopes = (await liveSession.client.response(liveTaskScopesRequest, "scopes"))
|
||||||
|
.body.scopes;
|
||||||
|
const liveTaskRuntimeRef = liveTaskScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||||
|
.variablesReference;
|
||||||
|
const liveTaskOutputRef = liveTaskScopes.find((scope) => scope.name === "Recent Output")
|
||||||
|
.variablesReference;
|
||||||
|
const liveTaskRuntimeRequest = liveSession.client.send("variables", {
|
||||||
|
variablesReference: liveTaskRuntimeRef
|
||||||
|
});
|
||||||
|
const liveTaskRuntime = (await liveSession.client.response(liveTaskRuntimeRequest, "variables"))
|
||||||
|
.body.variables;
|
||||||
|
assert(
|
||||||
|
liveTaskRuntime.some(
|
||||||
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
liveTaskRuntime.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("completed through live services")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(liveTaskRuntime.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(liveTaskRuntime.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
const liveTaskOutputRequest = liveSession.client.send("variables", {
|
||||||
|
variablesReference: liveTaskOutputRef
|
||||||
|
});
|
||||||
|
const liveTaskOutput = (await liveSession.client.response(liveTaskOutputRequest, "variables"))
|
||||||
|
.body.variables;
|
||||||
|
assert(liveTaskOutput.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(liveTaskOutput.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
assert(
|
||||||
|
liveTaskOutput.some((variable) =>
|
||||||
|
String(variable.value).includes("attached node completed task")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (liveSession) {
|
||||||
|
await liveSession.client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
killChild(liveWorker);
|
||||||
|
killChild(liveCoordinator && liveCoordinator.child);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failingProject = createFailingProject();
|
||||||
|
let failedSession;
|
||||||
|
try {
|
||||||
|
failedSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "local-services",
|
||||||
|
breakpointLine: 42,
|
||||||
|
project: failingProject
|
||||||
|
});
|
||||||
|
assert.strictEqual(failedSession.stopped.body.threadId, 2);
|
||||||
|
|
||||||
|
const failedStackRequest = failedSession.client.send("stackTrace", {
|
||||||
|
threadId: 2,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const failedStack = (await failedSession.client.response(failedStackRequest, "stackTrace")).body
|
||||||
|
.stackFrames;
|
||||||
|
assert.strictEqual(failedStack.length, 1);
|
||||||
|
assert.match(failedStack[0].name, /compile linux::run/);
|
||||||
|
|
||||||
|
const failedScopesRequest = failedSession.client.send("scopes", { frameId: failedStack[0].id });
|
||||||
|
const failedScopes = (await failedSession.client.response(failedScopesRequest, "scopes")).body
|
||||||
|
.scopes;
|
||||||
|
const failedRuntimeRef = failedScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||||
|
.variablesReference;
|
||||||
|
const failedOutputRef = failedScopes.find((scope) => scope.name === "Recent Output")
|
||||||
|
.variablesReference;
|
||||||
|
|
||||||
|
const failedRuntimeRequest = failedSession.client.send("variables", {
|
||||||
|
variablesReference: failedRuntimeRef
|
||||||
|
});
|
||||||
|
const failedRuntime = (await failedSession.client.response(failedRuntimeRequest, "variables"))
|
||||||
|
.body.variables;
|
||||||
|
assert(
|
||||||
|
failedRuntime.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("failed through local services")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
failedRuntime.some(
|
||||||
|
(variable) => variable.name === "state" && String(variable.value).includes("Failed")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedOutputRequest = failedSession.client.send("variables", {
|
||||||
|
variablesReference: failedOutputRef
|
||||||
|
});
|
||||||
|
const failedOutput = (await failedSession.client.response(failedOutputRequest, "variables")).body
|
||||||
|
.variables;
|
||||||
|
assert(
|
||||||
|
failedOutput.some((variable) => String(variable.value).includes("attached node failed task"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedRestartRequest = failedSession.client.send("restartFrame", {
|
||||||
|
frameId: failedStack[0].id,
|
||||||
|
sourceEdit: { compatibility: "compatible" }
|
||||||
|
});
|
||||||
|
await failedSession.client.response(failedRestartRequest, "restartFrame");
|
||||||
|
await failedSession.client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "output" &&
|
||||||
|
String(message.body.output).includes("Restarted failed task")
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedRuntimeAfterRestartRequest = failedSession.client.send("variables", {
|
||||||
|
variablesReference: failedRuntimeRef
|
||||||
|
});
|
||||||
|
const failedRuntimeAfterRestart = (
|
||||||
|
await failedSession.client.response(failedRuntimeAfterRestartRequest, "variables")
|
||||||
|
).body.variables;
|
||||||
|
assert(
|
||||||
|
failedRuntimeAfterRestart.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("failed task restarted")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedOutputAfterRestartRequest = failedSession.client.send("variables", {
|
||||||
|
variablesReference: failedOutputRef
|
||||||
|
});
|
||||||
|
const failedOutputAfterRestart = (
|
||||||
|
await failedSession.client.response(failedOutputAfterRestartRequest, "variables")
|
||||||
|
).body.variables;
|
||||||
|
assert(
|
||||||
|
failedOutputAfterRestart.some((variable) =>
|
||||||
|
String(variable.value).includes("task restarted from VFS checkpoint")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (failedSession) {
|
||||||
|
await failedSession.client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
fs.rmSync(failingProject, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "local-services",
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLine: 42
|
||||||
|
});
|
||||||
|
assert.strictEqual(mainSession.stopped.body.threadId, 1);
|
||||||
|
|
||||||
|
const mainStackRequest = mainSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const mainStack = (await mainSession.client.response(mainStackRequest, "stackTrace"))
|
||||||
|
.body.stackFrames;
|
||||||
|
assert.strictEqual(mainStack.length, 1);
|
||||||
|
assert.match(mainStack[0].name, /build virtual process::run/);
|
||||||
|
assert.strictEqual(mainStack[0].line, 42);
|
||||||
|
assert.strictEqual(mainStack[0].source.path, launchSource);
|
||||||
|
assert.strictEqual(mainStack[0].source.sourceReference || 0, 0);
|
||||||
|
|
||||||
|
const mainSourceRequest = mainSession.client.send("source", { source: mainStack[0].source });
|
||||||
|
const mainSource = (await mainSession.client.response(mainSourceRequest, "source")).body;
|
||||||
|
assert.match(mainSource.content, /build/);
|
||||||
|
|
||||||
|
const mainScopesRequest = mainSession.client.send("scopes", { frameId: mainStack[0].id });
|
||||||
|
const mainScopes = (await mainSession.client.response(mainScopesRequest, "scopes")).body.scopes;
|
||||||
|
const mainRuntimeRef = mainScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||||
|
.variablesReference;
|
||||||
|
const mainRuntimeRequest = mainSession.client.send("variables", {
|
||||||
|
variablesReference: mainRuntimeRef
|
||||||
|
});
|
||||||
|
const mainRuntime = (await mainSession.client.response(mainRuntimeRequest, "variables")).body
|
||||||
|
.variables;
|
||||||
|
assert(
|
||||||
|
mainRuntime.some(
|
||||||
|
(variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
mainRuntime.some(
|
||||||
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await mainSession.client.close();
|
||||||
|
|
||||||
|
const multiMainSession = await launchToBreakpoint({
|
||||||
|
runtimeBackend: "local-services",
|
||||||
|
project: launchProject,
|
||||||
|
breakpointLines: [42, 43]
|
||||||
|
});
|
||||||
|
assert.strictEqual(multiMainSession.stopped.body.threadId, 1);
|
||||||
|
const firstMainStackRequest = multiMainSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const firstMainStack = (await multiMainSession.client.response(firstMainStackRequest, "stackTrace"))
|
||||||
|
.body.stackFrames;
|
||||||
|
assert.strictEqual(firstMainStack[0].line, 42);
|
||||||
|
|
||||||
|
const continueMainRequest = multiMainSession.client.send("continue", { threadId: 1 });
|
||||||
|
await multiMainSession.client.response(continueMainRequest, "continue");
|
||||||
|
await multiMainSession.client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "continued"
|
||||||
|
);
|
||||||
|
const secondMainStop = await multiMainSession.client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "stopped" &&
|
||||||
|
message.body.reason === "breakpoint"
|
||||||
|
);
|
||||||
|
assert.strictEqual(secondMainStop.body.threadId, 1);
|
||||||
|
const secondMainStackRequest = multiMainSession.client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const secondMainStack = (
|
||||||
|
await multiMainSession.client.response(secondMainStackRequest, "stackTrace")
|
||||||
|
).body.stackFrames;
|
||||||
|
assert.strictEqual(secondMainStack[0].line, 43);
|
||||||
|
await multiMainSession.client.close();
|
||||||
|
|
||||||
|
console.log("DAP smoke passed");
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error(err.stack || err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
336
scripts/docs-smoke.js
Normal file
336
scripts/docs-smoke.js
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const readme = fs.readFileSync(path.join(repo, "README.md"), "utf8");
|
||||||
|
const userFacingDocs = [
|
||||||
|
"README.md",
|
||||||
|
"MVP.md",
|
||||||
|
"acceptance_criteria.md",
|
||||||
|
"acceptance_criteria_phase2.md",
|
||||||
|
].map((file) => [file, fs.readFileSync(path.join(repo, file), "utf8")]);
|
||||||
|
const publicAcceptance = fs.readFileSync(
|
||||||
|
path.join(repo, "scripts/acceptance-public.sh"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const publicSplit = fs.readFileSync(
|
||||||
|
path.join(repo, "scripts/verify-public-split.sh"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const privateAcceptance = fs.readFileSync(
|
||||||
|
path.join(repo, "scripts/acceptance-private.sh"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const requiredReadmePatterns = [
|
||||||
|
["quickstart heading", /## Quickstart/],
|
||||||
|
["workspace build", /cargo build --workspace/],
|
||||||
|
["CLI install", /cargo install --path crates\/disasmer-cli --bin disasmer/],
|
||||||
|
["node install", /cargo install --path crates\/disasmer-node --bin disasmer-node/],
|
||||||
|
[
|
||||||
|
"coordinator install",
|
||||||
|
/cargo install --path crates\/disasmer-coordinator --bin disasmer-coordinator/,
|
||||||
|
],
|
||||||
|
["DAP install", /cargo install --path crates\/disasmer-dap --bin disasmer-debug-dap/],
|
||||||
|
["VS Code local extension", /code --extensionDevelopmentPath/],
|
||||||
|
["local coordinator", /disasmer-coordinator --listen/],
|
||||||
|
["node attach", /disasmer node attach --coordinator/],
|
||||||
|
["automatic local run", /disasmer run --local --project examples\/launch-build-demo build/],
|
||||||
|
["demo run", /disasmer run --local --coordinator/],
|
||||||
|
["entrypoint selection", /disasmer run \[entry\]/],
|
||||||
|
["implicit hosted mode", /uses the hosted coordinator/],
|
||||||
|
["local override", /force local coordinator mode/],
|
||||||
|
["project override", /--project[\s\S]*overrides the project\s+directory/],
|
||||||
|
["VS Code debug", /Disasmer: Launch\s+Virtual Process/],
|
||||||
|
["artifact download smoke", /node scripts\/artifact-download-smoke\.js/],
|
||||||
|
["artifact export smoke", /node scripts\/artifact-export-smoke\.js/],
|
||||||
|
["acceptance report smoke", /node scripts\/acceptance-report-smoke\.js/],
|
||||||
|
["public private boundary smoke", /node scripts\/public-private-boundary-smoke\.js/],
|
||||||
|
["release blocker smoke", /node scripts\/release-blocker-smoke\.js/],
|
||||||
|
["explicit export", /attached receiver node or user-provided\s+storage integration/],
|
||||||
|
["cleanup", /Cleanup for the local quickstart/],
|
||||||
|
["flush docs", /`flush\(\)` publishes metadata/],
|
||||||
|
["sync docs", /`sync\(\)` is explicit/],
|
||||||
|
["storage integration is user code", /User-provided storage\/export integrations are ordinary project code or external\s+commands/],
|
||||||
|
["no managed artifact store feature", /does not provide\s+or manage an explicit artifact-store feature/],
|
||||||
|
["best-effort retention", /best-effort retained on nodes/],
|
||||||
|
[
|
||||||
|
"secure downloads",
|
||||||
|
/Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked/,
|
||||||
|
],
|
||||||
|
["node trust", /Users attach their own nodes for real work/],
|
||||||
|
["self-hosted trusted teams", /self-hosted local clouds, trusted teams, or VPN deployments/],
|
||||||
|
["self-hosted coordinator smoke", /node scripts\/self-hosted-coordinator-smoke\.js/],
|
||||||
|
["wasmtime node smoke", /node scripts\/wasmtime-node-smoke\.js/],
|
||||||
|
["wasmtime command host import", /node `disasmer\.cmd_run` host import/],
|
||||||
|
["Windows sandbox limitation", /Production-grade managed Windows sandboxing is behind an explicit backend stub/],
|
||||||
|
["hosted community limit", /community tier does not provide arbitrary hosted native commands or hosted containers/],
|
||||||
|
["browser login", /disasmer login --browser/],
|
||||||
|
["public-key agents", /disasmer agent enroll --public-key/],
|
||||||
|
["noninteractive agent CLI", /DISASMER_AGENT_PUBLIC_KEY=<agent-public-key> disasmer run build/],
|
||||||
|
["agent key lifecycle", /register, list, rotate, and revoke an agent key/],
|
||||||
|
["capability auto-detect", /auto-detects OS, architecture/],
|
||||||
|
["capability override", /--cap <name>/],
|
||||||
|
["non-Git source provider", /Non-Git source providers can implement the public source-provider interface/],
|
||||||
|
["first-run diagnostics", /## First-Run Diagnostics/],
|
||||||
|
["missing nodes diagnostic", /Missing nodes/],
|
||||||
|
["missing environment diagnostic", /Missing environments/],
|
||||||
|
["quota diagnostic", /Quota limits/],
|
||||||
|
["unavailable artifact diagnostic", /Unavailable artifacts/],
|
||||||
|
["auth diagnostic", /Auth failures/],
|
||||||
|
["debug freeze diagnostic", /Failed debug freezes/],
|
||||||
|
["source-provider diagnostic", /Source-provider capability gaps/],
|
||||||
|
["browser and VS Code report metadata", /browser\/VS Code harness metadata/],
|
||||||
|
["Podman incomplete report", /Linux Podman backend behavior is marked `incomplete`/],
|
||||||
|
["manual Windows validation workflow", /manual `Windows validation`\s+workflow/],
|
||||||
|
["intermittent Forgejo Windows runner", /intermittent Windows runner/],
|
||||||
|
["Windows validation env gate", /DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner"/],
|
||||||
|
["Windows validation attach", /runs `disasmer node attach`/],
|
||||||
|
["public dry-run operator endpoint", /https:\/\/disasmer\.michelpaulissen\.com:9443/],
|
||||||
|
["public dry-run DNS record", /record is live[\s\S]*no resolver override is required|DNS record is deployed[\s\S]*no resolver override is required/],
|
||||||
|
["public dry-run real deployment", /real externally reachable service/],
|
||||||
|
["public dry-run selected users", /shared with selected users/],
|
||||||
|
["public dry-run Forgejo repo", /git\.michelpaulissen\.com/],
|
||||||
|
["public dry-run Forgejo Release assets", /Forgejo Release publishes compiled\s+assets/],
|
||||||
|
["public dry-run filters root markdown", /root `\*\.md` files/],
|
||||||
|
["public dry-run filters Forgejo workflows by default", /\.forgejo\/\*\*` by\s+default/],
|
||||||
|
["public dry-run Forgejo workflow opt in", /--include-forgejo-workflows/],
|
||||||
|
["public dry-run selected-user quickstart", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\*\.md/],
|
||||||
|
["public dry-run selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\*\.md/],
|
||||||
|
["public dry-run resolver instructions env", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS=<instructions>/],
|
||||||
|
["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY="<ip-address> disasmer\.michelpaulissen\.com"/],
|
||||||
|
["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=<ip-address>/],
|
||||||
|
["public dry-run prep script", /node scripts\/prepare-public-release-dryrun\.js/],
|
||||||
|
["public dry-run publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE=1/],
|
||||||
|
["public dry-run public repo remote", /DISASMER_PUBLIC_REPO_REMOTE=ssh:\/\/git\.michelpaulissen\.com/],
|
||||||
|
["public dry-run Forgejo workflow", /Public release dry run assets/],
|
||||||
|
["public dry-run manifest", /public-release-manifest\.json/],
|
||||||
|
["public dry-run release publisher", /node scripts\/publish-public-release-dryrun\.js/],
|
||||||
|
["public dry-run non-e2e preflight", /node scripts\/public-release-dryrun-preflight\.js/],
|
||||||
|
["public dry-run Forgejo token", /DISASMER_FORGEJO_TOKEN=<token>/],
|
||||||
|
["public dry-run publisher infers repo", /publisher infers the Forgejo owner and repository name/],
|
||||||
|
["public dry-run Forgejo repo owner override", /DISASMER_PUBLIC_REPO_OWNER=<owner>/],
|
||||||
|
["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=<public-repo>/],
|
||||||
|
["public dry-run service smoke", /public-release-dryrun-service-smoke\.js/],
|
||||||
|
["public dry-run service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:9443/],
|
||||||
|
["public dry-run private hosted coordinator", /default operator at `disasmer\.michelpaulissen\.com:9443` is the private hosted\s+coordinator from `private\/hosted-policy`/],
|
||||||
|
["public dry-run not standalone public coordinator", /does not mean deploying the standalone\s+open-source\/public coordinator as the hosted operator/],
|
||||||
|
["public dry-run validates both coordinators", /dry-run acceptance validates both coordinator implementations/],
|
||||||
|
["public dry-run validates public coordinator separately", /standalone public\/open-source\s+coordinator is validated separately/],
|
||||||
|
["public browser login site", /disasmer\.michelpaulissen\.com\/auth\/browser\/start/],
|
||||||
|
["public browser login local callback", /local callback/],
|
||||||
|
["public dry-run barebones HTML no CSS", /barebones HTML with\s+no CSS/],
|
||||||
|
["browser login plan diagnostic", /disasmer login --browser --plan/],
|
||||||
|
["public dry-run deployment prep", /prepare-public-release-dryrun-deployment\.js/],
|
||||||
|
["public dry-run systemd unit", /systemd unit that\s+binds `0\.0\.0\.0:9443`/],
|
||||||
|
["public dry-run e2e runner", /public-release-dryrun-e2e\.js/],
|
||||||
|
["public dry-run e2e gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/],
|
||||||
|
["public dry-run e2e service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:9443/],
|
||||||
|
["public dry-run final verifier", /public-release-dryrun-final-evidence\.js/],
|
||||||
|
["public dry-run final gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1/],
|
||||||
|
["public dry-run e2e evidence", /public-release-dryrun-e2e\.json/],
|
||||||
|
["public dry-run e2e checks", /downloaded release assets[\s\S]*VS Code debugger behavior[\s\S]*artifact metadata/],
|
||||||
|
["public operator compatibility smoke", /public-operator-compat-smoke\.js/],
|
||||||
|
["public operator compatibility purpose", /public CLI and public\s+node runtime can attach, launch work through coordinator task assignment, and\s+publish debug\/log\/artifact metadata/],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, pattern] of requiredReadmePatterns) {
|
||||||
|
assert.match(readme, pattern, `README missing ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [file, contents] of userFacingDocs) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
contents,
|
||||||
|
/community-tier/i,
|
||||||
|
`${file} should use "community tier" in user-facing prose`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const script of [publicAcceptance, publicSplit]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/docs-smoke.js"),
|
||||||
|
"public acceptance gates must run docs-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/cli-install-smoke.js"),
|
||||||
|
"public acceptance gates must run cli-install-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/cli-login-smoke.js"),
|
||||||
|
"public acceptance gates must run cli-login-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-report-smoke.js"),
|
||||||
|
"public acceptance gates must run acceptance-report-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run acceptance-doc-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-environment-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run acceptance-environment-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run acceptance-evidence-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-private-boundary-smoke.js"),
|
||||||
|
"public acceptance gates must run public-private-boundary-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/release-blocker-smoke.js"),
|
||||||
|
"public acceptance gates must run release-blocker-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run resource-metering-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run hostile-input-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run tenant-isolation-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-story-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run public-story-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-release-dryrun-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run public-release-dryrun-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/prepare-public-release-dryrun.js"),
|
||||||
|
"public acceptance gates must run prepare-public-release-dryrun.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||||
|
"public acceptance gates must run self-hosted-coordinator-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-local-demo-matrix-smoke.js"),
|
||||||
|
"public acceptance gates must run public-local-demo-matrix-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("scripts/release-source-scan.sh"),
|
||||||
|
"public acceptance gates must run release-source-scan.sh"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/flagship-demo-smoke.js"),
|
||||||
|
"public acceptance gates must run flagship-demo-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/cancellation-smoke.js"),
|
||||||
|
"public acceptance gates must run cancellation-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/sdk-spawn-runtime-smoke.js"),
|
||||||
|
"public acceptance gates must run sdk-spawn-runtime-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/node-lifecycle-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run node-lifecycle-contract-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/artifact-export-smoke.js"),
|
||||||
|
"public acceptance gates must run artifact-export-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/windows-validation-contract-smoke.js"),
|
||||||
|
"public acceptance gates must run windows-validation-contract-smoke.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
publicAcceptance.includes("node scripts/podman-backend-smoke.js"),
|
||||||
|
"public acceptance must run the Linux Podman backend smoke when Podman is available"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
publicAcceptance.includes("node scripts/wasmtime-node-smoke.js"),
|
||||||
|
"public acceptance must run the Wasmtime node smoke"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||||
|
"private acceptance must run resource-metering-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||||
|
"private acceptance must run hostile-input-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||||
|
"private acceptance must run tenant-isolation-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||||
|
"private acceptance must run acceptance-doc-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/acceptance-environment-contract-smoke.js"),
|
||||||
|
"private acceptance must run acceptance-environment-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||||
|
"private acceptance must run acceptance-evidence-contract-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"),
|
||||||
|
"private acceptance must run hosted-deployment-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/public-operator-compat-smoke.js"),
|
||||||
|
"private acceptance must run public-operator-compat-smoke.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||||
|
"private acceptance must run self-hosted-coordinator-smoke.js before final dry-run evidence"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"),
|
||||||
|
"private acceptance must prepare public release dry-run deployment bundle"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"),
|
||||||
|
"private acceptance must be able to run public-release-dryrun-service-smoke.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const script of [publicAcceptance, privateAcceptance]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-release-dryrun-final-evidence.js"),
|
||||||
|
"acceptance must be able to run public-release-dryrun-final-evidence.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"),
|
||||||
|
"acceptance must gate public-release-dryrun-final-evidence.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
publicAcceptance.includes("node scripts/public-release-dryrun-e2e.js"),
|
||||||
|
"public acceptance must be able to run public-release-dryrun-e2e.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
publicAcceptance.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"),
|
||||||
|
"public acceptance must gate public-release-dryrun-e2e.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Docs smoke passed");
|
||||||
76
scripts/flagship-demo-smoke.js
Normal file
76
scripts/flagship-demo-smoke.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const extension = require("../vscode-extension/extension");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
const source = fs.readFileSync(path.join(project, "src/build.rs"), "utf8");
|
||||||
|
const forbiddenSourceAssumptions =
|
||||||
|
/\b(?:std::fs|std::process|Command::new|git|podman|docker|localhost|127\.0\.0\.1)|\/home\/|\/Users\/|C:\\Users\\/i;
|
||||||
|
|
||||||
|
const envs = extension.discoverEnvironmentNames(project);
|
||||||
|
assert.deepStrictEqual(envs, ["linux", "windows"]);
|
||||||
|
assert.deepStrictEqual(extension.diagnoseEnvReferences(source, envs), []);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
forbiddenSourceAssumptions,
|
||||||
|
"flagship build source must not rely on coordinator-side filesystem, Git, shell, container, or machine-local assumptions"
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspection = JSON.parse(
|
||||||
|
cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"bundle",
|
||||||
|
"inspect",
|
||||||
|
"--project",
|
||||||
|
project
|
||||||
|
],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(inspection.project, project);
|
||||||
|
assert.strictEqual(
|
||||||
|
inspection.source_provider_manifest.coordinator_requires_checkout_access,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
inspection.source_provider_manifest.transfer_policy.local_source_bytes_remain_node_local,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
inspection.source_provider_manifest.transfer_policy.coordinator_receives_source_bytes_by_default,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
inspection.source_provider_manifest.transfer_policy.default_full_repo_tarball,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
inspection.source_provider_manifest.transfer_policy.allowed_remote_transfer.sort(),
|
||||||
|
["ExplicitSnapshotChunks", "RequiredContent"]
|
||||||
|
);
|
||||||
|
assert.strictEqual(inspection.metadata.embeds_full_container_images, false);
|
||||||
|
assert(inspection.metadata.environments.some((env) => env.name === "linux"));
|
||||||
|
assert(inspection.metadata.environments.some((env) => env.name === "windows"));
|
||||||
|
assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs"));
|
||||||
|
|
||||||
|
cp.execFileSync("cargo", ["test", "-p", "launch-build-demo"], {
|
||||||
|
cwd: repo,
|
||||||
|
stdio: "inherit"
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Flagship demo smoke passed");
|
||||||
176
scripts/hostile-input-contract-smoke.js
Normal file
176
scripts/hostile-input-contract-smoke.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(segments) {
|
||||||
|
const fullPath = path.join(repo, ...segments);
|
||||||
|
if (!fs.existsSync(fullPath)) return null;
|
||||||
|
return fs.readFileSync(fullPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing hostile-input evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectGate(script, gateName) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||||
|
`${gateName} must run hostile-input-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreSource = read("crates/disasmer-core/src/source.rs");
|
||||||
|
const coreCapabilities = read("crates/disasmer-core/src/capability.rs");
|
||||||
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||||
|
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||||
|
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["source manifests validate shape", /pub fn validate_public_mvp\(&self\)[\s\S]*self\.validate_shape\(\)\?/],
|
||||||
|
["source manifests reject invalid digests", /SourceManifestError::InvalidDigest/],
|
||||||
|
["source manifests reject invalid custom providers", /SourceManifestError::InvalidProviderId/],
|
||||||
|
["source manifests reject control characters", /DescriptionControlCharacter/],
|
||||||
|
["source manifests reject coordinator checkout access", /CoordinatorCheckoutAccess/],
|
||||||
|
["source manifests reject default source-byte upload", /CoordinatorReceivesSourceBytes/],
|
||||||
|
]) {
|
||||||
|
expect(coreSource, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["capability reports validate public shape", /pub fn validate_public_report\(&self\)/],
|
||||||
|
["capability reports validate architecture labels", /InvalidArchitecture/],
|
||||||
|
["capability reports validate OS labels", /InvalidOsLabel/],
|
||||||
|
["capability reports validate source providers", /InvalidSourceProvider/],
|
||||||
|
["source provider ids reject path traversal", /valid_source_provider_id/],
|
||||||
|
]) {
|
||||||
|
expect(coreCapabilities, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["coordinator task log tails are bounded", /MAX_TASK_LOG_TAIL_BYTES: usize = 256 \* 1024/],
|
||||||
|
["coordinator validates reported stdout tails", /ReportTaskLog[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/],
|
||||||
|
["coordinator validates completed task stdout tails", /TaskCompleted[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/],
|
||||||
|
["coordinator rejects oversized log tail in unit coverage", /"x"\.repeat\(MAX_TASK_LOG_TAIL_BYTES \+ 1\)/],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["service rejects malformed node capability report", /fn service_rejects_malformed_node_capability_report\(\)/],
|
||||||
|
["capability report rejection leaves descriptors empty", /assert!\(service\.node_descriptors\.is_empty\(\)\)/],
|
||||||
|
["node capability report rejects cross-scope writes", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/],
|
||||||
|
["task completion rejects cross-scope writes", /task completion outside node scope|outside/],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, source, patterns] of [
|
||||||
|
[
|
||||||
|
"artifact download smoke",
|
||||||
|
artifactDownloadSmoke,
|
||||||
|
[
|
||||||
|
/const crossTenant = await send/,
|
||||||
|
/const crossProject = await send/,
|
||||||
|
/const guessed = await send/,
|
||||||
|
/const crossActorOpen = await send/,
|
||||||
|
/token is invalid/,
|
||||||
|
/tenant mismatch/,
|
||||||
|
/project mismatch/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"operator panel smoke",
|
||||||
|
operatorPanelSmoke,
|
||||||
|
[
|
||||||
|
/render_operator_panel/,
|
||||||
|
/submit_panel_event/,
|
||||||
|
/assert\(!JSON\.stringify\(panel\)\.includes\("<script"\)\)/,
|
||||||
|
/assert\(!JSON\.stringify\(panel\)\.toLowerCase\(\)\.includes\("oauth"\)\)/,
|
||||||
|
/rate limit/i,
|
||||||
|
/exceeds download limit/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"scheduler smoke",
|
||||||
|
schedulerSmoke,
|
||||||
|
[/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source preparation smoke",
|
||||||
|
sourcePreparationSmoke,
|
||||||
|
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectGate(publicAcceptance, "public acceptance");
|
||||||
|
expectGate(publicSplit, "public split acceptance");
|
||||||
|
expectGate(privateAcceptance, "private acceptance");
|
||||||
|
|
||||||
|
const hostedService = maybeRead([
|
||||||
|
"private",
|
||||||
|
"hosted-policy",
|
||||||
|
"src",
|
||||||
|
"bin",
|
||||||
|
"disasmer-hosted-service.rs",
|
||||||
|
]);
|
||||||
|
const hostedSmoke = maybeRead([
|
||||||
|
"private",
|
||||||
|
"hosted-policy",
|
||||||
|
"scripts",
|
||||||
|
"hosted-community-smoke.js",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (hostedService && hostedSmoke) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["hosted service turns malformed JSON into error responses", /serde_json::from_str::<HostedRequest>[\s\S]*HostedResponse::Error/],
|
||||||
|
["tenant ids are validated", /fn tenant_id\(value: String\)[\s\S]*validate_identifier\("tenant", &value\)\?/],
|
||||||
|
["project ids are validated", /fn project_id\(value: String\)[\s\S]*validate_identifier\("project", &value\)\?/],
|
||||||
|
["user ids are validated", /fn user_id\(value: String\)[\s\S]*validate_identifier\("user", &value\)\?/],
|
||||||
|
["node ids are validated", /fn node_id\(value: String\)[\s\S]*validate_identifier\("node", &value\)\?/],
|
||||||
|
["process ids are validated", /fn process_id\(value: String\)[\s\S]*validate_identifier\("process", &value\)\?/],
|
||||||
|
["task ids are validated", /fn task_id\(value: String\)[\s\S]*validate_identifier\("task", &value\)\?/],
|
||||||
|
["artifact ids are validated", /fn artifact_id\(value: String\)[\s\S]*validate_identifier\("artifact", &value\)\?/],
|
||||||
|
["identifiers reject empty and control/path characters", /fn validate_identifier[\s\S]*trim\(\)\.is_empty\(\)[\s\S]*ch\.is_control\(\) \|\| ch == '\/' \|\| ch == '\\\\'/],
|
||||||
|
["OIDC and agent text fields are bounded", /fn validate_text[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||||
|
["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||||
|
["logs are bounded at service boundary", /fn validate_log[\s\S]*512 \* 1024/],
|
||||||
|
["debug requests validate process and task before inspection", /HostedRequest::DebugProcess[\s\S]*process_id\(process\)\?[\s\S]*task_id\(task\)\?[\s\S]*debug_process/],
|
||||||
|
["download requests validate artifact before action", /HostedRequest::DownloadAction[\s\S]*artifact_id\(artifact\)\?[\s\S]*download_action/],
|
||||||
|
["log completion requests validate task and stdout before recording", /HostedRequest::RecordUserNodeTaskCompletion[\s\S]*task_id\(task\)\?[\s\S]*validate_log\("task stdout", &stdout\)\?[\s\S]*record_user_node_task_completion/],
|
||||||
|
]) {
|
||||||
|
expect(hostedService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["running service rejects malformed JSON", /sendRaw\(addr, "\{not-json"\)/],
|
||||||
|
["running service rejects invalid tenant id", /tenant: " "/],
|
||||||
|
["running service rejects invalid project id", /project: "project\\nbad"/],
|
||||||
|
["running service rejects invalid task id", /task: "compile-linux\\nescape"/],
|
||||||
|
["running service rejects oversized logs", /stdout: "x"\.repeat\(256 \* 1024 \+ 1\)/],
|
||||||
|
["running service rejects cross-tenant debug", /const crossTenantDebug = await send/],
|
||||||
|
["running service rejects cross-tenant metadata", /const crossTenantMetadata = await send/],
|
||||||
|
["running service rejects cross-tenant download", /const crossTenantDownload = await send/],
|
||||||
|
["foreign snapshots do not reveal objects", /foreignSnapshot[\s\S]*snapshot\.nodes, \[\][\s\S]*snapshot\.logs, \[\][\s\S]*snapshot\.artifacts, \[\]/],
|
||||||
|
]) {
|
||||||
|
expect(hostedSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Hostile input contract smoke passed");
|
||||||
188
scripts/local-services-smoke.js
Normal file
188
scripts/local-services-smoke.js
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
const line = buffer.slice(0, newline).trim();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(line));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNode(addr, enrollmentGrant) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-a",
|
||||||
|
"--enrollment-grant",
|
||||||
|
enrollmentGrant,
|
||||||
|
"--public-key",
|
||||||
|
"node-a-public-key",
|
||||||
|
"--process",
|
||||||
|
"vp-local",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--project",
|
||||||
|
project,
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/demo-test-output.txt"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
const nodePid = child.pid;
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve({ pid: nodePid, report: JSON.parse(stdout.trim().split("\n").at(-1)) });
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
assert(Number.isInteger(coordinator.pid));
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const grant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_grant",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
grant: "grant-local-services-node",
|
||||||
|
now_epoch_seconds: 0,
|
||||||
|
ttl_seconds: 900
|
||||||
|
});
|
||||||
|
assert.strictEqual(grant.type, "node_enrollment_grant_created");
|
||||||
|
|
||||||
|
const { pid: nodePid, report } = await runNode(addr, grant.grant);
|
||||||
|
assert(Number.isInteger(nodePid));
|
||||||
|
assert.notStrictEqual(nodePid, coordinator.pid);
|
||||||
|
assert.strictEqual(report.node_status, "completed");
|
||||||
|
assert.strictEqual(report.status_code, 0);
|
||||||
|
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(report.heartbeat_response.type, "node_heartbeat");
|
||||||
|
assert.strictEqual(report.capability_response.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(report.task_assignment_response.type, "task_placement");
|
||||||
|
assert.strictEqual(report.debug_command_response.type, "debug_command");
|
||||||
|
assert.strictEqual(report.log_event_response.type, "task_log_recorded");
|
||||||
|
assert.strictEqual(report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||||
|
assert.strictEqual(report.session_requests, 10);
|
||||||
|
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/demo-test-output.txt");
|
||||||
|
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
process: "vp-local"
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "node-a");
|
||||||
|
assert.strictEqual(events.events[0].process, "vp-local");
|
||||||
|
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||||
|
assert.strictEqual(events.events[0].status_code, 0);
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/demo-test-output.txt");
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Local services smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
270
scripts/node-attach-smoke.js
Executable file
270
scripts/node-attach-smoke.js
Executable file
|
|
@ -0,0 +1,270 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const demoProject = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAttach(addr, grant) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"node",
|
||||||
|
"attach",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-attach",
|
||||||
|
"--public-key",
|
||||||
|
"node-attach-public-key",
|
||||||
|
"--enrollment-grant",
|
||||||
|
grant,
|
||||||
|
"--cap",
|
||||||
|
"quic-direct",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`node attach failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout));
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
new Error(`node attach output was not JSON: ${stdout}\n${error.stack || error.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAttachedNodeWork(addr) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"node-attach",
|
||||||
|
"--process",
|
||||||
|
"vp-node-attach",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--project",
|
||||||
|
demoProject,
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/node-attach-output.txt",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`attached node work failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`attached node work output was not JSON: ${stdout}\n${error.stack || error.message}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const grant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_grant",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator",
|
||||||
|
grant: "grant-node-attach",
|
||||||
|
now_epoch_seconds: 0,
|
||||||
|
ttl_seconds: 900
|
||||||
|
});
|
||||||
|
assert.strictEqual(grant.type, "node_enrollment_grant_created");
|
||||||
|
assert.strictEqual(grant.tenant, "tenant");
|
||||||
|
assert.strictEqual(grant.project, "project");
|
||||||
|
assert.strictEqual(grant.grant, "grant-node-attach");
|
||||||
|
assert.strictEqual(grant.scope, "node:attach");
|
||||||
|
assert.strictEqual(grant.expires_at_epoch_seconds, 900);
|
||||||
|
|
||||||
|
const report = await runAttach(addr, grant.grant);
|
||||||
|
assert.strictEqual(report.plan.node, "node-attach");
|
||||||
|
assert.strictEqual(report.plan.coordinator, `${addr.host}:${addr.port}`);
|
||||||
|
assert.strictEqual(report.plan.enrollment.grant, "grant-node-attach");
|
||||||
|
assert.match(report.plan.enrollment.public_key_fingerprint, /^sha256:[0-9a-f]{64}$/);
|
||||||
|
assert.strictEqual(
|
||||||
|
report.plan.enrollment.exchanges_short_lived_grant_for_long_lived_node_identity,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.ok(report.plan.capabilities.arch.length > 0);
|
||||||
|
assert.ok(report.plan.capabilities.source_providers.includes("filesystem"));
|
||||||
|
assert.ok(report.plan.capabilities.capabilities.includes("QuicDirect"));
|
||||||
|
assert.strictEqual(report.boundary.cli_contacted_coordinator, true);
|
||||||
|
assert.strictEqual(report.boundary.used_enrollment_exchange, true);
|
||||||
|
assert.strictEqual(report.boundary.coordinator_session_requests, 3);
|
||||||
|
assert.strictEqual(report.coordinator_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(report.coordinator_response.node, "node-attach");
|
||||||
|
assert.strictEqual(report.coordinator_response.credential.node, "node-attach");
|
||||||
|
assert.strictEqual(report.coordinator_response.credential.scope, "node:attach");
|
||||||
|
assert.strictEqual(report.coordinator_response.credential.credential_kind, "NodeCredential");
|
||||||
|
assert.match(
|
||||||
|
report.coordinator_response.credential.capability_policy_digest,
|
||||||
|
/^sha256:[0-9a-f]{64}$/
|
||||||
|
);
|
||||||
|
assert.strictEqual(report.heartbeat_response.type, "node_heartbeat");
|
||||||
|
assert.strictEqual(report.capability_response.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const heartbeat = await send(addr, {
|
||||||
|
type: "node_heartbeat",
|
||||||
|
node: "node-attach",
|
||||||
|
});
|
||||||
|
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||||
|
assert.strictEqual(heartbeat.node, "node-attach");
|
||||||
|
|
||||||
|
const work = await runAttachedNodeWork(addr);
|
||||||
|
assert.strictEqual(work.node_status, "completed");
|
||||||
|
assert.strictEqual(work.virtual_thread, "compile-linux");
|
||||||
|
assert.strictEqual(work.status_code, 0);
|
||||||
|
assert.strictEqual(work.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(
|
||||||
|
work.staged_artifact.path,
|
||||||
|
"/vfs/artifacts/node-attach-output.txt"
|
||||||
|
);
|
||||||
|
assert.strictEqual(work.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator",
|
||||||
|
process: "vp-node-attach",
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "node-attach");
|
||||||
|
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||||
|
assert.strictEqual(
|
||||||
|
events.events[0].artifact_path,
|
||||||
|
"/vfs/artifacts/node-attach-output.txt"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Node attach smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
138
scripts/node-lifecycle-contract-smoke.js
Executable file
138
scripts/node-lifecycle-contract-smoke.js
Executable file
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing node lifecycle evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMain = read("crates/disasmer-node/src/main.rs");
|
||||||
|
const nodeLib = read("crates/disasmer-node/src/lib.rs");
|
||||||
|
const coordinatorCore = read("crates/disasmer-coordinator/src/lib.rs");
|
||||||
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
|
const localServicesSmoke = read("scripts/local-services-smoke.js");
|
||||||
|
const cancellationSmoke = read("scripts/cancellation-smoke.js");
|
||||||
|
const wasmtimeSmoke = read("scripts/wasmtime-node-smoke.js");
|
||||||
|
const debugCore = read("crates/disasmer-core/src/debug.rs");
|
||||||
|
const readme = read("README.md");
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
(nodeMain.match(/CoordinatorSession::connect/g) || []).length,
|
||||||
|
1,
|
||||||
|
"node runtime should open one coordinator session in the local process-boundary runtime"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["enrollment exchange over session", /"type": "exchange_node_enrollment_grant"/],
|
||||||
|
["node attach over session", /"type": "attach_node"/],
|
||||||
|
["heartbeat over session", /"type": "node_heartbeat"/],
|
||||||
|
["capability report over session", /"type": "report_node_capabilities"/],
|
||||||
|
["task placement over session", /"type": "schedule_task"/],
|
||||||
|
["process start over session", /"type": "start_process"/],
|
||||||
|
["reconnect over session", /"type": "reconnect_node"/],
|
||||||
|
["debug command polling over session", /"type": "poll_debug_command"/],
|
||||||
|
["log event over session", /"type": "report_task_log"/],
|
||||||
|
["VFS metadata over session", /"type": "report_vfs_metadata"/],
|
||||||
|
["task control polling over session", /"type": "poll_task_control"/],
|
||||||
|
["completion over session", /"type": "task_completed"/],
|
||||||
|
["cancellation uses same session", /wait_for_cancellation\(session, args, &task\)/],
|
||||||
|
["request count is reported", /session\.requests\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(nodeMain, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["local smoke asserts persistent request count", /assert\.strictEqual\(report\.session_requests, 10\)/],
|
||||||
|
["local smoke uses enrollment exchange", /assert\.strictEqual\(report\.registration_response\.type, "node_enrollment_exchanged"\)/],
|
||||||
|
["local smoke verifies capability report", /assert\.strictEqual\(report\.capability_response\.type, "node_capabilities_recorded"\)/],
|
||||||
|
["local smoke verifies task placement", /assert\.strictEqual\(report\.task_assignment_response\.type, "task_placement"\)/],
|
||||||
|
["local smoke verifies debug command channel", /assert\.strictEqual\(report\.debug_command_response\.type, "debug_command"\)/],
|
||||||
|
["local smoke verifies log event", /assert\.strictEqual\(report\.log_event_response\.type, "task_log_recorded"\)/],
|
||||||
|
["local smoke verifies VFS metadata", /assert\.strictEqual\(report\.vfs_metadata_response\.type, "vfs_metadata_recorded"\)/],
|
||||||
|
["local smoke records task completion", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
|
||||||
|
["local smoke verifies artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/demo-test-output\.txt"\)/],
|
||||||
|
["local smoke verifies output accounting", /assert\.strictEqual\(events\.events\[0\]\.status_code, 0\)/],
|
||||||
|
]) {
|
||||||
|
expect(localServicesSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["cancellation request is external client request", /type: "cancel_task"/],
|
||||||
|
["node reports cancelled terminal state", /assert\.strictEqual\(report\.terminal_state, "cancelled"\)/],
|
||||||
|
["cancelled completion is recorded", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
|
||||||
|
["coordinator stores cancelled task event", /assert\.strictEqual\(events\.events\[0\]\.terminal_state, "cancelled"\)/],
|
||||||
|
["control flag is cleared after terminal state", /assert\.strictEqual\(control\.cancel_requested, false\)/],
|
||||||
|
]) {
|
||||||
|
expect(cancellationSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["coordinator rejects stale process ownership", /fn node_reconnect_rejects_stale_process_epoch_after_restart\(\)/],
|
||||||
|
["reconnect preserves enrolled node identity", /reconnect_node\(&NodeId::from\("node"\), None\)/],
|
||||||
|
["stale process epoch is rejected", /CoordinatorError::StaleProcessEpoch/],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorCore, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["coordinator delivers cancellation to connected node", /fn service_delivers_cancellation_to_connected_node_and_records_terminal_state\(\)/],
|
||||||
|
["node polls task control", /CoordinatorRequest::PollTaskControl/],
|
||||||
|
["cancelled terminal state is recorded", /TaskTerminalState::Cancelled/],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["native lifecycle test exists", /fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume\(\)/],
|
||||||
|
["native freeze succeeds when supported", /lifecycle\.freeze_for_debug_epoch\(\)\.unwrap\(\)/],
|
||||||
|
["native resume succeeds", /lifecycle\.resume_after_debug_epoch\(\)/],
|
||||||
|
["native cancel reaches lifecycle", /lifecycle\.cancel\(\)/],
|
||||||
|
["unsupported freeze errors", /BackendError::DebugFreezeUnsupported/],
|
||||||
|
["wasmtime runtime exposes freeze resume probe", /pub fn freeze_resume_i32_export_probe/],
|
||||||
|
["wasmtime runtime captures Wasm frame locals", /debug_i32_export_snapshot[\s\S]*local_values/],
|
||||||
|
["wasmtime runtime creates Wasm debug participant", /kind: DebugParticipantKind::WasmTask/],
|
||||||
|
["wasmtime debug participant carries local values", /local_values: snapshot\.local_values\.clone\(\)/],
|
||||||
|
["wasmtime runtime resumes after freeze", /epoch\.continue_all\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(nodeLib, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["wasmtime smoke runs debug freeze resume mode", /--debug-freeze-resume/],
|
||||||
|
["wasmtime smoke verifies frozen state", /debugReport\.frozen_state, "Frozen"/],
|
||||||
|
["wasmtime smoke verifies resumed state", /debugReport\.resumed_state, "Running"/],
|
||||||
|
["wasmtime smoke verifies frame local values", /debugReport\.local_values[\s\S]*wasm_local_0/],
|
||||||
|
["wasmtime smoke proves node runtime reached wasm task", /node_runtime_reached_wasm_task/],
|
||||||
|
["wasmtime smoke proves node captured locals", /node_runtime_captured_wasm_locals/],
|
||||||
|
]) {
|
||||||
|
expect(wasmtimeSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["debug model freezes wasm and command participants", /fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks\(\)/],
|
||||||
|
["debug model rejects unsupported freeze", /fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop\(\)/],
|
||||||
|
["debug model resumes frozen participants", /fn continue_resumes_every_frozen_participant\(\)/],
|
||||||
|
["debug model includes captured locals", /local_values/],
|
||||||
|
["wasm participants are modeled", /DebugParticipantKind::WasmTask/],
|
||||||
|
["controlled native command participants are modeled", /DebugParticipantKind::ControlledNativeCommand/],
|
||||||
|
]) {
|
||||||
|
expect(debugCore, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["docs describe one node coordinator session", /keeps one node-to-coordinator JSON-line session open/],
|
||||||
|
["docs describe cancellation terminal event", /records a cancelled terminal event/],
|
||||||
|
["docs describe failed freeze diagnostic", /Failed debug freezes/],
|
||||||
|
]) {
|
||||||
|
expect(readme, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Node lifecycle contract smoke passed");
|
||||||
356
scripts/operator-panel-smoke.js
Executable file
356
scripts/operator-panel-smoke.js
Executable file
|
|
@ -0,0 +1,356 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runNode(addr) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"panel-node",
|
||||||
|
"--process",
|
||||||
|
"vp-panel",
|
||||||
|
"--task",
|
||||||
|
"compile-linux",
|
||||||
|
"--project",
|
||||||
|
project,
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/panel-output.txt"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function widget(panel, id) {
|
||||||
|
const item = panel.widgets[id];
|
||||||
|
assert(item, `missing panel widget ${id}`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const report = await runNode(addr);
|
||||||
|
assert.strictEqual(report.node_status, "completed");
|
||||||
|
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
const rendered = await send(addr, {
|
||||||
|
type: "render_operator_panel",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
actor_user: "user",
|
||||||
|
max_download_bytes: 1024 * 1024,
|
||||||
|
stopped: false
|
||||||
|
});
|
||||||
|
assert.strictEqual(rendered.type, "operator_panel");
|
||||||
|
const panel = rendered.panel;
|
||||||
|
assert.strictEqual(panel.tenant, "tenant");
|
||||||
|
assert.strictEqual(panel.project, "project");
|
||||||
|
assert.strictEqual(panel.process, "vp-panel");
|
||||||
|
assert.strictEqual(panel.program_ui_events_enabled, true);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(widget(panel, "process-status").kind, {
|
||||||
|
Text: { value: "running" }
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(widget(panel, "task-progress").kind, {
|
||||||
|
Progress: { current: 1, total: 1 }
|
||||||
|
});
|
||||||
|
assert.match(
|
||||||
|
widget(panel, "task-summary").kind.Text.value,
|
||||||
|
/compile-linux:Some\(0\):panel-node/
|
||||||
|
);
|
||||||
|
assert.match(widget(panel, "recent-logs").kind.Text.value, /stdout=\d+ stderr=\d+/);
|
||||||
|
const downloadWidget = widget(panel, "download-artifact").kind;
|
||||||
|
assert.deepStrictEqual(downloadWidget, {
|
||||||
|
ArtifactDownload: { artifact: "panel-output.txt" }
|
||||||
|
});
|
||||||
|
assert(!JSON.stringify(downloadWidget).includes("url_path"));
|
||||||
|
assert(!JSON.stringify(downloadWidget).includes("scoped_token_digest"));
|
||||||
|
assert.deepStrictEqual(widget(panel, "debug-process").kind, {
|
||||||
|
Button: { action: "debug-process" }
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(widget(panel, "cancel-process").kind, {
|
||||||
|
Button: { action: "cancel-process" }
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(widget(panel, "restart-selected-task").kind, {
|
||||||
|
Button: { action: "restart-task" }
|
||||||
|
});
|
||||||
|
assert(panel.control_plane_actions.includes("DebugProcess"));
|
||||||
|
assert(panel.control_plane_actions.includes("CancelProcess"));
|
||||||
|
assert(
|
||||||
|
panel.control_plane_actions.some(
|
||||||
|
(action) => action.RestartTask === "compile-linux"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
panel.control_plane_actions.some(
|
||||||
|
(action) => action.DownloadArtifact === "panel-output.txt"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(!JSON.stringify(panel).includes("<script"));
|
||||||
|
assert(!JSON.stringify(panel).toLowerCase().includes("oauth"));
|
||||||
|
|
||||||
|
const panelLink = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "panel-button",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60
|
||||||
|
});
|
||||||
|
assert.strictEqual(panelLink.type, "artifact_download_link");
|
||||||
|
assert.strictEqual(panelLink.link.artifact, downloadWidget.ArtifactDownload.artifact);
|
||||||
|
assert.match(panelLink.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
|
||||||
|
assert.deepStrictEqual(panelLink.link.source, { RetainedNode: "panel-node" });
|
||||||
|
assert.match(panelLink.link.url_path, /\/artifacts\/tenant\/project\/vp-panel\/panel-output\.txt$/);
|
||||||
|
|
||||||
|
const panelStream = await send(addr, {
|
||||||
|
type: "open_artifact_download_stream",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "panel-button",
|
||||||
|
token_digest: panelLink.link.scoped_token_digest,
|
||||||
|
now_epoch_seconds: 11,
|
||||||
|
chunk_bytes: 16
|
||||||
|
});
|
||||||
|
assert.strictEqual(panelStream.type, "artifact_download_stream");
|
||||||
|
assert.strictEqual(panelStream.link.artifact, downloadWidget.ArtifactDownload.artifact);
|
||||||
|
assert.deepStrictEqual(panelStream.link.source, { RetainedNode: "panel-node" });
|
||||||
|
assert.strictEqual(panelStream.streamed_bytes, 16);
|
||||||
|
|
||||||
|
const apiTooLarge = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||||
|
max_bytes: 1,
|
||||||
|
token_nonce: "too-small",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60
|
||||||
|
});
|
||||||
|
assert.strictEqual(apiTooLarge.type, "error");
|
||||||
|
assert.match(apiTooLarge.message, /exceeds download limit/);
|
||||||
|
|
||||||
|
const panelTooLarge = await send(addr, {
|
||||||
|
type: "render_operator_panel",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
actor_user: "user",
|
||||||
|
max_download_bytes: 1,
|
||||||
|
stopped: false
|
||||||
|
});
|
||||||
|
assert.strictEqual(panelTooLarge.type, "error");
|
||||||
|
assert.match(panelTooLarge.message, /exceeds download limit/);
|
||||||
|
|
||||||
|
const accepted = await send(addr, {
|
||||||
|
type: "submit_panel_event",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
widget_id: "debug-process",
|
||||||
|
kind: "ButtonClicked",
|
||||||
|
max_events: 1
|
||||||
|
});
|
||||||
|
assert.strictEqual(accepted.type, "panel_event_accepted");
|
||||||
|
assert.strictEqual(accepted.used_events, 1);
|
||||||
|
assert.strictEqual(accepted.max_events, 1);
|
||||||
|
|
||||||
|
const rateLimited = await send(addr, {
|
||||||
|
type: "submit_panel_event",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
widget_id: "debug-process",
|
||||||
|
kind: "ButtonClicked",
|
||||||
|
max_events: 1
|
||||||
|
});
|
||||||
|
assert.strictEqual(rateLimited.type, "error");
|
||||||
|
assert.match(rateLimited.message, /rate limit/i);
|
||||||
|
|
||||||
|
const stopped = await send(addr, {
|
||||||
|
type: "render_operator_panel",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
actor_user: "user",
|
||||||
|
max_download_bytes: 1024 * 1024,
|
||||||
|
stopped: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(stopped.type, "operator_panel");
|
||||||
|
assert.strictEqual(stopped.panel.program_ui_events_enabled, false);
|
||||||
|
assert.deepStrictEqual(widget(stopped.panel, "process-status").kind, {
|
||||||
|
Text: { value: "stopped" }
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
widget(stopped.panel, "task-progress").kind,
|
||||||
|
widget(panel, "task-progress").kind
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
widget(stopped.panel, "task-summary").kind,
|
||||||
|
widget(panel, "task-summary").kind
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
widget(stopped.panel, "recent-logs").kind,
|
||||||
|
widget(panel, "recent-logs").kind
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
widget(stopped.panel, "download-artifact").kind,
|
||||||
|
widget(panel, "download-artifact").kind
|
||||||
|
);
|
||||||
|
assert(stopped.panel.control_plane_actions.includes("DebugProcess"));
|
||||||
|
assert(
|
||||||
|
stopped.panel.control_plane_actions.some(
|
||||||
|
(action) => action.DownloadArtifact === "panel-output.txt"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const frozenEvent = await send(addr, {
|
||||||
|
type: "submit_panel_event",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
widget_id: "debug-process",
|
||||||
|
kind: "ButtonClicked",
|
||||||
|
max_events: 10
|
||||||
|
});
|
||||||
|
assert.strictEqual(frozenEvent.type, "error");
|
||||||
|
assert.match(frozenEvent.message, /program UI events are disabled/i);
|
||||||
|
|
||||||
|
const crossTenant = await send(addr, {
|
||||||
|
type: "render_operator_panel",
|
||||||
|
tenant: "other",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-panel",
|
||||||
|
actor_user: "user",
|
||||||
|
max_download_bytes: 1024 * 1024,
|
||||||
|
stopped: false
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenant.type, "error");
|
||||||
|
assert.match(crossTenant.message, /scope|tenant|project/i);
|
||||||
|
} catch (error) {
|
||||||
|
if (coordinatorStderr) {
|
||||||
|
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Operator panel smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
80
scripts/podman-backend-smoke.js
Executable file
80
scripts/podman-backend-smoke.js
Executable file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const baseImage = "docker.io/library/alpine:3.20";
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
return cp.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: options.stdio || ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function incomplete(reason) {
|
||||||
|
const error = new Error(`Linux Podman backend incomplete: ${reason}`);
|
||||||
|
error.code = "DISASMER_PODMAN_INCOMPLETE";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePodmanBaseImage() {
|
||||||
|
try {
|
||||||
|
run("podman", ["--version"]);
|
||||||
|
} catch (error) {
|
||||||
|
incomplete(`podman command is unavailable (${error.message})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootless;
|
||||||
|
try {
|
||||||
|
rootless = run("podman", ["info", "--format", "{{.Host.Security.Rootless}}"]).trim();
|
||||||
|
} catch (error) {
|
||||||
|
incomplete(`podman info did not report rootless status (${error.message})`);
|
||||||
|
}
|
||||||
|
if (rootless !== "true") {
|
||||||
|
incomplete(`podman is not running in rootless mode (reported ${JSON.stringify(rootless)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run("podman", ["image", "exists", baseImage]);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
run("podman", ["pull", baseImage], { stdio: "inherit" });
|
||||||
|
} catch (error) {
|
||||||
|
incomplete(`unable to make ${baseImage} available (${error.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensurePodmanBaseImage();
|
||||||
|
|
||||||
|
const stdout = run("cargo", [
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-podman-smoke"
|
||||||
|
]);
|
||||||
|
const report = JSON.parse(stdout.trim().split("\n").at(-1));
|
||||||
|
|
||||||
|
assert.strictEqual(report.podman_status, "completed");
|
||||||
|
assert.strictEqual(report.status_code, 0);
|
||||||
|
assert.strictEqual(report.stdout, "podman-ok:node-local source\n");
|
||||||
|
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(report.uses_full_repo_tarball, false);
|
||||||
|
assert.strictEqual(report.coordinator_routed_file_reads, false);
|
||||||
|
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/podman-smoke.txt");
|
||||||
|
|
||||||
|
console.log("Podman backend smoke passed");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "DISASMER_PODMAN_INCOMPLETE") {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
608
scripts/prepare-public-release-dryrun.js
Executable file
608
scripts/prepare-public-release-dryrun.js
Executable file
|
|
@ -0,0 +1,608 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const outputRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-dryrun")
|
||||||
|
);
|
||||||
|
const publicTree = path.join(outputRoot, "public-tree");
|
||||||
|
const assetsDir = path.join(outputRoot, "assets");
|
||||||
|
const stagingDir = path.join(outputRoot, "staging");
|
||||||
|
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
const forgejoHost = "git.michelpaulissen.com";
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const includeForgejoWorkflows =
|
||||||
|
args.has("--include-forgejo-workflows") ||
|
||||||
|
/^(1|true|yes)$/i.test(process.env.DISASMER_INCLUDE_FORGEJO_WORKFLOWS || "");
|
||||||
|
const filteredTopLevel = ["private", "experiments", ".git", "target"];
|
||||||
|
const publicRepoBranch = process.env.DISASMER_PUBLIC_REPO_BRANCH || "main";
|
||||||
|
const publicBinaries = [
|
||||||
|
"disasmer",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"disasmer-node",
|
||||||
|
"disasmer-debug-dap",
|
||||||
|
];
|
||||||
|
|
||||||
|
function commandOutput(command, args, options = {}) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
cp.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
stdio: "inherit",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredOutPatterns() {
|
||||||
|
return [
|
||||||
|
"private/**",
|
||||||
|
"experiments/**",
|
||||||
|
".git",
|
||||||
|
"target",
|
||||||
|
"/*.md",
|
||||||
|
...(includeForgejoWorkflows ? [] : [".forgejo/**"]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRootMarkdown(relativePath, entry) {
|
||||||
|
return (
|
||||||
|
!relativePath.includes(path.sep) &&
|
||||||
|
(entry.isFile() || entry.isSymbolicLink()) &&
|
||||||
|
path.extname(entry.name).toLowerCase() === ".md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFilter(entry, relativePath) {
|
||||||
|
const topLevel = relativePath.split(path.sep)[0];
|
||||||
|
if (filteredTopLevel.includes(topLevel)) return true;
|
||||||
|
if (topLevel === ".forgejo" && !includeForgejoWorkflows) return true;
|
||||||
|
if (isRootMarkdown(relativePath, entry)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFilteredTree(src, dest, relative = "") {
|
||||||
|
ensureDir(dest);
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(src, { withFileTypes: true })
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
||||||
|
if (shouldFilter(entry, childRelative)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = path.join(src, entry.name);
|
||||||
|
const to = path.join(dest, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyFilteredTree(from, to, childRelative);
|
||||||
|
} else if (entry.isSymbolicLink()) {
|
||||||
|
fs.symlinkSync(fs.readlinkSync(from), to);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
fs.copyFileSync(from, to);
|
||||||
|
fs.chmodSync(to, fs.statSync(from).mode & 0o777);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFilteredTree() {
|
||||||
|
for (const excluded of ["private", "experiments"]) {
|
||||||
|
if (fs.existsSync(path.join(publicTree, excluded))) {
|
||||||
|
throw new Error(`${excluded}/ leaked into the dry-run public tree`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) {
|
||||||
|
throw new Error(".forgejo/ leaked into the host-neutral dry-run public tree");
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(publicTree, { withFileTypes: true })) {
|
||||||
|
if (isRootMarkdown(entry.name, entry)) {
|
||||||
|
throw new Error(`root Markdown file leaked into the dry-run public tree: ${entry.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkFiles(root, relative = "") {
|
||||||
|
const dir = path.join(root, relative);
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...walkFiles(root, childRelative));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(childRelative);
|
||||||
|
} else if (entry.isSymbolicLink()) {
|
||||||
|
files.push(childRelative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashTree(root) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
for (const file of walkFiles(root)) {
|
||||||
|
const absolute = path.join(root, file);
|
||||||
|
const stat = fs.lstatSync(absolute);
|
||||||
|
hash.update(file.replaceAll(path.sep, "/"));
|
||||||
|
hash.update("\0");
|
||||||
|
hash.update(String(stat.mode & 0o777));
|
||||||
|
hash.update("\0");
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
hash.update("symlink");
|
||||||
|
hash.update("\0");
|
||||||
|
hash.update(fs.readlinkSync(absolute));
|
||||||
|
} else {
|
||||||
|
hash.update(fs.readFileSync(absolute));
|
||||||
|
}
|
||||||
|
hash.update("\0");
|
||||||
|
}
|
||||||
|
return `sha256:${hash.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(file) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarGz(output, cwd, inputs) {
|
||||||
|
run("tar", ["-czf", output, "-C", cwd, ...inputs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformName() {
|
||||||
|
return `${os.platform()}-${os.arch()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryName(name) {
|
||||||
|
return process.platform === "win32" ? `${name}.exe` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicBinaries() {
|
||||||
|
run("cargo", ["build", "--workspace", "--bins", "--release"], {
|
||||||
|
cwd: publicTree,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageBinaryAssets(releaseName) {
|
||||||
|
const stageRoot = path.join(stagingDir, "binaries");
|
||||||
|
const binDir = path.join(stageRoot, "bin");
|
||||||
|
fs.rmSync(stageRoot, { recursive: true, force: true });
|
||||||
|
ensureDir(binDir);
|
||||||
|
|
||||||
|
for (const binary of publicBinaries) {
|
||||||
|
const fileName = binaryName(binary);
|
||||||
|
const built = path.join(publicTree, "target", "release", fileName);
|
||||||
|
if (!fs.existsSync(built)) {
|
||||||
|
throw new Error(`expected release binary ${built}`);
|
||||||
|
}
|
||||||
|
const staged = path.join(binDir, fileName);
|
||||||
|
fs.copyFileSync(built, staged);
|
||||||
|
fs.chmodSync(staged, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archive = path.join(
|
||||||
|
assetsDir,
|
||||||
|
`disasmer-public-binaries-${releaseName}-${platformName()}.tar.gz`
|
||||||
|
);
|
||||||
|
tarGz(archive, stageRoot, ["."]);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageSourceAsset(releaseName) {
|
||||||
|
const archive = path.join(assetsDir, `disasmer-public-source-${releaseName}.tar.gz`);
|
||||||
|
tarGz(archive, publicTree, ["."]);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageExtensionAsset() {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(publicTree, "vscode-extension/package.json"), "utf8")
|
||||||
|
);
|
||||||
|
const archive = path.join(
|
||||||
|
assetsDir,
|
||||||
|
`${packageJson.name}-${packageJson.version}.vsix`
|
||||||
|
);
|
||||||
|
run(
|
||||||
|
"npx",
|
||||||
|
[
|
||||||
|
"--yes",
|
||||||
|
"@vscode/vsce",
|
||||||
|
"package",
|
||||||
|
"--allow-missing-repository",
|
||||||
|
"--out",
|
||||||
|
archive,
|
||||||
|
],
|
||||||
|
{ cwd: path.join(publicTree, "vscode-extension") }
|
||||||
|
);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolverInstructions() {
|
||||||
|
if (process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) {
|
||||||
|
return process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim();
|
||||||
|
}
|
||||||
|
if (process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY) {
|
||||||
|
return [
|
||||||
|
"Add the controlled hosts entry supplied for this dry run:",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(),
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
if (process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP) {
|
||||||
|
return [
|
||||||
|
"Add this controlled hosts entry for the dry run:",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
`${process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP} disasmer.michelpaulissen.com`,
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"`disasmer.michelpaulissen.com` should resolve through public DNS. If it",
|
||||||
|
"does not resolve yet, wait for DNS propagation or use the fallback hosts",
|
||||||
|
"entry supplied with the invitation.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) {
|
||||||
|
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
`# Disasmer Public Dry Run
|
||||||
|
|
||||||
|
This dry run is a real Disasmer deployment for selected external users. If
|
||||||
|
public DNS has not propagated yet, use the fallback resolution instructions
|
||||||
|
below.
|
||||||
|
|
||||||
|
The default operator is the private hosted coordinator at
|
||||||
|
\`https://disasmer.michelpaulissen.com:9443\`. The word \`public\` in this dry run
|
||||||
|
refers to the public Forgejo repository, release downloads, selected-user
|
||||||
|
network access, and public client protocol compatibility. It does not mean the
|
||||||
|
server is the standalone open-source/public coordinator.
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
${resolution}
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Download the binary archive for your platform from the Forgejo Release at
|
||||||
|
\`git.michelpaulissen.com\`.
|
||||||
|
2. Verify it against \`SHA256SUMS\`.
|
||||||
|
3. Extract the archive and put the \`bin/\` directory on your \`PATH\`.
|
||||||
|
4. Download \`disasmer-vscode-*.vsix\` if you want the debugger and Disasmer
|
||||||
|
side views, then install it:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
code --install-extension disasmer-vscode-*.vsix
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Connect
|
||||||
|
|
||||||
|
Use the default operator endpoint:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
disasmer login --browser
|
||||||
|
disasmer bundle inspect --project examples/launch-build-demo
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`disasmer login --browser\` opens your browser through the barebones
|
||||||
|
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
|
||||||
|
through a local callback.
|
||||||
|
|
||||||
|
Enroll a node identity with the grant supplied by the invitation:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
disasmer node attach --coordinator https://disasmer.michelpaulissen.com:9443 --enrollment-grant <grant> --public-key <public-key>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
That command proves the identity/enrollment path and then exits. To actually run
|
||||||
|
work for the flagship workflow, leave a worker process running in another
|
||||||
|
terminal. Use a fresh enrollment grant, or skip the attach command above and use
|
||||||
|
the worker command directly:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <grant> --worker --emit-ready
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then run the flagship workflow from the filtered public repository while that
|
||||||
|
worker is still running:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
disasmer run --project examples/launch-build-demo build
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The dry-run public tree identity is \`${publicTreeIdentity}\`.
|
||||||
|
The release name is \`${releaseName}\`.
|
||||||
|
`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInviteAsset(releaseName, publicTreeIdentity, resolution) {
|
||||||
|
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_INVITE-${releaseName}.md`);
|
||||||
|
const publicRepo =
|
||||||
|
process.env.DISASMER_PUBLIC_REPO_URL ||
|
||||||
|
"https://git.michelpaulissen.com/<owner>/<public-repo>";
|
||||||
|
const releaseUrl =
|
||||||
|
process.env.DISASMER_FORGEJO_RELEASE_URL ||
|
||||||
|
"the Forgejo Release attached to the public repository";
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
`# Disasmer Public Dry Run Invite
|
||||||
|
|
||||||
|
This invite is for selected external users, such as friends helping test the
|
||||||
|
MVP release experience. The deployment is real and externally reachable, but it
|
||||||
|
is intentionally not broadly advertised yet.
|
||||||
|
|
||||||
|
The default operator behind this invite is the private hosted coordinator at
|
||||||
|
\`https://disasmer.michelpaulissen.com:9443\`. The public part is the Forgejo
|
||||||
|
repository, release downloads, network-reachable dry run, and public client
|
||||||
|
protocol used by the binaries; the hosted server is not the standalone public
|
||||||
|
coordinator.
|
||||||
|
|
||||||
|
If public DNS has not propagated yet, make sure \`disasmer.michelpaulissen.com\`
|
||||||
|
resolves to the deployment host before running the CLI.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Public repository: ${publicRepo}
|
||||||
|
- Release downloads: ${releaseUrl}
|
||||||
|
- Default operator endpoint: ${defaultOperatorEndpoint}
|
||||||
|
- Public tree identity: ${publicTreeIdentity}
|
||||||
|
- Release name: ${releaseName}
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
${resolution}
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
1. Download the binary archive for your platform and \`SHA256SUMS\` from the
|
||||||
|
Forgejo Release.
|
||||||
|
2. Extract the archive and put \`bin/\` on your \`PATH\`.
|
||||||
|
3. Optionally install \`disasmer-vscode-*.vsix\` with
|
||||||
|
\`code --install-extension disasmer-vscode-*.vsix\`.
|
||||||
|
4. Run \`disasmer login --browser\`; it opens the barebones
|
||||||
|
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
|
||||||
|
through a local callback.
|
||||||
|
5. Enroll a node identity with the enrollment grant supplied out of band.
|
||||||
|
6. Start a long-lived worker with a fresh enrollment grant, or use the worker
|
||||||
|
command directly instead of step 5:
|
||||||
|
\`disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <grant> --worker --emit-ready\`.
|
||||||
|
7. Run \`disasmer run --project examples/launch-build-demo build\` from the
|
||||||
|
public repository checkout while the worker process is still running.
|
||||||
|
`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSha256Sums(assets) {
|
||||||
|
const sumsPath = path.join(assetsDir, "SHA256SUMS");
|
||||||
|
const lines = assets
|
||||||
|
.map((asset) => `${sha256File(asset)} ${path.basename(asset)}`)
|
||||||
|
.sort()
|
||||||
|
.join("\n");
|
||||||
|
fs.writeFileSync(sumsPath, `${lines}\n`);
|
||||||
|
return sumsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolEnv(name) {
|
||||||
|
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePublicTreeProvenance(sourceCommit, releaseName) {
|
||||||
|
const provenance = {
|
||||||
|
kind: "disasmer-filtered-public-tree",
|
||||||
|
source_commit: sourceCommit,
|
||||||
|
release_name: releaseName,
|
||||||
|
filtered_out: filteredOutPatterns(),
|
||||||
|
public_export: {
|
||||||
|
host_neutral: !includeForgejoWorkflows,
|
||||||
|
include_forgejo_workflows: includeForgejoWorkflows,
|
||||||
|
},
|
||||||
|
forgejo_host: forgejoHost,
|
||||||
|
default_operator_endpoint: defaultOperatorEndpoint,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(publicTree, "DISASMER_PUBLIC_TREE.json"),
|
||||||
|
`${JSON.stringify(provenance, null, 2)}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) {
|
||||||
|
const remote =
|
||||||
|
process.env.DISASMER_PUBLIC_REPO_REMOTE ||
|
||||||
|
process.env.DISASMER_PUBLIC_REPO_URL ||
|
||||||
|
null;
|
||||||
|
const enabled = boolEnv("DISASMER_PUBLISH_PUBLIC_TREE");
|
||||||
|
const result = {
|
||||||
|
enabled,
|
||||||
|
remote,
|
||||||
|
branch: publicRepoBranch,
|
||||||
|
commit: null,
|
||||||
|
pushed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!remote) {
|
||||||
|
throw new Error(
|
||||||
|
"DISASMER_PUBLISH_PUBLIC_TREE requires DISASMER_PUBLIC_REPO_REMOTE or DISASMER_PUBLIC_REPO_URL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!remote.includes(forgejoHost)) {
|
||||||
|
throw new Error(`public repo remote must point at ${forgejoHost}: ${remote}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run("git", ["init"], { cwd: publicTree });
|
||||||
|
run("git", ["checkout", "-B", publicRepoBranch], { cwd: publicTree });
|
||||||
|
run("git", ["config", "user.name", "Disasmer release dry run"], {
|
||||||
|
cwd: publicTree,
|
||||||
|
});
|
||||||
|
run("git", ["config", "user.email", "release-dryrun@disasmer.invalid"], {
|
||||||
|
cwd: publicTree,
|
||||||
|
});
|
||||||
|
run("git", ["add", "."], { cwd: publicTree });
|
||||||
|
run(
|
||||||
|
"git",
|
||||||
|
[
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
`Public dry run ${releaseName}`,
|
||||||
|
"-m",
|
||||||
|
`Source commit: ${sourceCommit}`,
|
||||||
|
"-m",
|
||||||
|
`Public tree identity: ${publicTreeIdentity}`,
|
||||||
|
],
|
||||||
|
{ cwd: publicTree }
|
||||||
|
);
|
||||||
|
result.commit = commandOutput("git", ["rev-parse", "HEAD"], { cwd: publicTree });
|
||||||
|
run("git", ["remote", "add", "public", remote], { cwd: publicTree });
|
||||||
|
const pushArgs = ["push", "public", `HEAD:${publicRepoBranch}`];
|
||||||
|
if (boolEnv("DISASMER_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) {
|
||||||
|
run(
|
||||||
|
"git",
|
||||||
|
[
|
||||||
|
"fetch",
|
||||||
|
"public",
|
||||||
|
`refs/heads/${publicRepoBranch}:refs/remotes/public/${publicRepoBranch}`,
|
||||||
|
],
|
||||||
|
{ cwd: publicTree }
|
||||||
|
);
|
||||||
|
pushArgs.splice(1, 0, "--force-with-lease");
|
||||||
|
}
|
||||||
|
run("git", pushArgs, { cwd: publicTree });
|
||||||
|
result.pushed = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const sourceCommit =
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||||
|
"unknown";
|
||||||
|
const shortCommit = sourceCommit === "unknown" ? "unknown" : sourceCommit.slice(0, 12);
|
||||||
|
const releaseName = process.env.DISASMER_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`;
|
||||||
|
const sourceStatus = commandOutput("git", ["status", "--short"]);
|
||||||
|
const sourceTreeClean = sourceStatus === null ? null : sourceStatus === "";
|
||||||
|
|
||||||
|
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||||
|
ensureDir(publicTree);
|
||||||
|
ensureDir(assetsDir);
|
||||||
|
ensureDir(stagingDir);
|
||||||
|
|
||||||
|
copyFilteredTree(repo, publicTree);
|
||||||
|
assertFilteredTree();
|
||||||
|
writePublicTreeProvenance(sourceCommit, releaseName);
|
||||||
|
const publicTreeIdentity = hashTree(publicTree);
|
||||||
|
const sourceArchive = stageSourceAsset(releaseName);
|
||||||
|
|
||||||
|
run("node", ["scripts/public-release-dryrun-contract-smoke.js"], {
|
||||||
|
cwd: publicTree,
|
||||||
|
});
|
||||||
|
const publicTreePublish = publishPublicTree(
|
||||||
|
releaseName,
|
||||||
|
sourceCommit,
|
||||||
|
publicTreeIdentity
|
||||||
|
);
|
||||||
|
buildPublicBinaries();
|
||||||
|
const binaryArchive = stageBinaryAssets(releaseName);
|
||||||
|
const extensionArchive = stageExtensionAsset();
|
||||||
|
const resolution = resolverInstructions();
|
||||||
|
const gettingStarted = writeGettingStartedAsset(
|
||||||
|
releaseName,
|
||||||
|
publicTreeIdentity,
|
||||||
|
resolution
|
||||||
|
);
|
||||||
|
const invite = writeInviteAsset(releaseName, publicTreeIdentity, resolution);
|
||||||
|
const assets = [
|
||||||
|
sourceArchive,
|
||||||
|
binaryArchive,
|
||||||
|
extensionArchive,
|
||||||
|
gettingStarted,
|
||||||
|
invite,
|
||||||
|
];
|
||||||
|
const sha256Sums = writeSha256Sums(assets);
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
kind: "disasmer-public-release-dryrun",
|
||||||
|
release_name: releaseName,
|
||||||
|
source_commit: sourceCommit,
|
||||||
|
source_tree_clean: sourceTreeClean,
|
||||||
|
public_tree_identity: publicTreeIdentity,
|
||||||
|
public_tree: publicTree,
|
||||||
|
filtered_out: filteredOutPatterns(),
|
||||||
|
public_export: {
|
||||||
|
host_neutral: !includeForgejoWorkflows,
|
||||||
|
include_forgejo_workflows: includeForgejoWorkflows,
|
||||||
|
},
|
||||||
|
forgejo_host: forgejoHost,
|
||||||
|
public_repo_url: process.env.DISASMER_PUBLIC_REPO_URL || publicTreePublish.remote,
|
||||||
|
public_repo_remote: process.env.DISASMER_PUBLIC_REPO_REMOTE || null,
|
||||||
|
public_tree_publish: publicTreePublish,
|
||||||
|
forgejo_release_url: process.env.DISASMER_FORGEJO_RELEASE_URL || null,
|
||||||
|
default_operator_endpoint: defaultOperatorEndpoint,
|
||||||
|
dns_publication_state:
|
||||||
|
process.env.DISASMER_DNS_PUBLICATION_STATE || "published",
|
||||||
|
resolver_override:
|
||||||
|
process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns",
|
||||||
|
platform: platformName(),
|
||||||
|
tool_versions: {
|
||||||
|
node: process.version,
|
||||||
|
rustc: commandOutput("rustc", ["--version"]) || null,
|
||||||
|
cargo: commandOutput("cargo", ["--version"]) || null,
|
||||||
|
tar: commandOutput("tar", ["--version"]) || null,
|
||||||
|
},
|
||||||
|
commands: [
|
||||||
|
"node scripts/public-release-dryrun-contract-smoke.js",
|
||||||
|
...(publicTreePublish.enabled
|
||||||
|
? [`git push public HEAD:${publicRepoBranch}`]
|
||||||
|
: []),
|
||||||
|
"cargo build --workspace --bins --release",
|
||||||
|
],
|
||||||
|
assets: [...assets, sha256Sums].map((asset) => ({
|
||||||
|
file: asset,
|
||||||
|
name: path.basename(asset),
|
||||||
|
sha256: sha256File(asset),
|
||||||
|
})),
|
||||||
|
notes: [
|
||||||
|
"Upload the assets to the Forgejo Release for the filtered public repository.",
|
||||||
|
"The real service deployment and full e2e dry run remain separate acceptance evidence.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifestPath = path.join(outputRoot, "public-release-manifest.json");
|
||||||
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ manifest: manifestPath, assets: assetsDir }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
71
scripts/public-browser-login-contract-smoke.js
Normal file
71
scripts/public-browser-login-contract-smoke.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const infraRepo = path.resolve(repo, "..", "michelpaulissen.com");
|
||||||
|
|
||||||
|
function read(relativePath, base = repo) {
|
||||||
|
return fs.readFileSync(path.join(base, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing public browser login evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliSource = read("crates/disasmer-cli/src/main.rs");
|
||||||
|
const cliSmoke = read("scripts/cli-browser-login-flow-smoke.js");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const phase2 = read("acceptance_criteria_phase2.md");
|
||||||
|
const base = read("acceptance_criteria.md");
|
||||||
|
|
||||||
|
for (const [name, source] of [
|
||||||
|
["base acceptance", base],
|
||||||
|
["phase 2 acceptance", phase2],
|
||||||
|
]) {
|
||||||
|
expect(source, `${name} released binary browser criterion`, /Public released binaries default to a real human browser\/account flow/);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cliSource, "browser login start constant", /DEFAULT_BROWSER_LOGIN_START: &str = "https:\/\/disasmer\.michelpaulissen\.com\/auth\/browser\/start"/);
|
||||||
|
expect(cliSource, "default OIDC issuer", /DEFAULT_OIDC_ISSUER_URL: &str = "https:\/\/auth\.michelpaulissen\.com"/);
|
||||||
|
expect(cliSource, "fixed localhost callback", /BROWSER_CALLBACK_ADDR: &str = "127\.0\.0\.1:45173"/);
|
||||||
|
expect(cliSource, "diagnostic plan flag", /plan: bool/);
|
||||||
|
expect(cliSource, "interactive browser branch", /args\.browser && !args\.plan[\s\S]*execute_interactive_browser_login/);
|
||||||
|
expect(cliSource, "browser command override", /DISASMER_BROWSER_OPEN_COMMAND/);
|
||||||
|
expect(cliSource, "callback listener", /TcpListener::bind\(BROWSER_CALLBACK_ADDR\)/);
|
||||||
|
expect(cliSource, "callback completion", /execute_browser_login_completion_for_plan\(args, plan\)/);
|
||||||
|
expect(cliSmoke, "smoke uses fake browser opener", /DISASMER_BROWSER_OPEN_COMMAND/);
|
||||||
|
expect(cliSmoke, "smoke verifies callback code", /browser-smoke-code/);
|
||||||
|
expect(cliSmoke, "smoke verifies coordinator completion", /scoped_cli_session_received/);
|
||||||
|
expect(publicAcceptance, "public acceptance runs browser flow smoke", /node scripts\/cli-browser-login-flow-smoke\.js/);
|
||||||
|
|
||||||
|
if (fs.existsSync(infraRepo)) {
|
||||||
|
const stack = read("modules/stack.nix", infraRepo);
|
||||||
|
const hypervisor = read("hosts/hypervisor/default.nix", infraRepo);
|
||||||
|
const oauth = read("modules/oauth-bootstrap.nix", infraRepo);
|
||||||
|
const site = read("disasmer-site/index.html", infraRepo);
|
||||||
|
|
||||||
|
expect(stack, "Disasmer public host", /publicHost = "disasmer\.michelpaulissen\.com"/);
|
||||||
|
expect(stack, "Disasmer API port", /apiPort = 9443/);
|
||||||
|
expect(hypervisor, "nginx Disasmer vhost", /\$\{stack\.hosts\.disasmer\.publicHost\}/);
|
||||||
|
expect(hypervisor, "nginx Disasmer site root", /root = \.\.\/\.\.\/disasmer-site/);
|
||||||
|
expect(hypervisor, "nginx browser start route", /locations\."= \/auth\/browser\/start"\.return/);
|
||||||
|
expect(hypervisor, "nginx redirects to Authentik", /https:\/\/\$\{stack\.hosts\.authentik\.publicHost\}\/application\/o\/authorize/);
|
||||||
|
expect(hypervisor, "nginx passes state", /state=\$arg_state/);
|
||||||
|
expect(hypervisor, "nginx passes redirect URI", /redirect_uri=\$arg_redirect_uri/);
|
||||||
|
expect(hypervisor, "hosted service uses configured API port", /stack\.hosts\.disasmer\.apiPort/);
|
||||||
|
expect(oauth, "Disasmer Authentik provider", /name: disasmer-provider/);
|
||||||
|
expect(oauth, "Disasmer public OIDC client", /client_type: public/);
|
||||||
|
expect(oauth, "Disasmer client id", /client_id: \${disasmerClientId}/);
|
||||||
|
expect(oauth, "Disasmer localhost redirect", /http:\/\/127\.0\.0\.1:45173\/callback/);
|
||||||
|
expect(oauth, "Disasmer Authentik application", /slug: disasmer/);
|
||||||
|
expect(site, "barebones site describes CLI login", /disasmer login --browser/);
|
||||||
|
expect(site, "barebones site states UX deferral", /Layout and UX work are deferred/);
|
||||||
|
assert.doesNotMatch(site, /<style|stylesheet|\.css|style=|class=/i, "Disasmer dry-run site must remain barebones HTML with no CSS");
|
||||||
|
} else {
|
||||||
|
console.warn("Skipping VPS config checks because ../michelpaulissen.com is not present");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Public browser login contract smoke passed");
|
||||||
69
scripts/public-local-demo-matrix-smoke.js
Executable file
69
scripts/public-local-demo-matrix-smoke.js
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing public local demo evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const cliInstall = read("scripts/cli-install-smoke.js");
|
||||||
|
const flagship = read("scripts/flagship-demo-smoke.js");
|
||||||
|
const cliLocalRun = read("scripts/cli-local-run-smoke.js");
|
||||||
|
const nodeAttach = read("scripts/node-attach-smoke.js");
|
||||||
|
const vscodeExtension = read("scripts/vscode-extension-smoke.js");
|
||||||
|
const vscodeF5 = read("scripts/vscode-f5-smoke.js");
|
||||||
|
const artifactDownload = read("scripts/artifact-download-smoke.js");
|
||||||
|
const artifactExport = read("scripts/artifact-export-smoke.js");
|
||||||
|
|
||||||
|
for (const script of [publicAcceptance, publicSplit]) {
|
||||||
|
for (const smoke of [
|
||||||
|
"scripts/cli-install-smoke.js",
|
||||||
|
"scripts/flagship-demo-smoke.js",
|
||||||
|
"scripts/cli-local-run-smoke.js",
|
||||||
|
"scripts/node-attach-smoke.js",
|
||||||
|
"scripts/vscode-extension-smoke.js",
|
||||||
|
"scripts/vscode-f5-smoke.js",
|
||||||
|
"scripts/artifact-download-smoke.js",
|
||||||
|
"scripts/artifact-export-smoke.js",
|
||||||
|
]) {
|
||||||
|
assert(script.includes(`node ${smoke}`), `public demo gate must run ${smoke}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/disasmer-cli/);
|
||||||
|
expect(cliInstall, "CLI install smoke targets flagship project", /const project = path\.join\(repo, "examples\/launch-build-demo"\)/);
|
||||||
|
expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project\]/);
|
||||||
|
|
||||||
|
expect(flagship, "flagship project source is Rust workflow", /examples\/launch-build-demo[\s\S]*src\/build\.rs/);
|
||||||
|
expect(flagship, "flagship source avoids local machine assumptions", /forbiddenSourceAssumptions/);
|
||||||
|
expect(flagship, "flagship cargo test runs", /cargo[\s\S]*test[\s\S]*launch-build-demo/);
|
||||||
|
|
||||||
|
expect(cliLocalRun, "local run starts node process", /cli_process_started_node_process[\s\S]*true/);
|
||||||
|
expect(cliLocalRun, "local run records logs and metadata", /log_event_response[\s\S]*task_log_recorded[\s\S]*vfs_metadata_response[\s\S]*vfs_metadata_recorded/);
|
||||||
|
expect(cliLocalRun, "local run stages artifact", /\/vfs\/artifacts\/cli-run-output\.txt/);
|
||||||
|
|
||||||
|
expect(nodeAttach, "Linux node attach creates enrollment grant", /create_node_enrollment_grant/);
|
||||||
|
expect(nodeAttach, "Linux node attach uses enrollment exchange", /used_enrollment_exchange[\s\S]*true/);
|
||||||
|
expect(nodeAttach, "attached Linux node runs work", /runAttachedNodeWork[\s\S]*node_status[\s\S]*completed/);
|
||||||
|
|
||||||
|
expect(vscodeExtension, "extension contributes debugger", /contributes\.debuggers[\s\S]*type === "disasmer"/);
|
||||||
|
expect(vscodeF5, "F5 uses local-services backend", /runtimeBackend[\s\S]*local-services/);
|
||||||
|
expect(vscodeF5, "F5 exposes virtual thread", /compile linux[\s\S]*virtual thread/);
|
||||||
|
expect(vscodeF5, "F5 records coordinator task event", /coordinator_task_events[\s\S]*value === 1/);
|
||||||
|
|
||||||
|
expect(artifactDownload, "artifact download creates scoped link", /create_artifact_download_link/);
|
||||||
|
expect(artifactDownload, "artifact download opens stream", /open_artifact_download_stream/);
|
||||||
|
expect(artifactExport, "artifact export targets receiver node", /export_artifact_to_node[\s\S]*node-export-receiver/);
|
||||||
|
expect(artifactExport, "artifact export rejects coordinator bulk relay", /coordinator_bulk_relay_allowed[\s\S]*false/);
|
||||||
|
|
||||||
|
console.log("Public local demo matrix smoke passed");
|
||||||
182
scripts/public-private-boundary-smoke.js
Normal file
182
scripts/public-private-boundary-smoke.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function walk(relativePath, files = []) {
|
||||||
|
const fullPath = path.join(repo, relativePath);
|
||||||
|
if (!fs.existsSync(fullPath)) return files;
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const base = path.basename(relativePath);
|
||||||
|
if (["target", "node_modules", ".git"].includes(base)) return files;
|
||||||
|
for (const entry of fs.readdirSync(fullPath)) {
|
||||||
|
walk(path.join(relativePath, entry), files);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
files.push(relativePath);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPublicTree(sourceRoot, destinationRoot, relativePath = ".") {
|
||||||
|
const skippedDirectories = new Set([
|
||||||
|
".git",
|
||||||
|
"target",
|
||||||
|
"node_modules",
|
||||||
|
"private",
|
||||||
|
"experiments",
|
||||||
|
]);
|
||||||
|
const parts = relativePath.split(path.sep).filter(Boolean);
|
||||||
|
if (parts.some((part) => skippedDirectories.has(part))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = path.join(sourceRoot, relativePath);
|
||||||
|
const destination = path.join(destinationRoot, relativePath);
|
||||||
|
const stat = fs.statSync(source);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(source)) {
|
||||||
|
copyPublicTree(sourceRoot, destinationRoot, path.join(relativePath, entry));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||||
|
fs.copyFileSync(source, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPublicSplitTreeIsCoherent() {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-split-"));
|
||||||
|
try {
|
||||||
|
copyPublicTree(repo, tmp);
|
||||||
|
|
||||||
|
for (const forbidden of ["private", "experiments"]) {
|
||||||
|
assert(
|
||||||
|
!fs.existsSync(path.join(tmp, forbidden)),
|
||||||
|
`${forbidden} must be absent from the public split tree`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceToml = fs.readFileSync(path.join(tmp, "Cargo.toml"), "utf8");
|
||||||
|
const membersBlock = workspaceToml.match(/members\s*=\s*\[([\s\S]*?)\]/);
|
||||||
|
assert(membersBlock, "public workspace must define members");
|
||||||
|
const members = [...membersBlock[1].matchAll(/"([^"]+)"/g)].map(
|
||||||
|
(match) => match[1]
|
||||||
|
);
|
||||||
|
assert(members.length > 0, "public workspace must list members");
|
||||||
|
for (const member of members) {
|
||||||
|
assert(
|
||||||
|
!member.startsWith("private/") && !member.startsWith("experiments/"),
|
||||||
|
`public workspace member must not point at filtered source: ${member}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fs.existsSync(path.join(tmp, member, "Cargo.toml")),
|
||||||
|
`public workspace member is missing after split: ${member}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const required of [
|
||||||
|
"scripts/acceptance-public.sh",
|
||||||
|
"scripts/verify-public-split.sh",
|
||||||
|
"scripts/public-private-boundary-smoke.js",
|
||||||
|
"scripts/public-local-demo-matrix-smoke.js",
|
||||||
|
"vscode-extension/package.json",
|
||||||
|
"examples/launch-build-demo/Cargo.toml",
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
fs.existsSync(path.join(tmp, required)),
|
||||||
|
`public split tree is missing ${required}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicSourceFiles() {
|
||||||
|
return [
|
||||||
|
...walk("crates"),
|
||||||
|
...walk("examples"),
|
||||||
|
...walk("scripts"),
|
||||||
|
...walk("vscode-extension"),
|
||||||
|
"Cargo.toml",
|
||||||
|
].filter((file) => {
|
||||||
|
if (file === "scripts/acceptance-private.sh") return false;
|
||||||
|
if (file === "scripts/acceptance-evidence-contract-smoke.js") return false;
|
||||||
|
if (file === "scripts/acceptance-environment-contract-smoke.js") return false;
|
||||||
|
if (file === "scripts/docs-smoke.js") return false;
|
||||||
|
if (file === "scripts/public-private-boundary-smoke.js") return false;
|
||||||
|
if (file === "scripts/release-blocker-smoke.js") return false;
|
||||||
|
return /\.(rs|toml|js|json|sh)$/.test(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenPublicPatterns = [
|
||||||
|
/\bdisasmer[_-]hosted[_-]policy\b/,
|
||||||
|
/private\/hosted-policy/,
|
||||||
|
/path\s*=\s*["'][^"']*private\//,
|
||||||
|
/include!\s*\([^)]*private\//,
|
||||||
|
/mod\s+private_hosted/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of publicSourceFiles()) {
|
||||||
|
const content = read(file);
|
||||||
|
for (const pattern of forbiddenPublicPatterns) {
|
||||||
|
assert(
|
||||||
|
!pattern.test(content),
|
||||||
|
`public source ${file} must not reference private hosted code via ${pattern}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = read("Cargo.toml");
|
||||||
|
assert(
|
||||||
|
!/members\s*=\s*\[[\s\S]*private\//.test(workspace),
|
||||||
|
"public workspace members must not include private hosted crates"
|
||||||
|
);
|
||||||
|
assertPublicSplitTreeIsCoherent();
|
||||||
|
|
||||||
|
const privateHostedRoot = path.join(repo, "private", "hosted-policy");
|
||||||
|
if (fs.existsSync(privateHostedRoot)) {
|
||||||
|
const privateCargo = read("private/hosted-policy/Cargo.toml");
|
||||||
|
assert.match(privateCargo, /name\s*=\s*"disasmer-hosted-policy"/);
|
||||||
|
assert.match(privateCargo, /disasmer-core\s*=\s*\{/);
|
||||||
|
assert.match(privateCargo, /disasmer-coordinator\s*=\s*\{/);
|
||||||
|
|
||||||
|
const privateLib = read("private/hosted-policy/src/lib.rs");
|
||||||
|
for (const required of [
|
||||||
|
"AuthentikOidcConfig",
|
||||||
|
"CommunityTierPolicy",
|
||||||
|
"AdminControls",
|
||||||
|
"preflight_zero_capability_hosted_wasm",
|
||||||
|
"impl CapabilityPolicy for CommunityTierPolicy",
|
||||||
|
"AuthContext",
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
privateLib.includes(required),
|
||||||
|
`private hosted policy is missing ${required}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateFiles = walk("private").filter((file) => !file.includes("/target/"));
|
||||||
|
const privateNodeRuntimeFiles = privateFiles.filter((file) =>
|
||||||
|
/(^|\/)(disasmer-node|node-runtime)(\/|$)/.test(file)
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
privateNodeRuntimeFiles,
|
||||||
|
[],
|
||||||
|
"hosted mode must not carry a forked private node runtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Public/private boundary smoke passed");
|
||||||
278
scripts/public-release-dryrun-contract-smoke.js
Normal file
278
scripts/public-release-dryrun-contract-smoke.js
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
const serviceHost = "disasmer.michelpaulissen.com";
|
||||||
|
const forgejoHost = "git.michelpaulissen.com";
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(relativePath) {
|
||||||
|
const file = path.join(repo, relativePath);
|
||||||
|
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert(source !== null && source !== undefined, `missing source for ${name}`);
|
||||||
|
assert.match(source, pattern, `missing public release dry-run evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase2 = maybeRead("acceptance_criteria_phase2.md");
|
||||||
|
const readme = maybeRead("README.md");
|
||||||
|
const cliSource = read("crates/disasmer-cli/src/main.rs");
|
||||||
|
const cliLoginSmoke = read("scripts/cli-login-smoke.js");
|
||||||
|
const vscodePackage = JSON.parse(read("vscode-extension/package.json"));
|
||||||
|
const vscodeExtension = read("vscode-extension/extension.js");
|
||||||
|
const vscodeSmoke = read("scripts/vscode-extension-smoke.js");
|
||||||
|
const prepScript = read("scripts/prepare-public-release-dryrun.js");
|
||||||
|
const publishScript = read("scripts/publish-public-release-dryrun.js");
|
||||||
|
const preflightScript = read("scripts/public-release-dryrun-preflight.js");
|
||||||
|
const e2eScript = read("scripts/public-release-dryrun-e2e.js");
|
||||||
|
const finalEvidenceScript = read("scripts/public-release-dryrun-final-evidence.js");
|
||||||
|
const workflow = maybeRead(".forgejo/workflows/public-release-dryrun.yml");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
|
||||||
|
for (const [name, source] of [
|
||||||
|
["phase 2 criteria", phase2],
|
||||||
|
["README", readme],
|
||||||
|
]) {
|
||||||
|
if (source) {
|
||||||
|
expect(source, name, new RegExp(serviceHost.replaceAll(".", "\\.")));
|
||||||
|
expect(source, `${name} DNS publication state`, /DNS record|public DNS|dns[-_]publication/i);
|
||||||
|
expect(source, `${name} resolver fallback`, /no resolver override|required resolver override|hosts entry|controlled resolution|fallback/i);
|
||||||
|
expect(source, `${name} Forgejo host`, new RegExp(forgejoHost.replaceAll(".", "\\.")));
|
||||||
|
expect(source, `${name} Forgejo Release`, /Forgejo Release/);
|
||||||
|
expect(source, `${name} compiled assets`, /compiled\s+(release\s+)?assets|compiled release assets/);
|
||||||
|
expect(source, `${name} filtered tree`, /private\/\*\*[\s\S]*experiments\/\*\*/);
|
||||||
|
expect(source, `${name} root Markdown export filter`, /root `\*\.md`|root Markdown/);
|
||||||
|
expect(source, `${name} host-neutral workflow filter`, /\.forgejo[\s\S]*--include-forgejo-workflows/);
|
||||||
|
expect(source, `${name} GitHub release out of scope`, /GitHub[\s\S]*outside this dry run|public GitHub-release[\s\S]*out of scope/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cliSource, "CLI default operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT: &str = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||||
|
expect(cliSource, "login uses default operator", /default_value_t = default_operator_endpoint\(\)/);
|
||||||
|
expect(cliSource, "hosted run records operator endpoint", /operator_endpoint[\s\S]*Some\(default_operator_endpoint\(\)\)/);
|
||||||
|
expect(cliSource, "hosted URL maps to JSON-line transport", /fn json_line_transport_addr[\s\S]*\("https:\/\/", 443\)/);
|
||||||
|
expect(cliSource, "JSON-line session uses mapped transport", /TcpStream::connect\(&transport_addr\)/);
|
||||||
|
expect(cliSource, "default operator maps to port 9443", /json_line_transport_addr\(DEFAULT_OPERATOR_ENDPOINT\)[\s\S]*"disasmer\.michelpaulissen\.com:9443"/);
|
||||||
|
assert.doesNotMatch(cliSource, /coord\.disasmer\.invalid/);
|
||||||
|
|
||||||
|
expect(cliLoginSmoke, "CLI login smoke covers default operator", new RegExp(`defaultOperatorEndpoint = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||||
|
expect(vscodeExtension, "VS Code default operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||||
|
assert.strictEqual(
|
||||||
|
vscodePackage.contributes.debuggers[0].configurationAttributes.launch.properties.operatorEndpoint.default,
|
||||||
|
serviceEndpoint
|
||||||
|
);
|
||||||
|
expect(vscodeSmoke, "VS Code smoke covers operator endpoint", /operatorEndpoint\.default/);
|
||||||
|
|
||||||
|
for (const binary of [
|
||||||
|
"disasmer",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"disasmer-node",
|
||||||
|
"disasmer-debug-dap",
|
||||||
|
]) {
|
||||||
|
expect(prepScript, `release prep includes ${binary}`, new RegExp(`"${binary}"`));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["release prep filters private and experiments", /filteredTopLevel = \["private", "experiments", "\.git", "target"\]/],
|
||||||
|
["release prep filters root Markdown", /isRootMarkdown[\s\S]*path\.extname\(entry\.name\)\.toLowerCase\(\) === "\.md"/],
|
||||||
|
["release prep filters .forgejo by default", /topLevel === "\.forgejo" && !includeForgejoWorkflows/],
|
||||||
|
["release prep has Forgejo workflow opt-in argument", /--include-forgejo-workflows/],
|
||||||
|
["release prep builds public release bins", /cargo"[\s\S]*"build"[\s\S]*"--workspace"[\s\S]*"--bins"[\s\S]*"--release"/],
|
||||||
|
["release prep writes binary archive", /disasmer-public-binaries-\$\{releaseName\}-\$\{platformName\(\)\}\.tar\.gz/],
|
||||||
|
["release prep writes source archive", /disasmer-public-source-\$\{releaseName\}\.tar\.gz/],
|
||||||
|
["release prep writes extension VSIX", /\$\{packageJson\.name\}-\$\{packageJson\.version\}\.vsix[\s\S]*@vscode\/vsce[\s\S]*package/],
|
||||||
|
["release prep writes selected-user guide", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\$\{releaseName\}\.md/],
|
||||||
|
["release prep writes selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\$\{releaseName\}\.md/],
|
||||||
|
["release prep accepts resolver instructions", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS/],
|
||||||
|
["release prep accepts hosts entry", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY/],
|
||||||
|
["release prep accepts deployment IP", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP/],
|
||||||
|
["selected-user guide documents DNS or fallback", /public DNS[\s\S]*(fallback|hosts entry)|DNS record[\s\S]*controlled resolution/],
|
||||||
|
["selected-user guide says private hosted coordinator", /default operator is the private hosted coordinator/],
|
||||||
|
["selected-user guide rejects standalone public coordinator", /standalone open-source\/public coordinator/],
|
||||||
|
["selected-user invite documents friends dry run", /friends helping test[\s\S]*not broadly advertised/],
|
||||||
|
["selected-user invite says hosted server is private", /hosted server is not the standalone public\s+coordinator/],
|
||||||
|
["selected-user guide documents default login", /disasmer login --browser/],
|
||||||
|
["selected-user guide documents VSIX install", /code --install-extension disasmer-vscode-\*\.vsix/],
|
||||||
|
["selected-user guide documents node attach", /disasmer node attach --coordinator https:\/\/disasmer\.michelpaulissen\.com:9443/],
|
||||||
|
["release prep writes checksums", /SHA256SUMS/],
|
||||||
|
["release prep writes manifest", /public-release-manifest\.json/],
|
||||||
|
["release prep writes public tree provenance", /DISASMER_PUBLIC_TREE\.json/],
|
||||||
|
["release prep records public tree identity", /public_tree_identity: publicTreeIdentity/],
|
||||||
|
["release prep records DNS state", /dns_publication_state:/],
|
||||||
|
["release prep records resolver override", /resolver_override:/],
|
||||||
|
["release prep records Forgejo URLs", /public_repo_url:[\s\S]*public_repo_remote:[\s\S]*forgejo_release_url:/],
|
||||||
|
["release prep requires publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE/],
|
||||||
|
["release prep requires Forgejo remote", /remote\.includes\(forgejoHost\)/],
|
||||||
|
["release prep can push filtered tree", /git"[\s\S]*"push"[\s\S]*"public"[\s\S]*`HEAD:\$\{publicRepoBranch\}`/],
|
||||||
|
["release prep records publish result", /public_tree_publish: publicTreePublish/],
|
||||||
|
["release prep publishes tree before building target", /const publicTreePublish = publishPublicTree[\s\S]*buildPublicBinaries\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(prepScript, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["release publisher requires Forgejo token", /DISASMER_FORGEJO_TOKEN/],
|
||||||
|
["release publisher infers Forgejo repo identity", /function resolveRepoIdentity\(manifest\)[\s\S]*parseForgejoRepoIdentity/],
|
||||||
|
["release publisher uses manifest public repo URL", /manifest\.public_repo_url[\s\S]*manifest\.public_repo_remote/],
|
||||||
|
["release publisher uses token auth", /Authorization: `token \$\{token\}`/],
|
||||||
|
["release publisher lists releases", /\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases\?limit=100/],
|
||||||
|
["release publisher creates release", /POST[\s\S]*\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases/],
|
||||||
|
["release publisher reloads release details", /function loadRelease\(releaseId\)[\s\S]*\/releases\/\$\{releaseId\}/],
|
||||||
|
["release publisher uploads assets", /releases\/\$\{release\.id\}\/assets\?name=\$\{encodeURIComponent\(asset\.name\)\}/],
|
||||||
|
["release publisher uses attachment multipart field", /multipartFile\("attachment", asset\.file\)/],
|
||||||
|
["release publisher skips already attached assets", /existingAssetByName\(release, asset\.name\)/],
|
||||||
|
["release publisher rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||||
|
["release publisher checks manifest kind", /manifest\.kind !== "disasmer-public-release-dryrun"/],
|
||||||
|
["release publisher requires public tree push", /public tree must be pushed before publishing the Forgejo Release/],
|
||||||
|
["release publisher writes evidence report", /public-release-dryrun-forgejo-release\.json/],
|
||||||
|
["release publisher records reused assets", /reused_assets:/],
|
||||||
|
]) {
|
||||||
|
expect(publishScript, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["preflight rejects stale release manifests", /manifest\.source_commit[\s\S]*currentSourceCommit/],
|
||||||
|
["preflight verifies public branch commit", /remoteMain[\s\S]*manifest\.public_tree_publish\.commit/],
|
||||||
|
["preflight verifies local asset checksums", /parseSha256Sums[\s\S]*checksum mismatch/],
|
||||||
|
["preflight records pending external gates", /external_gates:[\s\S]*forgejo_release_publication[\s\S]*public_release_e2e/],
|
||||||
|
["preflight writes evidence report", /public-release-dryrun-preflight\.json/],
|
||||||
|
]) {
|
||||||
|
expect(preflightScript, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/],
|
||||||
|
["e2e runner rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||||
|
["e2e runner downloads Forgejo Release assets", /downloadReleaseAssets/],
|
||||||
|
["e2e runner verifies SHA256SUMS", /verifyChecksums/],
|
||||||
|
["e2e runner clones public repo", /"git", \["clone", "--depth", "1"/],
|
||||||
|
["e2e runner rejects private tree leaks", /private[\s\S]*experiments[\s\S]*target/],
|
||||||
|
["e2e runner rejects root Markdown leaks", /root Markdown file leaked into public repo/],
|
||||||
|
["e2e runner rejects .forgejo leaks by default", /\.forgejo\/ leaked into public repo/],
|
||||||
|
["e2e runner checks public tree identity", /hashTree\(checkout\)[\s\S]*manifest\.public_tree_identity/],
|
||||||
|
["e2e runner extracts public binaries", /tar"[\s\S]*"-xzf"/],
|
||||||
|
["e2e runner loads public coordinator binary", /executable\(installDir, "disasmer-coordinator"\)/],
|
||||||
|
["e2e runner checks default operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
|
||||||
|
["e2e runner completes browser login", /--complete-browser-code/],
|
||||||
|
["e2e runner uses public CLI node attach", /"node"[\s\S]*"attach"[\s\S]*serviceEndpoint/],
|
||||||
|
["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/],
|
||||||
|
["e2e runner launches task through coordinator", /type: "launch_task"/],
|
||||||
|
["e2e runner verifies assignment polling", /worker_assignment_poll_verified/],
|
||||||
|
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
|
||||||
|
["e2e runner records public coordinator validation", /public_coordinator_validated/],
|
||||||
|
["e2e runner verifies task events", /type: "list_task_events"/],
|
||||||
|
["e2e runner verifies download link", /type: "create_artifact_download_link"/],
|
||||||
|
["e2e runner verifies VS Code debugger", /scripts\/vscode-f5-smoke\.js/],
|
||||||
|
["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/],
|
||||||
|
]) {
|
||||||
|
expect(e2eScript, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["final evidence reads manifest", /public-release-manifest\.json/],
|
||||||
|
["final evidence rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||||
|
["final evidence verifies filtered export", /function assertFilteredOut\(manifest\)/],
|
||||||
|
["final evidence reads Forgejo Release report", /public-release-dryrun-forgejo-release\.json/],
|
||||||
|
["final evidence reads deployment manifest", /deployment-manifest\.json/],
|
||||||
|
["final evidence reads service smoke report", /public-release-dryrun-service\.json/],
|
||||||
|
["final evidence reads public operator compatibility report", /public-operator-compat\.json/],
|
||||||
|
["final evidence reads public coordinator compatibility report", /public-coordinator-compat\.json/],
|
||||||
|
["final evidence requires public e2e report", /public-release-dryrun-e2e\.json/],
|
||||||
|
["final evidence verifies Forgejo host", /forgejoHost = "git\.michelpaulissen\.com"/],
|
||||||
|
["final evidence verifies default operator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com:9443"/],
|
||||||
|
["final evidence requires private hosted coordinator", /operator_implementation[\s\S]*"private-hosted-coordinator"/],
|
||||||
|
["final evidence requires service private coordinator marker", /service\.operator_implementation[\s\S]*"private-hosted-coordinator"/],
|
||||||
|
["final evidence requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final evidence requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final evidence requires current public operator source", /compat\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final evidence requires current public operator release", /compat\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final evidence requires current public coordinator source", /publicCoordinator\.source_commit[\s\S]*manifest\.source_commit/],
|
||||||
|
["final evidence requires current public coordinator release", /publicCoordinator\.release_name[\s\S]*manifest\.release_name/],
|
||||||
|
["final evidence requires service launch task", /service\.evidence\.public_launch_task[\s\S]*"task_launched"/],
|
||||||
|
["final evidence requires launch task", /launch_task_response[\s\S]*"task_launched"/],
|
||||||
|
["final evidence requires assignment polling", /worker_assignment_poll_verified/],
|
||||||
|
["final evidence requires public coordinator validation", /public_coordinator_validated/],
|
||||||
|
["final evidence requires standalone public coordinator", /standalone-public-coordinator/],
|
||||||
|
["final evidence requires pushed public tree", /public_tree_publish[\s\S]*pushed/],
|
||||||
|
["final evidence requires release assets", /downloaded_release_assets/],
|
||||||
|
["final evidence requires VS Code debugger", /vscode_debugger_verified/],
|
||||||
|
["final evidence writes final report", /public-release-dryrun-final\.json/],
|
||||||
|
]) {
|
||||||
|
expect(finalEvidenceScript, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["manual Forgejo workflow", /workflow_dispatch:/],
|
||||||
|
["Linux Forgejo asset job", /linux-assets:[\s\S]*runs-on: docker/],
|
||||||
|
["Windows Forgejo asset job", /windows-assets:[\s\S]*runs-on: windows/],
|
||||||
|
["Windows runner caveat", /intermittently online/],
|
||||||
|
["workflow runs release prep", /node scripts\/prepare-public-release-dryrun\.js/],
|
||||||
|
["workflow uploads assets", /actions\/upload-artifact@v4[\s\S]*target\/public-release-dryrun\/assets\/\*/],
|
||||||
|
]) {
|
||||||
|
expect(workflow, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-release-dryrun-contract-smoke.js"),
|
||||||
|
`${scriptName} must run public-release-dryrun-contract-smoke.js`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/prepare-public-release-dryrun.js"),
|
||||||
|
`${scriptName} must run prepare-public-release-dryrun.js`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/publish-public-release-dryrun.js"),
|
||||||
|
`${scriptName} must be able to publish the Forgejo Release`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("DISASMER_FORGEJO_TOKEN"),
|
||||||
|
`${scriptName} must gate Forgejo Release publishing on a token`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes('if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]'),
|
||||||
|
`${scriptName} must not require owner/repo env when the manifest can infer the Forgejo repository`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["private acceptance", privateAcceptance],
|
||||||
|
]) {
|
||||||
|
if (scriptName === "public acceptance") {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-release-dryrun-e2e.js"),
|
||||||
|
"public acceptance must be able to run public-release-dryrun-e2e.js"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"),
|
||||||
|
"public acceptance must gate public-release-dryrun-e2e.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-release-dryrun-final-evidence.js"),
|
||||||
|
`${scriptName} must be able to run public-release-dryrun-final-evidence.js`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"),
|
||||||
|
`${scriptName} must gate final public release dry-run evidence`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Public release dry-run contract smoke passed");
|
||||||
847
scripts/public-release-dryrun-e2e.js
Executable file
847
scripts/public-release-dryrun-e2e.js
Executable file
|
|
@ -0,0 +1,847 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const https = require("https");
|
||||||
|
const net = require("net");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const releaseRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-dryrun")
|
||||||
|
);
|
||||||
|
const acceptanceRoot = path.join(repo, "target/acceptance");
|
||||||
|
const manifestPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
||||||
|
path.join(releaseRoot, "public-release-manifest.json");
|
||||||
|
const forgejoReportPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json");
|
||||||
|
const reportPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-e2e.json");
|
||||||
|
const serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
const serviceHost = "disasmer.michelpaulissen.com";
|
||||||
|
const serviceAddr =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:9443`;
|
||||||
|
const forgejoHost = "git.michelpaulissen.com";
|
||||||
|
const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1";
|
||||||
|
const oidcIssuer = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL;
|
||||||
|
const oidcCode = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE;
|
||||||
|
const oidcClientId =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CLIENT_ID || "disasmer";
|
||||||
|
const dnsPublicationState =
|
||||||
|
process.env.DISASMER_DNS_PUBLICATION_STATE || "published";
|
||||||
|
const resolverOverride =
|
||||||
|
process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns";
|
||||||
|
|
||||||
|
function requireEnabled() {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new Error(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 is required because this runs the real public dry-run e2e"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!oidcIssuer || !oidcCode) {
|
||||||
|
throw new Error(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL and DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE are required"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const commandLine = [command, ...args].join(" ");
|
||||||
|
commands.push(commandLine);
|
||||||
|
return cp.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
timeout: 15 * 60 * 1000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runJson(command, args, options = {}) {
|
||||||
|
const output = run(command, args, options);
|
||||||
|
try {
|
||||||
|
return JSON.parse(output);
|
||||||
|
} catch (_) {
|
||||||
|
const line = output
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.at(-1);
|
||||||
|
return JSON.parse(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandOutput(command, args, options = {}) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSourceCommit() {
|
||||||
|
return (
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(file) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAddr(value) {
|
||||||
|
const match = /^([^:]+):(\d+)$/.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`service address must be host:port, got ${value}`);
|
||||||
|
}
|
||||||
|
return { host: match[1], port: Number(match[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error(`timed out waiting for ${message.type}`));
|
||||||
|
}, 30000);
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.destroy();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForJsonLine(child, label = "process", timeoutMs = 240000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`timed out waiting for JSON line from ${label}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
function cleanup() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout.off("data", onData);
|
||||||
|
child.off("exit", onExit);
|
||||||
|
}
|
||||||
|
function onData(chunk) {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
cleanup();
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onExit(code) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`${label} exited before JSON line with code ${code}`));
|
||||||
|
}
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
child.once("exit", onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopChild(child) {
|
||||||
|
if (!child || child.exitCode !== null) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(url, dest, redirects = 0) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = url.startsWith("http://") ? http : https;
|
||||||
|
const request = client.get(url, (response) => {
|
||||||
|
if (
|
||||||
|
[301, 302, 303, 307, 308].includes(response.statusCode) &&
|
||||||
|
response.headers.location &&
|
||||||
|
redirects < 5
|
||||||
|
) {
|
||||||
|
response.resume();
|
||||||
|
download(new URL(response.headers.location, url).toString(), dest, redirects + 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
response.resume();
|
||||||
|
reject(new Error(`download ${url} failed with ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureDir(path.dirname(dest));
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
response.pipe(file);
|
||||||
|
file.on("finish", () => file.close(resolve));
|
||||||
|
file.on("error", reject);
|
||||||
|
});
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseAssetDownloads(forgejoReport) {
|
||||||
|
const assets = [
|
||||||
|
...(forgejoReport.uploaded_assets || []),
|
||||||
|
...(forgejoReport.reused_assets || []),
|
||||||
|
];
|
||||||
|
const byName = new Map();
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset.name && asset.browser_download_url) {
|
||||||
|
byName.set(asset.name, asset.browser_download_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadReleaseAssets(manifest, forgejoReport, assetsDir) {
|
||||||
|
const downloads = releaseAssetDownloads(forgejoReport);
|
||||||
|
const downloaded = [];
|
||||||
|
for (const asset of manifest.assets) {
|
||||||
|
const dest = path.join(assetsDir, asset.name);
|
||||||
|
const url = downloads.get(asset.name);
|
||||||
|
if (url) {
|
||||||
|
await download(url, dest);
|
||||||
|
} else if (process.env.DISASMER_PUBLIC_RELEASE_USE_LOCAL_ASSETS === "1") {
|
||||||
|
fs.copyFileSync(asset.file, dest);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Forgejo Release report has no download URL for ${asset.name}`);
|
||||||
|
}
|
||||||
|
downloaded.push(dest);
|
||||||
|
}
|
||||||
|
return downloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyChecksums(assetsDir) {
|
||||||
|
const sumsPath = path.join(assetsDir, "SHA256SUMS");
|
||||||
|
assert(fs.existsSync(sumsPath), "SHA256SUMS must be downloaded from the release");
|
||||||
|
for (const line of fs.readFileSync(sumsPath, "utf8").split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim());
|
||||||
|
assert(match, `invalid SHA256SUMS line: ${line}`);
|
||||||
|
const [, expected, name] = match;
|
||||||
|
const file = path.join(assetsDir, name);
|
||||||
|
assert(fs.existsSync(file), `checksum references missing asset ${name}`);
|
||||||
|
assert.strictEqual(sha256File(file), expected, `checksum mismatch for ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryArchive(manifest) {
|
||||||
|
const platform = `${os.platform()}-${os.arch()}`;
|
||||||
|
const asset = manifest.assets.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.name.startsWith("disasmer-public-binaries-") &&
|
||||||
|
candidate.name.includes(platform)
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(`release manifest has no binary archive for ${platform}`);
|
||||||
|
}
|
||||||
|
return asset.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkFiles(root, relative = "") {
|
||||||
|
const dir = path.join(root, relative);
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((entry) => relative || entry.name !== ".git")
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...walkFiles(root, childRelative));
|
||||||
|
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
||||||
|
files.push(childRelative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashTree(root) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
for (const file of walkFiles(root)) {
|
||||||
|
const absolute = path.join(root, file);
|
||||||
|
const stat = fs.lstatSync(absolute);
|
||||||
|
hash.update(file.replaceAll(path.sep, "/"));
|
||||||
|
hash.update("\0");
|
||||||
|
hash.update(String(stat.mode & 0o777));
|
||||||
|
hash.update("\0");
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
hash.update("symlink");
|
||||||
|
hash.update("\0");
|
||||||
|
hash.update(fs.readlinkSync(absolute));
|
||||||
|
} else {
|
||||||
|
hash.update(fs.readFileSync(absolute));
|
||||||
|
}
|
||||||
|
hash.update("\0");
|
||||||
|
}
|
||||||
|
return `sha256:${hash.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFilteredPublicCheckout(checkout, manifest) {
|
||||||
|
for (const forbidden of ["private", "experiments", "target"]) {
|
||||||
|
assert(!fs.existsSync(path.join(checkout, forbidden)), `${forbidden}/ leaked into public repo`);
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(checkout, { withFileTypes: true })) {
|
||||||
|
assert(
|
||||||
|
!(entry.isFile() && path.extname(entry.name).toLowerCase() === ".md"),
|
||||||
|
`root Markdown file leaked into public repo: ${entry.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const includeForgejoWorkflows =
|
||||||
|
manifest.public_export && manifest.public_export.include_forgejo_workflows;
|
||||||
|
if (!includeForgejoWorkflows) {
|
||||||
|
assert(!fs.existsSync(path.join(checkout, ".forgejo")), ".forgejo/ leaked into public repo");
|
||||||
|
}
|
||||||
|
const provenancePath = path.join(checkout, "DISASMER_PUBLIC_TREE.json");
|
||||||
|
assert(fs.existsSync(provenancePath), "public checkout must include DISASMER_PUBLIC_TREE.json");
|
||||||
|
const provenance = readJson(provenancePath);
|
||||||
|
assert.strictEqual(provenance.source_commit, manifest.source_commit);
|
||||||
|
assert.strictEqual(provenance.release_name, manifest.release_name);
|
||||||
|
for (const expected of ["private/**", "experiments/**", ".git", "target", "/*.md"]) {
|
||||||
|
assert(provenance.filtered_out.includes(expected), `public provenance missing ${expected}`);
|
||||||
|
}
|
||||||
|
if (!includeForgejoWorkflows) {
|
||||||
|
assert(
|
||||||
|
provenance.filtered_out.includes(".forgejo/**"),
|
||||||
|
"public provenance must record .forgejo filtering"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.strictEqual(hashTree(checkout), manifest.public_tree_identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executable(root, name) {
|
||||||
|
const suffix = process.platform === "win32" ? ".exe" : "";
|
||||||
|
const file = path.join(root, "bin", `${name}${suffix}`);
|
||||||
|
assert(fs.existsSync(file), `missing release binary ${file}`);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [];
|
||||||
|
|
||||||
|
async function validateStandalonePublicCoordinator(disasmerCoordinator, disasmerNode, checkout) {
|
||||||
|
let coordinator;
|
||||||
|
let worker;
|
||||||
|
let workerStderr = "";
|
||||||
|
const suffix = String(Date.now());
|
||||||
|
const tenant = `public-coordinator-${suffix}`;
|
||||||
|
const project = `self-hosted-${suffix}`;
|
||||||
|
const node = `public-node-${suffix}`;
|
||||||
|
const processId = `vp-public-coordinator-${suffix}`;
|
||||||
|
const task = "compile-linux";
|
||||||
|
const artifactPath = "/vfs/artifacts/public-coordinator-output.txt";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coordinatorArgs = ["--listen", "127.0.0.1:0"];
|
||||||
|
commands.push([disasmerCoordinator, ...coordinatorArgs].join(" "));
|
||||||
|
coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, { cwd: checkout });
|
||||||
|
const ready = await waitForJsonLine(coordinator, "standalone public coordinator");
|
||||||
|
const addr = parseAddr(ready.listen);
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const created = await send(addr, {
|
||||||
|
type: "create_project",
|
||||||
|
tenant,
|
||||||
|
actor_user: "developer",
|
||||||
|
project,
|
||||||
|
name: "Public Coordinator Dry Run",
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
["project", "project_created"].includes(created.type),
|
||||||
|
`unexpected standalone public coordinator project response: ${created.type}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const workerArgs = [
|
||||||
|
"--coordinator",
|
||||||
|
ready.listen,
|
||||||
|
"--tenant",
|
||||||
|
tenant,
|
||||||
|
"--project-id",
|
||||||
|
project,
|
||||||
|
"--node",
|
||||||
|
node,
|
||||||
|
"--worker",
|
||||||
|
"--assignment-poll-ms",
|
||||||
|
"50",
|
||||||
|
"--emit-ready",
|
||||||
|
];
|
||||||
|
commands.push([disasmerNode, ...workerArgs].join(" "));
|
||||||
|
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
|
||||||
|
worker.stderr.on("data", (chunk) => {
|
||||||
|
workerStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
const workerReady = await waitForJsonLine(worker, "standalone public coordinator worker");
|
||||||
|
assert.strictEqual(workerReady.node_status, "ready");
|
||||||
|
assert.strictEqual(workerReady.mode, "worker");
|
||||||
|
assert.strictEqual(workerReady.node, node);
|
||||||
|
|
||||||
|
const started = await send(addr, {
|
||||||
|
type: "start_process",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
process: processId,
|
||||||
|
});
|
||||||
|
assert.strictEqual(started.type, "process_started");
|
||||||
|
|
||||||
|
const launch = await send(addr, {
|
||||||
|
type: "launch_task",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor_user: "developer",
|
||||||
|
process: processId,
|
||||||
|
task,
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: null,
|
||||||
|
required_artifacts: [],
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
command: "cargo",
|
||||||
|
command_args: [
|
||||||
|
"test",
|
||||||
|
"--quiet",
|
||||||
|
"--manifest-path",
|
||||||
|
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
|
||||||
|
],
|
||||||
|
artifact_path: artifactPath,
|
||||||
|
});
|
||||||
|
assert.strictEqual(launch.type, "task_launched");
|
||||||
|
assert.strictEqual(launch.placement.node, node);
|
||||||
|
assert.strictEqual(launch.assignment.node, node);
|
||||||
|
assert.strictEqual(launch.assignment.process, processId);
|
||||||
|
|
||||||
|
const nodeRun = await waitForJsonLine(worker, "standalone public coordinator worker completion");
|
||||||
|
assert.strictEqual(nodeRun.node_status, "completed");
|
||||||
|
assert.strictEqual(nodeRun.registration_response.type, "node_attached");
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.node, node);
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.task, task);
|
||||||
|
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor_user: "developer",
|
||||||
|
process: processId,
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, node);
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, artifactPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operator_implementation: "standalone-public-coordinator",
|
||||||
|
coordinator_ready: Boolean(ready.listen),
|
||||||
|
project_response: created.type,
|
||||||
|
process_response: started.type,
|
||||||
|
launch_task_response: launch.type,
|
||||||
|
assignment_response: "task_assignment",
|
||||||
|
worker_status: nodeRun.node_status,
|
||||||
|
task_events: events.events.length,
|
||||||
|
node,
|
||||||
|
process: processId,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (workerStderr.trim()) {
|
||||||
|
console.error(workerStderr);
|
||||||
|
}
|
||||||
|
await stopChild(worker);
|
||||||
|
await stopChild(coordinator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
requireEnabled();
|
||||||
|
const manifest = readJson(manifestPath);
|
||||||
|
const forgejoReport = readJson(forgejoReportPath);
|
||||||
|
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.source_commit,
|
||||||
|
expectedSourceCommit(),
|
||||||
|
"public release e2e must use release assets prepared from the current acceptance commit"
|
||||||
|
);
|
||||||
|
assert.strictEqual(manifest.default_operator_endpoint, serviceEndpoint);
|
||||||
|
assert.strictEqual(manifest.public_tree_publish.pushed, true);
|
||||||
|
assert.strictEqual(forgejoReport.release_name, manifest.release_name);
|
||||||
|
|
||||||
|
const addr = parseAddr(serviceAddr);
|
||||||
|
assert.strictEqual(addr.host, serviceHost);
|
||||||
|
assert.strictEqual(addr.port, 9443);
|
||||||
|
|
||||||
|
const workRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_E2E_WORKDIR ||
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-dryrun-e2e-"))
|
||||||
|
);
|
||||||
|
const assetsDir = path.join(workRoot, "assets");
|
||||||
|
const installDir = path.join(workRoot, "install");
|
||||||
|
const checkout = path.join(workRoot, "public-repo");
|
||||||
|
ensureDir(workRoot);
|
||||||
|
fs.rmSync(assetsDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(installDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(checkout, { recursive: true, force: true });
|
||||||
|
ensureDir(assetsDir);
|
||||||
|
ensureDir(installDir);
|
||||||
|
|
||||||
|
await downloadReleaseAssets(manifest, forgejoReport, assetsDir);
|
||||||
|
verifyChecksums(assetsDir);
|
||||||
|
|
||||||
|
const publicRepositoryUrl =
|
||||||
|
process.env.DISASMER_PUBLIC_REPO_URL ||
|
||||||
|
manifest.public_repo_url ||
|
||||||
|
manifest.public_repo_remote;
|
||||||
|
assert(publicRepositoryUrl, "public repository URL is required");
|
||||||
|
assert(publicRepositoryUrl.includes(forgejoHost));
|
||||||
|
run("git", ["clone", "--depth", "1", publicRepositoryUrl, checkout]);
|
||||||
|
assertFilteredPublicCheckout(checkout, manifest);
|
||||||
|
|
||||||
|
run("tar", ["-xzf", path.join(assetsDir, binaryArchive(manifest)), "-C", installDir]);
|
||||||
|
const disasmer = executable(installDir, "disasmer");
|
||||||
|
const disasmerCoordinator = executable(installDir, "disasmer-coordinator");
|
||||||
|
const disasmerNode = executable(installDir, "disasmer-node");
|
||||||
|
|
||||||
|
const defaultLoginPlan = runJson(disasmer, ["login"], { cwd: checkout });
|
||||||
|
assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint);
|
||||||
|
|
||||||
|
const suffix = String(Date.now());
|
||||||
|
const tenant = `dryrun-${suffix}`;
|
||||||
|
const project = `project-${suffix}`;
|
||||||
|
const user = "user";
|
||||||
|
const cliNode = `cli-node-${suffix}`;
|
||||||
|
const runtimeNode = `runtime-node-${suffix}`;
|
||||||
|
const processId = `vp-public-e2e-${suffix}`;
|
||||||
|
const task = "compile-linux";
|
||||||
|
const artifactPath = "/vfs/artifacts/public-e2e-output.txt";
|
||||||
|
const artifact = "public-e2e-output.txt";
|
||||||
|
|
||||||
|
const login = runJson(
|
||||||
|
disasmer,
|
||||||
|
[
|
||||||
|
"login",
|
||||||
|
"--browser",
|
||||||
|
"--complete-browser-code",
|
||||||
|
oidcCode,
|
||||||
|
"--oidc-issuer-url",
|
||||||
|
oidcIssuer,
|
||||||
|
"--oidc-client-id",
|
||||||
|
oidcClientId,
|
||||||
|
"--tenant",
|
||||||
|
tenant,
|
||||||
|
"--project-id",
|
||||||
|
project,
|
||||||
|
"--user",
|
||||||
|
user,
|
||||||
|
],
|
||||||
|
{ cwd: checkout }
|
||||||
|
);
|
||||||
|
assert.strictEqual(login.plan.coordinator, serviceEndpoint);
|
||||||
|
assert.strictEqual(login.boundary.cli_contacted_coordinator, true);
|
||||||
|
assert.strictEqual(login.boundary.scoped_cli_session_received, true);
|
||||||
|
assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false);
|
||||||
|
|
||||||
|
const created = await send(addr, {
|
||||||
|
type: "create_project",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
name: "Public Release Dry Run E2E",
|
||||||
|
});
|
||||||
|
assert.strictEqual(created.type, "project");
|
||||||
|
|
||||||
|
const cliGrant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_token",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
grant_id: `cli-grant-${suffix}`,
|
||||||
|
scope: "node:attach",
|
||||||
|
expires_at_epoch_seconds: 4102444800,
|
||||||
|
});
|
||||||
|
assert.strictEqual(cliGrant.type, "enrollment_grant");
|
||||||
|
|
||||||
|
const attach = runJson(
|
||||||
|
disasmer,
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
"attach",
|
||||||
|
"--coordinator",
|
||||||
|
serviceEndpoint,
|
||||||
|
"--tenant",
|
||||||
|
tenant,
|
||||||
|
"--project-id",
|
||||||
|
project,
|
||||||
|
"--node",
|
||||||
|
cliNode,
|
||||||
|
"--public-key",
|
||||||
|
`${cliNode}-public-key`,
|
||||||
|
"--enrollment-grant",
|
||||||
|
cliGrant.grant.grant_id,
|
||||||
|
],
|
||||||
|
{ cwd: checkout }
|
||||||
|
);
|
||||||
|
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat");
|
||||||
|
|
||||||
|
let worker;
|
||||||
|
let workerStderr = "";
|
||||||
|
let started;
|
||||||
|
let launch;
|
||||||
|
let nodeRun;
|
||||||
|
let events;
|
||||||
|
try {
|
||||||
|
const runtimeGrant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_token",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
user,
|
||||||
|
grant_id: `runtime-grant-${suffix}`,
|
||||||
|
scope: "node:attach",
|
||||||
|
expires_at_epoch_seconds: 4102444800,
|
||||||
|
});
|
||||||
|
assert.strictEqual(runtimeGrant.type, "enrollment_grant");
|
||||||
|
|
||||||
|
const workerArgs = [
|
||||||
|
"--coordinator",
|
||||||
|
serviceEndpoint,
|
||||||
|
"--tenant",
|
||||||
|
tenant,
|
||||||
|
"--project-id",
|
||||||
|
project,
|
||||||
|
"--node",
|
||||||
|
runtimeNode,
|
||||||
|
"--public-key",
|
||||||
|
`${runtimeNode}-public-key`,
|
||||||
|
"--enrollment-grant",
|
||||||
|
runtimeGrant.grant.grant_id,
|
||||||
|
"--worker",
|
||||||
|
"--assignment-poll-ms",
|
||||||
|
"500",
|
||||||
|
"--emit-ready",
|
||||||
|
];
|
||||||
|
commands.push([disasmerNode, ...workerArgs].join(" "));
|
||||||
|
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
|
||||||
|
worker.stderr.on("data", (chunk) => {
|
||||||
|
workerStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
const workerReady = await waitForJsonLine(worker, "public release worker");
|
||||||
|
assert.strictEqual(workerReady.node_status, "ready");
|
||||||
|
assert.strictEqual(workerReady.mode, "worker");
|
||||||
|
assert.strictEqual(workerReady.node, runtimeNode);
|
||||||
|
|
||||||
|
started = await send(addr, {
|
||||||
|
type: "start_process",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
process: processId,
|
||||||
|
});
|
||||||
|
assert.strictEqual(started.type, "process_started");
|
||||||
|
|
||||||
|
launch = await send(addr, {
|
||||||
|
type: "launch_task",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor_user: user,
|
||||||
|
process: processId,
|
||||||
|
task,
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: null,
|
||||||
|
required_artifacts: [],
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: true,
|
||||||
|
command: "cargo",
|
||||||
|
command_args: [
|
||||||
|
"test",
|
||||||
|
"--quiet",
|
||||||
|
"--manifest-path",
|
||||||
|
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
|
||||||
|
],
|
||||||
|
artifact_path: artifactPath,
|
||||||
|
});
|
||||||
|
assert.strictEqual(launch.type, "task_launched");
|
||||||
|
assert.strictEqual(launch.process, processId);
|
||||||
|
assert.strictEqual(launch.task, task);
|
||||||
|
assert.strictEqual(launch.placement.node, runtimeNode);
|
||||||
|
assert.strictEqual(launch.assignment.node, runtimeNode);
|
||||||
|
assert.strictEqual(launch.assignment.process, processId);
|
||||||
|
assert.strictEqual(launch.assignment.task, task);
|
||||||
|
|
||||||
|
nodeRun = await waitForJsonLine(worker, "public release worker completion");
|
||||||
|
} finally {
|
||||||
|
await stopChild(worker);
|
||||||
|
}
|
||||||
|
if (workerStderr.trim() && (!nodeRun || nodeRun.node_status !== "completed")) {
|
||||||
|
console.error(workerStderr);
|
||||||
|
}
|
||||||
|
assert.strictEqual(nodeRun.node_status, "completed");
|
||||||
|
assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(nodeRun.capability_response.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.node, runtimeNode);
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
|
||||||
|
assert.strictEqual(nodeRun.task_assignment_response.task, task);
|
||||||
|
assert.strictEqual(nodeRun.debug_command_response.type, "debug_command");
|
||||||
|
assert.strictEqual(nodeRun.log_event_response.type, "task_log_recorded");
|
||||||
|
assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||||
|
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor_user: user,
|
||||||
|
process: processId,
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, artifactPath);
|
||||||
|
assert(events.events[0].artifact_digest, "task event must include artifact digest");
|
||||||
|
|
||||||
|
const link = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
actor_user: user,
|
||||||
|
artifact,
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: `nonce-${suffix}`,
|
||||||
|
now_epoch_seconds: 0,
|
||||||
|
ttl_seconds: 300,
|
||||||
|
});
|
||||||
|
assert.strictEqual(link.type, "artifact_download_link");
|
||||||
|
assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const publicCoordinator = await validateStandalonePublicCoordinator(
|
||||||
|
disasmerCoordinator,
|
||||||
|
disasmerNode,
|
||||||
|
checkout
|
||||||
|
);
|
||||||
|
|
||||||
|
run("node", ["scripts/vscode-f5-smoke.js"], { cwd: checkout, stdio: "pipe" });
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
kind: "disasmer-public-release-dryrun-e2e",
|
||||||
|
public_repository_url: publicRepositoryUrl,
|
||||||
|
release_name: manifest.release_name,
|
||||||
|
source_commit: manifest.source_commit,
|
||||||
|
public_tree_identity: manifest.public_tree_identity,
|
||||||
|
default_operator_endpoint: serviceEndpoint,
|
||||||
|
service_addr: serviceAddr,
|
||||||
|
dns_publication_state: dnsPublicationState,
|
||||||
|
resolver_override: resolverOverride,
|
||||||
|
downloaded_release_assets: true,
|
||||||
|
verified_checksums: true,
|
||||||
|
clean_public_checkout: true,
|
||||||
|
public_repo_build_or_install: true,
|
||||||
|
default_operator_selected: true,
|
||||||
|
browser_or_cli_login: true,
|
||||||
|
attached_user_node: true,
|
||||||
|
launch_task_verified: true,
|
||||||
|
worker_assignment_poll_verified: true,
|
||||||
|
worker_assignment_poll_protocol: "poll_task_assignment",
|
||||||
|
public_coordinator_validated: true,
|
||||||
|
public_coordinator_operator_implementation: publicCoordinator.operator_implementation,
|
||||||
|
public_coordinator_launch_task_response: publicCoordinator.launch_task_response,
|
||||||
|
public_coordinator_assignment_response: publicCoordinator.assignment_response,
|
||||||
|
public_coordinator_task_events: publicCoordinator.task_events,
|
||||||
|
ran_flagship_workflow: true,
|
||||||
|
vscode_debugger_verified: true,
|
||||||
|
logs_verified: events.events[0].stdout_bytes > 0 || events.events[0].stderr_bytes > 0,
|
||||||
|
artifact_metadata_verified: Boolean(
|
||||||
|
events.events[0].artifact_path && events.events[0].artifact_digest
|
||||||
|
),
|
||||||
|
artifact_download_or_export_verified: true,
|
||||||
|
tenant,
|
||||||
|
project,
|
||||||
|
process: processId,
|
||||||
|
node: runtimeNode,
|
||||||
|
launch_task_response: launch.type,
|
||||||
|
worker_assignment_process: nodeRun.task_assignment_response.process,
|
||||||
|
artifact,
|
||||||
|
commands,
|
||||||
|
tool_versions: {
|
||||||
|
node: process.version,
|
||||||
|
git: commandOutput("git", ["--version"]),
|
||||||
|
tar: commandOutput("tar", ["--version"]),
|
||||||
|
rustc: commandOutput("rustc", ["--version"], { cwd: checkout }),
|
||||||
|
cargo: commandOutput("cargo", ["--version"], { cwd: checkout }),
|
||||||
|
},
|
||||||
|
evidence: {
|
||||||
|
login: login.coordinator_response.type,
|
||||||
|
attach: attach.coordinator_response.type,
|
||||||
|
node_run: nodeRun.coordinator_response.type,
|
||||||
|
task_events: events.events.length,
|
||||||
|
download_link: link.type,
|
||||||
|
public_coordinator: publicCoordinator,
|
||||||
|
},
|
||||||
|
acceptance_result: "passed",
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(report)) {
|
||||||
|
if (key.endsWith("_verified") || key.endsWith("_validated") || key.endsWith("_selected") || key.endsWith("_checkout") || key.endsWith("_assets") || key === "public_repo_build_or_install" || key === "browser_or_cli_login" || key === "attached_user_node" || key === "ran_flagship_workflow" || key === "verified_checksums") {
|
||||||
|
assert.strictEqual(value, true, `${key} must be true`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(path.dirname(reportPath));
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
console.log(`Public release dry-run e2e passed: ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
412
scripts/public-release-dryrun-final-evidence.js
Executable file
412
scripts/public-release-dryrun-final-evidence.js
Executable file
|
|
@ -0,0 +1,412 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const releaseRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-dryrun")
|
||||||
|
);
|
||||||
|
const acceptanceRoot = path.join(repo, "target/acceptance");
|
||||||
|
const serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
const serviceHost = "disasmer.michelpaulissen.com";
|
||||||
|
const serviceAddr = `${serviceHost}:9443`;
|
||||||
|
const forgejoHost = "git.michelpaulissen.com";
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
manifest: process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
||||||
|
path.join(releaseRoot, "public-release-manifest.json"),
|
||||||
|
forgejoRelease: process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"),
|
||||||
|
deployment: process.env.DISASMER_PUBLIC_RELEASE_DEPLOYMENT_MANIFEST ||
|
||||||
|
path.join(releaseRoot, "deployment/stage/deployment-manifest.json"),
|
||||||
|
service: process.env.DISASMER_PUBLIC_RELEASE_SERVICE_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-service.json"),
|
||||||
|
publicOperatorCompat: process.env.DISASMER_PUBLIC_OPERATOR_COMPAT_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-operator-compat.json"),
|
||||||
|
publicCoordinatorCompat: process.env.DISASMER_PUBLIC_COORDINATOR_COMPAT_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-coordinator-compat.json"),
|
||||||
|
e2e: process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-e2e.json"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function readJson(name, file) {
|
||||||
|
assert(fs.existsSync(file), `missing ${name} evidence: ${file}`);
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIncludes(value, expected, message) {
|
||||||
|
assert(
|
||||||
|
typeof value === "string" && value.includes(expected),
|
||||||
|
`${message}: expected ${JSON.stringify(value)} to include ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNames(manifest) {
|
||||||
|
assert(Array.isArray(manifest.assets), "manifest assets must be an array");
|
||||||
|
return new Set(manifest.assets.map((asset) => asset.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedAssetNames(report) {
|
||||||
|
return new Set(
|
||||||
|
[...(report.uploaded_assets || []), ...(report.reused_assets || [])].map(
|
||||||
|
(asset) => asset.name
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEvidenceBooleans(report, fields) {
|
||||||
|
for (const field of fields) {
|
||||||
|
assert.strictEqual(report[field], true, `e2e report must set ${field}=true`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactToolVersions(...reports) {
|
||||||
|
const versions = {};
|
||||||
|
for (const [name, report] of reports) {
|
||||||
|
if (report && report.tool_versions) {
|
||||||
|
versions[name] = report.tool_versions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDnsState(value, name) {
|
||||||
|
assert(
|
||||||
|
["not-published", "published"].includes(value),
|
||||||
|
`${name} has unexpected DNS publication state: ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFilteredOut(manifest) {
|
||||||
|
assert(Array.isArray(manifest.filtered_out), "manifest filtered_out must be an array");
|
||||||
|
for (const expected of ["private/**", "experiments/**", ".git", "target", "/*.md"]) {
|
||||||
|
assert(
|
||||||
|
manifest.filtered_out.includes(expected),
|
||||||
|
`manifest filtered_out must include ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const includeForgejoWorkflows =
|
||||||
|
manifest.public_export && manifest.public_export.include_forgejo_workflows;
|
||||||
|
if (!includeForgejoWorkflows) {
|
||||||
|
assert(
|
||||||
|
manifest.filtered_out.includes(".forgejo/**"),
|
||||||
|
"manifest filtered_out must include .forgejo/** for the host-neutral export"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandOutput(command, args) {
|
||||||
|
try {
|
||||||
|
return require("child_process")
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSourceCommit() {
|
||||||
|
return (
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = readJson("public release manifest", inputs.manifest);
|
||||||
|
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.source_commit,
|
||||||
|
expectedSourceCommit(),
|
||||||
|
"final public release evidence must be generated from the current acceptance commit"
|
||||||
|
);
|
||||||
|
assert.strictEqual(manifest.default_operator_endpoint, serviceEndpoint);
|
||||||
|
assert.strictEqual(manifest.forgejo_host, forgejoHost);
|
||||||
|
assertDnsState(manifest.dns_publication_state, "manifest");
|
||||||
|
assert(manifest.resolver_override, "manifest must record resolver override");
|
||||||
|
assertFilteredOut(manifest);
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
|
||||||
|
true,
|
||||||
|
"filtered public tree must be pushed to Forgejo"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
manifest.public_repo_url || manifest.public_repo_remote || "",
|
||||||
|
forgejoHost,
|
||||||
|
"manifest public repository must point at Forgejo"
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifestAssets = assetNames(manifest);
|
||||||
|
for (const pattern of [
|
||||||
|
/^disasmer-public-source-/,
|
||||||
|
/^disasmer-public-binaries-/,
|
||||||
|
/^disasmer-vscode-/,
|
||||||
|
/^DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-/,
|
||||||
|
/^DISASMER_PUBLIC_DRYRUN_INVITE-/,
|
||||||
|
/^SHA256SUMS$/,
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
[...manifestAssets].some((name) => pattern.test(name)),
|
||||||
|
`manifest is missing asset matching ${pattern}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forgejoRelease = readJson("Forgejo Release report", inputs.forgejoRelease);
|
||||||
|
assert.strictEqual(
|
||||||
|
forgejoRelease.kind,
|
||||||
|
"disasmer-public-release-dryrun-forgejo-release"
|
||||||
|
);
|
||||||
|
assertIncludes(forgejoRelease.forgejo_url, forgejoHost, "Forgejo Release host");
|
||||||
|
assert.strictEqual(forgejoRelease.release_name, manifest.release_name);
|
||||||
|
assert.strictEqual(
|
||||||
|
forgejoRelease.default_operator_endpoint,
|
||||||
|
manifest.default_operator_endpoint
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
forgejoRelease.public_tree_identity,
|
||||||
|
manifest.public_tree_identity
|
||||||
|
);
|
||||||
|
assert.strictEqual(forgejoRelease.source_commit, manifest.source_commit);
|
||||||
|
const releaseAssets = uploadedAssetNames(forgejoRelease);
|
||||||
|
for (const asset of manifestAssets) {
|
||||||
|
assert(releaseAssets.has(asset), `Forgejo Release is missing asset ${asset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployment = readJson("deployment manifest", inputs.deployment);
|
||||||
|
assert.strictEqual(deployment.kind, "disasmer-public-release-dryrun-deployment");
|
||||||
|
assert.strictEqual(deployment.default_operator_endpoint, serviceEndpoint);
|
||||||
|
assert.strictEqual(deployment.operator_implementation, "private-hosted-coordinator");
|
||||||
|
assert.match(
|
||||||
|
deployment.public_release_meaning || "",
|
||||||
|
/public repository[\s\S]*public release assets[\s\S]*public client protocol/
|
||||||
|
);
|
||||||
|
assert.strictEqual(deployment.service_host, serviceHost);
|
||||||
|
assert.strictEqual(deployment.service_addr, serviceAddr);
|
||||||
|
assertDnsState(deployment.dns_publication_state, "deployment");
|
||||||
|
assert(deployment.resolver_override, "deployment manifest must record resolver override");
|
||||||
|
assert(deployment.deployed_service_commit, "deployment must record deployed service commit");
|
||||||
|
|
||||||
|
const service = readJson("live service smoke report", inputs.service);
|
||||||
|
assert.strictEqual(service.kind, "disasmer-public-release-dryrun-service");
|
||||||
|
assert.strictEqual(service.source_commit, manifest.source_commit);
|
||||||
|
assert.strictEqual(service.release_name, manifest.release_name);
|
||||||
|
assert.strictEqual(service.endpoint, serviceEndpoint);
|
||||||
|
assert.strictEqual(service.service_addr, serviceAddr);
|
||||||
|
assert.strictEqual(service.operator_implementation, "private-hosted-coordinator");
|
||||||
|
assertDnsState(service.dns_publication_state, "service smoke");
|
||||||
|
assert(service.resolver_override, "service smoke must record resolver override");
|
||||||
|
assert.strictEqual(
|
||||||
|
service.public_tree_identity || manifest.public_tree_identity,
|
||||||
|
manifest.public_tree_identity
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
service.deployed_service_commit || deployment.deployed_service_commit,
|
||||||
|
deployment.deployed_service_commit
|
||||||
|
);
|
||||||
|
for (const key of [
|
||||||
|
"ping",
|
||||||
|
"login",
|
||||||
|
"project",
|
||||||
|
"node_credential",
|
||||||
|
"heartbeat",
|
||||||
|
"public_worker_credential",
|
||||||
|
"public_worker_capabilities",
|
||||||
|
"public_launch_task",
|
||||||
|
"public_assignment_poll",
|
||||||
|
"public_worker_completion",
|
||||||
|
"process",
|
||||||
|
"task",
|
||||||
|
"debug",
|
||||||
|
"artifact_metadata",
|
||||||
|
"download_action",
|
||||||
|
"download_link",
|
||||||
|
"observability",
|
||||||
|
]) {
|
||||||
|
assert(service.evidence && service.evidence[key], `service smoke missing ${key}`);
|
||||||
|
}
|
||||||
|
assert.strictEqual(service.evidence.public_launch_task, "task_launched");
|
||||||
|
assert.strictEqual(service.evidence.public_assignment_poll, "task_assignment");
|
||||||
|
assert.strictEqual(service.evidence.public_worker_completion, "task_recorded");
|
||||||
|
if (service.acceptance_result) {
|
||||||
|
assert.strictEqual(service.acceptance_result, "passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const compat = readJson("public operator compatibility report", inputs.publicOperatorCompat);
|
||||||
|
assert.strictEqual(compat.kind, "disasmer-public-operator-compatibility");
|
||||||
|
assert.strictEqual(compat.source_commit, manifest.source_commit);
|
||||||
|
assert.strictEqual(compat.release_name, manifest.release_name);
|
||||||
|
assert.strictEqual(
|
||||||
|
compat.public_cli_attach.coordinator_response,
|
||||||
|
"node_enrollment_exchanged"
|
||||||
|
);
|
||||||
|
assert.strictEqual(compat.public_cli_attach.heartbeat_response, "node_heartbeat");
|
||||||
|
assert.strictEqual(compat.public_launch_task.process_started_response, "process_started");
|
||||||
|
assert.strictEqual(compat.public_launch_task.launch_response, "task_launched");
|
||||||
|
assert.strictEqual(compat.public_node_runtime.node_status, "completed");
|
||||||
|
assert.strictEqual(compat.public_node_runtime.task_assignment_response, "task_assignment");
|
||||||
|
assert.strictEqual(
|
||||||
|
compat.public_node_runtime.task_assignment_node,
|
||||||
|
compat.public_launch_task.assignment_node
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
compat.public_node_runtime.coordinator_response,
|
||||||
|
"task_recorded"
|
||||||
|
);
|
||||||
|
assert(compat.public_task_events >= 1, "public operator compat must record task events");
|
||||||
|
|
||||||
|
const publicCoordinator = readJson(
|
||||||
|
"public coordinator compatibility report",
|
||||||
|
inputs.publicCoordinatorCompat
|
||||||
|
);
|
||||||
|
assert.strictEqual(publicCoordinator.kind, "disasmer-public-coordinator-compatibility");
|
||||||
|
assert.strictEqual(publicCoordinator.source_commit, manifest.source_commit);
|
||||||
|
assert.strictEqual(publicCoordinator.release_name, manifest.release_name);
|
||||||
|
assert.strictEqual(
|
||||||
|
publicCoordinator.operator_implementation,
|
||||||
|
"standalone-public-coordinator"
|
||||||
|
);
|
||||||
|
assert.strictEqual(publicCoordinator.task_placement, "task_placement");
|
||||||
|
assert.strictEqual(publicCoordinator.task_completion, "task_recorded");
|
||||||
|
assert(publicCoordinator.task_events >= 1, "public coordinator compat must record task events");
|
||||||
|
assert.strictEqual(publicCoordinator.artifact_export_plan, "artifact_export_plan");
|
||||||
|
|
||||||
|
const e2e = readJson("public repository e2e report", inputs.e2e);
|
||||||
|
assert.strictEqual(e2e.kind, "disasmer-public-release-dryrun-e2e");
|
||||||
|
assert.strictEqual(e2e.default_operator_endpoint, serviceEndpoint);
|
||||||
|
assert.strictEqual(e2e.service_addr, serviceAddr);
|
||||||
|
assert.strictEqual(e2e.launch_task_verified, true);
|
||||||
|
assert.strictEqual(e2e.worker_assignment_poll_verified, true);
|
||||||
|
assert.strictEqual(e2e.worker_assignment_poll_protocol, "poll_task_assignment");
|
||||||
|
assert.strictEqual(e2e.launch_task_response, "task_launched");
|
||||||
|
assert.strictEqual(e2e.worker_assignment_process, e2e.process);
|
||||||
|
assert.strictEqual(e2e.public_coordinator_validated, true);
|
||||||
|
assert.strictEqual(
|
||||||
|
e2e.public_coordinator_operator_implementation,
|
||||||
|
"standalone-public-coordinator"
|
||||||
|
);
|
||||||
|
assert.strictEqual(e2e.public_coordinator_launch_task_response, "task_launched");
|
||||||
|
assert.strictEqual(e2e.public_coordinator_assignment_response, "task_assignment");
|
||||||
|
assert(e2e.public_coordinator_task_events >= 1, "e2e must record public coordinator task events");
|
||||||
|
if (e2e.dns_publication_state) {
|
||||||
|
assertDnsState(e2e.dns_publication_state, "public repository e2e");
|
||||||
|
}
|
||||||
|
if (e2e.acceptance_result) {
|
||||||
|
assert.strictEqual(e2e.acceptance_result, "passed");
|
||||||
|
}
|
||||||
|
assertIncludes(e2e.public_repository_url, forgejoHost, "e2e public repo URL");
|
||||||
|
assert.strictEqual(e2e.release_name, manifest.release_name);
|
||||||
|
assert.strictEqual(e2e.public_tree_identity, manifest.public_tree_identity);
|
||||||
|
assert.strictEqual(e2e.source_commit, manifest.source_commit);
|
||||||
|
assertEvidenceBooleans(e2e, [
|
||||||
|
"downloaded_release_assets",
|
||||||
|
"verified_checksums",
|
||||||
|
"clean_public_checkout",
|
||||||
|
"public_repo_build_or_install",
|
||||||
|
"default_operator_selected",
|
||||||
|
"browser_or_cli_login",
|
||||||
|
"attached_user_node",
|
||||||
|
"public_coordinator_validated",
|
||||||
|
"ran_flagship_workflow",
|
||||||
|
"vscode_debugger_verified",
|
||||||
|
"logs_verified",
|
||||||
|
"artifact_metadata_verified",
|
||||||
|
"artifact_download_or_export_verified",
|
||||||
|
]);
|
||||||
|
assert(Array.isArray(e2e.commands) && e2e.commands.length > 0);
|
||||||
|
assert(Array.isArray(e2e.tool_versions) || typeof e2e.tool_versions === "object");
|
||||||
|
|
||||||
|
const finalReport = {
|
||||||
|
kind: "disasmer-public-release-dryrun-final-evidence",
|
||||||
|
release_name: manifest.release_name,
|
||||||
|
source_commit: manifest.source_commit,
|
||||||
|
public_tree_identity: manifest.public_tree_identity,
|
||||||
|
deployed_service_commit: deployment.deployed_service_commit,
|
||||||
|
default_operator_endpoint: serviceEndpoint,
|
||||||
|
service_addr: serviceAddr,
|
||||||
|
dns_publication_state: {
|
||||||
|
manifest: manifest.dns_publication_state,
|
||||||
|
deployment: deployment.dns_publication_state,
|
||||||
|
service: service.dns_publication_state,
|
||||||
|
public_repository_e2e: e2e.dns_publication_state || null,
|
||||||
|
},
|
||||||
|
resolver_override: {
|
||||||
|
manifest: manifest.resolver_override,
|
||||||
|
deployment: deployment.resolver_override,
|
||||||
|
service: service.resolver_override,
|
||||||
|
public_repository_e2e: e2e.resolver_override || null,
|
||||||
|
},
|
||||||
|
deployment_config: {
|
||||||
|
repo: service.deployment_config_repo || null,
|
||||||
|
commit: service.deployment_config_commit || null,
|
||||||
|
system_generation: service.deployment_system_generation || null,
|
||||||
|
service_unit: service.service_unit || null,
|
||||||
|
},
|
||||||
|
coordinator_validation: {
|
||||||
|
private_hosted_default_operator: {
|
||||||
|
operator_implementation: service.operator_implementation,
|
||||||
|
service_addr: service.service_addr,
|
||||||
|
launch_task: service.evidence.public_launch_task,
|
||||||
|
assignment_poll: service.evidence.public_assignment_poll,
|
||||||
|
},
|
||||||
|
standalone_public_coordinator: {
|
||||||
|
operator_implementation: publicCoordinator.operator_implementation,
|
||||||
|
task_placement: publicCoordinator.task_placement,
|
||||||
|
task_events: publicCoordinator.task_events,
|
||||||
|
release_binary_e2e: {
|
||||||
|
launch_task: e2e.public_coordinator_launch_task_response,
|
||||||
|
assignment_poll: e2e.public_coordinator_assignment_response,
|
||||||
|
task_events: e2e.public_coordinator_task_events,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forgejo_host: forgejoHost,
|
||||||
|
public_repository_url: manifest.public_repo_url,
|
||||||
|
forgejo_release: {
|
||||||
|
owner: forgejoRelease.owner,
|
||||||
|
repo: forgejoRelease.repo,
|
||||||
|
release_id: forgejoRelease.release_id,
|
||||||
|
asset_count: releaseAssets.size,
|
||||||
|
},
|
||||||
|
tool_versions: compactToolVersions(
|
||||||
|
["manifest", manifest],
|
||||||
|
["service_smoke", service],
|
||||||
|
["public_repository_e2e", e2e]
|
||||||
|
),
|
||||||
|
acceptance_results: {
|
||||||
|
public_release_preparation: {
|
||||||
|
commands: manifest.commands,
|
||||||
|
result: "passed",
|
||||||
|
},
|
||||||
|
deployment_bundle: {
|
||||||
|
commands: deployment.commands,
|
||||||
|
result: "passed",
|
||||||
|
},
|
||||||
|
live_service_smoke: {
|
||||||
|
command: service.acceptance_command || null,
|
||||||
|
result: service.acceptance_result || "passed",
|
||||||
|
evidence: service.evidence,
|
||||||
|
},
|
||||||
|
public_coordinator_compatibility: {
|
||||||
|
result: "passed",
|
||||||
|
evidence: publicCoordinator,
|
||||||
|
},
|
||||||
|
public_repository_e2e: {
|
||||||
|
commands: e2e.commands,
|
||||||
|
result: "passed",
|
||||||
|
evidence: e2e.evidence,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
evidence_files: inputs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = path.join(acceptanceRoot, "public-release-dryrun-final.json");
|
||||||
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||||
|
fs.writeFileSync(output, `${JSON.stringify(finalReport, null, 2)}\n`);
|
||||||
|
console.log(`Public release dry-run final evidence passed: ${output}`);
|
||||||
247
scripts/public-release-dryrun-preflight.js
Executable file
247
scripts/public-release-dryrun-preflight.js
Executable file
|
|
@ -0,0 +1,247 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const releaseRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-dryrun")
|
||||||
|
);
|
||||||
|
const acceptanceRoot = path.join(repo, "target/acceptance");
|
||||||
|
const manifestPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
||||||
|
path.join(releaseRoot, "public-release-manifest.json");
|
||||||
|
const reportPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_PREFLIGHT_REPORT ||
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-preflight.json");
|
||||||
|
|
||||||
|
function commandOutput(command, args, options = {}) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSourceCommit() {
|
||||||
|
return (
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(file) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSha256Sums(file) {
|
||||||
|
const sums = new Map();
|
||||||
|
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const match = /^([0-9a-f]{64})\s+(.+)$/.exec(line.trim());
|
||||||
|
assert(match, `malformed SHA256SUMS line: ${line}`);
|
||||||
|
sums.set(path.basename(match[2]), match[1]);
|
||||||
|
}
|
||||||
|
return sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteHead(remote) {
|
||||||
|
const output = commandOutput("git", ["ls-remote", remote, "HEAD", "refs/heads/main"]);
|
||||||
|
if (!output) return null;
|
||||||
|
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||||
|
const head = lines.find((line) => line.endsWith("\tHEAD")) || lines[0];
|
||||||
|
return head && head.split(/\s+/)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function envState(name) {
|
||||||
|
return process.env[name] ? "set" : "unset";
|
||||||
|
}
|
||||||
|
|
||||||
|
function staleEvidence(file, currentSourceCommit) {
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
return { file, status: "missing", source_commit: null, release_name: null };
|
||||||
|
}
|
||||||
|
const evidence = readJson(file);
|
||||||
|
if (!evidence.source_commit) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
status: "unversioned",
|
||||||
|
source_commit: null,
|
||||||
|
release_name: evidence.release_name || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
status: evidence.source_commit === currentSourceCommit ? "current" : "stale",
|
||||||
|
source_commit: evidence.source_commit,
|
||||||
|
release_name: evidence.release_name || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`);
|
||||||
|
const manifest = readJson(manifestPath);
|
||||||
|
const currentSourceCommit = expectedSourceCommit();
|
||||||
|
const currentTreeStatus = commandOutput("git", ["status", "--short"]) || "";
|
||||||
|
assert.strictEqual(
|
||||||
|
currentTreeStatus,
|
||||||
|
"",
|
||||||
|
"public release preflight requires a clean source tree"
|
||||||
|
);
|
||||||
|
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.source_commit,
|
||||||
|
currentSourceCommit,
|
||||||
|
"public release manifest must be regenerated for the current acceptance commit"
|
||||||
|
);
|
||||||
|
assert.strictEqual(manifest.source_tree_clean, true, "public release prep must start clean");
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
|
||||||
|
true,
|
||||||
|
"filtered public tree must be pushed to Forgejo before release publication"
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicRepoRemote = manifest.public_repo_url || manifest.public_repo_remote;
|
||||||
|
assert(publicRepoRemote, "manifest must record public repository URL or remote");
|
||||||
|
const remoteMain = remoteHead(publicRepoRemote);
|
||||||
|
assert.strictEqual(
|
||||||
|
remoteMain,
|
||||||
|
manifest.public_tree_publish.commit,
|
||||||
|
"Forgejo public repository main branch must match the prepared public tree commit"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(Array.isArray(manifest.assets) && manifest.assets.length > 0, "manifest assets missing");
|
||||||
|
const checksumAsset = manifest.assets.find((asset) => asset.name === "SHA256SUMS");
|
||||||
|
assert(checksumAsset, "manifest must include SHA256SUMS");
|
||||||
|
assert(fs.existsSync(checksumAsset.file), `missing checksum asset: ${checksumAsset.file}`);
|
||||||
|
const checksums = parseSha256Sums(checksumAsset.file);
|
||||||
|
const assets = manifest.assets.map((asset) => {
|
||||||
|
assert(fs.existsSync(asset.file), `missing release asset: ${asset.file}`);
|
||||||
|
const actual = sha256File(asset.file);
|
||||||
|
const expected = checksums.get(asset.name);
|
||||||
|
if (asset.name !== "SHA256SUMS") {
|
||||||
|
assert.strictEqual(actual, expected, `checksum mismatch for ${asset.name}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: asset.name,
|
||||||
|
file: asset.file,
|
||||||
|
bytes: fs.statSync(asset.file).size,
|
||||||
|
sha256: actual,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const evidence = [
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-service.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-operator-compat.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-coordinator-compat.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-e2e.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
staleEvidence(
|
||||||
|
path.join(acceptanceRoot, "public-release-dryrun-final.json"),
|
||||||
|
currentSourceCommit
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
kind: "disasmer-public-release-dryrun-preflight",
|
||||||
|
source_commit: currentSourceCommit,
|
||||||
|
release_name: manifest.release_name,
|
||||||
|
public_tree_commit: manifest.public_tree_publish.commit,
|
||||||
|
public_repo_url: publicRepoRemote,
|
||||||
|
public_repo_remote_head: remoteMain,
|
||||||
|
source_tree_clean: currentTreeStatus === "",
|
||||||
|
local_assets_ready: true,
|
||||||
|
assets,
|
||||||
|
evidence,
|
||||||
|
external_gates: {
|
||||||
|
forgejo_release_publication: {
|
||||||
|
status: envState("DISASMER_FORGEJO_TOKEN") === "set" ? "ready" : "pending",
|
||||||
|
env: {
|
||||||
|
DISASMER_FORGEJO_TOKEN: envState("DISASMER_FORGEJO_TOKEN"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
live_service_smoke: {
|
||||||
|
status:
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" &&
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" &&
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
|
||||||
|
? "ready"
|
||||||
|
: "pending",
|
||||||
|
env: {
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR"
|
||||||
|
),
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
|
||||||
|
),
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
public_release_e2e: {
|
||||||
|
status:
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" &&
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" &&
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" &&
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
|
||||||
|
? "ready"
|
||||||
|
: "pending",
|
||||||
|
env: {
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset",
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
|
||||||
|
),
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
|
||||||
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
final_evidence: {
|
||||||
|
status:
|
||||||
|
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" &&
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL === "1"
|
||||||
|
? "ready"
|
||||||
|
: "pending",
|
||||||
|
env: {
|
||||||
|
DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL || "unset",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ report: reportPath, release_name: report.release_name }, null, 2));
|
||||||
99
scripts/public-story-contract-smoke.js
Normal file
99
scripts/public-story-contract-smoke.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing public-story evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readme = read("README.md");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js");
|
||||||
|
const dapSmoke = read("scripts/dap-smoke.js");
|
||||||
|
const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const artifactExportSmoke = read("scripts/artifact-export-smoke.js");
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/public-story-contract-smoke.js"),
|
||||||
|
`${scriptName} must run public-story-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
[
|
||||||
|
"README states public story",
|
||||||
|
/one virtual process, many virtual threads\/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"README states local-first bytes policy",
|
||||||
|
/local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"README states normal debugger controls",
|
||||||
|
/ordinary debugger\s+controls for breakpoints, continue, pause, and restart/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
expect(readme, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["CLI local run starts a node process", /cli_process_started_node_process/],
|
||||||
|
["CLI local run checks node is separate from CLI", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, cliPid\)/],
|
||||||
|
["CLI local run checks node is separate from coordinator", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, coordinator\.pid\)/],
|
||||||
|
["CLI local run records one node task event", /assert\.strictEqual\(events\.events\.length, 1\)/],
|
||||||
|
["CLI local run records artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/cli-run-output\.txt"\)/],
|
||||||
|
["CLI local-only run starts coordinator", /cli_process_started_coordinator_process[\s\S]*true/],
|
||||||
|
["CLI local-only run hides coordinator address requirement", /\["run"[\s\S]*"--local"[\s\S]*"--project"[\s\S]*project/],
|
||||||
|
]) {
|
||||||
|
expect(cliLocalRunSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["DAP smoke uses local-services runtime", /runtimeBackend: "local-services"/],
|
||||||
|
["DAP smoke exposes one target with four virtual threads", /threads\.map\(\(thread\) => thread\.id\)[\s\S]*\[1, 2, 3, 4\]/],
|
||||||
|
["DAP smoke binds main breakpoint", /assert\.match\(mainStack\[0\]\.name, \/build virtual process::run\/\)/],
|
||||||
|
["DAP smoke binds Linux task breakpoint", /assert\.match\(stack\[0\]\.name, \/compile linux::run\/\)/],
|
||||||
|
["DAP smoke all-stops on breakpoint", /assert\.strictEqual\(stopped\.body\.allThreadsStopped, true\)/],
|
||||||
|
["DAP smoke supports pause all-stop", /assert\.strictEqual\(paused\.body\.allThreadsStopped, true\)/],
|
||||||
|
["DAP smoke supports selected restart", /Restarted selected task/],
|
||||||
|
["DAP smoke supports failed task restart", /Restarted failed task/],
|
||||||
|
["DAP smoke avoids native child debugger claims", /doesNotMatch\(stack\[0\]\.name, \/podman\|cmd\\\.exe\|powershell\|pid\|native child\/i\)/],
|
||||||
|
["DAP smoke crosses coordinator-node boundary", /coordinator_task_events[\s\S]*value === 1/],
|
||||||
|
]) {
|
||||||
|
expect(dapSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["flagship source does not need coordinator checkout", /coordinator_requires_checkout_access[\s\S]*false/],
|
||||||
|
["flagship source bytes remain node-local", /local_source_bytes_remain_node_local[\s\S]*true/],
|
||||||
|
["flagship avoids default source upload", /coordinator_receives_source_bytes_by_default[\s\S]*false/],
|
||||||
|
["flagship avoids default repo tarball", /default_full_repo_tarball[\s\S]*false/],
|
||||||
|
]) {
|
||||||
|
expect(flagshipDemoSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, source, pattern] of [
|
||||||
|
["artifact download creates scoped link", artifactDownloadSmoke, /create_artifact_download_link/],
|
||||||
|
["artifact download records retained-node source", artifactDownloadSmoke, /assert\.deepStrictEqual\(link\.link\.source, \{ RetainedNode: "node-download" \}\)/],
|
||||||
|
["artifact download opens scoped stream", artifactDownloadSmoke, /open_artifact_download_stream/],
|
||||||
|
["artifact export targets attached receiver node", artifactExportSmoke, /export_artifact_to_node[\s\S]*node-export-receiver/],
|
||||||
|
["artifact export disables coordinator bulk relay", artifactExportSmoke, /coordinator_bulk_relay_allowed[\s\S]*false/],
|
||||||
|
]) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Public story contract smoke passed");
|
||||||
336
scripts/publish-public-release-dryrun.js
Executable file
336
scripts/publish-public-release-dryrun.js
Executable file
|
|
@ -0,0 +1,336 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const https = require("https");
|
||||||
|
const path = require("path");
|
||||||
|
const cp = require("child_process");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const releaseRoot = path.resolve(
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||||
|
path.join(repo, "target/public-release-dryrun")
|
||||||
|
);
|
||||||
|
const manifestPath = path.join(releaseRoot, "public-release-manifest.json");
|
||||||
|
const reportPath = path.join(
|
||||||
|
repo,
|
||||||
|
"target/acceptance/public-release-dryrun-forgejo-release.json"
|
||||||
|
);
|
||||||
|
const forgejoUrl = (
|
||||||
|
process.env.DISASMER_FORGEJO_URL || "https://git.michelpaulissen.com"
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
const token = process.env.DISASMER_FORGEJO_TOKEN;
|
||||||
|
let owner = process.env.DISASMER_PUBLIC_REPO_OWNER;
|
||||||
|
let repoName = process.env.DISASMER_PUBLIC_REPO_NAME;
|
||||||
|
|
||||||
|
function requireEnv(name, value) {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandOutput(command, args) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSourceCommit() {
|
||||||
|
return (
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseForgejoRepoIdentity(remote) {
|
||||||
|
if (!remote) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathname = remote;
|
||||||
|
try {
|
||||||
|
pathname = new URL(remote).pathname;
|
||||||
|
} catch (_) {
|
||||||
|
const scpLike = /^[^@/]+@[^:]+:(.+)$/.exec(remote);
|
||||||
|
if (scpLike) {
|
||||||
|
pathname = scpLike[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = pathname
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
.replace(/\.git$/, "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
owner: parts[parts.length - 2],
|
||||||
|
repoName: parts[parts.length - 1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRepoIdentity(manifest) {
|
||||||
|
if (owner && repoName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferred = parseForgejoRepoIdentity(
|
||||||
|
manifest.public_repo_url ||
|
||||||
|
manifest.public_repo_remote ||
|
||||||
|
process.env.DISASMER_PUBLIC_REPO_REMOTE
|
||||||
|
);
|
||||||
|
owner = owner || (inferred && inferred.owner);
|
||||||
|
repoName = repoName || (inferred && inferred.repoName);
|
||||||
|
if (!owner || !repoName) {
|
||||||
|
throw new Error(
|
||||||
|
"DISASMER_PUBLIC_REPO_OWNER and DISASMER_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiPath(pathname) {
|
||||||
|
return `/api/v1${pathname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(method, pathname, { body, headers = {} } = {}) {
|
||||||
|
const url = new URL(apiPath(pathname), forgejoUrl);
|
||||||
|
const payload =
|
||||||
|
body === undefined
|
||||||
|
? null
|
||||||
|
: Buffer.isBuffer(body)
|
||||||
|
? body
|
||||||
|
: Buffer.from(JSON.stringify(body));
|
||||||
|
const requestHeaders = {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
if (payload) {
|
||||||
|
requestHeaders["Content-Length"] = payload.length;
|
||||||
|
if (!requestHeaders["Content-Type"]) {
|
||||||
|
requestHeaders["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8");
|
||||||
|
let parsed = null;
|
||||||
|
if (text.trim()) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch (_) {
|
||||||
|
parsed = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${method} ${url.pathname} failed with ${res.statusCode}: ${text}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ status: res.statusCode, body: parsed });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
if (payload) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function multipartFile(fieldName, file) {
|
||||||
|
const boundary = `disasmer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const name = path.basename(file);
|
||||||
|
const header = Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="${fieldName}"; filename="${name}"\r\n` +
|
||||||
|
"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
);
|
||||||
|
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||||
|
return {
|
||||||
|
body: Buffer.concat([header, fs.readFileSync(file), footer]),
|
||||||
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existingRelease(tagName) {
|
||||||
|
const releases = await request(
|
||||||
|
"GET",
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases?limit=100`
|
||||||
|
);
|
||||||
|
return (releases.body || []).find((release) => release.tag_name === tagName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrReuseRelease(manifest) {
|
||||||
|
const tagName = manifest.release_name;
|
||||||
|
const found = await existingRelease(tagName);
|
||||||
|
if (found) {
|
||||||
|
return { release: found, created: false };
|
||||||
|
}
|
||||||
|
const targetCommitish =
|
||||||
|
(manifest.public_tree_publish && manifest.public_tree_publish.commit) ||
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_TARGET ||
|
||||||
|
"main";
|
||||||
|
const body = [
|
||||||
|
"Disasmer public release dry run.",
|
||||||
|
"",
|
||||||
|
`Default operator endpoint: ${manifest.default_operator_endpoint}`,
|
||||||
|
`DNS publication state: ${manifest.dns_publication_state}`,
|
||||||
|
`Resolver override: ${manifest.resolver_override}`,
|
||||||
|
`Public tree identity: ${manifest.public_tree_identity}`,
|
||||||
|
`Source commit: ${manifest.source_commit}`,
|
||||||
|
].join("\n");
|
||||||
|
const created = await request(
|
||||||
|
"POST",
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases`,
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
tag_name: tagName,
|
||||||
|
target_commitish: targetCommitish,
|
||||||
|
name: tagName,
|
||||||
|
body,
|
||||||
|
draft: false,
|
||||||
|
prerelease: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return { release: created.body, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRelease(releaseId) {
|
||||||
|
const response = await request(
|
||||||
|
"GET",
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${releaseId}`
|
||||||
|
);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAsset(release, asset) {
|
||||||
|
const { body, contentType } = multipartFile("attachment", asset.file);
|
||||||
|
const response = await request(
|
||||||
|
"POST",
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`,
|
||||||
|
{
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingAssetByName(release, name) {
|
||||||
|
return Array.isArray(release.assets)
|
||||||
|
? release.assets.find((asset) => asset.name === name) || null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
requireEnv("DISASMER_FORGEJO_TOKEN", token);
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
throw new Error(`missing public release manifest: ${manifestPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||||
|
resolveRepoIdentity(manifest);
|
||||||
|
if (manifest.kind !== "disasmer-public-release-dryrun") {
|
||||||
|
throw new Error(`unexpected public release manifest kind: ${manifest.kind}`);
|
||||||
|
}
|
||||||
|
if (manifest.source_commit !== expectedSourceCommit()) {
|
||||||
|
throw new Error(
|
||||||
|
"public release manifest is stale; regenerate it for the current acceptance commit before publishing the Forgejo Release"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const publicTreeAlreadyPushed =
|
||||||
|
process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1";
|
||||||
|
if (
|
||||||
|
!publicTreeAlreadyPushed &&
|
||||||
|
(!manifest.public_tree_publish || manifest.public_tree_publish.pushed !== true)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with DISASMER_PUBLISH_PUBLIC_TREE=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
||||||
|
throw new Error("public release manifest has no assets");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { release: releaseResult, created } = await createOrReuseRelease(manifest);
|
||||||
|
const release = await loadRelease(releaseResult.id);
|
||||||
|
const uploaded = [];
|
||||||
|
const reused = [];
|
||||||
|
for (const asset of manifest.assets) {
|
||||||
|
if (!fs.existsSync(asset.file)) {
|
||||||
|
throw new Error(`missing release asset: ${asset.file}`);
|
||||||
|
}
|
||||||
|
const existing = existingAssetByName(release, asset.name);
|
||||||
|
if (existing) {
|
||||||
|
reused.push(existing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uploaded.push(await uploadAsset(release, asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
const report = {
|
||||||
|
kind: "disasmer-public-release-dryrun-forgejo-release",
|
||||||
|
forgejo_url: forgejoUrl,
|
||||||
|
owner,
|
||||||
|
repo: repoName,
|
||||||
|
release_id: release.id,
|
||||||
|
release_name: release.name || manifest.release_name,
|
||||||
|
tag_name: release.tag_name || manifest.release_name,
|
||||||
|
release_created: created,
|
||||||
|
default_operator_endpoint: manifest.default_operator_endpoint,
|
||||||
|
public_tree_identity: manifest.public_tree_identity,
|
||||||
|
source_commit: manifest.source_commit,
|
||||||
|
uploaded_assets: uploaded.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
name: asset.name,
|
||||||
|
size: asset.size,
|
||||||
|
browser_download_url: asset.browser_download_url,
|
||||||
|
})),
|
||||||
|
reused_assets: reused.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
name: asset.name,
|
||||||
|
size: asset.size,
|
||||||
|
browser_download_url: asset.browser_download_url,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{ report: reportPath, uploaded: uploaded.length, reused: reused.length },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
152
scripts/quic-smoke.js
Executable file
152
scripts/quic-smoke.js
Executable file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendezvousRequest(overrides = {}) {
|
||||||
|
return {
|
||||||
|
type: "request_rendezvous",
|
||||||
|
scope: {
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-quic",
|
||||||
|
object: { Artifact: "quic-artifact" },
|
||||||
|
authorization_subject: "node-a-to-node-b",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
node: "node-a",
|
||||||
|
advertised_addr: "node-a.mesh.invalid:4433",
|
||||||
|
public_key_fingerprint: "sha256:node-a-public-key",
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
node: "node-b",
|
||||||
|
advertised_addr: "node-b.mesh.invalid:4433",
|
||||||
|
public_key_fingerprint: "sha256:node-b-public-key",
|
||||||
|
},
|
||||||
|
direct_connectivity: true,
|
||||||
|
failure_reason: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const output = cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-node", "--bin", "disasmer-quic-smoke"],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
);
|
||||||
|
const report = JSON.parse(output.trim().split("\n").at(-1));
|
||||||
|
|
||||||
|
assert.strictEqual(report.kind, "disasmer_quic_smoke");
|
||||||
|
assert.strictEqual(report.transport, "NativeQuic");
|
||||||
|
assert.strictEqual(report.rust_native_quic, true);
|
||||||
|
assert.strictEqual(report.authenticated_direct_connection, true);
|
||||||
|
assert.strictEqual(report.coordinator_assisted_rendezvous, true);
|
||||||
|
assert.strictEqual(report.coordinator_bulk_relay_allowed, false);
|
||||||
|
assert.strictEqual(report.source_node, "node-a");
|
||||||
|
assert.strictEqual(report.destination_node, "node-b");
|
||||||
|
assert.strictEqual(report.scope.tenant, "tenant");
|
||||||
|
assert.strictEqual(report.scope.project, "project");
|
||||||
|
assert.strictEqual(report.scope.process, "vp-quic");
|
||||||
|
assert.deepStrictEqual(report.scope.object, { Artifact: "quic-artifact" });
|
||||||
|
assert.ok(report.authorization_digest.startsWith("sha256:"));
|
||||||
|
assert.ok(report.request_bytes > 0);
|
||||||
|
assert.strictEqual(report.server_received_request_bytes, report.request_bytes);
|
||||||
|
assert.ok(report.payload_bytes > 0);
|
||||||
|
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0",
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
|
||||||
|
const plan = await send(addr, rendezvousRequest());
|
||||||
|
assert.strictEqual(plan.type, "rendezvous_plan");
|
||||||
|
assert.strictEqual(plan.charged_rendezvous_attempts, 1);
|
||||||
|
assert.strictEqual(plan.plan.transport, "NativeQuic");
|
||||||
|
assert.strictEqual(plan.plan.scope.tenant, "tenant");
|
||||||
|
assert.strictEqual(plan.plan.scope.project, "project");
|
||||||
|
assert.strictEqual(plan.plan.source.node, "node-a");
|
||||||
|
assert.strictEqual(plan.plan.destination.node, "node-b");
|
||||||
|
assert.strictEqual(plan.plan.coordinator_assisted_rendezvous, true);
|
||||||
|
assert.strictEqual(plan.plan.coordinator_bulk_relay_allowed, false);
|
||||||
|
assert.ok(plan.plan.authorization_digest.startsWith("sha256:"));
|
||||||
|
|
||||||
|
const failed = await send(
|
||||||
|
addr,
|
||||||
|
rendezvousRequest({
|
||||||
|
direct_connectivity: false,
|
||||||
|
failure_reason: "nat traversal failed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.strictEqual(failed.type, "error");
|
||||||
|
assert.match(failed.message, /nat traversal failed/);
|
||||||
|
assert.match(failed.message, /coordinator bulk relay is disabled/);
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("QUIC smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
190
scripts/release-blocker-smoke.js
Executable file
190
scripts/release-blocker-smoke.js
Executable file
|
|
@ -0,0 +1,190 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(relativePath) {
|
||||||
|
const fullPath = path.join(repo, relativePath);
|
||||||
|
if (!fs.existsSync(fullPath)) return null;
|
||||||
|
return fs.readFileSync(fullPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(source, heading) {
|
||||||
|
const marker = `## ${heading}`;
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
assert(start >= 0, `missing section ${marker}`);
|
||||||
|
const next = source.indexOf("\n## ", start + marker.length);
|
||||||
|
return source.slice(start, next >= 0 ? next : source.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing release-blocker evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenDemoBlockerPattern = new RegExp(
|
||||||
|
[
|
||||||
|
"flagship demo requires",
|
||||||
|
["undocumented", "manual state"].join(" "),
|
||||||
|
["hard-coded", "local paths"].join(" "),
|
||||||
|
["demo-only", "credentials"].join(" "),
|
||||||
|
["hidden", "setup"].join(" "),
|
||||||
|
].join("[\\s\\S]*")
|
||||||
|
);
|
||||||
|
const hiddenDemoScanPattern = new RegExp(
|
||||||
|
`demo_setup_pattern='${[
|
||||||
|
["undocumented", "manual state"].join(" "),
|
||||||
|
["hidden", "setup"].join(" "),
|
||||||
|
["demo-only", "credentials?"].join(" "),
|
||||||
|
["hard-coded", "local paths?"].join(" "),
|
||||||
|
]
|
||||||
|
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||||
|
.join("\\\\|")}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
const phase2 = read("acceptance_criteria_phase2.md");
|
||||||
|
const base = read("acceptance_criteria.md");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||||
|
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||||
|
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||||
|
const hostedCommunitySmoke = maybeRead("private/hosted-policy/scripts/hosted-community-smoke.js");
|
||||||
|
const releaseSourceScan = read("scripts/release-source-scan.sh");
|
||||||
|
const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js");
|
||||||
|
|
||||||
|
const releaseBlockers = section(phase2, "20. Release blockers");
|
||||||
|
expect(
|
||||||
|
releaseBlockers,
|
||||||
|
"cross-tenant access is listed as a release blocker",
|
||||||
|
/Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata/
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
releaseBlockers,
|
||||||
|
"manual-state flagship blocker is listed",
|
||||||
|
hiddenDemoBlockerPattern
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
section(base, "21. Authorization and tenant isolation"),
|
||||||
|
"base criteria require tenant isolation failures to block release",
|
||||||
|
/Tenant isolation failures are treated as release blockers/
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
for (const smoke of [
|
||||||
|
"scripts/artifact-download-smoke.js",
|
||||||
|
"scripts/operator-panel-smoke.js",
|
||||||
|
"scripts/source-preparation-smoke.js",
|
||||||
|
"scripts/scheduler-placement-smoke.js",
|
||||||
|
"scripts/flagship-demo-smoke.js",
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
script.includes(`node ${smoke}`),
|
||||||
|
`${scriptName} must run ${smoke} as part of tenant-isolation release blocking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
script.includes("scripts/release-source-scan.sh"),
|
||||||
|
`${scriptName} must run release-source-scan.sh as part of release blocking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-community-smoke.js"),
|
||||||
|
"private acceptance must run hosted community cross-tenant checks"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"),
|
||||||
|
"private acceptance must run hosted deployment checks"
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundaryEvidence = [
|
||||||
|
[
|
||||||
|
"artifact download",
|
||||||
|
artifactDownloadSmoke,
|
||||||
|
[/const crossTenant = await send/, /const crossTenantOpen = await send/, /tenant mismatch/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"operator panel",
|
||||||
|
operatorPanelSmoke,
|
||||||
|
[/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source preparation",
|
||||||
|
sourcePreparationSmoke,
|
||||||
|
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"scheduler/node capability",
|
||||||
|
schedulerSmoke,
|
||||||
|
[/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (hostedCommunitySmoke) {
|
||||||
|
boundaryEvidence.push([
|
||||||
|
"hosted community",
|
||||||
|
hostedCommunitySmoke,
|
||||||
|
[
|
||||||
|
/const foreignAgentList = await send/,
|
||||||
|
/const crossTenantMetadata = await send/,
|
||||||
|
/const crossTenantDownload = await send/,
|
||||||
|
/tenant mismatch/,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, source, patterns] of boundaryEvidence) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["release source scan rejects manual demo state", hiddenDemoScanPattern],
|
||||||
|
["release source scan rejects hidden local paths", /hidden_local_pattern='file:\/\/\|\/home\/\[.*\]_.-\]\+\/\|\/Users\/\[.*\]_.-\]\+\/\|C:\\\\Users\\\\\|https\?:\/\/\(localhost\|127\\\.0\\\.0\\\.1\)/],
|
||||||
|
]) {
|
||||||
|
expect(releaseSourceScan, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["public split excludes private modules", /--exclude='\.\/private'/],
|
||||||
|
["public split excludes experiments", /--exclude='\.\/experiments'/],
|
||||||
|
["public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/],
|
||||||
|
["public split builds copied workspace bins", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/],
|
||||||
|
["public split installs CLI from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-install-smoke\.js\)/],
|
||||||
|
["public split installs VS Code extension from copied tree", /\(cd "\$tmp_dir" && node scripts\/vscode-extension-smoke\.js\)/],
|
||||||
|
["public split attaches node from copied tree", /\(cd "\$tmp_dir" && node scripts\/node-attach-smoke\.js\)/],
|
||||||
|
["public split runs local services from copied tree", /\(cd "\$tmp_dir" && node scripts\/local-services-smoke\.js\)/],
|
||||||
|
["public split runs CLI local workflow from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-local-run-smoke\.js\)/],
|
||||||
|
["public split runs artifact download from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-download-smoke\.js\)/],
|
||||||
|
["public split runs artifact export from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-export-smoke\.js\)/],
|
||||||
|
["public split runs DAP smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/dap-smoke\.js\)/],
|
||||||
|
["public split runs flagship demo smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/flagship-demo-smoke\.js\)/],
|
||||||
|
]) {
|
||||||
|
expect(publicSplit, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["flagship demo rejects local machine assumptions", /forbiddenSourceAssumptions/],
|
||||||
|
["flagship demo rejects coordinator checkout access", /coordinator_requires_checkout_access[\s\S]*false/],
|
||||||
|
["flagship demo asserts local source bytes stay node-local", /local_source_bytes_remain_node_local[\s\S]*true/],
|
||||||
|
["flagship demo asserts coordinator receives no source bytes by default", /coordinator_receives_source_bytes_by_default[\s\S]*false/],
|
||||||
|
["flagship demo asserts no default full repo tarball", /default_full_repo_tarball[\s\S]*false/],
|
||||||
|
]) {
|
||||||
|
expect(flagshipDemoSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Release blocker smoke passed");
|
||||||
71
scripts/release-source-scan.sh
Executable file
71
scripts/release-source-scan.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo"
|
||||||
|
|
||||||
|
release_paths=(
|
||||||
|
Cargo.toml
|
||||||
|
Cargo.lock
|
||||||
|
README.md
|
||||||
|
crates
|
||||||
|
examples
|
||||||
|
private
|
||||||
|
scripts
|
||||||
|
vscode-extension
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_release_paths=()
|
||||||
|
for path in "${release_paths[@]}"; do
|
||||||
|
if [[ -e "$path" ]]; then
|
||||||
|
existing_release_paths+=("$path")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
prose_scan_paths=(
|
||||||
|
README.md
|
||||||
|
crates
|
||||||
|
examples
|
||||||
|
private
|
||||||
|
scripts
|
||||||
|
vscode-extension
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_prose_scan_paths=()
|
||||||
|
for path in "${prose_scan_paths[@]}"; do
|
||||||
|
if [[ -e "$path" ]]; then
|
||||||
|
existing_prose_scan_paths+=("$path")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
scan_globs=(
|
||||||
|
--glob '!**/target/**'
|
||||||
|
--glob '!**/node_modules/**'
|
||||||
|
--glob '!scripts/release-source-scan.sh'
|
||||||
|
)
|
||||||
|
|
||||||
|
placeholder_pattern='debugger-gate|experiments/debugger-gate|DISASMER-DEMO|device-code-placeholder|artifact://demo|vp-local-demo'
|
||||||
|
if rg -n "${scan_globs[@]}" "$placeholder_pattern" "${existing_release_paths[@]}"; then
|
||||||
|
echo "release source scan failed: stale experiment/demo placeholder reference found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
demo_setup_pattern='undocumented manual state|hidden setup|demo-only credentials?|hard-coded local paths?'
|
||||||
|
if rg -n "${scan_globs[@]}" "$demo_setup_pattern" "${existing_prose_scan_paths[@]}"; then
|
||||||
|
echo "release source scan failed: demo requires hidden setup or demo-only state" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
hidden_local_pattern='file://|/home/[[:alnum:]_.-]+/|/Users/[[:alnum:]_.-]+/|C:\\Users\\|https?://(localhost|127\.0\.0\.1)[^[:space:]]*/artifacts/'
|
||||||
|
if rg -n "${scan_globs[@]}" "$hidden_local_pattern" "${existing_release_paths[@]}"; then
|
||||||
|
echo "release source scan failed: hidden local path or local artifact URL found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
public_wording_pattern='reddit|hacker news|lobsters|launch forum|traffic source|free tier'
|
||||||
|
if rg -n "${scan_globs[@]}" "$public_wording_pattern" "${existing_prose_scan_paths[@]}"; then
|
||||||
|
echo "release source scan failed: public-facing launch-forum/free-tier wording found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release source scan passed"
|
||||||
166
scripts/resource-metering-contract-smoke.js
Normal file
166
scripts/resource-metering-contract-smoke.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(segments) {
|
||||||
|
const fullPath = path.join(repo, ...segments);
|
||||||
|
if (!fs.existsSync(fullPath)) return null;
|
||||||
|
return fs.readFileSync(fullPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing resource metering evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectGate(script, gateName) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||||
|
`${gateName} must run resource-metering-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreLimits = read("crates/disasmer-core/src/limits.rs");
|
||||||
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||||
|
const quicSmoke = read("scripts/quic-smoke.js");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["API call limit kind", /LimitKind::ApiCall/],
|
||||||
|
["spawn limit kind", /LimitKind::Spawn/],
|
||||||
|
["log bytes limit kind", /LimitKind::LogBytes/],
|
||||||
|
["metadata bytes limit kind", /LimitKind::MetadataBytes/],
|
||||||
|
["debug read bytes limit kind", /LimitKind::DebugReadBytes/],
|
||||||
|
["UI event limit kind", /LimitKind::UiEvent/],
|
||||||
|
["rendezvous attempt limit kind", /LimitKind::RendezvousAttempt/],
|
||||||
|
["artifact download bytes limit kind", /LimitKind::ArtifactDownloadBytes/],
|
||||||
|
["hosted fuel limit kind", /LimitKind::HostedFuel/],
|
||||||
|
[
|
||||||
|
"preflight can check without consuming",
|
||||||
|
/pub fn can_charge\([\s\S]*used\.saturating_add\(amount\) > limit/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"charge goes through preflight",
|
||||||
|
/pub fn charge\([\s\S]*self\.can_charge\(limits, kind\.clone\(\), amount\)\?/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
expect(coreLimits, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
[
|
||||||
|
"rendezvous charges before transport planning",
|
||||||
|
/CoordinatorRequest::RequestRendezvous[\s\S]*rendezvous_meter\.charge\([\s\S]*LimitKind::RendezvousAttempt[\s\S]*plan_authenticated_direct_bulk_transfer/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"artifact link creation preflights downloadable bytes before link creation",
|
||||||
|
/CoordinatorRequest::CreateArtifactDownloadLink[\s\S]*downloadable_size[\s\S]*download_meter\.can_charge\([\s\S]*LimitKind::ArtifactDownloadBytes[\s\S]*create_download_link/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"artifact stream opening and chunks charge download bytes",
|
||||||
|
/CoordinatorRequest::OpenArtifactDownloadStream[\s\S]*open_download_stream\([\s\S]*&mut self\.download_meter[\s\S]*stream_download_chunk\([\s\S]*charged_download_bytes/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, source, patterns] of [
|
||||||
|
[
|
||||||
|
"rendezvous smoke",
|
||||||
|
quicSmoke,
|
||||||
|
[/charged_rendezvous_attempts, 1/, /coordinator bulk relay is disabled/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"artifact download smoke",
|
||||||
|
artifactDownloadSmoke,
|
||||||
|
[
|
||||||
|
/charged_download_bytes, 16/,
|
||||||
|
/revoked/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"operator panel smoke",
|
||||||
|
operatorPanelSmoke,
|
||||||
|
[
|
||||||
|
/type: "submit_panel_event"/,
|
||||||
|
/max_events: 1/,
|
||||||
|
/used_events, 1/,
|
||||||
|
/rate limit/i,
|
||||||
|
/max_download_bytes: 1/,
|
||||||
|
/exceeds download limit/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(source, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectGate(publicAcceptance, "public acceptance");
|
||||||
|
expectGate(publicSplit, "public split acceptance");
|
||||||
|
expectGate(privateAcceptance, "private acceptance");
|
||||||
|
|
||||||
|
const privateHostedLib = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
|
||||||
|
const privateHostedSmoke = maybeRead([
|
||||||
|
"private",
|
||||||
|
"hosted-policy",
|
||||||
|
"scripts",
|
||||||
|
"hosted-community-smoke.js",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (privateHostedLib && privateHostedSmoke) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
[
|
||||||
|
"hosted API authorization meters API calls before policy decisions",
|
||||||
|
/fn authorize\([\s\S]*LimitKind::ApiCall[\s\S]*policy\.decide/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hosted spawn preflights before process start",
|
||||||
|
/pub fn start_user_node_process\([\s\S]*LimitKind::Spawn[\s\S]*let active = self\.coordinator\.start_process/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hosted zero-capability Wasm preflights through trial meter",
|
||||||
|
/preflight_zero_capability_hosted_wasm\([\s\S]*let mut trial_meter = self\.meter\.clone\(\)[\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::HostedMemoryBytes[\s\S]*LimitKind::HostedWallClockMs[\s\S]*LimitKind::HostedStateBytes[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall[\s\S]*self\.meter = trial_meter/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hosted log recording preflights log bytes before storing logs",
|
||||||
|
/record_user_node_task_completion\([\s\S]*LimitKind::LogBytes[\s\S]*self\.logs\.push/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hosted debug reads preflight before inspection is created",
|
||||||
|
/debug_process\([\s\S]*LimitKind::DebugReadBytes[\s\S]*DebugEpoch::pause/,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hosted tests cover each zero-capability Wasm budget",
|
||||||
|
/hosted_zero_capability_wasm_preflight_rejects_each_budget_over_limit\([\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall/,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
expect(privateHostedLib, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["running service denies hosted native compute", /hosted_native_command_preflight/],
|
||||||
|
["running service rejects non-zero hosted capabilities", /required_capabilities: \["Network"\]/],
|
||||||
|
["running service rejects hosted fuel overage", /resource limit exceeded.*HostedFuel/i],
|
||||||
|
["running service rejects spawn quota before retry succeeds", /quotaExhausted[\s\S]*resource limit exceeded.*Spawn/i],
|
||||||
|
["spawn quota survives fresh client retry", /quotaRetryFromFreshClient[\s\S]*resource limit exceeded.*Spawn/i],
|
||||||
|
["spawn quota survives node reconnect", /quotaAfterNodeReconnect[\s\S]*resource limit exceeded.*Spawn/i],
|
||||||
|
["running service rejects over-limit logs", /resource limit exceeded.*LogBytes/i],
|
||||||
|
["running service exposes scoped debug inspection", /type: "debug_process"[\s\S]*debug_inspection/],
|
||||||
|
]) {
|
||||||
|
expect(privateHostedSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Resource metering contract smoke passed");
|
||||||
303
scripts/scheduler-placement-smoke.js
Executable file
303
scripts/scheduler-placement-smoke.js
Executable file
|
|
@ -0,0 +1,303 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function linuxCapabilities() {
|
||||||
|
return {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: [
|
||||||
|
"Command",
|
||||||
|
"Containers",
|
||||||
|
"RootlessPodman",
|
||||||
|
"VfsArtifacts"
|
||||||
|
],
|
||||||
|
environment_backends: ["Container"],
|
||||||
|
source_providers: ["filesystem"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachNode(addr, node) {
|
||||||
|
const attached = await send(addr, {
|
||||||
|
type: "attach_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node,
|
||||||
|
public_key: `${node}-public-key`
|
||||||
|
});
|
||||||
|
assert.strictEqual(attached.type, "node_attached");
|
||||||
|
assert.strictEqual(attached.node, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportNode(addr, node, locality) {
|
||||||
|
const recorded = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node,
|
||||||
|
capabilities: linuxCapabilities(),
|
||||||
|
cached_environment_digests: locality.cached_environment_digests,
|
||||||
|
dependency_cache_digests: locality.dependency_cache_digests,
|
||||||
|
source_snapshots: locality.source_snapshots,
|
||||||
|
artifact_locations: locality.artifact_locations,
|
||||||
|
direct_connectivity: locality.direct_connectivity !== false,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(recorded.node, node);
|
||||||
|
return recorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
await attachNode(addr, "cold-node");
|
||||||
|
await attachNode(addr, "warm-node");
|
||||||
|
|
||||||
|
const cold = await reportNode(addr, "cold-node", {
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: false
|
||||||
|
});
|
||||||
|
assert.strictEqual(cold.node_descriptors, 1);
|
||||||
|
|
||||||
|
const warm = await reportNode(addr, "warm-node", {
|
||||||
|
cached_environment_digests: ["sha256:env-linux-container"],
|
||||||
|
dependency_cache_digests: ["sha256:deps-toolchain"],
|
||||||
|
source_snapshots: ["sha256:source-tree"],
|
||||||
|
artifact_locations: ["toolchain-cache"]
|
||||||
|
});
|
||||||
|
assert.strictEqual(warm.node_descriptors, 2);
|
||||||
|
const reportedNodes = new Set([cold.node, warm.node]);
|
||||||
|
assert.strictEqual(reportedNodes.size, 2);
|
||||||
|
assert(reportedNodes.has("cold-node"));
|
||||||
|
assert(reportedNodes.has("warm-node"));
|
||||||
|
|
||||||
|
const inspected = await send(addr, {
|
||||||
|
type: "list_node_descriptors",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator"
|
||||||
|
});
|
||||||
|
assert.strictEqual(inspected.type, "node_descriptors");
|
||||||
|
assert.strictEqual(inspected.actor, "operator");
|
||||||
|
assert.strictEqual(inspected.descriptors.length, 2);
|
||||||
|
const warmDescriptor = inspected.descriptors.find(
|
||||||
|
(descriptor) => descriptor.id === "warm-node"
|
||||||
|
);
|
||||||
|
assert(warmDescriptor, "warm node descriptor must be visible to inspector state");
|
||||||
|
assert(warmDescriptor.capabilities.capabilities.includes("Command"));
|
||||||
|
assert(warmDescriptor.capabilities.capabilities.includes("RootlessPodman"));
|
||||||
|
assert(warmDescriptor.cached_environments.includes("sha256:env-linux-container"));
|
||||||
|
assert(warmDescriptor.dependency_caches.includes("sha256:deps-toolchain"));
|
||||||
|
assert(warmDescriptor.source_snapshots.includes("sha256:source-tree"));
|
||||||
|
assert(warmDescriptor.artifact_locations.includes("toolchain-cache"));
|
||||||
|
|
||||||
|
const crossScopeInspection = await send(addr, {
|
||||||
|
type: "list_node_descriptors",
|
||||||
|
tenant: "other-tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator"
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossScopeInspection.type, "node_descriptors");
|
||||||
|
assert.strictEqual(crossScopeInspection.descriptors.length, 0);
|
||||||
|
|
||||||
|
const crossTenantReport = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "other-tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "warm-node",
|
||||||
|
capabilities: linuxCapabilities(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenantReport.type, "error");
|
||||||
|
assert.match(crossTenantReport.message, /tenant\/project scope/);
|
||||||
|
|
||||||
|
const placement = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: {
|
||||||
|
os: "Linux",
|
||||||
|
arch: null,
|
||||||
|
capabilities: ["Containers", "RootlessPodman"]
|
||||||
|
},
|
||||||
|
environment_digest: "sha256:env-linux-container",
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: "sha256:deps-toolchain",
|
||||||
|
source_snapshot: "sha256:source-tree",
|
||||||
|
required_artifacts: ["toolchain-cache"],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(placement.type, "task_placement");
|
||||||
|
assert.strictEqual(placement.placement.node, "warm-node");
|
||||||
|
assert.ok(placement.placement.score > 0);
|
||||||
|
assert.ok(placement.placement.reasons.includes("warm environment cache"));
|
||||||
|
assert.ok(placement.placement.reasons.includes("warm dependency cache"));
|
||||||
|
assert.ok(placement.placement.reasons.includes("source snapshot already local"));
|
||||||
|
assert.ok(
|
||||||
|
placement.placement.reasons.includes("1 required artifact(s) already local")
|
||||||
|
);
|
||||||
|
|
||||||
|
const impossible = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["WindowsCommandDev"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: null,
|
||||||
|
required_artifacts: [],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(impossible.type, "error");
|
||||||
|
assert.match(impossible.message, /WindowsCommandDev/);
|
||||||
|
|
||||||
|
const quotaDenied = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: null,
|
||||||
|
required_artifacts: [],
|
||||||
|
quota_available: false,
|
||||||
|
policy_allowed: true,
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(quotaDenied.type, "error");
|
||||||
|
assert.match(quotaDenied.message, /quota unavailable for placement/);
|
||||||
|
|
||||||
|
const policyDenied = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: null,
|
||||||
|
required_artifacts: [],
|
||||||
|
quota_available: true,
|
||||||
|
policy_allowed: false,
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(policyDenied.type, "error");
|
||||||
|
assert.match(policyDenied.message, /policy denied placement/);
|
||||||
|
|
||||||
|
await reportNode(addr, "warm-node", {
|
||||||
|
cached_environment_digests: [],
|
||||||
|
dependency_cache_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: false
|
||||||
|
});
|
||||||
|
const disconnectedTransfer = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
dependency_cache: null,
|
||||||
|
source_snapshot: "sha256:source-tree",
|
||||||
|
required_artifacts: ["toolchain-cache"],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(disconnectedTransfer.type, "error");
|
||||||
|
assert.match(disconnectedTransfer.message, /source snapshot unavailable/);
|
||||||
|
assert.match(disconnectedTransfer.message, /required artifact\(s\) unavailable/);
|
||||||
|
assert.match(disconnectedTransfer.message, /direct connectivity unavailable/);
|
||||||
|
} catch (error) {
|
||||||
|
if (coordinatorStderr) {
|
||||||
|
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Scheduler placement smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
30
scripts/sdk-spawn-runtime-smoke.js
Executable file
30
scripts/sdk-spawn-runtime-smoke.js
Executable file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const sdk = fs.readFileSync(path.join(repo, "crates/disasmer-sdk/src/lib.rs"), "utf8");
|
||||||
|
|
||||||
|
assert.match(sdk, /pub struct RuntimeSpawnEvent/);
|
||||||
|
assert.match(sdk, /pub debugger_visible: bool/);
|
||||||
|
assert.match(sdk, /fn register_runtime_thread/);
|
||||||
|
assert.match(sdk, /register_runtime_thread\(id, self\.name, self\.env\)/);
|
||||||
|
assert.match(sdk, /pub fn debugger_visible\(&self\) -> bool/);
|
||||||
|
|
||||||
|
cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"test",
|
||||||
|
"-p",
|
||||||
|
"disasmer-sdk",
|
||||||
|
"spawn_task_start_registers_debugger_visible_runtime_thread",
|
||||||
|
"--",
|
||||||
|
"--exact",
|
||||||
|
],
|
||||||
|
{ cwd: repo, stdio: "inherit" }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("SDK spawn runtime smoke passed");
|
||||||
299
scripts/self-hosted-coordinator-smoke.js
Normal file
299
scripts/self-hosted-coordinator-smoke.js
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const teamArtifactDigest = `sha256:${"a".repeat(64)}`;
|
||||||
|
const releaseManifestPath =
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
||||||
|
path.join(repo, "target/public-release-dryrun/public-release-manifest.json");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function linuxNodeCapabilities() {
|
||||||
|
return {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "RootlessPodman", "VfsArtifacts", "QuicDirect"],
|
||||||
|
environment_backends: ["Container"],
|
||||||
|
source_providers: ["filesystem", "git"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandOutput(command, args) {
|
||||||
|
try {
|
||||||
|
return cp
|
||||||
|
.execFileSync(command, args, {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedSourceCommit() {
|
||||||
|
return (
|
||||||
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||||
|
commandOutput("git", ["rev-parse", "HEAD"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReleaseManifest() {
|
||||||
|
if (!fs.existsSync(releaseManifestPath)) return null;
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(releaseManifestPath, "utf8"));
|
||||||
|
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||||
|
const expectedCommit = expectedSourceCommit();
|
||||||
|
if (expectedCommit) {
|
||||||
|
assert.strictEqual(
|
||||||
|
manifest.source_commit,
|
||||||
|
expectedCommit,
|
||||||
|
"self-hosted coordinator smoke must use the manifest for the current acceptance commit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseIdentity() {
|
||||||
|
const manifest = readReleaseManifest();
|
||||||
|
return {
|
||||||
|
sourceCommit: manifest ? manifest.source_commit : expectedSourceCommit(),
|
||||||
|
releaseName:
|
||||||
|
(manifest && manifest.release_name) ||
|
||||||
|
process.env.DISASMER_PUBLIC_RELEASE_NAME ||
|
||||||
|
null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachTrustedNode(addr, node) {
|
||||||
|
const attached = await send(addr, {
|
||||||
|
type: "attach_node",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
node,
|
||||||
|
public_key: `${node}-public-key`
|
||||||
|
});
|
||||||
|
assert.strictEqual(attached.type, "node_attached");
|
||||||
|
|
||||||
|
const heartbeat = await send(addr, {
|
||||||
|
type: "node_heartbeat",
|
||||||
|
node
|
||||||
|
});
|
||||||
|
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||||
|
|
||||||
|
const reported = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
node,
|
||||||
|
capabilities: linuxNodeCapabilities(),
|
||||||
|
cached_environment_digests: ["sha256:env-team-linux"],
|
||||||
|
dependency_cache_digests: ["sha256:cargo-cache"],
|
||||||
|
source_snapshots: ["sha256:source-team"],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(reported.type, "node_capabilities_recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const release = releaseIdentity();
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
await attachTrustedNode(addr, "team-linux-a");
|
||||||
|
await attachTrustedNode(addr, "team-linux-b");
|
||||||
|
|
||||||
|
const placement = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
environment: {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "QuicDirect"]
|
||||||
|
},
|
||||||
|
environment_digest: "sha256:env-team-linux",
|
||||||
|
required_capabilities: ["VfsArtifacts"],
|
||||||
|
source_snapshot: "sha256:source-team",
|
||||||
|
required_artifacts: [],
|
||||||
|
prefer_node: "team-linux-a"
|
||||||
|
});
|
||||||
|
assert.strictEqual(placement.type, "task_placement");
|
||||||
|
assert.strictEqual(placement.placement.node, "team-linux-a");
|
||||||
|
assert(placement.placement.reasons.includes("preferred node"));
|
||||||
|
assert(placement.placement.reasons.includes("warm environment cache"));
|
||||||
|
assert(placement.placement.reasons.includes("source snapshot already local"));
|
||||||
|
|
||||||
|
const started = await send(addr, {
|
||||||
|
type: "start_process",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
process: "vp-team-build"
|
||||||
|
});
|
||||||
|
assert.strictEqual(started.type, "process_started");
|
||||||
|
|
||||||
|
const reconnected = await send(addr, {
|
||||||
|
type: "reconnect_node",
|
||||||
|
node: "team-linux-a",
|
||||||
|
process: "vp-team-build",
|
||||||
|
epoch: started.epoch
|
||||||
|
});
|
||||||
|
assert.strictEqual(reconnected.type, "node_reconnected");
|
||||||
|
|
||||||
|
const completed = await send(addr, {
|
||||||
|
type: "task_completed",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
process: "vp-team-build",
|
||||||
|
node: "team-linux-a",
|
||||||
|
task: "compile-linux",
|
||||||
|
status_code: 0,
|
||||||
|
stdout_bytes: 18,
|
||||||
|
stderr_bytes: 0,
|
||||||
|
artifact_path: "/vfs/artifacts/team-output.txt",
|
||||||
|
artifact_digest: teamArtifactDigest,
|
||||||
|
artifact_size_bytes: 18
|
||||||
|
});
|
||||||
|
assert.strictEqual(completed.type, "task_recorded");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
actor_user: "developer",
|
||||||
|
process: "vp-team-build"
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "team-linux-a");
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/team-output.txt");
|
||||||
|
|
||||||
|
const link = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
actor_user: "developer",
|
||||||
|
artifact: "team-output.txt",
|
||||||
|
max_bytes: 1024 * 1024,
|
||||||
|
token_nonce: "team-download",
|
||||||
|
now_epoch_seconds: 10,
|
||||||
|
ttl_seconds: 60
|
||||||
|
});
|
||||||
|
assert.strictEqual(link.type, "artifact_download_link");
|
||||||
|
assert.deepStrictEqual(link.link.source, { RetainedNode: "team-linux-a" });
|
||||||
|
|
||||||
|
const exportPlan = await send(addr, {
|
||||||
|
type: "export_artifact_to_node",
|
||||||
|
tenant: "team",
|
||||||
|
project: "self-hosted",
|
||||||
|
actor_user: "developer",
|
||||||
|
artifact: "team-output.txt",
|
||||||
|
receiver_node: "team-linux-b",
|
||||||
|
direct_connectivity: true,
|
||||||
|
failure_reason: ""
|
||||||
|
});
|
||||||
|
assert.strictEqual(exportPlan.type, "artifact_export_plan");
|
||||||
|
assert.strictEqual(exportPlan.source_node, "team-linux-a");
|
||||||
|
assert.strictEqual(exportPlan.receiver_node, "team-linux-b");
|
||||||
|
assert.strictEqual(exportPlan.plan.transport, "NativeQuic");
|
||||||
|
assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true);
|
||||||
|
assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false);
|
||||||
|
|
||||||
|
const reportPath = path.join(repo, "target/acceptance/public-coordinator-compat.json");
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
reportPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
kind: "disasmer-public-coordinator-compatibility",
|
||||||
|
source_commit: release.sourceCommit,
|
||||||
|
release_name: release.releaseName,
|
||||||
|
operator_implementation: "standalone-public-coordinator",
|
||||||
|
coordinator_addr: ready.listen,
|
||||||
|
ping: "pong",
|
||||||
|
nodes: ["team-linux-a", "team-linux-b"],
|
||||||
|
task_placement: placement.type,
|
||||||
|
process_started: started.type,
|
||||||
|
task_completion: completed.type,
|
||||||
|
task_events: events.events.length,
|
||||||
|
artifact_download_link: link.type,
|
||||||
|
artifact_export_plan: exportPlan.type,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Self-hosted coordinator smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
212
scripts/source-preparation-smoke.js
Executable file
212
scripts/source-preparation-smoke.js
Executable file
|
|
@ -0,0 +1,212 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCapableNode(sourceProviders = ["git"]) {
|
||||||
|
return {
|
||||||
|
os: "Linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "SourceFilesystem", "SourceGit"],
|
||||||
|
environment_backends: [],
|
||||||
|
source_providers: sourceProviders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const pending = await send(addr, {
|
||||||
|
type: "request_source_preparation",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
provider: "Git"
|
||||||
|
});
|
||||||
|
assert.strictEqual(pending.type, "source_preparation");
|
||||||
|
assert.strictEqual(pending.status.preparation.tenant, "tenant");
|
||||||
|
assert.strictEqual(pending.status.preparation.project, "project");
|
||||||
|
assert.strictEqual(pending.status.preparation.provider, "Git");
|
||||||
|
assert.strictEqual(
|
||||||
|
pending.status.preparation.coordinator_requires_checkout_access,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(pending.status.preparation.required_capabilities, [
|
||||||
|
"SourceGit"
|
||||||
|
]);
|
||||||
|
assert.match(pending.status.disposition.Pending.reason, /waiting|node/i);
|
||||||
|
|
||||||
|
for (const node of ["source-cold", "source-ready"]) {
|
||||||
|
const attached = await send(addr, {
|
||||||
|
type: "attach_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node,
|
||||||
|
public_key: `${node}-public-key`
|
||||||
|
});
|
||||||
|
assert.strictEqual(attached.type, "node_attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cold = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "source-cold",
|
||||||
|
capabilities: sourceCapableNode(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(cold.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const readyReport = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "source-ready",
|
||||||
|
capabilities: sourceCapableNode(),
|
||||||
|
cached_environment_digests: [],
|
||||||
|
source_snapshots: [],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(readyReport.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const assigned = await send(addr, {
|
||||||
|
type: "request_source_preparation",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
provider: "Git"
|
||||||
|
});
|
||||||
|
assert.strictEqual(assigned.type, "source_preparation");
|
||||||
|
assert(["source-cold", "source-ready"].includes(assigned.status.disposition.Assigned.node));
|
||||||
|
assert.strictEqual(
|
||||||
|
assigned.status.preparation.coordinator_requires_checkout_access,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const crossTenantCompletion = await send(addr, {
|
||||||
|
type: "complete_source_preparation",
|
||||||
|
tenant: "other",
|
||||||
|
project: "project",
|
||||||
|
node: "source-ready",
|
||||||
|
provider: "Git",
|
||||||
|
source_snapshot: "sha256:source-prepared"
|
||||||
|
});
|
||||||
|
assert.strictEqual(crossTenantCompletion.type, "error");
|
||||||
|
assert.match(crossTenantCompletion.message, /tenant\/project scope/i);
|
||||||
|
|
||||||
|
const completed = await send(addr, {
|
||||||
|
type: "complete_source_preparation",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "source-ready",
|
||||||
|
provider: "Git",
|
||||||
|
source_snapshot: "sha256:source-prepared"
|
||||||
|
});
|
||||||
|
assert.strictEqual(completed.type, "source_preparation_completed");
|
||||||
|
assert.strictEqual(completed.node, "source-ready");
|
||||||
|
assert.strictEqual(completed.provider, "Git");
|
||||||
|
assert.strictEqual(completed.source_snapshot, "sha256:source-prepared");
|
||||||
|
|
||||||
|
const placement = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: null,
|
||||||
|
environment_digest: null,
|
||||||
|
required_capabilities: ["SourceGit"],
|
||||||
|
source_snapshot: "sha256:source-prepared",
|
||||||
|
required_artifacts: [],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(placement.type, "task_placement");
|
||||||
|
assert.strictEqual(placement.placement.node, "source-ready");
|
||||||
|
assert(
|
||||||
|
placement.placement.reasons.includes("source snapshot already local"),
|
||||||
|
"completed source preparation must update node source locality"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (coordinatorStderr) {
|
||||||
|
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Source preparation smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
179
scripts/tenant-isolation-contract-smoke.js
Normal file
179
scripts/tenant-isolation-contract-smoke.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRead(segments) {
|
||||||
|
const fullPath = path.join(repo, ...segments);
|
||||||
|
if (!fs.existsSync(fullPath)) return null;
|
||||||
|
return fs.readFileSync(fullPath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing tenant-isolation evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectGate(script, gateName) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||||
|
`${gateName} must run tenant-isolation-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = read("crates/disasmer-core/src/auth.rs");
|
||||||
|
const artifact = read("crates/disasmer-core/src/artifact.rs");
|
||||||
|
const operatorPanel = read("crates/disasmer-core/src/operator_panel.rs");
|
||||||
|
const source = read("crates/disasmer-core/src/source.rs");
|
||||||
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
|
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||||
|
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||||
|
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||||
|
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||||
|
const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["auth contexts carry tenant and project", /pub struct AuthContext[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["enrollment grants carry tenant and project", /pub struct EnrollmentGrant[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["node credentials carry tenant and project", /pub struct NodeCredential[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["same tenant/project denies tenant mismatch", /pub fn same_tenant_project[\s\S]*tenant mismatch/],
|
||||||
|
["same tenant/project denies project mismatch", /pub fn same_tenant_project[\s\S]*project mismatch/],
|
||||||
|
["auth unit denies cross-tenant access", /fn tenant_project_scope_denies_cross_tenant_access\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(auth, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["artifact metadata carries tenant and project", /pub struct ArtifactMetadata[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["download links carry tenant project process and actor", /pub struct DownloadLink[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId[\s\S]*pub actor: Actor/],
|
||||||
|
["downloads authorize against scoped metadata", /let authz = same_tenant_project\(context, &scope\)/],
|
||||||
|
["artifact test denies cross-tenant download", /fn cross_tenant_download_is_denied_even_with_known_artifact_id\(\)/],
|
||||||
|
["artifact test denies cross-project download", /fn cross_project_download_is_denied_even_with_known_artifact_id\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(artifact, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["panel state carries tenant project and process", /pub struct PanelState[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/],
|
||||||
|
["panel events carry tenant project and process", /pub struct PanelEvent[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/],
|
||||||
|
["panel events reject scope mismatch", /PanelError::ScopeMismatch/],
|
||||||
|
["panel scope validation compares tenant project process", /self\.tenant != event\.tenant[\s\S]*self\.project != event\.project[\s\S]*self\.process != event\.process/],
|
||||||
|
]) {
|
||||||
|
expect(operatorPanel, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
source,
|
||||||
|
"source preparation carries tenant and project",
|
||||||
|
/pub struct SourcePreparation[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["project creation rejects foreign tenant reuse", /project id is outside the signed-in tenant scope/],
|
||||||
|
["project selection rejects foreign tenant", /project is outside the signed-in tenant scope/],
|
||||||
|
["project listing uses tenant-scoped context", /CoordinatorRequest::ListProjects[\s\S]*list_projects\(&context\)/],
|
||||||
|
["node capability reports check enrollment tenant scope", /node capability report is outside the enrolled tenant\/project scope/],
|
||||||
|
["operator panel stop state is tenant/project/process keyed", /type PanelStopKey = \(TenantId, ProjectId, ProcessId\)/],
|
||||||
|
["download service tests tenant mismatch", /cross_tenant\.to_string\(\)\.contains\("tenant mismatch"\)/],
|
||||||
|
["download service tests project mismatch", /cross_project\.to_string\(\)\.contains\("project mismatch"\)/],
|
||||||
|
["node capability test rejects cross-scope report", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/],
|
||||||
|
["source preparation test rejects cross-scope completion", /fn service_rejects_source_preparation_completion_outside_node_scope\(\)/],
|
||||||
|
]) {
|
||||||
|
expect(coordinatorService, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, sourceText, patterns] of [
|
||||||
|
[
|
||||||
|
"artifact download smoke",
|
||||||
|
artifactDownloadSmoke,
|
||||||
|
[
|
||||||
|
/const crossTenant = await send/,
|
||||||
|
/const crossProject = await send/,
|
||||||
|
/const crossTenantOpen = await send/,
|
||||||
|
/const crossProjectOpen = await send/,
|
||||||
|
/tenant mismatch/,
|
||||||
|
/project mismatch/,
|
||||||
|
/token is invalid/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"operator panel smoke",
|
||||||
|
operatorPanelSmoke,
|
||||||
|
[/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"scheduler and capability smoke",
|
||||||
|
schedulerSmoke,
|
||||||
|
[
|
||||||
|
/const crossScopeInspection = await send/,
|
||||||
|
/assert\.strictEqual\(crossScopeInspection\.descriptors\.length, 0\)/,
|
||||||
|
/const crossTenantReport = await send/,
|
||||||
|
/tenant\\\/project scope/,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source preparation smoke",
|
||||||
|
sourcePreparationSmoke,
|
||||||
|
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(sourceText, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(releaseBlockerSmoke, "release blockers list tenant-isolation failure", /cross-tenant access is listed as a release blocker/);
|
||||||
|
expectGate(publicAcceptance, "public acceptance");
|
||||||
|
expectGate(publicSplit, "public split acceptance");
|
||||||
|
expectGate(privateAcceptance, "private acceptance");
|
||||||
|
|
||||||
|
const hostedLib = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
|
||||||
|
const hostedSmoke = maybeRead([
|
||||||
|
"private",
|
||||||
|
"hosted-policy",
|
||||||
|
"scripts",
|
||||||
|
"hosted-community-smoke.js",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (hostedLib && hostedSmoke) {
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["hosted agent keys are tenant/project keyed", /agent_public_keys: BTreeMap<\(TenantId, ProjectId, AgentId\), AgentPublicKeyRecord>/],
|
||||||
|
["hosted agent records carry tenant and project", /pub struct AgentPublicKeyRecord[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted node statuses carry tenant and project", /pub struct HostedNodeStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted process statuses carry tenant and project", /pub struct HostedProcessStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted log entries carry tenant and project", /pub struct HostedLogEntry[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted debug statuses carry tenant and project", /pub struct HostedDebugSessionStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted observability snapshots carry tenant and project", /pub struct HostedObservabilitySnapshot[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||||
|
["hosted observability filters nodes by tenant/project", /node_statuses[\s\S]*filter\(\|status\| status\.tenant == context\.tenant && status\.project == context\.project\)/],
|
||||||
|
["hosted observability filters logs by tenant/project", /log_entries[\s\S]*filter\(\|entry\| entry\.tenant == context\.tenant && entry\.project == context\.project\)/],
|
||||||
|
["hosted observability filters artifacts by tenant/project", /metadata\.tenant == context\.tenant && metadata\.project == context\.project/],
|
||||||
|
["hosted observability filters debug by tenant/project", /debug_sessions[\s\S]*filter\(\|status\| status\.tenant == context\.tenant && status\.project == context\.project\)/],
|
||||||
|
["hosted artifact metadata inspection authorizes scoped metadata", /artifact_metadata_for_context[\s\S]*self\.authorize\(context, &scope, Action::Inspect/],
|
||||||
|
["hosted scheduling rejects foreign nodes", /hosted scheduling targets only authorized user-attached project nodes/],
|
||||||
|
]) {
|
||||||
|
expect(hostedLib, name, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, pattern] of [
|
||||||
|
["foreign agent list is empty", /const foreignAgentList = await send[\s\S]*records, \[\]/],
|
||||||
|
["foreign start is denied", /const foreignStart = await send[\s\S]*authorized user-attached project nodes\|tenant mismatch/],
|
||||||
|
["cross-project debug is denied", /const crossProjectDebug = await send[\s\S]*tenant\|project\|debug/],
|
||||||
|
["cross-actor debug is denied", /const crossActorDebug = await send[\s\S]*debug\|permission\|actor\|user/],
|
||||||
|
["cross-tenant metadata is denied", /const crossTenantMetadata = await send[\s\S]*tenant mismatch/],
|
||||||
|
["foreign snapshot reveals no objects", /foreignSnapshot[\s\S]*snapshot\.nodes, \[\][\s\S]*snapshot\.processes, \[\][\s\S]*snapshot\.logs, \[\][\s\S]*snapshot\.artifacts, \[\][\s\S]*snapshot\.debug_sessions, \[\]/],
|
||||||
|
["cross-tenant download is denied", /const crossTenantDownload = await send[\s\S]*tenant mismatch/],
|
||||||
|
]) {
|
||||||
|
expect(hostedSmoke, name, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Tenant isolation contract smoke passed");
|
||||||
107
scripts/user-session-token-boundary-smoke.js
Executable file
107
scripts/user-session-token-boundary-smoke.js
Executable file
|
|
@ -0,0 +1,107 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(file) {
|
||||||
|
return fs.readFileSync(path.join(repo, file), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBalancedBlock(source, marker) {
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
assert(start >= 0, `missing marker ${marker}`);
|
||||||
|
const open = source.indexOf("{", start);
|
||||||
|
assert(open >= 0, `missing opening brace for ${marker}`);
|
||||||
|
let depth = 0;
|
||||||
|
for (let index = open; index < source.length; index += 1) {
|
||||||
|
const char = source[index];
|
||||||
|
if (char === "{") depth += 1;
|
||||||
|
if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return source.slice(start, index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`missing closing brace for ${marker}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEnumVariant(source, variant) {
|
||||||
|
const marker = ` ${variant} {`;
|
||||||
|
return extractBalancedBlock(source, marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenUserSessionCredentials =
|
||||||
|
/\b(BrowserSession|CliDeviceSession|BrowserLoginFlow|CliLoginFlow|authorization_url|verification_url|device_code|access_token|refresh_token|provider_token|provider_tokens|session_token|user_token|oauth_token)\b/i;
|
||||||
|
|
||||||
|
function assertNoUserSessionCredential(surface, text) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
text,
|
||||||
|
forbiddenUserSessionCredentials,
|
||||||
|
`${surface} must not carry user OAuth/browser/session credentials`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||||
|
for (const variant of [
|
||||||
|
"AttachNode",
|
||||||
|
"NodeHeartbeat",
|
||||||
|
"ReportNodeCapabilities",
|
||||||
|
"RequestRendezvous",
|
||||||
|
"RequestSourcePreparation",
|
||||||
|
"CompleteSourcePreparation",
|
||||||
|
"StartProcess",
|
||||||
|
"ReconnectNode",
|
||||||
|
"CancelTask",
|
||||||
|
"PollTaskControl",
|
||||||
|
"TaskCompleted",
|
||||||
|
]) {
|
||||||
|
assertNoUserSessionCredential(
|
||||||
|
`CoordinatorRequest::${variant}`,
|
||||||
|
extractEnumVariant(coordinatorService, variant)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeRuntime = read("crates/disasmer-node/src/lib.rs");
|
||||||
|
for (const marker of [
|
||||||
|
"pub struct LinuxCommandRunPlan",
|
||||||
|
"pub struct LinuxCommandTaskOutput",
|
||||||
|
"pub struct CapturedCommandLogs",
|
||||||
|
"pub struct VirtualThreadCommand",
|
||||||
|
"pub struct CommandOutput",
|
||||||
|
]) {
|
||||||
|
assertNoUserSessionCredential(marker, extractBalancedBlock(nodeRuntime, marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
const coreExecution = read("crates/disasmer-core/src/execution.rs");
|
||||||
|
assertNoUserSessionCredential(
|
||||||
|
"CommandInvocation",
|
||||||
|
extractBalancedBlock(coreExecution, "pub struct CommandInvocation")
|
||||||
|
);
|
||||||
|
|
||||||
|
const dapAdapter = read("crates/disasmer-dap/src/main.rs");
|
||||||
|
assertNoUserSessionCredential(
|
||||||
|
"DAP variables response",
|
||||||
|
extractBalancedBlock(dapAdapter, "fn variables_response")
|
||||||
|
);
|
||||||
|
|
||||||
|
const panel = read("crates/disasmer-core/src/operator_panel.rs");
|
||||||
|
assertNoUserSessionCredential(
|
||||||
|
"PanelEvent",
|
||||||
|
extractBalancedBlock(panel, "pub struct PanelEvent")
|
||||||
|
);
|
||||||
|
|
||||||
|
const auth = read("crates/disasmer-core/src/auth.rs");
|
||||||
|
assert.match(
|
||||||
|
auth,
|
||||||
|
/task_credentials_do_not_contain_user_session/,
|
||||||
|
"core auth must keep the task credential user-session guard"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
auth,
|
||||||
|
/CredentialKind::BrowserSession \| CredentialKind::CliDeviceSession/,
|
||||||
|
"task credential guard must reject browser and CLI sessions"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("User session token boundary smoke passed");
|
||||||
70
scripts/verify-public-split.sh
Executable file
70
scripts/verify-public-split.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source_commit="$(git -C "$repo_root" rev-parse HEAD)"
|
||||||
|
export DISASMER_ACCEPTANCE_COMMIT="$source_commit"
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
tar \
|
||||||
|
--exclude='./.git' \
|
||||||
|
--exclude='./target' \
|
||||||
|
--exclude='./private' \
|
||||||
|
--exclude='./experiments' \
|
||||||
|
-C "$repo_root" \
|
||||||
|
-cf - . | tar -C "$tmp_dir" -xf -
|
||||||
|
|
||||||
|
if find "$tmp_dir" -path "$tmp_dir/private" -print -quit | grep -q .; then
|
||||||
|
echo "private directory leaked into public split" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if find "$tmp_dir" -path "$tmp_dir/experiments" -print -quit | grep -q .; then
|
||||||
|
echo "experiments directory leaked into public split" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cargo test --workspace --manifest-path "$tmp_dir/Cargo.toml"
|
||||||
|
cargo build --workspace --bins --manifest-path "$tmp_dir/Cargo.toml"
|
||||||
|
(cd "$tmp_dir" && node scripts/acceptance-report-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/acceptance-doc-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/acceptance-evidence-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/public-private-boundary-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/release-blocker-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/resource-metering-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/hostile-input-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/tenant-isolation-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/public-story-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/public-release-dryrun-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/self-hosted-coordinator-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/public-local-demo-matrix-smoke.js)
|
||||||
|
(cd "$tmp_dir" && scripts/release-source-scan.sh)
|
||||||
|
(cd "$tmp_dir" && node scripts/prepare-public-release-dryrun.js)
|
||||||
|
if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then
|
||||||
|
(cd "$tmp_dir" && node scripts/publish-public-release-dryrun.js)
|
||||||
|
fi
|
||||||
|
(cd "$tmp_dir" && node scripts/docs-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cli-login-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cli-install-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/sdk-spawn-runtime-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/node-lifecycle-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/vscode-extension-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/vscode-f5-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/node-attach-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/local-services-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cancellation-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/cli-local-run-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/artifact-download-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/artifact-export-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/operator-panel-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/source-preparation-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/scheduler-placement-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/windows-best-effort-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/windows-validation-contract-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/quic-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/dap-smoke.js)
|
||||||
|
(cd "$tmp_dir" && node scripts/flagship-demo-smoke.js)
|
||||||
179
scripts/vscode-extension-smoke.js
Executable file
179
scripts/vscode-extension-smoke.js
Executable file
|
|
@ -0,0 +1,179 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
|
||||||
|
const extension = require("../vscode-extension/extension");
|
||||||
|
const packageJson = require("../vscode-extension/package.json");
|
||||||
|
const extensionSource = fs.readFileSync(
|
||||||
|
path.join(__dirname, "../vscode-extension/extension.js"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const readme = fs.readFileSync(path.join(__dirname, "../README.md"), "utf8");
|
||||||
|
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
assert.strictEqual(packageJson.main, "./extension.js");
|
||||||
|
assert(fs.existsSync(path.join(__dirname, "../vscode-extension", packageJson.main)));
|
||||||
|
assert.deepStrictEqual(packageJson.dependencies || {}, {});
|
||||||
|
assert.match(readme, /code --extensionDevelopmentPath "\$\(pwd\)\/vscode-extension"/);
|
||||||
|
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-vscode-"));
|
||||||
|
fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "envs/linux/Containerfile"), "FROM alpine\n");
|
||||||
|
fs.mkdirSync(path.join(root, ".disasmer"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
extension.disasmerViewStatePath(root),
|
||||||
|
JSON.stringify({
|
||||||
|
nodes: [{ id: "node-linux", status: "online", capabilities: "Command RootlessPodman" }],
|
||||||
|
processes: [{ id: "vp-build", status: "running", entry: "build" }],
|
||||||
|
logs: [{ task: "compile-linux", message: "stdout=12 stderr=0", bytes: 12 }],
|
||||||
|
artifacts: [{ path: "/vfs/artifacts/app.tar.zst", status: "retained", size: 12 }],
|
||||||
|
inspector: [{ label: "debug", value: "attached" }]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const envs = extension.discoverEnvironmentNames(root);
|
||||||
|
assert.deepStrictEqual(envs, ["linux"]);
|
||||||
|
|
||||||
|
const diagnostics = extension.diagnoseEnvReferences(
|
||||||
|
'let _ = env!("linux"); let _ = env!("windows");',
|
||||||
|
envs
|
||||||
|
);
|
||||||
|
assert.strictEqual(diagnostics.length, 1);
|
||||||
|
assert.strictEqual(diagnostics[0].name, "windows");
|
||||||
|
assert.match(diagnostics[0].message, /envs\/windows\/Containerfile/);
|
||||||
|
|
||||||
|
const inspectCommand = extension.bundleInspectCommand(root, "/repo");
|
||||||
|
assert.strictEqual(inspectCommand.command, "cargo");
|
||||||
|
assert.deepStrictEqual(inspectCommand.args.slice(0, 8), [
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"bundle"
|
||||||
|
]);
|
||||||
|
assert(inspectCommand.args.includes("inspect"));
|
||||||
|
assert(inspectCommand.args.includes("--project"));
|
||||||
|
assert(inspectCommand.args.includes(root));
|
||||||
|
|
||||||
|
const refreshed = extension.refreshBundleBeforeLaunch(root, "/repo", (command, args, options) => {
|
||||||
|
assert.strictEqual(command, "cargo");
|
||||||
|
assert(args.includes("bundle"));
|
||||||
|
assert.strictEqual(options.cwd, "/repo");
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: JSON.stringify({ metadata: { identity: "sha256:abc" } }),
|
||||||
|
stderr: ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.strictEqual(refreshed.metadata.identity, "sha256:abc");
|
||||||
|
|
||||||
|
const launch = extension.resolveDisasmerDebugConfiguration(
|
||||||
|
{ uri: { fsPath: root } },
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(launch, {
|
||||||
|
name: "Disasmer: Launch Virtual Process",
|
||||||
|
type: "disasmer",
|
||||||
|
request: "launch",
|
||||||
|
entry: "build",
|
||||||
|
project: root,
|
||||||
|
runtimeBackend: "local-services",
|
||||||
|
operatorEndpoint: defaultOperatorEndpoint
|
||||||
|
});
|
||||||
|
assert.strictEqual(extension.defaultOperatorEndpoint(), defaultOperatorEndpoint);
|
||||||
|
|
||||||
|
const adapter = extension.debugAdapterExecutableSpec(root, repo);
|
||||||
|
assert.strictEqual(adapter.command, "cargo");
|
||||||
|
assert.deepStrictEqual(adapter.args, [
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-dap",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-debug-dap"
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(adapter.options, { cwd: repo });
|
||||||
|
|
||||||
|
const releasedAdapterPath = path.join(root, process.platform === "win32" ? "disasmer-debug-dap.exe" : "disasmer-debug-dap");
|
||||||
|
fs.writeFileSync(releasedAdapterPath, "");
|
||||||
|
const releasedAdapter = extension.debugAdapterExecutableSpec(root, repo);
|
||||||
|
assert.strictEqual(releasedAdapter.command, releasedAdapterPath);
|
||||||
|
assert.deepStrictEqual(releasedAdapter.args, []);
|
||||||
|
assert.deepStrictEqual(releasedAdapter.options, { cwd: root });
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
extension.refreshBundleBeforeLaunch(root, "/repo", () => ({
|
||||||
|
status: 1,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "missing environment linux"
|
||||||
|
})),
|
||||||
|
/missing environment linux/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
packageJson.contributes.viewsContainers.activitybar.some(
|
||||||
|
(container) => container.id === "disasmer" && container.title === "Disasmer"
|
||||||
|
),
|
||||||
|
"package.json must contribute a Disasmer activity-bar container"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fs.existsSync(path.join(__dirname, "../vscode-extension/resources/disasmer.svg")),
|
||||||
|
"Disasmer activity-bar icon must exist"
|
||||||
|
);
|
||||||
|
const packageViewIds = packageJson.contributes.views.disasmer.map((view) => view.id).sort();
|
||||||
|
const descriptorViewIds = extension.disasmerViewDescriptors().map((view) => view.id).sort();
|
||||||
|
assert.deepStrictEqual(descriptorViewIds, [
|
||||||
|
"disasmer.artifacts",
|
||||||
|
"disasmer.inspector",
|
||||||
|
"disasmer.logs",
|
||||||
|
"disasmer.nodes",
|
||||||
|
"disasmer.processes"
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(packageViewIds, descriptorViewIds);
|
||||||
|
for (const viewId of descriptorViewIds) {
|
||||||
|
const items = extension.disasmerViewItems(
|
||||||
|
extension.loadDisasmerViewState(root),
|
||||||
|
viewId
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
items.length > 0 && !items[0].label.startsWith("No "),
|
||||||
|
`${viewId} should render state-backed items`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
extension.disasmerViewItems(extension.loadDisasmerViewState(root), "disasmer.nodes")[0],
|
||||||
|
{
|
||||||
|
label: "node-linux",
|
||||||
|
description: "online Command RootlessPodman"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
packageJson.contributes.debuggers.some((debuggerContribution) => debuggerContribution.type === "disasmer"),
|
||||||
|
"package.json must contribute the disasmer debugger type"
|
||||||
|
);
|
||||||
|
for (const viewId of descriptorViewIds) {
|
||||||
|
assert(
|
||||||
|
packageJson.activationEvents.includes(`onView:${viewId}`),
|
||||||
|
`${viewId} should activate the extension when opened`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const launchProperties =
|
||||||
|
packageJson.contributes.debuggers[0].configurationAttributes.launch.properties;
|
||||||
|
assert.strictEqual(launchProperties.runtimeBackend.default, "local-services");
|
||||||
|
assert(launchProperties.runtimeBackend.enum.includes("live-services"));
|
||||||
|
assert.strictEqual(launchProperties.operatorEndpoint.default, defaultOperatorEndpoint);
|
||||||
|
assert.match(extensionSource, /registerDebugAdapterDescriptorFactory\("disasmer"/);
|
||||||
|
assert.match(extensionSource, /disasmer-debug-dap/);
|
||||||
|
assert.match(extensionSource, /\.disasmer\/views\.json/);
|
||||||
|
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
console.log("VS Code extension smoke passed");
|
||||||
371
scripts/vscode-f5-smoke.js
Executable file
371
scripts/vscode-f5-smoke.js
Executable file
|
|
@ -0,0 +1,371 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const extension = require("../vscode-extension/extension");
|
||||||
|
|
||||||
|
class DapClient {
|
||||||
|
constructor(spec) {
|
||||||
|
this.child = cp.spawn(spec.command, spec.args, {
|
||||||
|
cwd: spec.options && spec.options.cwd,
|
||||||
|
detached: process.platform !== "win32"
|
||||||
|
});
|
||||||
|
this.seq = 1;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.messages = [];
|
||||||
|
this.waiters = [];
|
||||||
|
this.stderr = "";
|
||||||
|
|
||||||
|
this.child.stdout.on("data", (chunk) => {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
this.parse();
|
||||||
|
});
|
||||||
|
this.child.stderr.on("data", (chunk) => {
|
||||||
|
this.stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
this.child.on("exit", () => this.flushWaiters());
|
||||||
|
}
|
||||||
|
|
||||||
|
send(command, args = {}) {
|
||||||
|
const seq = this.seq++;
|
||||||
|
const message = { seq, type: "request", command, arguments: args };
|
||||||
|
const payload = Buffer.from(JSON.stringify(message));
|
||||||
|
this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
||||||
|
this.child.stdin.write(payload);
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async response(seq, command) {
|
||||||
|
const message = await this.waitFor(
|
||||||
|
(item) =>
|
||||||
|
item.type === "response" &&
|
||||||
|
item.request_seq === seq &&
|
||||||
|
item.command === command
|
||||||
|
);
|
||||||
|
if (!message.success) {
|
||||||
|
throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
if (this.child.exitCode !== null) return;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGKILL");
|
||||||
|
} catch (_) {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor(predicate, timeoutMs = 240000) {
|
||||||
|
const existing = this.messages.find(predicate);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.terminate();
|
||||||
|
reject(new Error(`timed out waiting for DAP message\n${this.stderr}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.waiters.push({ predicate, resolve, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
while (true) {
|
||||||
|
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) return;
|
||||||
|
const header = this.buffer.slice(0, headerEnd).toString();
|
||||||
|
const match = header.match(/Content-Length: (\d+)/i);
|
||||||
|
if (!match) throw new Error(`bad DAP header: ${header}`);
|
||||||
|
const length = Number(match[1]);
|
||||||
|
const start = headerEnd + 4;
|
||||||
|
const end = start + length;
|
||||||
|
if (this.buffer.length < end) return;
|
||||||
|
const payload = this.buffer.slice(start, end).toString();
|
||||||
|
this.buffer = this.buffer.slice(end);
|
||||||
|
this.messages.push(JSON.parse(payload));
|
||||||
|
this.flushWaiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushWaiters() {
|
||||||
|
for (const waiter of [...this.waiters]) {
|
||||||
|
const message = this.messages.find(waiter.predicate);
|
||||||
|
if (!message) continue;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||||
|
waiter.resolve(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.child.exitCode !== null) return;
|
||||||
|
const seq = this.send("disconnect");
|
||||||
|
await this.response(seq, "disconnect");
|
||||||
|
this.child.stdin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const project = path.join(repo, "examples/launch-build-demo");
|
||||||
|
const launchConfig = extension.resolveDisasmerDebugConfiguration(
|
||||||
|
{ uri: { fsPath: project } },
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(launchConfig.type, "disasmer");
|
||||||
|
assert.strictEqual(launchConfig.request, "launch");
|
||||||
|
assert.strictEqual(launchConfig.entry, "build");
|
||||||
|
assert.strictEqual(launchConfig.project, project);
|
||||||
|
assert.strictEqual(launchConfig.runtimeBackend, "local-services");
|
||||||
|
|
||||||
|
const inspection = extension.refreshBundleBeforeLaunch(project, repo);
|
||||||
|
assert.match(inspection.metadata.identity, /^sha256:/);
|
||||||
|
|
||||||
|
const client = new DapClient(extension.debugAdapterExecutableSpec(repo));
|
||||||
|
try {
|
||||||
|
const initialize = client.send("initialize", {
|
||||||
|
adapterID: "disasmer",
|
||||||
|
linesStartAt1: true,
|
||||||
|
columnsStartAt1: true
|
||||||
|
});
|
||||||
|
await client.response(initialize, "initialize");
|
||||||
|
|
||||||
|
const launch = client.send("launch", launchConfig);
|
||||||
|
await client.response(launch, "launch");
|
||||||
|
await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "initialized"
|
||||||
|
);
|
||||||
|
|
||||||
|
const breakpoints = client.send("setBreakpoints", {
|
||||||
|
source: { path: path.join(project, "src/build.rs") },
|
||||||
|
breakpoints: [{ line: 22 }, { line: 31 }, { line: 60 }]
|
||||||
|
});
|
||||||
|
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
|
||||||
|
assert.strictEqual(breakpointResponse.body.breakpoints[0].verified, true);
|
||||||
|
|
||||||
|
const configurationDone = client.send("configurationDone");
|
||||||
|
await client.response(configurationDone, "configurationDone");
|
||||||
|
const stopped = await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "stopped"
|
||||||
|
);
|
||||||
|
assert.strictEqual(stopped.body.allThreadsStopped, true);
|
||||||
|
assert.strictEqual(stopped.body.reason, "breakpoint");
|
||||||
|
|
||||||
|
const threadsRequest = client.send("threads");
|
||||||
|
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||||
|
const linuxThread = threads.find((thread) => thread.name.includes("compile linux"));
|
||||||
|
assert(linuxThread, "F5 launch must expose the Linux task as a virtual thread");
|
||||||
|
|
||||||
|
const stackRequest = client.send("stackTrace", {
|
||||||
|
threadId: linuxThread.id,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
|
||||||
|
assert.strictEqual(stack[0].line, 22);
|
||||||
|
assert.strictEqual(stack[0].source.path, path.join(project, "src/build.rs"));
|
||||||
|
assert.strictEqual(stack[0].source.sourceReference || 0, 0);
|
||||||
|
|
||||||
|
const sourceRequest = client.send("source", { source: stack[0].source });
|
||||||
|
const source = (await client.response(sourceRequest, "source")).body;
|
||||||
|
assert.match(source.content, /compile_linux/);
|
||||||
|
|
||||||
|
const nextRequest = client.send("next", { threadId: linuxThread.id });
|
||||||
|
await client.response(nextRequest, "next");
|
||||||
|
const stepped = await client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "stopped" &&
|
||||||
|
message.body.reason === "step"
|
||||||
|
);
|
||||||
|
assert.strictEqual(stepped.body.threadId, linuxThread.id);
|
||||||
|
|
||||||
|
const steppedStackRequest = client.send("stackTrace", {
|
||||||
|
threadId: linuxThread.id,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const steppedStack = (await client.response(steppedStackRequest, "stackTrace")).body.stackFrames;
|
||||||
|
assert.strictEqual(steppedStack[0].line, 23);
|
||||||
|
|
||||||
|
const scopesRequest = client.send("scopes", { frameId: steppedStack[0].id });
|
||||||
|
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
|
||||||
|
const localsScope = scopes.find((scope) => scope.name === "Source Locals");
|
||||||
|
const argsScope = scopes.find((scope) => scope.name === "Task Args and Handles");
|
||||||
|
const runtimeScope = scopes.find((scope) => scope.name === "Disasmer Runtime");
|
||||||
|
const outputScope = scopes.find((scope) => scope.name === "Recent Output");
|
||||||
|
assert(localsScope, "F5 launch must expose source locals scope");
|
||||||
|
assert(argsScope, "F5 launch must expose task args and handles");
|
||||||
|
assert(runtimeScope, "F5 launch must expose Disasmer runtime state");
|
||||||
|
assert(outputScope, "F5 launch must expose recent output state");
|
||||||
|
|
||||||
|
const localsRequest = client.send("variables", {
|
||||||
|
variablesReference: localsScope.variablesReference
|
||||||
|
});
|
||||||
|
const locals = (await client.response(localsRequest, "variables")).body.variables;
|
||||||
|
assert(
|
||||||
|
locals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "unavailable-local-diagnostic" &&
|
||||||
|
String(variable.value).includes("cannot be inspected")
|
||||||
|
),
|
||||||
|
"source locals scope must report unavailable real Rust locals explicitly"
|
||||||
|
);
|
||||||
|
|
||||||
|
const argsRequest = client.send("variables", {
|
||||||
|
variablesReference: argsScope.variablesReference
|
||||||
|
});
|
||||||
|
const args = (await client.response(argsRequest, "variables")).body.variables;
|
||||||
|
assert(args.some((variable) => variable.name === "return_value"));
|
||||||
|
assert(args.some((variable) => variable.name === "artifact"));
|
||||||
|
assert(args.some((variable) => variable.name === "source_snapshot"));
|
||||||
|
assert(args.some((variable) => variable.name === "blob"));
|
||||||
|
assert(args.some((variable) => variable.name === "vfs_mounts"));
|
||||||
|
|
||||||
|
const runtimeRequest = client.send("variables", {
|
||||||
|
variablesReference: runtimeScope.variablesReference
|
||||||
|
});
|
||||||
|
const runtime = (await client.response(runtimeRequest, "variables")).body.variables;
|
||||||
|
assert(
|
||||||
|
runtime.some(
|
||||||
|
(variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"
|
||||||
|
),
|
||||||
|
"extension-resolved F5 launch must use the real local-services backend"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
runtime.some(
|
||||||
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||||
|
),
|
||||||
|
"F5 launch must cross the coordinator/node boundary and record a task event"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
runtime.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "command_status" &&
|
||||||
|
String(variable.value).includes("completed through local services")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(runtime.some((variable) => variable.name === "command_spec"));
|
||||||
|
assert(runtime.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(runtime.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
|
||||||
|
const outputRequest = client.send("variables", {
|
||||||
|
variablesReference: outputScope.variablesReference
|
||||||
|
});
|
||||||
|
const output = (await client.response(outputRequest, "variables")).body.variables;
|
||||||
|
assert(output.some((variable) => variable.name === "stdout_tail"));
|
||||||
|
assert(output.some((variable) => variable.name === "stderr_tail"));
|
||||||
|
|
||||||
|
const continueToWasmLocals = client.send("continue", { threadId: linuxThread.id });
|
||||||
|
await client.response(continueToWasmLocals, "continue");
|
||||||
|
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||||
|
const wasmLocalsStop = await client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "stopped" &&
|
||||||
|
message.body.reason === "breakpoint" &&
|
||||||
|
message.body.threadId === 1
|
||||||
|
);
|
||||||
|
assert.strictEqual(wasmLocalsStop.body.allThreadsStopped, true);
|
||||||
|
|
||||||
|
const wasmLocalsStackRequest = client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const wasmLocalsStack = (await client.response(wasmLocalsStackRequest, "stackTrace")).body
|
||||||
|
.stackFrames;
|
||||||
|
assert.strictEqual(wasmLocalsStack[0].line, 31);
|
||||||
|
const wasmLocalsScopesRequest = client.send("scopes", {
|
||||||
|
frameId: wasmLocalsStack[0].id
|
||||||
|
});
|
||||||
|
const wasmLocalsScopes = (await client.response(wasmLocalsScopesRequest, "scopes")).body.scopes;
|
||||||
|
const wasmLocalsScope = wasmLocalsScopes.find((scope) => scope.name === "Wasm Frame Locals");
|
||||||
|
assert(wasmLocalsScope, "F5 launch must expose Wasm frame locals scope");
|
||||||
|
const wasmLocalsRequest = client.send("variables", {
|
||||||
|
variablesReference: wasmLocalsScope.variablesReference
|
||||||
|
});
|
||||||
|
const wasmLocals = (await client.response(wasmLocalsRequest, "variables")).body.variables;
|
||||||
|
assert(
|
||||||
|
wasmLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "wasm_local_0" &&
|
||||||
|
String(variable.value).includes("41") &&
|
||||||
|
variable.type === "wasm-frame-local"
|
||||||
|
),
|
||||||
|
"Wasm frame locals must expose runtime values in the VS Code variables path"
|
||||||
|
);
|
||||||
|
|
||||||
|
const continueToLocals = client.send("continue", { threadId: 1 });
|
||||||
|
await client.response(continueToLocals, "continue");
|
||||||
|
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||||
|
const sourceLocalsStop = await client.waitFor(
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "stopped" &&
|
||||||
|
message.body.reason === "breakpoint" &&
|
||||||
|
message.body.threadId === 1
|
||||||
|
);
|
||||||
|
assert.strictEqual(sourceLocalsStop.body.allThreadsStopped, true);
|
||||||
|
|
||||||
|
const sourceLocalsStackRequest = client.send("stackTrace", {
|
||||||
|
threadId: 1,
|
||||||
|
startFrame: 0,
|
||||||
|
levels: 1
|
||||||
|
});
|
||||||
|
const sourceLocalsStack = (await client.response(sourceLocalsStackRequest, "stackTrace")).body
|
||||||
|
.stackFrames;
|
||||||
|
assert.strictEqual(sourceLocalsStack[0].line, 60);
|
||||||
|
|
||||||
|
const sourceLocalsScopesRequest = client.send("scopes", {
|
||||||
|
frameId: sourceLocalsStack[0].id
|
||||||
|
});
|
||||||
|
const sourceLocalsScopes = (await client.response(sourceLocalsScopesRequest, "scopes")).body
|
||||||
|
.scopes;
|
||||||
|
const sourceLocalsScope = sourceLocalsScopes.find((scope) => scope.name === "Source Locals");
|
||||||
|
assert(sourceLocalsScope, "source-local breakpoint must expose source locals");
|
||||||
|
const sourceLocalsRequest = client.send("variables", {
|
||||||
|
variablesReference: sourceLocalsScope.variablesReference
|
||||||
|
});
|
||||||
|
const sourceLocals = (await client.response(sourceLocalsRequest, "variables")).body.variables;
|
||||||
|
assert(
|
||||||
|
sourceLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "linux" &&
|
||||||
|
String(variable.value).includes("TaskHandle") &&
|
||||||
|
String(variable.value).includes("compile-linux") &&
|
||||||
|
String(variable.value).includes("virtual_thread_id = 2")
|
||||||
|
),
|
||||||
|
"source locals must include the spawned Linux task handle value"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sourceLocals.some((variable) => variable.name === "linux_thread" && variable.value === "2"),
|
||||||
|
"source locals must include the Linux virtual thread id value"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sourceLocals.some(
|
||||||
|
(variable) =>
|
||||||
|
variable.name === "linux_artifact" && String(variable.value).includes("Artifact")
|
||||||
|
),
|
||||||
|
"source locals must include the Linux artifact handle value"
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (error) {
|
||||||
|
client.terminate();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("VS Code F5 smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
115
scripts/wasmtime-node-smoke.js
Normal file
115
scripts/wasmtime-node-smoke.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const wasmTarget = path.join(
|
||||||
|
repo,
|
||||||
|
"target",
|
||||||
|
"wasm32-unknown-unknown",
|
||||||
|
"debug",
|
||||||
|
"launch_build_demo.wasm"
|
||||||
|
);
|
||||||
|
|
||||||
|
cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
["build", "-p", "launch-build-demo", "--target", "wasm32-unknown-unknown"],
|
||||||
|
{ cwd: repo, stdio: "inherit" }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(fs.existsSync(wasmTarget), `missing compiled Wasm module at ${wasmTarget}`);
|
||||||
|
|
||||||
|
const output = cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-wasmtime-smoke",
|
||||||
|
"--",
|
||||||
|
wasmTarget,
|
||||||
|
"task_add_one",
|
||||||
|
"41",
|
||||||
|
"42",
|
||||||
|
],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = JSON.parse(output);
|
||||||
|
assert.strictEqual(report.type, "wasmtime_task_smoke");
|
||||||
|
assert.strictEqual(report.export, "task_add_one");
|
||||||
|
assert.strictEqual(report.arg, 41);
|
||||||
|
assert.strictEqual(report.result, 42);
|
||||||
|
|
||||||
|
const debugOutput = cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-wasmtime-smoke",
|
||||||
|
"--",
|
||||||
|
"--debug-freeze-resume",
|
||||||
|
wasmTarget,
|
||||||
|
"task_add_one",
|
||||||
|
"41",
|
||||||
|
"42",
|
||||||
|
],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
);
|
||||||
|
const debugReport = JSON.parse(debugOutput);
|
||||||
|
assert.strictEqual(debugReport.type, "wasmtime_debug_freeze_resume_smoke");
|
||||||
|
assert.strictEqual(debugReport.export, "task_add_one");
|
||||||
|
assert.strictEqual(debugReport.task, "task_add_one");
|
||||||
|
assert.strictEqual(debugReport.frozen_state, "Frozen");
|
||||||
|
assert.strictEqual(debugReport.resumed_state, "Running");
|
||||||
|
assert(debugReport.stack_frames.some((frame) => String(frame).includes("task_add_one")));
|
||||||
|
assert(
|
||||||
|
debugReport.local_values.some(
|
||||||
|
([name, value]) => name === "wasm_local_0" && String(value).includes("41")
|
||||||
|
),
|
||||||
|
"Wasmtime debug snapshot must expose the real i32 argument as a frame local"
|
||||||
|
);
|
||||||
|
assert.strictEqual(debugReport.node_runtime_captured_wasm_locals, true);
|
||||||
|
assert.strictEqual(debugReport.result, 42);
|
||||||
|
assert.strictEqual(debugReport.node_runtime_reached_wasm_task, true);
|
||||||
|
|
||||||
|
const hostCommandOutput = cp.execFileSync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-wasmtime-smoke",
|
||||||
|
"--",
|
||||||
|
"--host-command",
|
||||||
|
],
|
||||||
|
{ cwd: repo, encoding: "utf8" }
|
||||||
|
);
|
||||||
|
const hostCommandReport = JSON.parse(hostCommandOutput);
|
||||||
|
assert.strictEqual(hostCommandReport.type, "wasmtime_host_command_smoke");
|
||||||
|
assert.strictEqual(hostCommandReport.export, "compile-linux");
|
||||||
|
assert.strictEqual(hostCommandReport.export_result, 0);
|
||||||
|
assert.strictEqual(hostCommandReport.virtual_thread, "compile-linux");
|
||||||
|
assert.strictEqual(hostCommandReport.stdout, "linux-build-artifact");
|
||||||
|
assert.strictEqual(
|
||||||
|
hostCommandReport.staged_artifact.path,
|
||||||
|
"/vfs/artifacts/linux/app.tar.zst"
|
||||||
|
);
|
||||||
|
assert.strictEqual(hostCommandReport.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(hostCommandReport.manifest_objects, 1);
|
||||||
|
assert.strictEqual(hostCommandReport.node_host_import, "disasmer.cmd_run");
|
||||||
|
assert.strictEqual(hostCommandReport.flagship_linux_build_task, true);
|
||||||
|
assert.strictEqual(hostCommandReport.node_executed_host_command, true);
|
||||||
|
assert.strictEqual(hostCommandReport.hosted_control_plane_ran_command, false);
|
||||||
|
|
||||||
|
console.log("Wasmtime node smoke passed");
|
||||||
235
scripts/windows-best-effort-smoke.js
Executable file
235
scripts/windows-best-effort-smoke.js
Executable file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
const nodeMain = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/main.rs"), "utf8");
|
||||||
|
const nodeLib = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/lib.rs"), "utf8");
|
||||||
|
const executionCore = fs.readFileSync(
|
||||||
|
path.join(repo, "crates/disasmer-core/src/execution.rs"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const dapMain = fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/main.rs"), "utf8");
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowsCapabilities() {
|
||||||
|
return {
|
||||||
|
os: "Windows",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "WindowsCommandDev", "VfsArtifacts"],
|
||||||
|
environment_backends: ["WindowsCommandDev"],
|
||||||
|
source_providers: ["filesystem"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWindowsBackendBoundary() {
|
||||||
|
assert.match(nodeMain, /CoordinatorSession::connect\(&args\.coordinator\)/);
|
||||||
|
assert.match(nodeMain, /"type": "task_completed"/);
|
||||||
|
assert.doesNotMatch(nodeMain, /WindowsCommandDev|windows-command-dev|cfg\(windows\)/);
|
||||||
|
|
||||||
|
const linuxStart = nodeLib.indexOf("impl CommandBackend for LinuxRootlessPodmanBackend");
|
||||||
|
const windowsStart = nodeLib.indexOf("pub struct WindowsCommandDevBackend");
|
||||||
|
assert(linuxStart >= 0, "Linux backend implementation must be present");
|
||||||
|
assert(windowsStart > linuxStart, "Windows backend must be separate from Linux backend");
|
||||||
|
const linuxBackend = nodeLib.slice(linuxStart, windowsStart);
|
||||||
|
assert.doesNotMatch(linuxBackend, /WindowsCommandDev|WindowsSandbox|windows-command-dev/);
|
||||||
|
assert.match(nodeLib, /impl CommandBackend for WindowsCommandDevBackend/);
|
||||||
|
assert.match(nodeLib, /impl CommandBackend for WindowsSandboxStubBackend/);
|
||||||
|
assert.match(executionCore, /\bLinuxRootlessPodman\b/);
|
||||||
|
assert.match(executionCore, /\bWindowsCommandDev\b/);
|
||||||
|
assert.match(executionCore, /\bStubbedWindowsSandbox\b/);
|
||||||
|
|
||||||
|
assert.match(dapMain, /thread\(WINDOWS_THREAD, "compile-windows", "compile windows"/);
|
||||||
|
assert.match(dapMain, /fn launch_threads_include_windows_task_in_same_virtual_process\(\)/);
|
||||||
|
assert.match(dapMain, /fn windows_thread_runtime_variables_share_virtual_process\(\)/);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
assertWindowsBackendBoundary();
|
||||||
|
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const attached = await send(addr, {
|
||||||
|
type: "attach_node",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "windows-node",
|
||||||
|
public_key: "windows-node-public-key"
|
||||||
|
});
|
||||||
|
assert.strictEqual(attached.type, "node_attached");
|
||||||
|
assert.strictEqual(attached.node, "windows-node");
|
||||||
|
|
||||||
|
const recorded = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "windows-node",
|
||||||
|
capabilities: windowsCapabilities(),
|
||||||
|
cached_environment_digests: ["sha256:env-windows-command-dev"],
|
||||||
|
source_snapshots: ["sha256:source-tree"],
|
||||||
|
artifact_locations: [],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||||
|
assert.strictEqual(recorded.node, "windows-node");
|
||||||
|
|
||||||
|
const placement = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: {
|
||||||
|
os: "Windows",
|
||||||
|
arch: null,
|
||||||
|
capabilities: ["WindowsCommandDev"]
|
||||||
|
},
|
||||||
|
environment_digest: "sha256:env-windows-command-dev",
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
source_snapshot: "sha256:source-tree",
|
||||||
|
required_artifacts: [],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(placement.type, "task_placement");
|
||||||
|
assert.strictEqual(placement.placement.node, "windows-node");
|
||||||
|
assert.ok(placement.placement.reasons.includes("warm environment cache"));
|
||||||
|
assert.ok(placement.placement.reasons.includes("source snapshot already local"));
|
||||||
|
|
||||||
|
const started = await send(addr, {
|
||||||
|
type: "start_process",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-windows"
|
||||||
|
});
|
||||||
|
assert.strictEqual(started.type, "process_started");
|
||||||
|
assert.strictEqual(started.process, "vp-windows");
|
||||||
|
|
||||||
|
const reconnected = await send(addr, {
|
||||||
|
type: "reconnect_node",
|
||||||
|
node: "windows-node",
|
||||||
|
process: "vp-windows",
|
||||||
|
epoch: started.epoch
|
||||||
|
});
|
||||||
|
assert.strictEqual(reconnected.type, "node_reconnected");
|
||||||
|
|
||||||
|
const recordedTask = await send(addr, {
|
||||||
|
type: "task_completed",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
process: "vp-windows",
|
||||||
|
node: "windows-node",
|
||||||
|
task: "windows-command-dev",
|
||||||
|
status_code: 0,
|
||||||
|
stdout_bytes: 18,
|
||||||
|
stderr_bytes: 0,
|
||||||
|
artifact_path: "/vfs/artifacts/windows-output.txt",
|
||||||
|
artifact_digest: "sha256:windows-artifact",
|
||||||
|
artifact_size_bytes: 18
|
||||||
|
});
|
||||||
|
assert.strictEqual(recordedTask.type, "task_recorded");
|
||||||
|
assert.strictEqual(recordedTask.task, "windows-command-dev");
|
||||||
|
|
||||||
|
const events = await send(addr, {
|
||||||
|
type: "list_task_events",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
process: "vp-windows"
|
||||||
|
});
|
||||||
|
assert.strictEqual(events.type, "task_events");
|
||||||
|
assert.strictEqual(events.events.length, 1);
|
||||||
|
assert.strictEqual(events.events[0].node, "windows-node");
|
||||||
|
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/windows-output.txt");
|
||||||
|
|
||||||
|
const link = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "windows-output.txt",
|
||||||
|
max_bytes: 1024,
|
||||||
|
token_nonce: "nonce"
|
||||||
|
});
|
||||||
|
assert.strictEqual(link.type, "artifact_download_link");
|
||||||
|
assert.strictEqual(link.link.process, "vp-windows");
|
||||||
|
assert.deepStrictEqual(link.link.source, { RetainedNode: "windows-node" });
|
||||||
|
} catch (error) {
|
||||||
|
if (coordinatorStderr) {
|
||||||
|
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Windows best-effort smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
457
scripts/windows-runner-smoke.js
Executable file
457
scripts/windows-runner-smoke.js
Executable file
|
|
@ -0,0 +1,457 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const cp = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const net = require("net");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
class DapClient {
|
||||||
|
constructor() {
|
||||||
|
this.child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
this.seq = 1;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.messages = [];
|
||||||
|
this.waiters = [];
|
||||||
|
this.stderr = "";
|
||||||
|
|
||||||
|
this.child.stdout.on("data", (chunk) => {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
this.parse();
|
||||||
|
});
|
||||||
|
this.child.stderr.on("data", (chunk) => {
|
||||||
|
this.stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
this.child.on("exit", () => this.flushWaiters());
|
||||||
|
}
|
||||||
|
|
||||||
|
send(command, args = {}) {
|
||||||
|
const seq = this.seq++;
|
||||||
|
const message = { seq, type: "request", command, arguments: args };
|
||||||
|
const payload = Buffer.from(JSON.stringify(message));
|
||||||
|
this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
||||||
|
this.child.stdin.write(payload);
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async response(seq, command) {
|
||||||
|
const message = await this.waitFor(
|
||||||
|
(item) =>
|
||||||
|
item.type === "response" &&
|
||||||
|
item.request_seq === seq &&
|
||||||
|
item.command === command
|
||||||
|
);
|
||||||
|
if (!message.success) {
|
||||||
|
throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor(predicate, timeoutMs = 120000) {
|
||||||
|
const existing = this.messages.find(predicate);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
reject(new Error(`timed out waiting for DAP message\n${this.stderr}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.waiters.push({ predicate, resolve, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
while (true) {
|
||||||
|
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) return;
|
||||||
|
const header = this.buffer.slice(0, headerEnd).toString();
|
||||||
|
const match = header.match(/Content-Length: (\d+)/i);
|
||||||
|
if (!match) throw new Error(`bad DAP header: ${header}`);
|
||||||
|
const length = Number(match[1]);
|
||||||
|
const start = headerEnd + 4;
|
||||||
|
const end = start + length;
|
||||||
|
if (this.buffer.length < end) return;
|
||||||
|
const payload = this.buffer.slice(start, end).toString();
|
||||||
|
this.buffer = this.buffer.slice(end);
|
||||||
|
this.messages.push(JSON.parse(payload));
|
||||||
|
this.flushWaiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushWaiters() {
|
||||||
|
for (const waiter of [...this.waiters]) {
|
||||||
|
const message = this.messages.find(waiter.predicate);
|
||||||
|
if (!message) continue;
|
||||||
|
clearTimeout(waiter.timer);
|
||||||
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
||||||
|
waiter.resolve(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.child.exitCode !== null) return;
|
||||||
|
const seq = this.send("disconnect");
|
||||||
|
await this.response(seq, "disconnect");
|
||||||
|
this.child.stdin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForJsonLine(child) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(addr, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(addr.port, addr.host, () => {
|
||||||
|
socket.write(`${JSON.stringify(message)}\n`);
|
||||||
|
});
|
||||||
|
let buffer = "";
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const newline = buffer.indexOf("\n");
|
||||||
|
if (newline < 0) return;
|
||||||
|
socket.end();
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWindowsAttach(addr, grant) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"node",
|
||||||
|
"attach",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"forgejo-windows-node",
|
||||||
|
"--public-key",
|
||||||
|
"forgejo-windows-node-public-key",
|
||||||
|
"--enrollment-grant",
|
||||||
|
grant,
|
||||||
|
"--cap",
|
||||||
|
"windows-command-dev"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Windows node attach failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout));
|
||||||
|
} catch (error) {
|
||||||
|
reject(
|
||||||
|
new Error(`Windows node attach output was not JSON: ${stdout}\n${error.stack || error.message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWindowsNode(addr, grant) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-node",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-node",
|
||||||
|
"--",
|
||||||
|
"--coordinator",
|
||||||
|
`${addr.host}:${addr.port}`,
|
||||||
|
"--tenant",
|
||||||
|
"tenant",
|
||||||
|
"--project-id",
|
||||||
|
"project",
|
||||||
|
"--node",
|
||||||
|
"forgejo-windows-node",
|
||||||
|
"--public-key",
|
||||||
|
"forgejo-windows-node-public-key",
|
||||||
|
"--enrollment-grant",
|
||||||
|
grant,
|
||||||
|
"--process",
|
||||||
|
"vp-forgejo-windows",
|
||||||
|
"--task",
|
||||||
|
"windows-command-dev",
|
||||||
|
"--command",
|
||||||
|
"cmd",
|
||||||
|
"--arg",
|
||||||
|
"/C",
|
||||||
|
"--arg",
|
||||||
|
"echo disasmer-windows-runner",
|
||||||
|
"--artifact",
|
||||||
|
"/vfs/artifacts/windows-runner-output.txt"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Windows node smoke failed with code ${code}\n${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDebuggerShowsWindowsThread() {
|
||||||
|
const client = new DapClient();
|
||||||
|
try {
|
||||||
|
const initialize = client.send("initialize", {
|
||||||
|
adapterID: "disasmer",
|
||||||
|
linesStartAt1: true,
|
||||||
|
columnsStartAt1: true
|
||||||
|
});
|
||||||
|
await client.response(initialize, "initialize");
|
||||||
|
|
||||||
|
const launch = client.send("launch", {
|
||||||
|
entry: "build",
|
||||||
|
project: path.join(repo, "examples/launch-build-demo"),
|
||||||
|
runtimeBackend: "simulated"
|
||||||
|
});
|
||||||
|
await client.response(launch, "launch");
|
||||||
|
await client.waitFor(
|
||||||
|
(message) => message.type === "event" && message.event === "initialized"
|
||||||
|
);
|
||||||
|
|
||||||
|
const configurationDone = client.send("configurationDone");
|
||||||
|
await client.response(configurationDone, "configurationDone");
|
||||||
|
|
||||||
|
const threadsRequest = client.send("threads");
|
||||||
|
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||||
|
assert(
|
||||||
|
threads.some((thread) => thread.name.includes("compile windows")),
|
||||||
|
"debugger must represent the Windows task as a virtual thread"
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
} catch (error) {
|
||||||
|
client.child.kill("SIGKILL");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
throw new Error(
|
||||||
|
`Windows runner validation requires win32; current platform is ${process.platform}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinator = cp.spawn(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--bin",
|
||||||
|
"disasmer-coordinator",
|
||||||
|
"--",
|
||||||
|
"--listen",
|
||||||
|
"127.0.0.1:0"
|
||||||
|
],
|
||||||
|
{ cwd: repo }
|
||||||
|
);
|
||||||
|
let coordinatorStderr = "";
|
||||||
|
coordinator.stderr.on("data", (chunk) => {
|
||||||
|
coordinatorStderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await waitForJsonLine(coordinator);
|
||||||
|
const [host, portText] = ready.listen.split(":");
|
||||||
|
const addr = { host, port: Number(portText) };
|
||||||
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||||
|
|
||||||
|
const attachGrant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_grant",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator",
|
||||||
|
grant: "grant-forgejo-windows-attach",
|
||||||
|
now_epoch_seconds: 0,
|
||||||
|
ttl_seconds: 900
|
||||||
|
});
|
||||||
|
assert.strictEqual(attachGrant.type, "node_enrollment_grant_created");
|
||||||
|
assert.strictEqual(attachGrant.scope, "node:attach");
|
||||||
|
|
||||||
|
const attach = await runWindowsAttach(addr, attachGrant.grant);
|
||||||
|
assert.strictEqual(attach.plan.node, "forgejo-windows-node");
|
||||||
|
assert.strictEqual(attach.boundary.cli_contacted_coordinator, true);
|
||||||
|
assert.strictEqual(attach.boundary.used_enrollment_exchange, true);
|
||||||
|
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(attach.coordinator_response.credential.scope, "node:attach");
|
||||||
|
assert(attach.plan.capabilities.capabilities.includes("WindowsCommandDev"));
|
||||||
|
|
||||||
|
const runtimeGrant = await send(addr, {
|
||||||
|
type: "create_node_enrollment_grant",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "operator",
|
||||||
|
grant: "grant-forgejo-windows-runtime",
|
||||||
|
now_epoch_seconds: 0,
|
||||||
|
ttl_seconds: 900
|
||||||
|
});
|
||||||
|
assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created");
|
||||||
|
|
||||||
|
const report = await runWindowsNode(addr, runtimeGrant.grant);
|
||||||
|
assert.strictEqual(report.node_status, "completed");
|
||||||
|
assert.strictEqual(report.virtual_thread, "windows-command-dev");
|
||||||
|
assert.strictEqual(report.status_code, 0);
|
||||||
|
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||||
|
assert.strictEqual(
|
||||||
|
report.staged_artifact.path,
|
||||||
|
"/vfs/artifacts/windows-runner-output.txt"
|
||||||
|
);
|
||||||
|
assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged");
|
||||||
|
assert.strictEqual(report.registration_response.credential.scope, "node:attach");
|
||||||
|
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||||
|
|
||||||
|
const recorded = await send(addr, {
|
||||||
|
type: "report_node_capabilities",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
node: "forgejo-windows-node",
|
||||||
|
capabilities: {
|
||||||
|
os: "Windows",
|
||||||
|
arch: "x86_64",
|
||||||
|
capabilities: ["Command", "WindowsCommandDev", "VfsArtifacts"],
|
||||||
|
environment_backends: ["WindowsCommandDev"],
|
||||||
|
source_providers: ["filesystem"]
|
||||||
|
},
|
||||||
|
cached_environment_digests: ["sha256:env-windows-command-dev"],
|
||||||
|
source_snapshots: ["sha256:source-tree"],
|
||||||
|
artifact_locations: ["windows-runner-output.txt"],
|
||||||
|
direct_connectivity: true,
|
||||||
|
online: true
|
||||||
|
});
|
||||||
|
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||||
|
|
||||||
|
const placement = await send(addr, {
|
||||||
|
type: "schedule_task",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
environment: {
|
||||||
|
os: "Windows",
|
||||||
|
arch: null,
|
||||||
|
capabilities: ["WindowsCommandDev"]
|
||||||
|
},
|
||||||
|
environment_digest: "sha256:env-windows-command-dev",
|
||||||
|
required_capabilities: ["Command"],
|
||||||
|
source_snapshot: "sha256:source-tree",
|
||||||
|
required_artifacts: ["windows-runner-output.txt"],
|
||||||
|
prefer_node: null
|
||||||
|
});
|
||||||
|
assert.strictEqual(placement.type, "task_placement");
|
||||||
|
assert.strictEqual(placement.placement.node, "forgejo-windows-node");
|
||||||
|
|
||||||
|
const link = await send(addr, {
|
||||||
|
type: "create_artifact_download_link",
|
||||||
|
tenant: "tenant",
|
||||||
|
project: "project",
|
||||||
|
actor_user: "user",
|
||||||
|
artifact: "windows-runner-output.txt",
|
||||||
|
max_bytes: 1024,
|
||||||
|
token_nonce: "nonce"
|
||||||
|
});
|
||||||
|
assert.strictEqual(link.type, "artifact_download_link");
|
||||||
|
assert.deepStrictEqual(link.link.source, {
|
||||||
|
RetainedNode: "forgejo-windows-node"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (coordinatorStderr) {
|
||||||
|
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
coordinator.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertDebuggerShowsWindowsThread();
|
||||||
|
|
||||||
|
const outDir = path.join(repo, "target", "acceptance");
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, "windows-runner.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
kind: "disasmer_windows_runner_validation",
|
||||||
|
platform: process.platform,
|
||||||
|
runner: process.env.FORGEJO_RUNNER_NAME || process.env.RUNNER_NAME || null,
|
||||||
|
validated: true
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Windows runner smoke passed");
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error.stack || error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
59
scripts/windows-validation-contract-smoke.js
Executable file
59
scripts/windows-validation-contract-smoke.js
Executable file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const repo = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(source, name, pattern) {
|
||||||
|
assert.match(source, pattern, `missing Windows validation evidence: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = read(".forgejo/workflows/windows-validation.yml");
|
||||||
|
const runnerSmoke = read("scripts/windows-runner-smoke.js");
|
||||||
|
const readme = read("README.md");
|
||||||
|
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||||
|
const publicSplit = read("scripts/verify-public-split.sh");
|
||||||
|
|
||||||
|
expect(workflow, "manual workflow dispatch", /workflow_dispatch/);
|
||||||
|
expect(workflow, "intermittent Forgejo Windows runner note", /intermittently online/);
|
||||||
|
expect(workflow, "Windows runner label", /runs-on:\s*windows/);
|
||||||
|
expect(workflow, "Windows validation acceptance report env", /DISASMER_WINDOWS_VALIDATION:\s*forgejo-windows-runner/);
|
||||||
|
expect(workflow, "acceptance report step", /node scripts\/acceptance-report\.js windows/);
|
||||||
|
expect(workflow, "Windows runtime unit coverage", /cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution/);
|
||||||
|
expect(workflow, "Windows runner smoke step", /node scripts\/windows-runner-smoke\.js/);
|
||||||
|
|
||||||
|
expect(runnerSmoke, "real Windows platform guard", /process\.platform !== "win32"/);
|
||||||
|
expect(runnerSmoke, "CLI node attach function", /function runWindowsAttach/);
|
||||||
|
expect(runnerSmoke, "CLI node attach command", /"node"[\s\S]*"attach"[\s\S]*"windows-command-dev"/);
|
||||||
|
expect(runnerSmoke, "enrollment grant creation", /create_node_enrollment_grant[\s\S]*grant-forgejo-windows-attach/);
|
||||||
|
expect(runnerSmoke, "CLI enrollment exchange asserted", /used_enrollment_exchange[\s\S]*true/);
|
||||||
|
expect(runnerSmoke, "runtime enrollment grant", /grant-forgejo-windows-runtime/);
|
||||||
|
expect(runnerSmoke, "runtime uses enrollment grant", /"--enrollment-grant"[\s\S]*grant/);
|
||||||
|
expect(runnerSmoke, "Windows command task runs", /"windows-command-dev"[\s\S]*"cmd"[\s\S]*"echo disasmer-windows-runner"/);
|
||||||
|
expect(runnerSmoke, "artifact metadata is asserted", /windows-runner-output\.txt[\s\S]*artifact_download_link/);
|
||||||
|
expect(runnerSmoke, "Windows placement is asserted", /schedule_task[\s\S]*WindowsCommandDev[\s\S]*forgejo-windows-node/);
|
||||||
|
expect(runnerSmoke, "debugger shows Windows virtual thread", /compile windows/);
|
||||||
|
expect(runnerSmoke, "validation result artifact", /disasmer_windows_runner_validation/);
|
||||||
|
|
||||||
|
expect(readme, "docs mention manual Windows workflow", /manual `Windows validation`\s+workflow/);
|
||||||
|
expect(readme, "docs mention intermittent runner", /intermittent Windows runner/);
|
||||||
|
expect(readme, "docs mention CLI attach in Windows validation", /runs `disasmer node attach`/);
|
||||||
|
expect(readme, "docs keep Windows unvalidated when report is not-run", /windows_validation: "not-run"[\s\S]*best-effort and unvalidated/);
|
||||||
|
|
||||||
|
for (const [scriptName, script] of [
|
||||||
|
["public acceptance", publicAcceptance],
|
||||||
|
["public split", publicSplit],
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
script.includes("node scripts/windows-validation-contract-smoke.js"),
|
||||||
|
`${scriptName} must run windows-validation-contract-smoke.js`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Windows validation contract smoke passed");
|
||||||
414
vscode-extension/extension.js
Normal file
414
vscode-extension/extension.js
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const childProcess = require("child_process");
|
||||||
|
|
||||||
|
const DEFAULT_OPERATOR_ENDPOINT = "https://disasmer.michelpaulissen.com:9443";
|
||||||
|
|
||||||
|
let vscode = null;
|
||||||
|
try {
|
||||||
|
vscode = require("vscode");
|
||||||
|
} catch (_) {
|
||||||
|
vscode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverEnvironmentNames(projectRoot) {
|
||||||
|
const envsDir = path.join(projectRoot, "envs");
|
||||||
|
if (!fs.existsSync(envsDir)) return [];
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(envsDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.filter((name) => {
|
||||||
|
const dir = path.join(envsDir, name);
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(dir, "Containerfile")) ||
|
||||||
|
fs.existsSync(path.join(dir, "Dockerfile"))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEnvReferences(text) {
|
||||||
|
const references = [];
|
||||||
|
const pattern = /env!\(\s*"([^"]+)"\s*\)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
references.push({
|
||||||
|
name: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: pattern.lastIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnoseEnvReferences(text, environmentNames) {
|
||||||
|
const known = new Set(environmentNames);
|
||||||
|
return findEnvReferences(text)
|
||||||
|
.filter((reference) => !known.has(reference.name))
|
||||||
|
.map((reference) => ({
|
||||||
|
...reference,
|
||||||
|
message: `Missing Disasmer environment "${reference.name}". Add envs/${reference.name}/Containerfile or envs/${reference.name}/Dockerfile.`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryCandidates(projectRoot, name) {
|
||||||
|
const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
|
||||||
|
return names.map((candidate) => path.join(projectRoot, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableWorkspaceBinary(projectRoot, name) {
|
||||||
|
if (!projectRoot) return null;
|
||||||
|
return binaryCandidates(projectRoot, name).find((candidate) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(candidate).isFile();
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCargoWorkspace(root) {
|
||||||
|
return fs.existsSync(path.join(root, "Cargo.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectPath(folder, value) {
|
||||||
|
const workspaceRoot = folder && folder.uri && folder.uri.fsPath;
|
||||||
|
if (!value || value === "${workspaceFolder}") return workspaceRoot;
|
||||||
|
if (workspaceRoot && typeof value === "string") {
|
||||||
|
return value.replace(/\$\{workspaceFolder\}/g, workspaceRoot);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dirname, "..")) {
|
||||||
|
const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer");
|
||||||
|
if (workspaceCli) {
|
||||||
|
return {
|
||||||
|
command: workspaceCli,
|
||||||
|
args: ["bundle", "inspect", "--project", projectRoot],
|
||||||
|
options: {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: "utf8"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "cargo",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"disasmer-cli",
|
||||||
|
"--bin",
|
||||||
|
"disasmer",
|
||||||
|
"--",
|
||||||
|
"bundle",
|
||||||
|
"inspect",
|
||||||
|
"--project",
|
||||||
|
projectRoot
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
cwd: adapterWorkspace,
|
||||||
|
encoding: "utf8"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBundleBeforeLaunch(
|
||||||
|
projectRoot,
|
||||||
|
adapterWorkspace = path.resolve(__dirname, ".."),
|
||||||
|
runner = childProcess.spawnSync
|
||||||
|
) {
|
||||||
|
const command = bundleInspectCommand(projectRoot, adapterWorkspace);
|
||||||
|
const result = runner(command.command, command.args, command.options);
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`Disasmer bundle refresh failed: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const detail = (result.stderr || result.stdout || "").trim();
|
||||||
|
throw new Error(
|
||||||
|
`Disasmer bundle refresh failed before debug launch${detail ? `: ${detail}` : "."}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspection;
|
||||||
|
try {
|
||||||
|
inspection = JSON.parse(result.stdout);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Disasmer bundle refresh returned invalid metadata: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (!inspection.metadata || !inspection.metadata.identity) {
|
||||||
|
throw new Error("Disasmer bundle refresh did not return a bundle identity.");
|
||||||
|
}
|
||||||
|
return inspection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disasmerViewDescriptors() {
|
||||||
|
return [
|
||||||
|
{ id: "disasmer.nodes", emptyLabel: "No attached nodes" },
|
||||||
|
{ id: "disasmer.processes", emptyLabel: "No virtual processes" },
|
||||||
|
{ id: "disasmer.logs", emptyLabel: "No log streams" },
|
||||||
|
{ id: "disasmer.artifacts", emptyLabel: "No artifacts" },
|
||||||
|
{ id: "disasmer.inspector", emptyLabel: "No inspector state" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function disasmerViewStatePath(projectRoot) {
|
||||||
|
return path.join(projectRoot, ".disasmer", "views.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDisasmerViewState() {
|
||||||
|
return {
|
||||||
|
nodes: [],
|
||||||
|
processes: [],
|
||||||
|
logs: [],
|
||||||
|
artifacts: [],
|
||||||
|
inspector: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDisasmerViewState(projectRoot, reader = fs.readFileSync) {
|
||||||
|
const statePath = disasmerViewStatePath(projectRoot);
|
||||||
|
if (!fs.existsSync(statePath)) {
|
||||||
|
return emptyDisasmerViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(reader(statePath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...emptyDisasmerViewState(),
|
||||||
|
inspector: [
|
||||||
|
{
|
||||||
|
label: "View state error",
|
||||||
|
value: error.message
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: asArray(parsed.nodes),
|
||||||
|
processes: asArray(parsed.processes),
|
||||||
|
logs: asArray(parsed.logs),
|
||||||
|
artifacts: asArray(parsed.artifacts),
|
||||||
|
inspector: asArray(parsed.inspector)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(label, description = "") {
|
||||||
|
return {
|
||||||
|
label: String(label || ""),
|
||||||
|
description: String(description || "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disasmerViewItems(state, viewId) {
|
||||||
|
switch (viewId) {
|
||||||
|
case "disasmer.nodes":
|
||||||
|
return state.nodes.map((node) =>
|
||||||
|
item(node.id || node.name || "node", [node.status, node.capabilities].filter(Boolean).join(" "))
|
||||||
|
);
|
||||||
|
case "disasmer.processes":
|
||||||
|
return state.processes.map((process) =>
|
||||||
|
item(process.id || process.process || "process", [process.status, process.entry].filter(Boolean).join(" "))
|
||||||
|
);
|
||||||
|
case "disasmer.logs":
|
||||||
|
return state.logs.map((log) =>
|
||||||
|
item(log.task || log.stream || "log", [log.message, log.bytes].filter(Boolean).join(" "))
|
||||||
|
);
|
||||||
|
case "disasmer.artifacts":
|
||||||
|
return state.artifacts.map((artifact) =>
|
||||||
|
item(artifact.path || artifact.id || "artifact", [artifact.status, artifact.size].filter(Boolean).join(" "))
|
||||||
|
);
|
||||||
|
case "disasmer.inspector":
|
||||||
|
return state.inspector.map((entry) =>
|
||||||
|
item(entry.label || entry.name || "inspector", entry.value || entry.detail || "")
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeItemsForView(projectRoot, descriptor) {
|
||||||
|
const state = projectRoot ? loadDisasmerViewState(projectRoot) : emptyDisasmerViewState();
|
||||||
|
const items = disasmerViewItems(state, descriptor.id);
|
||||||
|
return items.length > 0 ? items : [item(descriptor.emptyLabel)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVsCodeTreeItem(viewItem) {
|
||||||
|
const treeItem = new vscode.TreeItem(
|
||||||
|
viewItem.label,
|
||||||
|
vscode.TreeItemCollapsibleState.None
|
||||||
|
);
|
||||||
|
treeItem.description = viewItem.description;
|
||||||
|
return treeItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDisasmerDebugConfiguration(folder, config = {}) {
|
||||||
|
const project = resolveProjectPath(folder, config.project);
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
name: config.name || "Disasmer: Launch Virtual Process",
|
||||||
|
type: "disasmer",
|
||||||
|
request: "launch",
|
||||||
|
entry: config.entry || "build",
|
||||||
|
project,
|
||||||
|
runtimeBackend: config.runtimeBackend || "local-services",
|
||||||
|
operatorEndpoint: config.operatorEndpoint || DEFAULT_OPERATOR_ENDPOINT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOperatorEndpoint() {
|
||||||
|
return DEFAULT_OPERATOR_ENDPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugAdapterExecutableSpec(
|
||||||
|
projectRoot,
|
||||||
|
adapterWorkspace = path.resolve(__dirname, "..")
|
||||||
|
) {
|
||||||
|
const workspaceAdapter = executableWorkspaceBinary(projectRoot, "disasmer-debug-dap");
|
||||||
|
if (workspaceAdapter) {
|
||||||
|
return {
|
||||||
|
command: workspaceAdapter,
|
||||||
|
args: [],
|
||||||
|
options: { cwd: projectRoot }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasCargoWorkspace(adapterWorkspace)) {
|
||||||
|
return {
|
||||||
|
command: "disasmer-debug-dap",
|
||||||
|
args: [],
|
||||||
|
options: { cwd: projectRoot || process.cwd() }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "cargo",
|
||||||
|
args: ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
|
||||||
|
options: { cwd: adapterWorkspace }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDisasmerViews(context) {
|
||||||
|
if (!vscode) return;
|
||||||
|
const workspaceRoot =
|
||||||
|
vscode.workspace.workspaceFolders &&
|
||||||
|
vscode.workspace.workspaceFolders[0] &&
|
||||||
|
vscode.workspace.workspaceFolders[0].uri.fsPath;
|
||||||
|
const emitters = [];
|
||||||
|
for (const descriptor of disasmerViewDescriptors()) {
|
||||||
|
const emitter = new vscode.EventEmitter();
|
||||||
|
emitters.push(emitter);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.window.registerTreeDataProvider(descriptor.id, {
|
||||||
|
onDidChangeTreeData: emitter.event,
|
||||||
|
getTreeItem(item) {
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
getChildren() {
|
||||||
|
return treeItemsForView(workspaceRoot, descriptor).map(toVsCodeTreeItem);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
emitter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workspaceRoot && vscode.workspace.createFileSystemWatcher) {
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||||
|
new vscode.RelativePattern(workspaceRoot, ".disasmer/views.json")
|
||||||
|
);
|
||||||
|
const refresh = () => emitters.forEach((emitter) => emitter.fire());
|
||||||
|
context.subscriptions.push(
|
||||||
|
watcher,
|
||||||
|
watcher.onDidChange(refresh),
|
||||||
|
watcher.onDidCreate(refresh),
|
||||||
|
watcher.onDidDelete(refresh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(context) {
|
||||||
|
if (!vscode) return;
|
||||||
|
|
||||||
|
const diagnostics = vscode.languages.createDiagnosticCollection("disasmer");
|
||||||
|
context.subscriptions.push(diagnostics);
|
||||||
|
registerDisasmerViews(context);
|
||||||
|
|
||||||
|
const refresh = (document) => {
|
||||||
|
if (document.languageId !== "rust") return;
|
||||||
|
const folder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||||
|
if (!folder) return;
|
||||||
|
const environmentNames = discoverEnvironmentNames(folder.uri.fsPath);
|
||||||
|
const items = diagnoseEnvReferences(document.getText(), environmentNames).map((diagnostic) => {
|
||||||
|
const start = document.positionAt(diagnostic.start);
|
||||||
|
const end = document.positionAt(diagnostic.end);
|
||||||
|
return new vscode.Diagnostic(
|
||||||
|
new vscode.Range(start, end),
|
||||||
|
diagnostic.message,
|
||||||
|
vscode.DiagnosticSeverity.Error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
diagnostics.set(document.uri, items);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidOpenTextDocument(refresh),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)),
|
||||||
|
vscode.workspace.onDidSaveTextDocument(refresh),
|
||||||
|
vscode.debug.registerDebugAdapterDescriptorFactory("disasmer", {
|
||||||
|
createDebugAdapterDescriptor(session) {
|
||||||
|
const folder = session.workspaceFolder;
|
||||||
|
const project = session.configuration.project || (folder && folder.uri.fsPath);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Disasmer debug launch requires a workspace folder or project path.");
|
||||||
|
}
|
||||||
|
const adapterWorkspace = path.resolve(__dirname, "..");
|
||||||
|
refreshBundleBeforeLaunch(project, adapterWorkspace);
|
||||||
|
const spec = debugAdapterExecutableSpec(project, adapterWorkspace);
|
||||||
|
return new vscode.DebugAdapterExecutable(
|
||||||
|
spec.command,
|
||||||
|
spec.args,
|
||||||
|
spec.options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vscode.debug.registerDebugConfigurationProvider("disasmer", {
|
||||||
|
resolveDebugConfiguration(folder, config) {
|
||||||
|
return resolveDisasmerDebugConfiguration(folder, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const document of vscode.workspace.textDocuments) {
|
||||||
|
refresh(document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate() {}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
activate,
|
||||||
|
deactivate,
|
||||||
|
bundleInspectCommand,
|
||||||
|
debugAdapterExecutableSpec,
|
||||||
|
defaultOperatorEndpoint,
|
||||||
|
diagnoseEnvReferences,
|
||||||
|
disasmerViewDescriptors,
|
||||||
|
disasmerViewItems,
|
||||||
|
disasmerViewStatePath,
|
||||||
|
discoverEnvironmentNames,
|
||||||
|
findEnvReferences,
|
||||||
|
loadDisasmerViewState,
|
||||||
|
registerDisasmerViews,
|
||||||
|
refreshBundleBeforeLaunch,
|
||||||
|
resolveDisasmerDebugConfiguration
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue