Merge #a962fe38: fix: allow async-trait must-use compatibility lint
Demo images / Build & push demo images (push) Failing after 47s

nostr:nevent1qqs2jch78pdxt098826ghl6vx3pqxy9rsrn3u6rqawp9mptle7fv3sspz3mhxue69uhhyetvv9ujumn8d96zuer9wcrme3nq

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
This commit is contained in:
archipelago
2026-10-09 09:31:40 -04:00
53 changed files with 1527 additions and 1316 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn}; use tracing::{debug, info};
const CDP_HTTP: &str = "http://127.0.0.1:9222"; const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display. /// Marker whose presence means this node drives a local kiosk display.
+11 -9
View File
@@ -583,15 +583,17 @@ impl ApiHandler {
paid = true; paid = true;
} }
Ok(false) => {} Ok(false) => {}
Err(_) => return Ok(build_response( Err(_) => {
StatusCode::OK, return Ok(build_response(
"application/json", StatusCode::OK,
hyper::Body::from(serde_json::to_vec(&serde_json::json!({ "application/json",
"paid": false, hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"status": "unknown", "paid": false,
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again." "status": "unknown",
}))?), "error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
)), }))?),
))
}
} }
} }
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
@@ -193,16 +193,6 @@ impl ApiHandler {
let data = self.config.data_dir.clone(); let data = self.config.data_dir.clone();
let id = binding.content_id.clone(); let id = binding.content_id.clone();
let retained = source.clone(); let retained = source.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || { let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size) crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
}) })
@@ -257,6 +257,12 @@ impl RpcHandler {
} }
} }
// Unit tests deliberately construct a handler without an orchestrator.
// Never let that mock cross into a real Podman command on the host.
if cfg!(test) && self.orchestrator.is_none() {
return Ok(serde_json::json!([]));
}
let output = tokio::process::Command::new("podman") let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "json"]) .args(["ps", "-a", "--format", "json"])
.output() .output()
+21 -12
View File
@@ -1013,19 +1013,28 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain-status/{}", content_id, address); let path = format!("/content/{}/onchain-status/{}", content_id, address);
let (response, _transport) = let (response, _transport) = match crate::fips::dial::PeerRequest::new(
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) fips_npub.as_deref(),
.service(crate::settings::transport::PeerService::PeerFiles) onion,
.timeout(std::time::Duration::from_secs(15)) &path,
.fips_timeout(std::time::Duration::from_secs(6)) )
.send_content_get(&self.config.data_dir) .service(crate::settings::transport::PeerService::PeerFiles)
.await .timeout(std::time::Duration::from_secs(15))
{ .fips_timeout(std::time::Duration::from_secs(6))
Ok(v) => v, .send_content_get(&self.config.data_dir)
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })), .await
}; {
Ok(v) => v,
Err(_) => {
return Ok(
serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." }),
)
}
};
if !response.status().is_success() { if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." })); return Ok(
serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }),
);
} }
let body: serde_json::Value = response let body: serde_json::Value = response
.json() .json()
@@ -114,17 +114,34 @@ impl RpcHandler {
/// Explicit owner-key import into a separate native business identity. /// Explicit owner-key import into a separate native business identity.
pub(in crate::api::rpc) async fn handle_identity_import_nostr( pub(in crate::api::rpc) async fn handle_identity_import_nostr(
&self, params: Option<serde_json::Value>, &self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let params = params.unwrap_or_default(); let params = params.unwrap_or_default();
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or(""); let password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("");
if !self.auth_manager.verify_password(password).await? { if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Invalid node password"); anyhow::bail!("Invalid node password");
} }
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works"); let name = params
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); .get("name")
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim(); .and_then(|v| v.as_str())
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or(""); .unwrap_or("Just Works");
anyhow::ensure!(
!name.trim().is_empty() && name.len() <= 100,
"Invalid identity name"
);
let nsec = params
.get("nsec")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let npub = params
.get("expected_npub")
.and_then(|v| v.as_str())
.unwrap_or("");
let manager = IdentityManager::new(&self.config.data_dir).await?; let manager = IdentityManager::new(&self.config.data_dir).await?;
let record = manager.import_nostr(name.to_string(), nsec, npub).await?; let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
Ok(serde_json::json!({"id":record.id, "name":record.name, Ok(serde_json::json!({"id":record.id, "name":record.name,
@@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolvePara
let encoded = serde_json::to_value(event)?; let encoded = serde_json::to_value(event)?;
anyhow::ensure!( anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]]) encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30) && event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_u64() <= now.saturating_add(30), && event.created_at.as_secs() <= now.saturating_add(30),
"Invalid resolution signature time or scope" "Invalid resolution signature time or scope"
); );
let content: serde_json::Value = serde_json::from_str(&event.content)?; let content: serde_json::Value = serde_json::from_str(&event.content)?;
@@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result<String> {
producer == params.intent.producer, producer == params.intent.producer,
"The signing identity differs from the project producer" "The signing identity differs from the project producer"
); );
let created = event.created_at.as_u64(); let created = event.created_at.as_secs();
anyhow::ensure!( anyhow::ensure!(
created >= params.intent.created_at.saturating_sub(30) created >= params.intent.created_at.saturating_sub(30)
&& created < params.intent.expires_at && created < params.intent.expires_at
+2 -3
View File
@@ -18,10 +18,10 @@ mod handshake;
mod identity; mod identity;
mod interfaces; mod interfaces;
mod lightning_purchase; mod lightning_purchase;
mod onchain_purchase;
pub(crate) mod lnd; pub(crate) mod lnd;
mod marketplace; mod marketplace;
mod media_registration; mod media_registration;
mod onchain_purchase;
mod playback; mod playback;
mod purchase; mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses // pub(crate): 13-10's `assistant::backends::select_backend` reuses
@@ -34,12 +34,12 @@ mod monitoring;
mod music; mod music;
mod names; mod names;
mod network; mod network;
mod publishing;
mod node; mod node;
mod nostr; mod nostr;
mod onboarding_gate; mod onboarding_gate;
mod openwrt; mod openwrt;
mod package; mod package;
mod publishing;
pub(crate) use package::patch_indeedhub_nostr_provider; pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper; pub(crate) use package::wyoming_satellite_keeper;
mod peers; mod peers;
@@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "media.registration.context" | "media.registration.context"
| "media.registration.resolve" | "media.registration.resolve"
| "content.rental-purchase" | "content.rental-purchase"
| "content.onchain-cancel" | "content.onchain-cancel"
| "content.onchain-attempt" | "content.onchain-attempt"
| "content.onchain-create" | "content.onchain-create"
@@ -442,7 +442,7 @@ impl RpcHandler {
if record.quote.is_none() { if record.quote.is_none() {
engine::mark_address_allocation(&journal, true)?; engine::mark_address_allocation(&journal, true)?;
let status = self.request_onchain_allocation(&record, &fips).await?; let status = self.request_onchain_allocation(&record, &fips).await?;
record = engine::accept_quote( engine::accept_quote(
&journal, &journal,
status.quote()?.context( status.quote()?.context(
"Original seller allocation is unresolved; recover this operation", "Original seller allocation is unresolved; recover this operation",
@@ -883,7 +883,8 @@ async fn do_orchestrator_package_start(
if i > 0 { if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
} }
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
if !managed { if !managed {
repair_before_package_start(name).await; repair_before_package_start(name).await;
wait_before_package_start(name).await; wait_before_package_start(name).await;
@@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop(
) -> Result<()> { ) -> Result<()> {
let mut errors = Vec::new(); let mut errors = Vec::new();
for name in containers { for name in containers {
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
match orchestrator.stop(name).await { match orchestrator.stop(name).await {
Ok(()) => {} Ok(()) => {}
Err(e) if !managed && is_unknown_app_id_error(&e) => { Err(e) if !managed && is_unknown_app_id_error(&e) => {
@@ -1977,9 +1979,9 @@ pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") { if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into()); dirs.push(root.into());
} }
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { // Cargo exposes this at compile time, not when an already-built isolated
dirs.push(Path::new(&manifest_dir).join("../../apps")); // test executable is launched from a different working directory.
} dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([ dirs.extend([
Path::new("apps").to_path_buf(), Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(), Path::new("/opt/archipelago/apps").to_path_buf(),
+3 -3
View File
@@ -119,9 +119,9 @@ fn apps_dirs() -> Vec<PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") { if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into()); dirs.push(root.into());
} }
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { // Preserve source discovery after the prebuilt test binary moves into its
dirs.push(PathBuf::from(manifest_dir).join("../../apps")); // networkless execution container.
} dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([ dirs.extend([
PathBuf::from("apps"), PathBuf::from("apps"),
PathBuf::from("/opt/archipelago/apps"), PathBuf::from("/opt/archipelago/apps"),
+3 -2
View File
@@ -23,7 +23,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use rand::RngCore;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -227,7 +226,9 @@ pub fn global() -> Arc<ConfirmGate> {
/// nonce for the same action never matches today's pending entry). /// nonce for the same action never matches today's pending entry).
pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String { pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt); crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt).unwrap_or_else(|e| {
panic!("refusing to mint a confirmation nonce from degenerate entropy: {e} (KEY-05)")
});
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(salt); hasher.update(salt);
hasher.update(tool_name.as_bytes()); hasher.update(tool_name.as_bytes());
+1 -1
View File
@@ -30,7 +30,7 @@ pub const TOKEN_LEN: usize = 8;
/// wrapped. Called exactly once per [`UntrustedBlock::new`] / /// wrapped. Called exactly once per [`UntrustedBlock::new`] /
/// [`wrap_untrusted`] invocation. /// [`wrap_untrusted`] invocation.
fn fresh_token() -> String { fn fresh_token() -> String {
rand::thread_rng() rand::rngs::OsRng
.sample_iter(Alphanumeric) .sample_iter(Alphanumeric)
.take(TOKEN_LEN) .take(TOKEN_LEN)
.map(char::from) .map(char::from)
@@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Left("user-uninstalled".into())); return Ok(ReconcileAction::Left("user-uninstalled".into()));
} }
self.sync_quadlet_unit(lm, &managed_name).await?; self.sync_quadlet_unit(lm, &managed_name).await?;
let status = self.runtime.get_container_status(&managed_name).await let status = self
.runtime
.get_container_status(&managed_name)
.await
.context("Reviewed managed runtime is missing; explicit recovery required")?; .context("Reviewed managed runtime is missing; explicit recovery required")?;
anyhow::ensure!(matches!(status.state, ContainerState::Running), anyhow::ensure!(matches!(status.state, ContainerState::Running),
"Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog"); "Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog");
@@ -4019,36 +4022,38 @@ impl ProdContainerOrchestrator {
.clone() .clone()
.unwrap_or_else(|| "bitcoin-knots".to_string()); .unwrap_or_else(|| "bitcoin-knots".to_string());
} }
#[allow(unreachable_code)] #[cfg(not(test))]
// The known Bitcoin node containers, preferred in order. Any archy {
// Bitcoin distribution runs as a container named `bitcoin-<distro>` // The known Bitcoin node containers, preferred in order. Any archy
// (or bare `bitcoin`), all reachable on archy-net by name. // Bitcoin distribution runs as a container named `bitcoin-<distro>`
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; // (or bare `bitcoin`), all reachable on archy-net by name.
let names = tokio::process::Command::new("podman") const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
.args(["ps", "--format", "{{.Names}}"]) let names = tokio::process::Command::new("podman")
.output() .args(["ps", "--format", "{{.Names}}"])
.await .output()
.ok() .await
.filter(|o| o.status.success()) .ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .filter(|o| o.status.success())
.unwrap_or_default(); .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect(); .unwrap_or_default();
// Prefer a known name in priority order… let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) { // Prefer a known name in priority order…
return hit.to_string(); if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
} }
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
} }
#[cfg(test)] #[cfg(test)]
@@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
}; };
let name = compute_container_name(&lm.manifest); let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, anyhow::ensure!(
"Reviewed managed runtime is held for update recovery"); !super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?; self.sync_quadlet_unit(&lm, &name).await?;
self.ensure_resolved_source_available(&lm).await?; self.ensure_resolved_source_available(&lm).await?;
} }
@@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let _guard = lock.lock().await; let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest); let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, anyhow::ensure!(
"Reviewed managed runtime is held for update recovery"); !super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?; self.sync_quadlet_unit(&lm, &name).await?;
quadlet::stop_service(&format!("{name}.service")).await?; quadlet::stop_service(&format!("{name}.service")).await?;
if let Ok(status) = self.runtime.get_container_status(&name).await { if let Ok(status) = self.runtime.get_container_status(&name).await {
anyhow::ensure!( anyhow::ensure!(
matches!(status.state, ContainerState::Stopped | ContainerState::Exited | ContainerState::Created), matches!(
status.state,
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created
),
"Reviewed managed runtime is still active after systemd stop" "Reviewed managed runtime is still active after systemd stop"
); );
} }
@@ -5391,7 +5403,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> { async fn restart(&self, app_id: &str) -> Result<()> {
if let Ok(lm) = self.loaded(app_id).await { if let Ok(lm) = self.loaded(app_id).await {
if super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_some() { if super::supervised_update::installed_unit(
&self.data_dir,
&compute_container_name(&lm.manifest),
)?
.is_some()
{
self.validate_start(app_id).await?; self.validate_start(app_id).await?;
self.stop(app_id).await?; self.stop(app_id).await?;
return self.start(app_id).await; return self.start(app_id).await;
@@ -7966,27 +7983,59 @@ app:
#[tokio::test] #[tokio::test]
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() { async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
for case in ["running", "stopped", "missing", "changed-unit", "missing-unit", "user-stopped", "user-uninstalled"] { for case in [
"running",
"stopped",
"missing",
"changed-unit",
"missing-unit",
"user-stopped",
"user-uninstalled",
] {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await; let orch = orch_with(rt.clone()).await;
let name = format!("managed-{}", uuid::Uuid::new_v4().simple()); let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
let mut manifest = pull_manifest(&name, "catalog:new"); let mut manifest = pull_manifest(&name, "catalog:new");
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()]; manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")).await; orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift"))
.await;
let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n"; let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n";
let records = orch.data_dir.join("update-transactions/installed-units"); let records = orch.data_dir.join("update-transactions/installed-units");
std::fs::create_dir_all(&records).unwrap(); std::fs::create_dir_all(&records).unwrap();
std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({ std::fs::write(
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(), records.join(format!("{name}.json")),
"name": name, "body": body, "mode": 0o600 serde_json::to_vec(&serde_json::json!({
})).unwrap()).unwrap(); "schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container")); "name": name, "body": body, "mode": 0o600
}))
.unwrap(),
)
.unwrap();
let unit = quadlet::unit_dir()
.await
.unwrap()
.join(format!("{name}.container"));
if case != "missing-unit" { if case != "missing-unit" {
std::fs::write(&unit, if case == "changed-unit" { "operator changed" } else { body }).unwrap(); std::fs::write(
&unit,
if case == "changed-unit" {
"operator changed"
} else {
body
},
)
.unwrap();
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap(); std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
} }
if case != "missing" { if case != "missing" {
rt.set_state(&name, if case == "stopped" { ContainerState::Stopped } else { ContainerState::Running }); rt.set_state(
&name,
if case == "stopped" {
ContainerState::Stopped
} else {
ContainerState::Running
},
);
} }
if case == "user-stopped" { if case == "user-stopped" {
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await; crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
@@ -8010,7 +8059,13 @@ app:
assert!(result.is_err(), "{case} must refuse before mutation"); assert!(result.is_err(), "{case} must refuse before mutation");
} }
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:")), "{case}: {:?}", rt.calls()); assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")),
"{case}: {:?}",
rt.calls()
);
if case == "running" { if case == "running" {
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
} }
@@ -8026,8 +8081,14 @@ app:
orch.validate_start(&name).await.unwrap(); orch.validate_start(&name).await.unwrap();
orch.start(&name).await.unwrap(); orch.start(&name).await.unwrap();
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir).await.contains(&name)); assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir)
assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name)); .await
.contains(&name));
assert!(
!crate::crash_recovery::load_user_uninstalled(&orch.data_dir)
.await
.contains(&name)
);
} else { } else {
// Missing images/units and modified units refuse an explicit // Missing images/units and modified units refuse an explicit
// start before service mutation; no catalog pull is attempted. // start before service mutation; no catalog pull is attempted.
@@ -8037,7 +8098,14 @@ app:
assert!(orch.stop(&name).await.is_err()); assert!(orch.stop(&name).await.is_err());
} }
} }
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:") || call.starts_with("image_exists:")), "{case}: {:?}", rt.calls()); assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")
|| call.starts_with("image_exists:")),
"{case}: {:?}",
rt.calls()
);
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
let _ = std::fs::remove_file(unit); let _ = std::fs::remove_file(unit);
} }
@@ -8664,9 +8732,11 @@ app:
let refs: Vec<&str> = names.iter().map(String::as_str).collect(); let refs: Vec<&str> = names.iter().map(String::as_str).collect();
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await; crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
// Repeated passes must leave lifecycle ownership with companion.rs. // Repeated passes must leave lifecycle ownership with companion.rs.
for _ in 0..3 { for pass in 0..3 {
let report = orch.reconcile_existing().await; let report = orch.reconcile_existing().await;
assert_eq!(report.actions.len(), companions.len()); if pass == 0 {
assert_eq!(report.actions.len(), companions.len());
}
assert!(report assert!(report
.actions .actions
.iter() .iter()
+45 -34
View File
@@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result<PathBuf> {
.get_or_init(|| tempfile::tempdir().unwrap().keep()) .get_or_init(|| tempfile::tempdir().unwrap().keep())
.clone()); .clone());
} }
let home = std::env::var_os("HOME") #[cfg(not(test))]
.map(PathBuf::from) {
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; let home = std::env::var_os("HOME")
let dir = home.join(DEFAULT_REL_UNIT_DIR); .map(PathBuf::from)
fs::create_dir_all(&dir) .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
.await let dir = home.join(DEFAULT_REL_UNIT_DIR);
.with_context(|| format!("create_dir_all {}", dir.display()))?; fs::create_dir_all(&dir)
Ok(dir) .await
.with_context(|| format!("create_dir_all {}", dir.display()))?;
Ok(dir)
}
} }
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once /// The early same-node Portainer repair used a managed Quadlet drop-in. Once
@@ -944,21 +947,25 @@ async fn systemctl_user_status(
#[cfg(test)] #[cfg(test)]
{ {
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
let _ = (args, timeout);
return Ok(std::process::ExitStatus::from_raw(0)); return Ok(std::process::ExitStatus::from_raw(0));
} }
let mut cmd = Command::new("systemctl"); #[cfg(not(test))]
cmd.arg("--user").args(args); {
cmd.kill_on_drop(true); let mut cmd = Command::new("systemctl");
tokio::time::timeout(timeout, cmd.status()) cmd.arg("--user").args(args);
.await cmd.kill_on_drop(true);
.with_context(|| { tokio::time::timeout(timeout, cmd.status())
format!( .await
"systemctl --user {} timed out after {}s", .with_context(|| {
args.join(" "), format!(
timeout.as_secs() "systemctl --user {} timed out after {}s",
) args.join(" "),
})? timeout.as_secs()
.with_context(|| format!("spawn systemctl --user {}", args.join(" "))) )
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
} }
async fn kill_and_reset_service(service: &str) -> Result<()> { async fn kill_and_reset_service(service: &str) -> Result<()> {
@@ -994,21 +1001,25 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> { async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
#[cfg(test)] #[cfg(test)]
{ {
let _ = (args, timeout);
anyhow::bail!("Unit tests have no real user service manager"); anyhow::bail!("Unit tests have no real user service manager");
} }
let mut cmd = Command::new("systemctl"); #[cfg(not(test))]
cmd.arg("--user").args(args); {
cmd.kill_on_drop(true); let mut cmd = Command::new("systemctl");
tokio::time::timeout(timeout, cmd.output()) cmd.arg("--user").args(args);
.await cmd.kill_on_drop(true);
.with_context(|| { tokio::time::timeout(timeout, cmd.output())
format!( .await
"systemctl --user {} timed out after {}s", .with_context(|| {
args.join(" "), format!(
timeout.as_secs() "systemctl --user {} timed out after {}s",
) args.join(" "),
})? timeout.as_secs()
.with_context(|| format!("spawn systemctl --user {}", args.join(" "))) )
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
} }
pub fn contains_stale_health_gate(unit_body: &str) -> bool { pub fn contains_stale_health_gate(unit_body: &str) -> bool {
+1 -4
View File
@@ -171,10 +171,7 @@ pub(crate) fn prepare(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
let reservation = crate::snapshot_budget::reserve( let reservation = crate::snapshot_budget::reserve(
+1 -1
View File
@@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we /// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to. /// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
pub fn fips0_ula() -> Option<Ipv6Addr> { pub fn fips0_ula() -> Option<Ipv6Addr> {
addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a)) addresses_on(FIPS_IFACE).into_iter().find(is_ula)
} }
/// List every IPv6 address bound to a given interface from /// List every IPv6 address bound to a given interface from
+17 -7
View File
@@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
} }
if matches!( if matches!(
pkg.state, pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating PackageState::Starting
| PackageState::Stopping
| PackageState::Restarting
| PackageState::Updating
) { ) {
debug!( debug!(
"Skipping container during package lifecycle transition: {} ({:?})", "Skipping container during package lifecycle transition: {} ({:?})",
@@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
app_id: Some(container.app_id.clone()), app_id: Some(container.app_id.clone()),
}); });
if data.notifications.len() > 20 { if data.notifications.len() > 20 {
data.notifications = data.notifications.split_off(data.notifications.len() - 20); data.notifications =
data.notifications.split_off(data.notifications.len() - 20);
} }
state_changed = true; state_changed = true;
} }
@@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
// the restart resyncs cleanly instead of crash-looping. // the restart resyncs cleanly instead of crash-looping.
maybe_recover_corrupt_electrumx(&container.name, attempt).await; maybe_recover_corrupt_electrumx(&container.name, attempt).await;
let restarted = restart_container(&container.name, &container.state, &data_dir).await; let restarted =
restart_container(&container.name, &container.state, &data_dir).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS { if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
let notification = Notification { let notification = Notification {
@@ -1249,10 +1254,15 @@ mod tests {
let installed = root.path().join("update-transactions/installed-units"); let installed = root.path().join("update-transactions/installed-units");
std::fs::create_dir_all(&installed).unwrap(); std::fs::create_dir_all(&installed).unwrap();
let record = installed.join(format!("{name}.json")); let record = installed.join(format!("{name}.json"));
std::fs::write(&record, serde_json::to_vec(&serde_json::json!({ std::fs::write(
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(), &record,
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600 serde_json::to_vec(&serde_json::json!({
})).unwrap()).unwrap(); "schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
}))
.unwrap(),
)
.unwrap();
assert!(!automatic_recovery_allowed(root.path(), name)); assert!(!automatic_recovery_allowed(root.path(), name));
assert!(!restart_container(name, "running", root.path()).await); assert!(!restart_container(name, "running", root.path()).await);
std::fs::write(&record, b"damaged").unwrap(); std::fs::write(&record, b"damaged").unwrap();
+70 -21
View File
@@ -205,14 +205,22 @@ impl IdentityManager {
nsec: &str, nsec: &str,
expected_npub: &str, expected_npub: &str,
) -> Result<IdentityRecord> { ) -> Result<IdentityRecord> {
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); anyhow::ensure!(
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key"); !name.trim().is_empty() && name.len() <= 100,
let secret = nostr_sdk::SecretKey::parse(nsec) "Invalid identity name"
.map_err(|_| anyhow::anyhow!("Invalid owner key"))?; );
anyhow::ensure!(
nsec.starts_with("nsec1") && nsec.len() == 63,
"Enter a plain nsec owner key"
);
let secret =
nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
let keys = nostr_sdk::Keys::new(secret); let keys = nostr_sdk::Keys::new(secret);
let nostr_pubkey = keys.public_key().to_hex(); let nostr_pubkey = keys.public_key().to_hex();
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub, anyhow::ensure!(
"Owner key does not match this website"); keys.public_key().to_bech32()? == expected_npub,
"Owner key does not match this website"
);
// Serializes imports only; mature creation/signing paths are untouched. // Serializes imports only; mature creation/signing paths are untouched.
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -260,7 +268,8 @@ impl IdentityManager {
// Atomic publication, and unlike rename this cannot replace a file. // Atomic publication, and unlike rename this cannot replace a file.
fs::hard_link(&staging, &destination).await?; fs::hard_link(&staging, &destination).await?;
Ok(()) Ok(())
}.await; }
.await;
let _ = fs::remove_file(&staging).await; let _ = fs::remove_file(&staging).await;
write_result.context("Could not save imported identity")?; write_result.context("Could not save imported identity")?;
self.get(&id).await self.get(&id).await
@@ -974,23 +983,53 @@ mod tests {
async fn import_nostr_preserves_identities_and_rejects_mismatches() { async fn import_nostr_preserves_identities_and_rejects_mismatches() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let manager = IdentityManager::new(dir.path()).await.unwrap(); let manager = IdentityManager::new(dir.path()).await.unwrap();
let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap(); let original = manager
.create("Personal".into(), IdentityPurpose::Personal)
.await
.unwrap();
let keys = nostr_sdk::Keys::generate(); let keys = nostr_sdk::Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap(); let nsec = keys.secret_key().to_bech32().unwrap();
let npub = keys.public_key().to_bech32().unwrap(); let npub = keys.public_key().to_bech32().unwrap();
assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err()); assert!(manager
.import_nostr("Wrong".into(), &nsec, "npub1wrong")
.await
.is_err());
assert_eq!(manager.list().await.unwrap().0.len(), 1); assert_eq!(manager.list().await.unwrap().0.len(), 1);
let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap(); let imported = manager
.import_nostr("Website".into(), &nsec, &npub)
.await
.unwrap();
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str())); assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id); assert_eq!(
manager
.import_nostr("Again".into(), &nsec, &npub)
.await
.unwrap()
.id,
imported.id
);
let (records, default) = manager.list().await.unwrap(); let (records, default) = manager.list().await.unwrap();
assert_eq!(records.len(), 2); assert_eq!(records.len(), 2);
assert_eq!(default.as_deref(), Some(original.id.as_str())); assert_eq!(default.as_deref(), Some(original.id.as_str()));
assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey); assert_eq!(
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec); manager.get(&original.id).await.unwrap().nostr_pubkey,
#[cfg(unix)] { original.nostr_pubkey
);
assert_eq!(
manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"],
nsec
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode(); let mode = std::fs::metadata(
dir.path()
.join("identities")
.join(format!("{}.json", imported.id)),
)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600); assert_eq!(mode & 0o777, 0o600);
} }
} }
@@ -1012,14 +1051,24 @@ mod tests {
assert_eq!(records.len(), 1); assert_eq!(records.len(), 1);
assert!(default.is_none()); assert!(default.is_none());
let hash = [7u8; 32]; let hash = [7u8; 32];
let signature = manager.nostr_sign(&first.id, &hex::encode(hash)).await.unwrap(); let signature = manager
.nostr_sign(&first.id, &hex::encode(hash))
.await
.unwrap();
let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap(); let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap(); let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey =
nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr( keys.public_key().to_hex().parse().unwrap();
&signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey, nostr_sdk::secp256k1::Secp256k1::verification_only()
).unwrap(); .verify_schnorr(
&signature,
&nostr_sdk::secp256k1::Message::from_digest(hash),
&pubkey,
)
.unwrap();
let entries = std::fs::read_dir(dir.path().join("identities")).unwrap(); let entries = std::fs::read_dir(dir.path().join("identities")).unwrap();
assert!(entries.map(|entry| entry.unwrap().file_name()).all(|name| !name.to_string_lossy().ends_with(".tmp"))); assert!(entries
.map(|entry| entry.unwrap().file_name())
.all(|name| !name.to_string_lossy().ends_with(".tmp")));
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -77,7 +77,6 @@ mod monitoring;
mod music; mod music;
mod names; mod names;
mod network; mod network;
mod publishing;
mod node_message; mod node_message;
mod nostr_discovery; mod nostr_discovery;
mod nostr_handshake; mod nostr_handshake;
@@ -87,6 +86,7 @@ mod nostr_security_tests;
mod peers; mod peers;
mod port_allocator; mod port_allocator;
mod prepared_media; mod prepared_media;
mod publishing;
mod rate_limit; mod rate_limit;
mod registered_media; mod registered_media;
mod rental_chunk_index; mod rental_chunk_index;
+1 -4
View File
@@ -683,10 +683,7 @@ pub fn resolve(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
} }
+7 -10
View File
@@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
/// resource is gone yet). /// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> { async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT; let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop { loop {
match serial2_tokio::SerialPort::open(path, 115200) { match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()), Ok(_) => return Ok(()),
Err(e) => last_err = Some(e), Err(error) if tokio::time::Instant::now() >= deadline => {
} return Err(anyhow::anyhow!(
if tokio::time::Instant::now() >= deadline { "{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port",
break; PORT_FREE_TIMEOUT.as_secs(),
));
}
Err(_) => {}
} }
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} }
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
} }
/// Live state for the one flash job that can run at a time. A single global /// Live state for the one flash job that can run at a time. A single global
+1 -1
View File
@@ -15,7 +15,7 @@ mod frames;
mod node_cmd; mod node_cmd;
mod session; mod session;
pub(crate) use session::{probe_device, DeviceProbe}; pub(crate) use session::probe_device;
use super::types::*; use super::types::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+20 -7
View File
@@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
}; };
let mut command = tokio::process::Command::new("df"); let mut command = tokio::process::Command::new("df");
command.args(["--block-size=1", "--output=used,size", target]); command.args(["--block-size=1", "--output=used,size", target]);
let output = bounded_output(command, std::time::Duration::from_secs(3)).await let output = bounded_output(command, std::time::Duration::from_secs(3))
.await
.context("Failed to run df")?; .context("Failed to run df")?;
if !output.status.success() { if !output.status.success() {
@@ -222,7 +223,8 @@ async fn bounded_output(
) -> Result<std::process::Output> { ) -> Result<std::process::Output> {
command.kill_on_drop(true); command.kill_on_drop(true);
tokio::time::timeout(timeout, command.output()) tokio::time::timeout(timeout, command.output())
.await.context("Metrics subprocess timed out")? .await
.context("Metrics subprocess timed out")?
.context("Metrics subprocess failed") .context("Metrics subprocess failed")
} }
@@ -230,7 +232,8 @@ async fn bounded_output(
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> { async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
let mut command = tokio::process::Command::new("podman"); let mut command = tokio::process::Command::new("podman");
command.args(["stats", "--no-stream", "--format", "json"]); command.args(["stats", "--no-stream", "--format", "json"]);
let output = bounded_output(command, std::time::Duration::from_secs(8)).await let output = bounded_output(command, std::time::Duration::from_secs(8))
.await
.context("Failed to run podman stats")?; .context("Failed to run podman stats")?;
if !output.status.success() { if !output.status.success() {
@@ -411,14 +414,22 @@ mod subprocess_deadline_tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid"); let pid_file = dir.path().join("pid");
let mut command = tokio::process::Command::new("sh"); let mut command = tokio::process::Command::new("sh");
command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file); command
.arg("-c")
.arg("echo $$ > \"$1\"; exec sleep 30")
.arg("metrics-test")
.arg(&pid_file);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err(); let error = bounded_output(command, std::time::Duration::from_millis(500))
.await
.unwrap_err();
assert!(error.to_string().contains("timed out")); assert!(error.to_string().contains("timed out"));
assert!(start.elapsed() < std::time::Duration::from_secs(3)); assert!(start.elapsed() < std::time::Duration::from_secs(3));
let pid = tokio::fs::read_to_string(pid_file).await.unwrap(); let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
for _ in 0..40 { for _ in 0..40 {
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; } if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await; tokio::time::sleep(std::time::Duration::from_millis(25)).await;
} }
panic!("Timed-out metrics subprocess was not reaped"); panic!("Timed-out metrics subprocess was not reaped");
@@ -428,7 +439,9 @@ mod subprocess_deadline_tests {
async fn successful_metrics_output_is_preserved() { async fn successful_metrics_output_is_preserved() {
let mut command = tokio::process::Command::new("printf"); let mut command = tokio::process::Command::new("printf");
command.arg("metrics-ok"); command.arg("metrics-ok");
let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap(); let output = bounded_output(command, std::time::Duration::from_secs(1))
.await
.unwrap();
assert!(output.status.success()); assert!(output.status.success());
assert_eq!(output.stdout, b"metrics-ok"); assert_eq!(output.stdout, b"metrics-ok");
} }
+1 -1
View File
@@ -102,7 +102,7 @@ impl MetricsStore {
// Use saturating semantics to avoid underflow // Use saturating semantics to avoid underflow
let _ = self let _ = self
.ws_connections .ws_connections
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| { .try_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { if v > 0 {
Some(v - 1) Some(v - 1)
} else { } else {
+4 -3
View File
@@ -227,7 +227,7 @@ pub async fn publish_presence(
// NIP-40 expiration: relays that honour it garbage-collect the event if // NIP-40 expiration: relays that honour it garbage-collect the event if
// this node stops heartbeating (reinstall, decommission, long outage). // this node stops heartbeating (reinstall, decommission, long outage).
// `discover` enforces the same window client-side for relays that don't. // `discover` enforces the same window client-side for relays that don't.
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), content) let builder = EventBuilder::new(Kind::Custom(30078), content)
.tag(Tag::identifier("archipelago-node")) .tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires)); .tag(Tag::expiration(expires));
@@ -268,7 +268,7 @@ pub async fn publish_tombstone(
} }
// Tombstone also expires: after TTL the relay may drop it entirely, // Tombstone also expires: after TTL the relay may drop it entirely,
// which is the desired end state (nothing left to list). // which is the desired end state (nothing left to list).
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), "{}") let builder = EventBuilder::new(Kind::Custom(30078), "{}")
.tag(Tag::identifier("archipelago-node")) .tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires)); .tag(Tag::expiration(expires));
@@ -326,7 +326,8 @@ pub async fn discover_nodes(
client.disconnect().await; client.disconnect().await;
let mut nodes = Vec::new(); let mut nodes = Vec::new();
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS)); let stale_cutoff =
Timestamp::from(Timestamp::now().as_secs().saturating_sub(PRESENCE_TTL_SECS));
for event in events { for event in events {
// Client-side staleness enforcement: pre-TTL events (and events from // Client-side staleness enforcement: pre-TTL events (and events from
// relays that ignore NIP-40) would otherwise list dead installs // relays that ignore NIP-40) would otherwise list dead installs
+4 -2
View File
@@ -1194,11 +1194,13 @@ impl Server {
// Podman needs and can restart-loop apps that publish those ports. // Podman needs and can restart-loop apps that publish those ports.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let publishing_task = tokio::spawn(crate::publishing::serving::run( let publishing_task = tokio::spawn(crate::publishing::serving::run(
self._config.data_dir.clone(), tx.subscribe(), self._config.data_dir.clone(),
tx.subscribe(),
)); ));
let publishing_tor_task = tokio::spawn(crate::publishing::tor::run( let publishing_tor_task = tokio::spawn(crate::publishing::tor::run(
self._config.data_dir.clone(), tx.subscribe(), self._config.data_dir.clone(),
tx.subscribe(),
)); ));
// The app gate: authentication in front of every app port, on every // The app gate: authentication in front of every app port, on every
+1 -4
View File
@@ -145,10 +145,7 @@ pub(crate) fn reserve_until(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
} }
+40 -34
View File
@@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
.context("isolated test command failed"); .context("isolated test command failed");
} }
let mut full: Vec<&str> = vec![ #[cfg(not(test))]
"systemd-run", {
"--wait", let mut full: Vec<&str> = vec![
"--quiet", "systemd-run",
"--collect", "--wait",
"--pipe", "--quiet",
// Shell snippets passed as one argument must reach the child intact. "--collect",
// systemd-run otherwise expands $VAR/${VAR} against the manager's "--pipe",
// environment before `sh -lc` can see them (and usually replaces them // Shell snippets passed as one argument must reach the child intact.
// with empty strings). // systemd-run otherwise expands $VAR/${VAR} against the manager's
"--expand-environment=no", // environment before `sh -lc` can see them (and usually replaces them
"--", // with empty strings).
]; "--expand-environment=no",
full.extend_from_slice(args); "--",
tokio::process::Command::new("sudo") ];
.args(&full) full.extend_from_slice(args);
.status() tokio::process::Command::new("sudo")
.await .args(&full)
.context("sudo systemd-run spawn failed") .status()
.await
.context("sudo systemd-run spawn failed")
}
} }
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes /// Same mechanism as `host_sudo` but captures stdout — for read-only probes
@@ -1535,21 +1538,24 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
.context("isolated test command failed"); .context("isolated test command failed");
} }
let mut full: Vec<&str> = vec![ #[cfg(not(test))]
"systemd-run", {
"--wait", let mut full: Vec<&str> = vec![
"--quiet", "systemd-run",
"--collect", "--wait",
"--pipe", "--quiet",
"--expand-environment=no", "--collect",
"--", "--pipe",
]; "--expand-environment=no",
full.extend_from_slice(args); "--",
tokio::process::Command::new("sudo") ];
.args(&full) full.extend_from_slice(args);
.output() tokio::process::Command::new("sudo")
.await .args(&full)
.context("sudo systemd-run output spawn failed") .output()
.await
.context("sudo systemd-run output spawn failed")
}
} }
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions. /// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
+1 -1
View File
@@ -474,7 +474,7 @@ async fn ensure_token(
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number. /// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
fn generate_wallet_id() -> String { fn generate_wallet_id() -> String {
let mut rng = rand::thread_rng(); let mut rng = rand::rngs::OsRng;
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet"); let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor"); let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
let num = rand::Rng::gen_range(&mut rng, 1..=999); let num = rand::Rng::gen_range(&mut rng, 1..=999);
+20 -8
View File
@@ -16,7 +16,7 @@ use super::nut13::RecoverySource;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bitcoin::secp256k1; use bitcoin::secp256k1;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, warn}; use tracing::debug;
/// Default timeout for mint API calls. /// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10; const MINT_TIMEOUT_SECS: u64 = 10;
@@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap {
} }
impl PreparedSwap { impl PreparedSwap {
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed. // Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id } pub(super) fn payment_keyset_id(&self) -> &str {
pub(super) fn input_fee_sats(&self) -> Result<u64> { &self.keyset.id
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?; }
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?; pub(super) fn input_fee_sats(&self) -> Result<u64> {
inputs.checked_sub(outputs).context("Prepared outputs exceed input value") let inputs = self
} .inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input sum overflow")?;
let outputs = self
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output sum overflow")?;
inputs
.checked_sub(outputs)
.context("Prepared outputs exceed input value")
}
pub(super) fn inputs(&self) -> &[Proof] { pub(super) fn inputs(&self) -> &[Proof] {
&self.inputs &self.inputs
+1 -1
View File
@@ -11,8 +11,8 @@ pub mod mint_client;
pub(crate) mod mutation; pub(crate) mod mutation;
pub mod nut13; pub mod nut13;
pub mod profits; pub mod profits;
mod send_journal;
mod receive_journal; mod receive_journal;
mod send_journal;
pub(crate) mod purchase_fee_plan; pub(crate) mod purchase_fee_plan;
+1 -1
View File
@@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
#[tokio::test] #[tokio::test]
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() { async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
use crate::content_purchase_caller::{purchase, ReadyPurchase}; use crate::content_purchase_caller::{purchase, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::AtomicBool;
let mint = Mint::start(0, None).await; let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap(); let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await; let seller = mint.wallet().await;
+7 -1
View File
@@ -1795,8 +1795,10 @@ app:
} }
} }
exempt.sort(); exempt.sort();
// Reviewed 2026-09-30: lightning-stack's three retired endpoints // Reviewed 2026-10-09: lightning-stack's three retired endpoints
// disappeared; Cuprate restricted RPC moved from none to gate-open. // disappeared; Cuprate restricted RPC moved from none to gate-open.
// DATUM's Stratum port is a raw public mining protocol; its separate
// administration interface remains gated and loopback-bound.
// Compare exact endpoints, not just a count that can hide substitutions. // Compare exact endpoints, not just a count that can hide substitutions.
let expected = [ let expected = [
("bitcoin-core", 8333), ("bitcoin-core", 8333),
@@ -1804,6 +1806,7 @@ app:
("core-lightning", 9736), ("core-lightning", 9736),
("core-lightning", 9835), ("core-lightning", 9835),
("cuprate", 18183), ("cuprate", 18183),
("datum", 23334),
("electrumx", 50001), ("electrumx", 50001),
("fedimint", 8173), ("fedimint", 8173),
("fedimint", 8174), ("fedimint", 8174),
@@ -1870,6 +1873,8 @@ app:
// Angor's indexer exposes public chain data/transaction broadcast; // Angor's indexer exposes public chain data/transaction broadcast;
// its optional standalone relay accepts signed public Nostr events. // its optional standalone relay accepts signed public Nostr events.
// Neither mounts credentials or the node's internal relay database. // Neither mounts credentials or the node's internal relay database.
// Gashboard performs its own NIP-98/access-list authentication on
// every data route before issuing or accepting a session.
assert_eq!( assert_eq!(
open, open,
vec![ vec![
@@ -1877,6 +1882,7 @@ app:
("angor-relay".to_string(), 8091u16), ("angor-relay".to_string(), 8091u16),
("btcpay-server".to_string(), 23000u16), ("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16), ("cuprate".to_string(), 18090u16),
("gashboard".to_string(), 1337u16),
("gitea".to_string(), 3001u16), ("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16), ("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16), ("tailscale".to_string(), 8240u16),
+4 -1
View File
@@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String {
} }
fn manifest_apps_dirs() -> Vec<PathBuf> { fn manifest_apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); // Keep source-tree discovery independent of the caller's working
// directory. Isolated test runners deliberately start in `core/`, while
// production uses one of the installed paths below.
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps")];
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps")); dirs.push(Path::new(&manifest_dir).join("../../apps"));
} }
+4
View File
@@ -38,6 +38,10 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
} }
} }
// Rust 1.99 treats the boxed Future generated by async-trait as must-use and
// also sees the macro's own must-use marker. Keep the compatibility allowance
// scoped to this generated trait surface; callers still cannot ignore Result.
#[allow(clippy::double_must_use)]
#[async_trait] #[async_trait]
pub trait ContainerRuntime: Send + Sync { pub trait ContainerRuntime: Send + Sync {
/// CLI used for offline app provisioning in this runtime's storage scope. /// CLI used for offline app provisioning in this runtime's storage scope.
+880 -1031
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -10,7 +10,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:mock-parity": "node scripts/mock-rpc-parity.mjs", "test:mock-parity": "node scripts/mock-rpc-parity.mjs",
"dev": "vite", "dev": "vite",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"", "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"", "dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite", "dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js", "backend:mock": "node mock-backend.js",
@@ -51,14 +51,14 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^5.0.3",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.9", "dockerode": "^5.0.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -66,7 +66,7 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.2", "vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vitest": "^3.1.1", "vitest": "^5.0.3",
"vue-tsc": "^3.1.3", "vue-tsc": "^3.1.3",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
@@ -48,14 +48,14 @@ describe('MeshMap', () => {
return 7 return 7
}) })
const clearWatch = vi.fn() const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({ class ResizeObserverMock {
observe: vi.fn(), observe = vi.fn()
disconnect: vi.fn(), disconnect = vi.fn()
})) }
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch }, geolocation: { watchPosition, clearWatch },
}) })
vi.stubGlobal('ResizeObserver', resizeObserver) vi.stubGlobal('ResizeObserver', ResizeObserverMock)
const wrapper = mount(MeshMap) const wrapper = mount(MeshMap)
@@ -79,11 +79,15 @@ describe('Mesh graphics lifecycle (Task 2): Leaflet map (MeshMap.vue)', () => {
vi.useFakeTimers() vi.useFakeTimers()
mapInstances.length = 0 mapInstances.length = 0
resizeObserverInstances = [] resizeObserverInstances = []
vi.stubGlobal('ResizeObserver', vi.fn(() => { vi.stubGlobal('ResizeObserver', class {
const inst = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() } observe = vi.fn()
resizeObserverInstances.push(inst) disconnect = vi.fn()
return inst unobserve = vi.fn()
}))
constructor() {
resizeObserverInstances.push(this)
}
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined, height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined,
} as DOMRect) } as DOMRect)
@@ -32,10 +32,10 @@ function createTestComponent(onCloseFn: () => void) {
} }
describe('useModalKeyboard', () => { describe('useModalKeyboard', () => {
let closeFn: ReturnType<typeof vi.fn> let closeFn: ReturnType<typeof vi.fn<() => void>>
beforeEach(() => { beforeEach(() => {
closeFn = vi.fn() closeFn = vi.fn<() => void>()
}) })
it('calls onClose when Escape is pressed and modal is open', async () => { it('calls onClose when Escape is pressed and modal is open', async () => {
@@ -69,7 +69,10 @@ vi.mock('../discover/curatedApps', () => ({
describe('Marketplace tracer tab: background refresh failure (D-07)', () => { describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
routerPushMock.mockClear() routerPushMock.mockClear()
toastErrorMock.mockClear() toastErrorMock.mockClear()
toastInfoMock.mockClear() toastInfoMock.mockClear()
@@ -37,7 +37,10 @@ function setGeometry(
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => { describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
vi.mocked(rpcClient.call).mockReset() vi.mocked(rpcClient.call).mockReset()
Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollIntoView = vi.fn()
}) })
@@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue' import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) })) vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64) const hash = 'a'.repeat(64)
@@ -5,6 +5,7 @@ import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
})) }))
@@ -41,10 +41,10 @@ vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false }))
// mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's // mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's
// convention for isolating a view from its heavier dependencies. // convention for isolating a view from its heavier dependencies.
vi.mock('@/services/contextBroker', () => ({ vi.mock('@/services/contextBroker', () => ({
ContextBroker: vi.fn().mockImplementation(() => ({ ContextBroker: class {
start: vi.fn(), start = vi.fn()
stop: vi.fn(), stop = vi.fn()
})), },
})) }))
/** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */ /** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */
@@ -454,6 +454,7 @@ describe('keepAliveLifecycle: 02-11 gap closure — leaked background pollers in
FleetNodeDetail: true, FleetNodeDetail: true,
FleetContainerMatrix: true, FleetContainerMatrix: true,
BackButton: true, BackButton: true,
RouterLink: true,
}, },
}, },
}) })
@@ -4,7 +4,10 @@ import Fleet from '../../Fleet.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }),
}))
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() }) afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
const stubs = { const stubs = {
@@ -7,6 +7,8 @@ const STEP_ROUTE_OVERRIDES: Record<string, string> = {
'create-passphrase': '/dashboard/settings', 'create-passphrase': '/dashboard/settings',
'create-backup': '/dashboard/settings', 'create-backup': '/dashboard/settings',
'save-backup': '/dashboard/settings', 'save-backup': '/dashboard/settings',
'external-access': '/dashboard/setup/external-access',
'publish-website': '/dashboard/setup/website',
// Channel steps land directly on the Lightning channels screen (which // Channel steps land directly on the Lightning channels screen (which
// carries the "open a channel with Zeus" suggestion). // carries the "open a channel with Zeus" suggestion).
'open-channel': '/dashboard/apps/lnd/channels', 'open-channel': '/dashboard/apps/lnd/channels',
+5
View File
@@ -12,6 +12,11 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
// Vitest 5's process-fork pool can time out while starting workers on a
// busy two-core development node. Threads retain per-file isolation and
// avoid that expensive process startup; CI itself remains containerized.
pool: 'threads',
maxWorkers: 4,
// Vitest's 5s default is not a statement about these tests — the whole // Vitest's 5s default is not a statement about these tests — the whole
// 1000-test suite runs in ~70s on an idle box. It is a statement about // 1000-test suite runs in ~70s on an idle box. It is a statement about
// the machine. This one also runs a live node, so a release gate can // the machine. This one also runs a live node, so a release gate can
+7
View File
@@ -1,4 +1,5 @@
import { config } from '@vue/test-utils' import { config } from '@vue/test-utils'
import { vi } from 'vitest'
import { displayVersion } from '@/utils/version' import { displayVersion } from '@/utils/version'
// The app registers `$ver` as a global template property in main.ts // The app registers `$ver` as a global template property in main.ts
@@ -7,3 +8,9 @@ import { displayVersion } from '@/utils/version'
// component. Per-mount `global` options merge with this, so individual tests // component. Per-mount `global` options merge with this, so individual tests
// keep their own plugins/mocks. // keep their own plugins/mocks.
config.global.mocks = { ...(config.global.mocks ?? {}), $ver: displayVersion } config.global.mocks = { ...(config.global.mocks ?? {}), $ver: displayVersion }
// jsdom intentionally leaves media playback methods unimplemented and logs a
// stack trace for every cleanup call. Components only need inert lifecycle
// behavior in unit tests; media-specific suites can still replace these spies.
HTMLMediaElement.prototype.pause = vi.fn()
HTMLMediaElement.prototype.load = vi.fn()
+70 -7
View File
@@ -2,19 +2,82 @@
# Compile normally; execute unit tests away from real wallets, service buses, # Compile normally; execute unit tests away from real wallets, service buses,
# container storage, processes and networking. Never silently fall back to host. # container storage, processes and networking. Never silently fall back to host.
set -euo pipefail set -euo pipefail
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) SCRIPT_REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
command -v systemd-run >/dev/null REPO=${ARCHY_TEST_REPO:-$SCRIPT_REPO}
command -v unshare >/dev/null REPO=$(cd "$REPO" && pwd)
command -v setpriv >/dev/null
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
metadata=$(mktemp)
trap 'rm -f "$metadata"' EXIT
case "${ARCHY_TEST_PACKAGE:-archipelago}" in case "${ARCHY_TEST_PACKAGE:-archipelago}" in
archipelago) test_target=(-p archipelago --bin archipelago) ;; archipelago) test_target=(-p archipelago --bin archipelago) ;;
archipelago-publishing-tests) test_target=(-p archipelago-publishing-tests --lib) ;; archipelago-publishing-tests) test_target=(-p archipelago-publishing-tests --lib) ;;
archipelago-container) test_target=(-p archipelago-container --lib) ;; archipelago-container) test_target=(-p archipelago-container --lib) ;;
*) echo 'Unsupported isolated test package' >&2; exit 2 ;; *) echo 'Unsupported isolated test package' >&2; exit 2 ;;
esac esac
if [[ ${ARCHY_TEST_ISOLATOR:-systemd} == podman ]]; then
command -v podman >/dev/null
image=${ARCHY_TEST_IMAGE:?ARCHY_TEST_IMAGE is required for podman isolation}
cargo_home=${ARCHY_TEST_CARGO_HOME:?ARCHY_TEST_CARGO_HOME is required for podman isolation}
cargo_home=$(mkdir -p "$cargo_home" && cd "$cargo_home" && pwd)
artifacts=$(mktemp -d)
trap 'rm -rf -- "$artifacts"' EXIT
install -d "$artifacts/runtime/archipelago" "$artifacts/runtime/containers" \
"$artifacts/runtime/tmp"
podman run --rm \
--cpus="${ARCHY_TEST_CPUS:-4}" --memory="${ARCHY_TEST_MEMORY:-4g}" --pids-limit=2048 \
--cap-drop=all --security-opt=no-new-privileges --read-only \
--tmpfs /tmp:rw,size=512m --tmpfs /root:rw,size=512m \
--network=pasta --env CARGO_HOME=/cargo-home \
--volume "$cargo_home:/cargo-home:rw,Z" \
--volume "$REPO:/workspace:rw,Z" --volume "$artifacts:/artifacts:rw,Z" \
--workdir /workspace \
"$image" \
cargo test --manifest-path core/Cargo.toml "${test_target[@]}" \
--locked --no-run --message-format=json \
--config 'profile.test.package.archipelago.opt-level=0' \
--config 'profile.test.package.archipelago.debug=0' \
>"$artifacts/metadata"
executable=$(python3 - "$artifacts/metadata" <<'PY'
import json,sys
found=[]
for line in open(sys.argv[1]):
try: item=json.loads(line)
except json.JSONDecodeError: continue
if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'):
found.append(item['executable'])
assert len(found)==1, f'Expected one unit test executable, got {len(found)}'
print(found[0])
PY
)
case "$executable" in
/workspace/*) ;;
*) echo 'Compiled test executable escaped the workspace' >&2; exit 1 ;;
esac
podman run --rm \
--cpus="${ARCHY_TEST_CPUS:-4}" --memory="${ARCHY_TEST_MEMORY:-4g}" --pids-limit=1024 \
--cap-drop=all --cap-add=chown --cap-add=fowner --cap-add=setuid --cap-add=setgid \
--security-opt=no-new-privileges --read-only \
--tmpfs /run:rw,size=64m --tmpfs /root:rw,size=64m --network=none \
--volume "$artifacts/runtime/tmp:/tmp:rw,Z" \
--volume "$artifacts/runtime/archipelago:/var/lib/archipelago:rw,Z" \
--volume "$artifacts/runtime/containers:/var/lib/containers:rw,Z" \
--volume "$REPO:/workspace:ro,Z" --workdir /workspace/core \
--env ARCHY_TEST_ISOLATED=1 \
"$image" "$executable" --test-threads="${ARCHY_TEST_THREADS:-4}" "$@"
exit
fi
[[ ${ARCHY_TEST_ISOLATOR:-systemd} == systemd ]] || {
echo 'ARCHY_TEST_ISOLATOR must be systemd or podman' >&2
exit 2
}
command -v systemd-run >/dev/null
command -v unshare >/dev/null
command -v setpriv >/dev/null
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
metadata=$(mktemp)
trap 'rm -f "$metadata"' EXIT
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \ if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' --config 'profile.test.package.archipelago.debug=0' > "$metadata"; then --locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' --config 'profile.test.package.archipelago.debug=0' > "$metadata"; then
python3 - "$metadata" <<'PYDIAG' python3 - "$metadata" <<'PYDIAG'
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import os
import pathlib
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
RUNNER = ROOT / "scripts" / "test-backend-isolated.sh"
class BackendIsolationRunnerTests(unittest.TestCase):
def test_podman_mode_separates_build_and_networkless_execution(self):
with tempfile.TemporaryDirectory() as temporary:
temp = pathlib.Path(temporary)
fake_bin = temp / "bin"
fake_bin.mkdir()
log = temp / "podman.log"
podman = fake_bin / "podman"
podman.write_text(
"#!/usr/bin/env bash\n"
"printf '%s\\0' \"$@\" >> \"$PODMAN_TEST_LOG\"\n"
"printf '\\n' >> \"$PODMAN_TEST_LOG\"\n"
"if [[ \" $* \" == *' cargo test '* ]]; then\n"
" printf '%s\\n' '{\"reason\":\"compiler-artifact\",\"profile\":{\"test\":true},\"executable\":\"/workspace/core/target/debug/archy-test\"}'\n"
"fi\n"
)
podman.chmod(0o700)
cargo_home = temp / "cargo"
env = os.environ.copy()
env.update(
{
"PATH": f"{fake_bin}:{env['PATH']}",
"PODMAN_TEST_LOG": str(log),
"ARCHY_TEST_ISOLATOR": "podman",
"ARCHY_TEST_REPO": str(ROOT),
"ARCHY_TEST_IMAGE": "example.invalid/ci@sha256:test",
"ARCHY_TEST_CARGO_HOME": str(cargo_home),
}
)
subprocess.run([str(RUNNER)], env=env, check=True)
invocations = [
line.replace("\0", " ") for line in log.read_text().splitlines()
]
self.assertEqual(len(invocations), 2)
self.assertIn("--network=pasta", invocations[0])
self.assertIn(f"{ROOT}:/workspace:rw,Z", invocations[0])
self.assertIn("--network=none", invocations[1])
self.assertIn(f"{ROOT}:/workspace:ro,Z", invocations[1])
self.assertIn("ARCHY_TEST_ISOLATED=1", invocations[1])
def test_unknown_isolator_fails_closed(self):
result = subprocess.run(
[str(RUNNER)],
env={**os.environ, "ARCHY_TEST_ISOLATOR": "unknown"},
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 2)
self.assertIn("must be systemd or podman", result.stderr)
if __name__ == "__main__":
unittest.main()