Compare commits

..
Author SHA1 Message Date
archipelago 0677924a64 Merge current main and make purchase filing atomic under concurrent writes 2026-09-30 07:26:51 -04:00
ssmithxandClaude Opus 5.5 33477f284b fix(files): file purchased content into FileBrowser folders again
Every paid download logged "filing into filebrowser/Music/... failed
(non-fatal): Permission denied". The purchase played in-app but never
appeared in Files. FileBrowser's folders belong to its rootless container
range (host uid 100000, mode 755). This service is host uid 1000, outside
that range, so it can read them but not create files in them.

New container::filebrowser::save_new_file:
- Writes directly when the folder allows it.
- Otherwise writes through `podman unshare`, where that uid range is
  ours: to a temp file, then chowned to the folder's owner, set to 0644,
  and hard-linked into place. FileBrowser never sees a partial file and an
  existing file is never replaced. A missing folder is created and given
  its parent's owner. No sudo.
- Keeps the "name (2).ext" de-duplication the RPC did inline.

Checked the unshare script on amishparadise in a scratch folder owned
like FileBrowser's: new folder + file OK, owner/mode right, no clobber,
no temp file left, and the service can read the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:36:31 +00:00
43 changed files with 935 additions and 1633 deletions
+18 -77
View File
@@ -54,13 +54,9 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
}) })
} }
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated /// File purchases through an atomic no-clobber write in Files' own namespace.
/// API rather than writing host paths with the backend's unrelated UID. Its
/// override=false upload atomically refuses existing names, including races.
async fn file_purchase_in_files( async fn file_purchase_in_files(
client: &reqwest::Client, data_dir: &std::path::Path,
base_url: &str,
token: &str,
filename: &str, filename: &str,
mime: &str, mime: &str,
bytes: &[u8], bytes: &[u8],
@@ -72,59 +68,24 @@ async fn file_purchase_in_files(
} else { } else {
"Documents" "Documents"
}; };
let mut folder_url = reqwest::Url::parse(base_url)?; let root = data_dir.join("filebrowser");
folder_url anyhow::ensure!(
.path_segments_mut() tokio::fs::metadata(&root).await?.is_dir(),
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))? "Files storage is unavailable"
.extend(["api", "resources", folder, ""]); );
let response = client let name = std::path::Path::new(filename)
.get(folder_url.clone())
.header("X-Auth", token)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
let response = client
.post(folder_url.clone())
.header("X-Auth", token)
.send()
.await?;
if response.status() != reqwest::StatusCode::CONFLICT {
response.error_for_status()?;
}
} else {
response.error_for_status()?;
}
let base = std::path::Path::new(filename)
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.filter(|n| !n.is_empty()) .filter(|n| !n.is_empty())
.unwrap_or("download"); .unwrap_or("download");
let (stem, extension) = match base.rsplit_once('.') { let path =
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")), crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
_ => (base, String::new()), Ok(format!(
}; "{folder}/{}",
for attempt in 1..=100 { path.file_name()
let name = if attempt == 1 { .and_then(|n| n.to_str())
base.to_string() .context("Invalid Files name")?
} else { ))
format!("{stem} ({attempt}){extension}")
};
let mut url = folder_url.clone();
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
url.query_pairs_mut().append_pair("override", "false");
let response = client
.post(url)
.header("X-Auth", token)
.body(bytes.to_vec())
.send()
.await?;
if response.status() == reqwest::StatusCode::CONFLICT {
continue;
}
response.error_for_status()?;
return Ok(format!("{folder}/{name}"));
}
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
} }
impl RpcHandler { impl RpcHandler {
@@ -728,28 +689,8 @@ impl RpcHandler {
// The durable purchased-content cache above is primary. A Files copy // The durable purchased-content cache above is primary. A Files copy
// remains optional: a stopped FileBrowser must not undo a paid download. // remains optional: a stopped FileBrowser must not undo a paid download.
let filed = async { let filed =
let auth = self.handle_filebrowser_token().await?; file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
let token = auth
.get("token")
.and_then(|v| v.as_str())
.context("FileBrowser omitted its authentication token")?;
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build()?;
file_purchase_in_files(
&client,
"http://127.0.0.1:8083",
token,
&filename,
&mime_type,
&bytes,
)
.await
}
.await;
match filed { match filed {
Ok(path) => tracing::info!("paid download: filed into Files/{path}"), Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
Err(error) => tracing::warn!( Err(error) => tracing::warn!(
+20 -128
View File
@@ -1,69 +1,4 @@
use super::*; use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server,
};
use std::{
collections::VecDeque,
convert::Infallible,
sync::{Arc, Mutex},
};
struct FilesApi {
url: String,
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for FilesApi {
fn drop(&mut self) {
self.task.abort();
}
}
fn files_api(statuses: Vec<u16>) -> FilesApi {
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
let seen = Arc::new(Mutex::new(Vec::new()));
let history = seen.clone();
let server = Server::bind(&([127, 0, 0, 1], 0).into());
let address = server.local_addr();
let service = make_service_fn(move |_| {
let statuses = statuses.clone();
let seen = history.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
let statuses = statuses.clone();
let seen = seen.clone();
async move {
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
let method = request.method().to_string();
let uri = request.uri().to_string();
let body = hyper::body::to_bytes(request.into_body())
.await
.unwrap()
.to_vec();
seen.lock().unwrap().push((method, uri, body));
let status = statuses
.lock()
.unwrap()
.pop_front()
.expect("unexpected extra Files request");
Ok::<_, Infallible>(
Response::builder()
.status(status)
.body(Body::empty())
.unwrap(),
)
}
}))
}
});
FilesApi {
url: format!("http://{address}"),
seen,
task: tokio::spawn(async move {
server.serve(service).await.unwrap();
}),
}
}
#[test] #[test]
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() { fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
@@ -85,80 +20,37 @@ fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
} }
#[tokio::test] #[tokio::test]
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() { async fn files_copy_routes_media_and_sanitizes_the_filename() {
let api = files_api(vec![200, 409, 200]); let dir = tempfile::tempdir().unwrap();
let client = reqwest::Client::new(); tokio::fs::create_dir(dir.path().join("filebrowser"))
let path = file_purchase_in_files(
&client,
&api.url,
"test-session",
"../my #file?.txt",
"text/plain",
b"paid bytes",
)
.await .await
.unwrap(); .unwrap();
assert_eq!(path, "Documents/my #file? (2).txt");
let seen = api.seen.lock().unwrap();
assert_eq!(seen[0].0, "GET");
assert_eq!(seen[0].1, "/api/resources/Documents/");
assert_eq!(seen.len(), 3);
for (_, uri, body) in &seen[1..] {
assert!(uri.contains("override=false"));
assert!(uri.contains("%23file%3F"));
assert!(!uri.contains("../"));
assert_eq!(body, b"paid bytes");
}
}
#[tokio::test]
async fn files_copy_creates_missing_media_folder() {
for (mime, folder) in [ for (mime, folder) in [
("image/png", "Photos"), ("image/png", "Photos"),
("video/mp4", "Photos"), ("video/mp4", "Photos"),
("audio/ogg", "Music"), ("audio/mpeg", "Music"),
("text/plain", "Documents"),
] { ] {
let api = files_api(vec![404, 200, 200]); let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
let path = file_purchase_in_files(
&reqwest::Client::new(),
&api.url,
"test-session",
"file",
mime,
b"bytes",
)
.await .await
.unwrap(); .unwrap();
assert_eq!(path, format!("{folder}/file")); assert!(relative.starts_with(&format!("{folder}/name #?")));
let seen = api.seen.lock().unwrap(); assert_eq!(
assert_eq!(seen[1].0, "POST"); tokio::fs::read(dir.path().join("filebrowser").join(relative))
assert!(seen[1].1.ends_with('/')); .await
assert!(seen[1].2.is_empty()); .unwrap(),
assert_eq!(seen[2].2, b"bytes"); b"paid"
);
} }
} }
#[tokio::test] #[tokio::test]
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() { async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
for statuses in [ let dir = tempfile::tempdir().unwrap();
vec![401], assert!(
vec![503], file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
vec![404, 500],
vec![200, 507],
vec![200, 403],
] {
let expected = statuses.len();
let api = files_api(statuses);
assert!(file_purchase_in_files(
&reqwest::Client::new(),
&api.url,
"test-session",
"file.txt",
"text/plain",
b"bytes"
)
.await .await
.is_err()); .is_err()
assert_eq!(api.seen.lock().unwrap().len(), expected); );
} assert!(!dir.path().join("filebrowser").exists());
} }
@@ -89,15 +89,6 @@ impl RpcHandler {
match handler.handle_package_install(params).await { match handler.handle_package_install(params).await {
Ok(_) => { Ok(_) => {
info!("package.install {}: complete", package_id_spawn); info!("package.install {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
.await;
}
crate::crash_recovery::mark_installed(
&handler.config.data_dir,
&package_id_spawn,
)
.await;
// The install pipeline has verified the container is up // The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check). // and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with // Kick the scanner first so the fresh manifest (with
@@ -193,9 +184,7 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase // phase is cleared (None) so no stale InstallPhase
// lingers on the card. // lingers on the card.
let err_msg = format!("Install failed: {:#}", e); let err_msg = format!("Install failed: {:#}", e);
handler let (mut data, _) = handler.state_manager.get_snapshot().await;
.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) { if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped; entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress { entry.install_progress = Some(crate::data_model::InstallProgress {
@@ -204,9 +193,8 @@ impl RpcHandler {
phase: None, phase: None,
message: Some(err_msg), message: Some(err_msg),
}); });
handler.state_manager.update_data(data).await;
} }
})
.await;
} }
} }
}); });
@@ -264,11 +252,6 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await { match handler.handle_package_uninstall(params).await {
Ok(_) => { Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn); info!("package.uninstall {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
.await;
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
}
// Inner handler already removed the package entry on // Inner handler already removed the package entry on
// success. Nothing more to do here. // success. Nothing more to do here.
} }
@@ -399,13 +382,11 @@ impl RpcHandler {
/// call, but fires before the spawn so the UI sees it immediately. /// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) { async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles}; use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry { .or_insert_with(|| PackageDataEntry {
ui_ready: None,
state: PackageState::Installing, state: PackageState::Installing,
health: None, health: None,
exit_code: None, exit_code: None,
@@ -445,10 +426,8 @@ async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
uninstall_stage: None, uninstall_stage: None,
available_update: None, available_update: None,
}); });
entry.ui_ready = Some(false);
entry.state = PackageState::Installing; entry.state = PackageState::Installing;
}) state_manager.update_data(data).await;
.await;
} }
/// True when the failed install still has a real footprint: any container /// True when the failed install still has a real footprint: any container
@@ -506,9 +485,7 @@ async fn remove_entry_with_notification(
id_prefix: &str, id_prefix: &str,
message: &str, message: &str,
) { ) {
handler let (mut data, _) = handler.state_manager.get_snapshot().await;
.state_manager
.mutate_data(|data| {
data.package_data.remove(package_id); data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification { data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"), id: format!("{id_prefix}-{package_id}"),
@@ -521,8 +498,7 @@ async fn remove_entry_with_notification(
while data.notifications.len() > 20 { while data.notifications.len() > 20 {
data.notifications.remove(0); data.notifications.remove(0);
} }
}) handler.state_manager.update_data(data).await;
.await;
} }
/// Flip an existing entry's state and return the pre-flip value (or None if /// Flip an existing entry's state and return the pre-flip value (or None if
@@ -532,14 +508,11 @@ async fn flip_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone()); let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state; entry.state = new_state;
state_manager.update_data(data).await;
} else { } else {
warn!( warn!(
"flip_package_state: no entry for {} — cannot flip", "flip_package_state: no entry for {} — cannot flip",
@@ -547,8 +520,6 @@ async fn flip_package_state(
); );
} }
prev prev
})
.await
} }
/// Set state unconditionally (no-op if entry no longer exists). /// Set state unconditionally (no-op if entry no longer exists).
@@ -557,18 +528,13 @@ async fn set_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state { if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state; entry.state = new_state;
state_manager.update_data(data).await;
} }
} }
})
.await
} }
/// Set state and clear the uninstall_stage label. Used when an uninstall /// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -579,17 +545,12 @@ async fn set_package_state_and_clear_uninstall_stage(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state; entry.state = new_state;
entry.uninstall_stage = None; entry.uninstall_stage = None;
state_manager.update_data(data).await;
} }
})
.await
} }
/// Kick the container scanner to run immediately and wait for it to finish /// Kick the container scanner to run immediately and wait for it to finish
+106 -7
View File
@@ -545,7 +545,7 @@ impl RpcHandler {
// Keep legacy install flow as default while migration is in progress. // Keep legacy install flow as default while migration is in progress.
if orchestrator_managed { if orchestrator_managed {
let orchestrator_app_id = orchestrator_install_app_id(package_id); let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::PreparingApp) self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await; .await;
install_log(&format!( install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}", "INSTALL ORCH: {} — attempting orchestrator install as {}",
@@ -2053,8 +2053,25 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
} }
async fn cleanup_stale_package_ports(package_id: &str) { async fn cleanup_stale_package_ports(package_id: &str) {
// Never kill by port: another app or the management gate may own it. match package_id {
crate::container::ghost_reaper::reap_for_app(package_id).await; "grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"portainer" => cleanup_stale_pasta_port("9000").await,
_ => {}
}
} }
fn install_command_tail( fn install_command_tail(
@@ -2179,11 +2196,93 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
return true; return true;
} }
if stderr.contains("pasta failed") || stderr.contains("address already in use") { match package_id {
crate::container::ghost_reaper::reap_for_app(package_id).await; "grafana"
return true; if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3000").await;
true
} }
false "homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8888").await;
true
}
"uptime-kuma"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3002").await;
true
}
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
"portainer"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("9000").await;
true
}
_ => false,
}
}
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on a test node 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
async fn repair_nextcloud_permissions() { async fn repair_nextcloud_permissions() {
@@ -14,13 +14,11 @@ impl RpcHandler {
/// the rare case where the pull stream actually parses, but podman /// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr. /// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) { pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
self.state_manager let (mut data, _rev) = self.state_manager.get_snapshot().await;
.mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id)); .or_insert_with(|| create_installing_entry(package_id));
entry.ui_ready = Some(false);
entry.state = PackageState::Installing; entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase); let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress { entry.install_progress = Some(InstallProgress {
@@ -29,8 +27,7 @@ impl RpcHandler {
phase: existing_phase, phase: existing_phase,
message: None, message: None,
}); });
}) self.state_manager.update_data(data).await;
.await;
} }
/// Set the install pipeline phase and broadcast. This is the /// Set the install pipeline phase and broadcast. This is the
@@ -38,8 +35,7 @@ impl RpcHandler {
/// percentage and a user-facing label. Byte counters are retained /// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress. /// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) { pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
self.state_manager let (mut data, _rev) = self.state_manager.get_snapshot().await;
.mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
@@ -49,7 +45,6 @@ impl RpcHandler {
// Updates use Updating state — the wrapper has already flipped // Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it. // state to Updating, so don't clobber it.
if entry.state != PackageState::Updating { if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing; entry.state = PackageState::Installing;
} }
let (size, downloaded) = entry let (size, downloaded) = entry
@@ -63,21 +58,18 @@ impl RpcHandler {
phase: Some(phase), phase: Some(phase),
message: None, message: None,
}); });
}) self.state_manager.update_data(data).await;
.await;
} }
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin /// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters. /// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) { pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
self.state_manager let (mut data, _rev) = self.state_manager.get_snapshot().await;
.mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id)); .or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating { if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing; entry.state = PackageState::Installing;
} }
let (size, downloaded, phase) = entry let (size, downloaded, phase) = entry
@@ -91,33 +83,28 @@ impl RpcHandler {
phase, phase,
message: Some(message.to_string()), message: Some(message.to_string()),
}); });
}) self.state_manager.update_data(data).await;
.await;
} }
/// Clear install progress after pull completes or fails. /// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) { pub(super) async fn clear_install_progress(&self, package_id: &str) {
self.state_manager let (mut data, _rev) = self.state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None; entry.install_progress = None;
} }
}) self.state_manager.update_data(data).await;
.await;
} }
/// Set the uninstall stage label so the UI can show what's happening /// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change /// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container). /// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) { pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
self.state_manager let (mut data, _rev) = self.state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string()); entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing; entry.state = crate::data_model::PackageState::Removing;
} }
}) self.state_manager.update_data(data).await;
.await;
} }
/// Update install progress (static method for use in async closures). /// Update install progress (static method for use in async closures).
@@ -127,8 +114,7 @@ impl RpcHandler {
downloaded: u64, downloaded: u64,
total: u64, total: u64,
) { ) {
state_manager let (mut data, _rev) = state_manager.get_snapshot().await;
.mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
@@ -140,15 +126,13 @@ impl RpcHandler {
phase: existing_phase, phase: existing_phase,
message: None, message: None,
}); });
}) state_manager.update_data(data).await;
.await;
} }
} }
/// Create a minimal PackageDataEntry for a package being installed. /// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry { fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Installing, state: PackageState::Installing,
health: None, health: None,
exit_code: None, exit_code: None,
+54 -36
View File
@@ -1431,9 +1431,10 @@ async fn repair_before_package_start(container_name: &str) {
// published port and the data-dir file locks, so the replacement either // published port and the data-dir file locks, so the replacement either
// fails to bind (`address already in use`) or starts and dies on the // fails to bind (`address already in use`) or starts and dies on the
// lock — and `Restart=always` loops it there forever. Ordered before // lock — and `Restart=always` loops it there forever. Ordered before
// starting the replacement. A port sweep cannot distinguish a ghost // the port cleanup below: killing the owner is what actually frees the
// from the dashboard gate or another live app and must never kill it. // port, and the port sweep alone cannot tell a ghost from a live app.
crate::container::ghost_reaper::reap_for_app(container_name).await; crate::container::ghost_reaper::reap_for_app(container_name).await;
cleanup_runtime_host_ports(container_name).await;
} }
async fn wait_before_package_start(container_name: &str) { async fn wait_before_package_start(container_name: &str) {
@@ -1578,6 +1579,7 @@ async fn repair_netbird_network() {
async fn repair_nginx_proxy_manager_container() { async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await; repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await { if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return; return;
} }
@@ -1586,7 +1588,7 @@ async fn repair_nginx_proxy_manager_container() {
) )
.await; .await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await; let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await; cleanup_nginx_proxy_manager_ports().await;
if let Err(err) = recreate_nginx_proxy_manager_container().await { if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container"); tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
} }
@@ -1810,9 +1812,6 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> { pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
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"));
} }
@@ -2033,10 +2032,51 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return; return;
} }
// Only reap processes proven to belong to an absent container. The app let ports = runtime_host_ports(container_name);
// gate shares the app's port on other addresses and lives in this daemon; if !ports.is_empty() {
// killing port owners (or matching argv with pkill) kills the dashboard. cleanup_ports(&ports).await;
crate::container::ghost_reaper::reap_for_app(container_name).await; return;
}
}
async fn cleanup_runtime_host_ports(container_name: &str) {
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_ports(&[8081, 8084, 8444]).await;
}
async fn cleanup_ports(ports: &[u16]) {
for port in ports {
cleanup_stale_pasta_port(&port.to_string()).await;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool { pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
@@ -2055,16 +2095,13 @@ async fn flip_package_state(
package_id: &str, package_id: &str,
transitional: PackageState, transitional: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone()); let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.ui_ready = Some(false);
entry.state = transitional; entry.state = transitional;
state_manager.update_data(data).await;
} }
prev prev
})
.await
} }
/// Write the package entry's final state. No-op if the entry has since /// Write the package entry's final state. No-op if the entry has since
@@ -2074,18 +2111,13 @@ async fn set_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state { if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state; entry.state = new_state;
state_manager.update_data(data).await;
} }
} }
})
.await
} }
pub(super) async fn reconcile_companions_for(package_id: &str) { pub(super) async fn reconcile_companions_for(package_id: &str) {
@@ -2153,20 +2185,6 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn port_conflict_cleanup_preserves_live_host_listener() {
// The previous ss|kill sweep terminated the daemon's app gate on a
// restart. Keep a real listening socket owned by this test process.
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
.await
.unwrap();
let addr = listener.local_addr().unwrap();
cleanup_start_conflict("photoprism", "address already in use").await;
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
let _connection = listener.accept().await.unwrap();
drop(client);
}
#[test] #[test]
fn missing_container_classifier_covers_podman5_phrasings() { fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing // Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
+4 -12
View File
@@ -150,31 +150,23 @@ async fn flip_to_transitional(
app_id: &str, app_id: &str,
transitional: PackageState, transitional: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
let prev = data.package_data.get(app_id).map(|e| e.state.clone()); let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) { if let Some(entry) = data.package_data.get_mut(app_id) {
entry.ui_ready = Some(false);
entry.state = transitional; entry.state = transitional;
state_manager.update_data(data).await;
} }
prev prev
})
.await
} }
/// Set the entry's state to `new_state`. No-ops if the entry has since been /// Set the entry's state to `new_state`. No-ops if the entry has since been
/// removed (e.g. uninstall ran concurrently). /// removed (e.g. uninstall ran concurrently).
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) { async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
state_manager let (mut data, _) = state_manager.get_snapshot().await;
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(app_id) { if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state { if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state; entry.state = new_state;
state_manager.update_data(data).await;
} }
} }
})
.await
} }
-3
View File
@@ -114,9 +114,6 @@ impl PortMap {
/// there. /// there.
fn apps_dirs() -> Vec<PathBuf> { fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps")); dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
} }
+3 -48
View File
@@ -144,34 +144,6 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
.clone() .clone()
} }
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
std::sync::LazyLock::new(tokio::sync::Notify::new);
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
/// Installation must not wait for the minute sweep before becoming reachable.
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
pub async fn refresh_now() {
let mut completed = REFRESH_REV.subscribe();
REFRESH_KICK.notify_one();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
}
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
let mut external = false;
let mut tor = false;
for (claimed_port, address) in &status.claimed {
if *claimed_port != port {
continue;
}
if let Ok(ip) = address.parse::<IpAddr>() {
tor |= ip == GATE_TOR_UPSTREAM;
external |= !ip.is_loopback();
}
}
external && tor
}
/// Run the gate. Returns only on shutdown. /// Run the gate. Returns only on shutdown.
pub async fn run( pub async fn run(
gate: Arc<AppGate>, gate: Arc<AppGate>,
@@ -190,12 +162,11 @@ pub async fn run(
loop { loop {
tokio::select! { tokio::select! {
_ = interval.tick() => {} _ = interval.tick() => {
_ = REFRESH_KICK.notified() => {} sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = shutdown_rx.changed() => return, _ = shutdown_rx.changed() => return,
} }
sweep(&gate, &status, &mut held, &shutdown_rx).await;
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
} }
} }
@@ -490,19 +461,3 @@ mod tests {
assert!(!status.is_fully_enforced()); assert!(!status.is_fully_enforced());
} }
} }
#[cfg(test)]
mod readiness_tests {
use super::*;
#[test]
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
let mut status = GateStatus::default();
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "127.0.0.2".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3002, "192.0.2.10".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "192.0.2.10".into()));
assert!(port_claimed(&status, 3001));
}
}
-1
View File
@@ -322,7 +322,6 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry { fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles}; use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Running, state: PackageState::Running,
health: None, health: None,
exit_code: None, exit_code: None,
-1
View File
@@ -1069,7 +1069,6 @@ mod tests {
Description, Manifest, PackageDataEntry, PackageState, StaticFiles, Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
}; };
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Running, state: PackageState::Running,
health: None, health: None,
exit_code: None, exit_code: None,
@@ -3,9 +3,8 @@
use anyhow::Result; use anyhow::Result;
use archipelago_container::{ use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient, ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
}; };
use futures_util::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tracing::{debug, info}; use tracing::{debug, info};
@@ -26,15 +25,8 @@ impl DockerPackageScanner {
} }
/// Scan Docker containers and convert to package data /// Scan Docker containers and convert to package data
pub async fn scan_containers( pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
&self, let containers = self.runtime.list_containers().await?;
data_dir: &std::path::Path,
cached: &HashMap<String, PackageDataEntry>,
) -> Result<HashMap<String, PackageDataEntry>> {
let mut containers = self.runtime.list_containers().await?;
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
restore_absent_installed(&mut containers, &installed, &uninstalled);
debug!("Found {} containers", containers.len()); debug!("Found {} containers", containers.len());
@@ -147,18 +139,6 @@ impl DockerPackageScanner {
continue; continue;
} }
if container.id.is_empty() {
if let Some(previous) = cached.get(&app_id) {
let mut held = previous.clone();
held.state = PackageState::Stopped;
held.ui_ready = Some(false);
held.health = None;
held.exit_code = None;
packages.insert(app_id.clone(), held);
continue;
}
}
// Get metadata for this app // Get metadata for this app
let metadata = get_app_metadata(&app_id); let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the // Manifest-owned metadata (icon) wins over the static table: the
@@ -199,22 +179,14 @@ impl DockerPackageScanner {
let tor_address = read_tor_address(&app_id).await; let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag // Extract actual version from container image tag
let running_version = if container.id.is_empty() { let running_version = image_versions::extract_version_from_image(&container.image);
String::new() // Absence cannot establish the installed image version.
} else {
image_versions::extract_version_from_image(&container.image)
};
// Decoupled from the binary OTA: prefer the remote app catalog, // Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline. // falling back to the image-versions.sh pin when uncovered/offline.
let available_update = if container.id.is_empty() { let available_update =
None crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
} else {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
};
let package = PackageDataEntry { let package = PackageDataEntry {
ui_ready: Some(false),
state: package_state.clone(), state: package_state.clone(),
health: container.health.clone(), health: container.health.clone(),
exit_code: if package_state == PackageState::Exited { exit_code: if package_state == PackageState::Exited {
@@ -311,215 +283,10 @@ impl DockerPackageScanner {
); );
} }
let probes: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.state != PackageState::Running {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
Some((id.clone(), url))
})
.collect();
let mut results = futures_util::stream::iter(
probes
.into_iter()
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
)
.buffer_unordered(8);
while let Some((id, ready)) = results.next().await {
if let Some(pkg) = packages.get_mut(&id) {
pkg.ui_ready = Some(ready);
}
}
// HTTP on loopback can precede the LAN/Tor listener after install.
let port_map = crate::appgate::identity::build_port_map();
let gated: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.ui_ready != Some(true) {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.as_deref()?;
let port = launch_url_port(url)?;
port_map
.gated(port)
.filter(|gate| gate.declared)
.map(|_| (id.clone(), port))
})
.collect();
if !gated.is_empty() {
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
let status = shared_status();
let needs_refresh = {
let current = status.read().await;
gated.iter().any(|(_, port)| !port_claimed(&current, *port))
};
if needs_refresh {
refresh_now().await;
}
let current = status.read().await;
for (id, port) in gated {
if !port_claimed(&current, port) {
packages.get_mut(&id).unwrap().ui_ready = Some(false);
}
}
}
Ok(packages) Ok(packages)
} }
} }
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
fn restore_absent_installed(
containers: &mut Vec<ContainerStatus>,
installed: &std::collections::HashSet<String>,
uninstalled: &std::collections::HashSet<String>,
) {
fn canonical(name: &str) -> &str {
let name = name.strip_prefix("archy-").unwrap_or(name);
match name {
"immich_server" => "immich",
_ => name,
}
}
let mut present: std::collections::HashSet<String> = containers
.iter()
.map(|c| canonical(&c.name).to_owned())
.collect();
let removed: std::collections::HashSet<_> =
uninstalled.iter().map(|id| canonical(id)).collect();
for name in installed {
let id = canonical(name);
if removed.contains(id) || !present.insert(id.to_owned()) {
continue;
}
containers.push(ContainerStatus {
id: String::new(),
name: id.to_owned(),
state: ContainerState::Stopped,
health: None,
exit_code: None,
started_at: None,
image: String::new(),
created: String::new(),
ports: Vec::new(),
lan_address: None,
});
}
}
/// Probe the actual loopback upstream, not the app gate's login page. A bound
/// TCP socket alone can still reset requests or serve a startup 503.
async fn launch_http_ready(candidate: &str) -> bool {
let Ok(mut url) = reqwest::Url::parse(candidate) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if url.set_host(Some("127.0.0.1")).is_err() {
return false;
}
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
// Self-signed local app certificates are normal. This client only
// contacts loopback and never sends credentials or follows redirects.
.danger_accept_invalid_certs(true)
.build()
.expect("local readiness client")
});
match client.get(url).send().await {
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
Err(_) => false,
}
}
#[cfg(test)]
mod lifecycle_regression_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
.into_iter()
.map(str::to_owned)
.collect();
let removed = ["removed".to_owned()].into_iter().collect();
let mut containers = Vec::new();
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert!(containers
.iter()
.all(|c| c.state == ContainerState::Stopped));
containers[0].state = ContainerState::Running;
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert_eq!(containers[0].state, ContainerState::Running);
}
#[tokio::test]
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
for (status, expected) in [
(200, true),
(302, true),
(401, true),
(403, true),
(404, false),
(500, false),
(502, false),
(503, false),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
let n = stream.read(&mut buf).await.unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
});
assert_eq!(
launch_http_ready(&format!("http://localhost:{port}/start")).await,
expected,
"status {status}"
);
task.await.unwrap();
}
}
#[tokio::test]
async fn readiness_rejects_tcp_accept_without_http() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
task.await.unwrap();
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
assert!(!launch_http_ready("file:///tmp/test").await);
}
}
struct AppMetadata { struct AppMetadata {
title: String, title: String,
description: String, description: String,
+420 -1
View File
@@ -5,7 +5,7 @@
//! starting the container with `--config /data/.filebrowser.json`. //! starting the container with `--config /data/.filebrowser.json`.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use tokio::fs; use tokio::fs;
use crate::update::host_sudo; use crate::update::host_sudo;
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''") s.replace('\'', "'\\''")
} }
/// Save a complete purchase without overwriting any existing directory entry.
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
save_new_file_with(dir, name, bytes, write_via_userns).await
}
fn validate_filename(name: &str) -> Result<()> {
anyhow::ensure!(
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains(['/', '\\', '\0'])
&& name.len() <= 255,
"Invalid purchased filename"
);
Ok(())
}
async fn save_new_file_with<F, Fut>(
dir: &Path,
name: &str,
bytes: &[u8],
fallback: F,
) -> Result<PathBuf>
where
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<PathBuf>>,
{
validate_filename(name)?;
// Never follow a user-created destination directory symlink.
match fs::symlink_metadata(dir).await {
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
save_after_direct_result(
write_direct(dir, name, bytes).await,
dir,
name,
bytes,
fallback,
)
.await
}
async fn save_after_direct_result<F, Fut>(
result: std::io::Result<PathBuf>,
dir: &Path,
name: &str,
bytes: &[u8],
fallback: F,
) -> Result<PathBuf>
where
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<PathBuf>>,
{
match result {
Ok(path) => Ok(path),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
.await
.context("Saving purchase in Files user namespace")
}
Err(error) => Err(error).context("Saving purchase in Files"),
}
}
fn numbered_name(name: &str, attempt: usize) -> String {
if attempt == 1 {
return name.to_owned();
}
match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
_ => format!("{name} ({attempt})"),
}
}
struct PendingFile(PathBuf);
impl Drop for PendingFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
use tokio::io::AsyncWriteExt;
fs::create_dir_all(dir).await?;
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_path)
.await?;
let temp = PendingFile(temp_path);
file.write_all(bytes).await?;
file.set_permissions(std::fs::Permissions::from_mode(0o644))
.await?;
file.sync_all().await?;
for attempt in 1..=100 {
let target = dir.join(numbered_name(name, attempt));
match fs::hard_link(&temp.0, &target).await {
Ok(()) => return Ok(target),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Too many existing copies; purchase cache retained",
))
}
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
// collisions; ln -T refuses files, symlinks and directories, including races.
const WRITE_VIA_USERNS: &str = r#"set -eu
dir=$1
name=$2
expected=$3
[ ! -L "$dir" ] || exit 1
if [ ! -d "$dir" ]; then
mkdir -p -- "$dir"
chown --reference="$(dirname -- "$dir")" -- "$dir"
fi
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
cat > "$tmp"
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
chown --reference="$dir" -- "$tmp"
chmod 0644 -- "$tmp"
sync -f -- "$tmp"
stem=$name
ext=
case "$name" in
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
esac
n=1
while [ "$n" -le 100 ]; do
candidate=$name
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
dst="$dir/$candidate"
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
printf '%s' "$candidate"
exit 0
fi
# A conflict may be a dangling symlink; never follow it or overwrite it.
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
n=$((n + 1))
done
exit 1
"#;
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
use tokio::io::AsyncWriteExt;
let mut child = tokio::process::Command::new("podman")
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
.arg(&dir)
.arg(&name)
.arg(bytes.len().to_string())
.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Starting Files namespace writer")?;
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
let operation = async {
let fed = stdin.write_all(&bytes).await;
drop(stdin);
let output = child.wait_with_output().await?;
anyhow::ensure!(
output.status.success(),
"Files namespace writer failed: {}",
output.status
);
fed.context("Sending purchase bytes to Files")?;
let chosen =
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
validate_filename(&chosen)?;
anyhow::ensure!(
(1..=100).any(|n| numbered_name(&name, n) == chosen),
"Files writer returned an unexpected name"
);
Ok(dir.join(chosen))
};
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
.await
.context("Files namespace writer timed out")?
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -152,3 +343,231 @@ mod tests {
assert_eq!(second, EnsureOutcome::Unchanged); assert_eq!(second, EnsureOutcome::Unchanged);
} }
} }
#[cfg(test)]
mod purchase_write_tests {
use super::*;
use std::{
collections::HashSet,
os::unix::fs::{symlink, PermissionsExt},
};
fn no_temps(dir: &Path) {
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".archy-saving")));
}
#[tokio::test]
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("song.mp3"), b"original")
.await
.unwrap();
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
assert_eq!(fs::read(target).await.unwrap(), b"new");
assert_eq!(
fs::read(dir.path().join("song.mp3")).await.unwrap(),
b"original"
);
no_temps(dir.path());
}
#[tokio::test]
async fn simultaneous_saves_publish_unique_complete_files() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for n in 0..24u8 {
let dir = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
let bytes = vec![n; 32768];
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
assert_eq!(fs::read(&path).await.unwrap(), bytes);
path
}));
}
let mut paths = HashSet::new();
for task in tasks {
assert!(paths.insert(task.await.unwrap()));
}
assert_eq!(paths.len(), 24);
no_temps(dir.path());
}
#[tokio::test]
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("name")).await.unwrap();
symlink("missing", dir.path().join("name (2)")).unwrap();
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
assert_eq!(path.file_name().unwrap(), "name (3)");
assert!(dir.path().join("name").is_dir());
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
.await
.unwrap()
.is_symlink());
no_temps(dir.path());
}
#[tokio::test]
async fn invalid_names_and_symlink_destination_are_refused() {
let dir = tempfile::tempdir().unwrap();
for name in [
"",
".",
"..",
"../escape",
"/absolute",
"a/b",
"a\\b",
"a\0b",
] {
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
}
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), dir.path().join("Music")).unwrap();
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
.await
.is_err());
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
let dir = tempfile::tempdir().unwrap();
for n in 1..=100 {
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
.await
.unwrap();
}
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
for n in 1..=100 {
assert_eq!(
fs::read(dir.path().join(numbered_name("a.txt", n)))
.await
.unwrap(),
b"keep"
);
}
no_temps(dir.path());
}
#[tokio::test]
async fn permission_fallback_is_exercised_without_skipping_as_root() {
let dir = tempfile::tempdir().unwrap();
let result = save_after_direct_result(
Err(std::io::ErrorKind::PermissionDenied.into()),
dir.path(),
"a",
b"abc",
|dir, name, bytes| async move {
assert_eq!(bytes, b"abc");
Ok(dir.join(name))
},
)
.await
.unwrap();
assert_eq!(result, dir.path().join("a"));
assert!(save_after_direct_result(
Err(std::io::ErrorKind::PermissionDenied.into()),
dir.path(),
"a",
b"abc",
|_, _, _| async { anyhow::bail!("namespace unavailable") }
)
.await
.unwrap_err()
.to_string()
.contains("namespace"));
assert!(save_after_direct_result(
Err(std::io::ErrorKind::StorageFull.into()),
dir.path(),
"a",
b"abc",
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
)
.await
.is_err());
}
async fn run_script(
dir: &Path,
name: &str,
bytes: &[u8],
expected: usize,
) -> std::process::Output {
use tokio::io::AsyncWriteExt;
let mut child = tokio::process::Command::new("sh")
.args(["-c", WRITE_VIA_USERNS, "sh"])
.arg(dir)
.arg(name)
.arg(expected.to_string())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
input.write_all(bytes).await.unwrap();
drop(input);
child.wait_with_output().await.unwrap()
}
#[tokio::test]
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Music");
let name = "song ' $() ; #.mp3";
for n in 1..=2 {
let output = run_script(&folder, name, b"abc", 3).await;
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let chosen = String::from_utf8(output.stdout).unwrap();
assert_eq!(chosen, numbered_name(name, n));
let path = folder.join(chosen);
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
assert_eq!(
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
0o644
);
}
no_temps(&folder);
}
#[tokio::test]
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
let dir = tempfile::tempdir().unwrap();
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
assert!(!output.status.success());
assert!(!dir.path().join("never.bin").exists());
no_temps(dir.path());
}
#[tokio::test]
async fn namespace_script_does_not_link_inside_existing_directory() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("name")).await.unwrap();
symlink("missing", dir.path().join("name (2)")).unwrap();
let output = run_script(dir.path(), "name", b"abc", 3).await;
assert!(output.status.success());
assert_eq!(output.stdout, b"name (3)");
assert_eq!(
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
0
);
no_temps(dir.path());
}
#[test]
fn names_keep_extensions_and_dotfiles() {
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
assert_eq!(numbered_name("README", 2), "README (2)");
}
}
@@ -3483,9 +3483,11 @@ impl ProdContainerOrchestrator {
} }
async fn cleanup_stale_grafana_port(&self) { async fn cleanup_stale_grafana_port(&self) {
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a let _ = tokio::process::Command::new("pkill")
// Grafana container proven absent from Podman's inventory. .args(["-f", "pasta.*3001"])
crate::container::ghost_reaper::reap_for_app("grafana").await; .output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
async fn detect_host_facts(&self) -> HostFacts { async fn detect_host_facts(&self) -> HostFacts {
+1 -38
View File
@@ -194,7 +194,6 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
// Installation is a decision, not a runtime observation, so it gets a record // Installation is a decision, not a runtime observation, so it gets a record
// of its own that no amount of downtime erodes. // of its own that no amount of downtime erodes.
const INSTALLED_APPS_FILE: &str = "installed-apps.json"; const INSTALLED_APPS_FILE: &str = "installed-apps.json";
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Load the durable set of installed app ids / container names. /// Load the durable set of installed app ids / container names.
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> { pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
@@ -221,23 +220,12 @@ pub async fn load_installed_apps_if_recorded(
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) { async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE); let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) { if let Ok(json) = serde_json::to_string_pretty(installed) {
let tmp = path.with_extension("json.tmp"); let _ = fs::write(&path, json).await;
let result = async {
fs::write(&tmp, json).await?;
fs::File::open(&tmp).await?.sync_all().await?;
fs::rename(&tmp, &path).await?;
fs::File::open(data_dir).await?.sync_all().await
}
.await;
if let Err(error) = result {
warn!(%error, "could not persist installed apps");
}
} }
} }
/// Record that an app is installed. Called when an install succeeds. /// Record that an app is installed. Called when an install succeeds.
pub async fn mark_installed(data_dir: &Path, name: &str) { pub async fn mark_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await; let mut installed = load_installed_apps(data_dir).await;
if installed.insert(name.to_string()) { if installed.insert(name.to_string()) {
save_installed_apps(data_dir, &installed).await; save_installed_apps(data_dir, &installed).await;
@@ -247,7 +235,6 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the /// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
/// two must move together or a reinstall-after-uninstall leaves a stale claim. /// two must move together or a reinstall-after-uninstall leaves a stale claim.
pub async fn clear_installed(data_dir: &Path, name: &str) { pub async fn clear_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await; let mut installed = load_installed_apps(data_dir).await;
if installed.remove(name) { if installed.remove(name) {
save_installed_apps(data_dir, &installed).await; save_installed_apps(data_dir, &installed).await;
@@ -265,7 +252,6 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
/// need it. Runs on every boot, so an app installed before the upgrade is /// need it. Runs on every boot, so an app installed before the upgrade is
/// still picked up whenever it is next seen alive. /// still picked up whenever it is next seen alive.
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) { pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
if present_container_names.is_empty() { if present_container_names.is_empty() {
return; return;
} }
@@ -1511,26 +1497,3 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod installed_concurrency_tests {
use super::*;
#[tokio::test]
async fn concurrent_install_records_are_not_lost() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for i in 0..24 {
let path = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
mark_installed(&path, &format!("app-{i}")).await;
}));
}
for task in tasks {
task.await.unwrap();
}
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
clear_installed(dir.path(), "app-3").await;
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
assert!(!dir.path().join("installed-apps.json.tmp").exists());
}
}
-6
View File
@@ -146,10 +146,6 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry { pub struct PackageDataEntry {
/// Whether the app's HTTP upstream answered this scan (independent of
/// container health and blockchain sync). Missing on older nodes.
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
pub ui_ready: Option<bool>,
pub state: PackageState, pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null /// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -301,8 +297,6 @@ pub enum InstallPhase {
/// `podman pull` in progress (the longest phase — up to several /// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks). /// minutes for large images on slow networks).
PullingImage, PullingImage,
/// Orchestrator owns download/build and startup as one operation.
PreparingApp,
/// Creating data directories, writing app-specific configs /// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown). /// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer, CreatingContainer,
+12 -80
View File
@@ -1765,17 +1765,12 @@ fn merge_preserving_transitional(
}; };
crate::data_model::PackageDataEntry { crate::data_model::PackageDataEntry {
state: state.clone(), state,
// install_progress and uninstall_stage are also owned by the // install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them. // initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(), install_progress: existing.install_progress.clone(),
uninstall_stage: existing.uninstall_stage.clone(), uninstall_stage: existing.uninstall_stage.clone(),
// Everything else comes from the fresh scan. // Everything else comes from the fresh scan.
ui_ready: if state == crate::data_model::PackageState::Running {
fresh.ui_ready
} else {
Some(false)
},
health: fresh.health.clone(), health: fresh.health.clone(),
exit_code: fresh.exit_code, exit_code: fresh.exit_code,
static_files: fresh.static_files.clone(), static_files: fresh.static_files.clone(),
@@ -1814,10 +1809,7 @@ async fn scan_and_update_packages(
absence_tracker: &mut HashMap<String, u32>, absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>, transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> { ) -> Result<()> {
let (before_scan, _) = state.get_snapshot().await; let mut packages = scanner.scan_containers().await?;
let mut packages = scanner
.scan_containers(data_dir, &before_scan.package_data)
.await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await; let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() { for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) { if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
@@ -1878,14 +1870,11 @@ async fn scan_and_update_packages(
// once at load ~2). Better to keep saying "scanning…" than to say "empty". // once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) { if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed { if tor_changed || update_changed {
state let mut data = current_data;
.mutate_data(|data| {
data.server_info.tor_address = tor_addr.clone(); data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available; data.server_info.status_info.updated = update_available;
}) state.update_data(data).await;
.await;
} }
return Ok(()); return Ok(());
} }
@@ -1910,13 +1899,6 @@ async fn scan_and_update_packages(
// died without cleanup and let the scan override it. // died without cleanup and let the scan override it.
let now = Instant::now(); let now = Instant::now();
for (id, pkg) in &packages { for (id, pkg) in &packages {
if user_uninstalled.contains(id)
|| user_uninstalled.contains(&format!("archy-{id}"))
|| (before_scan.package_data.contains_key(id)
&& !current_data.package_data.contains_key(id))
{
continue;
}
absence_tracker.remove(id); absence_tracker.remove(id);
let existing = merged.get(id); let existing = merged.get(id);
let overwrite = match existing { let overwrite = match existing {
@@ -2072,40 +2054,22 @@ async fn scan_and_update_packages(
} }
if changed || tor_changed || first_scan || update_changed { if changed || tor_changed || first_scan || update_changed {
state let mut data = current_data;
.mutate_data(|data| { data.package_data = merged;
// A lifecycle operation may have started/finished while this scan
// awaited probes or disk I/O. Never overwrite that newer entry or
// resurrect one that an uninstall removed in the meantime.
apply_scanned_packages(&mut data.package_data, &current_data.package_data, &merged);
data.server_info.tor_address = tor_addr.clone(); data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t)); data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true; data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available; data.server_info.status_info.updated = update_available;
}) state.update_data(data).await;
.await; debug!(
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
changed, tor_changed, first_scan, update_changed
);
} }
Ok(()) Ok(())
} }
fn apply_scanned_packages(
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
base: &HashMap<String, crate::data_model::PackageDataEntry>,
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, fresh) in scanned {
if latest.get(id) == base.get(id) {
latest.insert(id.clone(), fresh.clone());
}
}
for id in base.keys() {
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
latest.remove(id);
}
}
}
async fn normalize_reachable_package_health( async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>, packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) { ) {
@@ -2304,7 +2268,6 @@ mod merge_tests {
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry { fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state, state,
health: health.map(|s| s.to_string()), health: health.map(|s| s.to_string()),
exit_code: None, exit_code: None,
@@ -2317,37 +2280,6 @@ mod merge_tests {
} }
} }
#[test]
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
let running = make_entry(PackageState::Running, Some("healthy"));
let restarting = make_entry(PackageState::Restarting, None);
let base = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
let mut latest = [
("restart".into(), restarting.clone()),
("new".into(), running.clone()),
]
.into_iter()
.collect();
let scanned = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
apply_scanned_packages(&mut latest, &base, &scanned);
assert_eq!(latest.get("restart"), Some(&restarting));
assert_eq!(latest.get("new"), Some(&running));
assert!(!latest.contains_key("uninstalled"));
apply_scanned_packages(&mut latest, &base, &HashMap::new());
assert_eq!(latest.get("restart"), Some(&restarting));
assert!(latest.contains_key("new"));
}
#[test] #[test]
fn peer_path_filter_allows_content_catalog_and_items() { fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing // Regression: the content *catalog* is exactly "/content" (no trailing
-41
View File
@@ -54,21 +54,6 @@ impl StateManager {
let _ = self.broadcast_tx.send(message); let _ = self.broadcast_tx.send(message);
} }
/// Apply a small state change while holding the write lock. A lifecycle
/// task must not replace the entire model from an earlier snapshot.
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
let mut data = self.data.write().await;
let result = change(&mut data);
let mut rev = self.revision.write().await;
*rev += 1;
let _ = self.broadcast_tx.send(WebSocketMessage {
rev: *rev,
data: Some(data.clone()),
patch: None,
});
result
}
/// Get a WebSocket message with the current state /// Get a WebSocket message with the current state
pub async fn get_initial_message(&self) -> WebSocketMessage { pub async fn get_initial_message(&self) -> WebSocketMessage {
let (data, rev) = self.get_snapshot().await; let (data, rev) = self.get_snapshot().await;
@@ -205,29 +190,3 @@ mod tests {
assert_eq!(rev, 1); assert_eq!(rev, 1);
} }
} }
#[cfg(test)]
mod atomic_mutation_tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_preserve_independent_entries() {
let state = Arc::new(StateManager::new());
let mut tasks = Vec::new();
for i in 0..24 {
let state = state.clone();
tasks.push(tokio::spawn(async move {
state
.mutate_data(|data| {
data.peer_health.insert(format!("peer-{i}"), true);
})
.await;
}));
}
for task in tasks {
task.await.unwrap();
}
let (data, revision) = state.get_snapshot().await;
assert_eq!(data.peer_health.len(), 24);
assert_eq!(revision, 24);
}
}
+3 -5
View File
@@ -16,11 +16,9 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces. GRASP, repository browser, issue, pull-request, or review interfaces.
The separate dependency patch refreshes the npm lockfile and moves `fflate` to The separate dependency patch refreshes the npm lockfile and moves `fflate` to
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. On 2026-09-30 the lockfile was refreshed again for `brace-expansion` 0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node install reports zero npm advisories; its type-check, 152 unit tests, and
installs failed the retained dependency audit. The resulting clean install Archipelago subpath production build pass. Keeping this mechanical security
reports zero npm advisories; its type-check, 152 unit tests, and Archipelago
subpath production build pass. The complete image also builds on the X250. Keeping this mechanical security
update separate makes both the upstream integration and future dependency update separate makes both the upstream integration and future dependency
refreshes auditable. refreshes auditable.
@@ -1,5 +1,5 @@
diff --git a/package-lock.json b/package-lock.json diff --git a/package-lock.json b/package-lock.json
index 20631bb..86b6f86 100644 index 20631bb..0933917 100644
--- a/package-lock.json --- a/package-lock.json
+++ b/package-lock.json +++ b/package-lock.json
@@ -63,7 +63,7 @@ @@ -63,7 +63,7 @@
@@ -495,9 +495,9 @@ index 20631bb..86b6f86 100644
- "version": "5.0.7", - "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.12", + "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -678,9 +678,9 @@ index 20631bb..86b6f86 100644
- "version": "1.1.15", - "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21", + "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -833,9 +833,9 @@ index 20631bb..86b6f86 100644
- "version": "3.1.3", - "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.8", + "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -859,9 +859,9 @@ index 20631bb..86b6f86 100644
- "version": "5.0.7", - "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.12", + "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -893,9 +893,9 @@ index 20631bb..86b6f86 100644
- "version": "10.2.0", - "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.7.2", + "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -1445,3 +1445,4 @@ index bd7190c..6aa5a6f 100644
import { vi } from "vitest"; import { vi } from "vitest";
// Mock window.matchMedia // Mock window.matchMedia
+21 -60
View File
@@ -14,73 +14,34 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Next release after 1.8.21 — reported 2026-09-30 ## Next release after 1.8.21 — reported 2026-09-30
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose - [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
smart-HTTP reachability from Portainer's actual request namespace, then provide and appears underneath the pruning information.** Operator reports white
one declarative topology and idempotent migration for fresh installs and styling with invisible text on the actual kiosk; the same flow works in remote
existing nodes. Preserve gate/auth boundaries, operator configuration, Brave. Reproduce on the X250's kiosk engine and record its version, display
repository/key/database mounts and Portainer stacks. Cover install order, scale and resolution. Inspect the native `<select>` in
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file `neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
acceptance with a disposable integration setup. Ship in both OTA and ISO; scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
a healthy Gitea root page is insufficient. Operator supplied a private handover; not a confirmed cause. Fix contrast and popup visibility without changing
deployment addresses and credentials must not be committed. version selection or pruning behavior. Validate Core and Knots, open/closed
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
alone do not establish that the kiosk rendering is fixed. Track for the next
release; the signed 1.8.21 artifacts remain unchanged.
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing ## Current repair and release tasks — 2026-09-29
ISO build contexts restored on-node; package staging/smoke checks added.
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
Nginx was a slow successful image pull. Aggregate progress label corrected.
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
- [ ] **Angor indexer service in the app store**, requested after the other Release is blocked until these pass; see [execution record](repair-release-20260929.md).
current repair/review work (2026-09-30). Follow the repository's app-development
and packaging documentation; treat it as a headless service unless upstream
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
whether an existing first-class relay meets Angor's requirements or a relay
must be packaged with the indexer, and use the Angor logo from angor.io for its
service icon. The mentioned setup-documentation link was not included; asked
the operator for it. Include this service in the next-release scope.
- [ ] **App lifecycle: keep installed apps visible through restart and hard - [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
refresh; gate embedded/browser launches on actual web and listener readiness.**
Source repair and scoped live acceptance passed; full release gate pending.
Includes durable inventory reconstruction,
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
and the app gate's post-install listener delay. See
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
- [x] Review and repair open paid-download PRs #161 and #162, refresh both
branches from main, run independent and combined isolated suites, and verify
rootless file permissions in disposable scratch storage. Combined result:
1,585 passed, zero failed, four existing tests ignored. See the
[review evidence and remaining acceptance work](pr-review-20260930.md).
- [ ] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets.
- [ ] Design durable recovery for an accepted payment whose response is lost.
Preserve the truthful unconfirmed-refund warning and prevent automatic
duplicate payment while that recovery work is outstanding.
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.**
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk
assertions and screenshot verify white-on-dark choices, selection changes and
layout above pruning controls. Focused component tests pass. Included in the
next-release source; published 1.8.21 artifacts remain unchanged.
## 1.8.21 repair and release tasks — completed 2026-09-30
See the [execution record](repair-release-20260929.md) for evidence and limits.
- [x] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
mint errors, fees, and refund reporting before live validation. mint errors, fees, and refund reporting before live validation.
- [x] Record Framework verification and the operator's acceptance of the - [ ] Complete the remaining Framework incident verification and evidence.
remaining display check before release. - [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
- [x] Replace the unavailable tx1138.com explorer default with mempool.space;
migrate the old default with fresh consent and preserve custom/local explorers. migrate the old default with fresh consent and preserve custom/local explorers.
- [x] Offer pruning in the Bitcoin installation version modal, using the same - [ ] Offer pruning in the Bitcoin installation version modal, using the same
pruning settings as automatic pruning even on large disks. pruning settings as automatic pruning even on large disks.
- [x] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin - [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
RPC readiness and show install/start/sync waiting states with automatic recovery. RPC readiness and show install/start/sync waiting states with automatic recovery.
- [x] Test the completed changes on this development box, then publish a new - [ ] Test the completed changes on this development box, then publish a new
signed OTA and raw ISO release. Record any remaining verification gaps. signed OTA and raw ISO release. Record any remaining verification gaps.
## Dev & build process (priority) ## Dev & build process (priority)
-111
View File
@@ -1,111 +0,0 @@
# App lifecycle repair — 2026-09-30
Status: source repairs, optimized build, new-node recovery and scoped live
lifecycle acceptance verified. Full release gate remains pending.
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
## Report
The operator reports that restarting an app can make it disappear, and a hard
refresh offers installation again. Newly installed apps sometimes fail to
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
app on the dev box exposed a separate restart failure.
## Findings and repairs
- Quadlet removes containers during stop/restart. The scanner protected existing
in-memory entries but did not reconstruct an absent app on a fresh daemon.
It now synthesizes stopped entries from the durable installed set, respecting
uninstall records, normalizing container prefixes, and preserving cached
metadata. Absence does not establish an image version or available update.
- Concurrent read/modify/write operations could lose installed-app records;
in-place writes could expose truncated JSON to readers. Serialize writers,
publish by atomic rename, and sync the file and parent directory. Legacy
package install/uninstall success paths update the durable record too.
- Scans and lifecycle/progress operations could replace a newer model from an
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
results only into entries unchanged since the scan's merge snapshot.
- Container running state and TCP accept alone did not establish HTTP readiness.
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
reject connection failures and server errors, accept normal redirects and
authentication challenges, and do not follow redirects or send credentials.
Self-signed HTTPS apps are probed locally without certificate validation.
- The app gate swept new listeners only every 60 seconds. Wake that sweep
immediately for a ready upstream whose declared gate port is not yet claimed,
and withhold readiness until external and Tor listener claims exist.
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
app cards, details, centralized embedded/browser launchers, and session frames.
Starting/restarting clears readiness immediately. A waiting frame does not
load an iframe and resumes when the backend reports readiness.
### New X250 findings
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
was missing. Copy the complete docker source tree for bundled and unbundled
ISOs, matching OTA packaging. Validate every manifest build context and
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
Refresh the existing pinned dependency patch, keeping the audit enabled.
Clean install/audit (zero advisories), type-check, 152 upstream tests and
subpath production build pass. The image builds on the X250 and `/healthz`
returns 200. No wallet or Bitcoin container restart was needed.
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
200. The orchestrated path previously labelled the entire download/build/start
operation "Creating container" at 70%. Give that aggregate operation its own
truthful label and earlier phase; no byte-level download estimate is claimed.
- Restore install progress immediately from an already-loaded server snapshot,
so a new store created after hard refresh does not wait for another mutation.
- Replace the install modal's native version popup with inline radio choices.
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
options have white text on dark backgrounds, and remain above pruning controls.
Screenshot and browser assertions captured; no install confirmation was clicked.
### Restart safety
The disposable fixture restart at 12:38:05 UTC stopped its container, then
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
12:38:35. The daemon owned the gate listener on the same port at other addresses.
Systemd restarted management; Bitcoin and LND container IDs/start times were
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
install recovery and Grafana preparation. Recovery now uses the existing
container-ID-aware ghost reaper: absent container ownership must be established
before a process is terminated. A real listening-socket regression checks that
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
## Validation
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
checks: nine passed. Production frontend build passed.
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
tests. Optimized backend build passed and was deployed to the development node.
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
concurrent durable writes, concurrent state changes, stale scan publication,
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
mobile retained the waiting screen through hard refresh without mounting an
iframe, then opened the exact fixture page automatically when ready.
- Restart retained the app in both state APIs throughout and returned to ready;
the management PID did not change. Stopping removed the Quadlet container;
restarting management reconstructed its installed/stopped entry without a
false update offer. Starting it again succeeded. Desktop and mobile continued
to show the installed app after hard refresh.
- LAN access required node authentication and returned exact fixture bytes after
authentication. The fixture was uninstalled through the package lifecycle API;
its temporary manifest root and service override were removed.
- Bitcoin and LND container IDs and start times stayed unchanged through all
scoped checks and management restarts. No wallet data was used by the fixture.
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
pages successfully, with no failed local resource loads.
## Limits
This prevents the identified lifecycle/readiness failures; it cannot guarantee
that an app or network never fails after a successful readiness check. Actual
application failures must remain visible rather than being labelled successful.
The full lifecycle/reboot release gate and funded acceptance of the reviewed
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
-127
View File
@@ -1,127 +0,0 @@
# Paid-download PR review — 2026-09-30
## Scope and result
Reviewed both open PRs from the repository pull-request list: [#161](https://source.archipelago-foundation.org/lfg2025/archy/pulls/161)
and [#162](https://source.archipelago-foundation.org/lfg2025/archy/pulls/162).
Both branches were updated from main, repaired and tested independently and
together. Their existing remote branches were advanced without rewriting the
contributors' history. They remain open for integration into the release after
1.8.21; no reviewed code was merged into main or deployed to a live wallet.
The signed 1.8.21 artifacts are unchanged.
| Candidate | Tested commit | Isolated backend result |
| --- | --- | --- |
| PR #161 | `971d4777` | 1,576 passed, 0 failed, 4 existing tests ignored |
| PR #162 | `0677924a` | 1,568 passed, 0 failed, 4 existing tests ignored |
| Both together | `4bf4bf1a` | 1,585 passed, 0 failed, 4 existing tests ignored |
Both individual branches also passed production `cargo check`, with the
repository's existing 16 warnings. The combined merge required no conflict
resolution. Backend tests ran through `scripts/test-backend-isolated.sh` so they
could not access host wallets, native services or production container storage.
## Findings and repairs
### #161 — payment delivery and file readability
- The branch conflicted with newer mint-fee, keyset-ID and truthful refund
reporting fixes. Preserve those implementations from main; do not reintroduce
its older unconditional “refunded” messages or duplicate keyset resolution.
- Opening a file before charging, then reopening/reading it afterward, still
permits a read failure after payment. Prepare the complete requested bytes
before redemption, including ranged reads. Tests delete or alter the backing
file during payment verification and still receive the prepared original data.
- Empty/out-of-bounds/reversed ranges could fail after redemption, and empty
files could underflow the range calculation. Validate ranges before charging
and return HTTP 416 when unsatisfiable.
- `chmod a+r` unnecessarily changed the permissions of shared paid/private
files. Read restricted FileBrowser files through the rootless namespace while
retaining their mode. Scope the fallback to regular files canonically inside
FileBrowser storage, and reject unauthorized peers before reading.
- A single-delivery flag must also prevent redirects and ambiguous transport
retries. Payment-bearing GET and POST requests now retain the first HTTP
response and do not retry after timeouts or disconnects that might follow
delivery. Refused connections and normal nonpayment browsing retain the
appropriate retry behavior.
- Interrupted response bodies now report the outcome using the actual local
refund result. Seller explanations are bounded, stripped of control
characters and explicitly identified as peer text.
- Original permission tests silently returned when run as root. Replacement
tests inject read/payment boundary failures, exercise them under the isolated
runner, and assert that read failures never invoke redemption.
### #162 — saving purchases in Files
- Its host-permission repair overlapped 1.8.21's authenticated Files API path.
Review of [FileBrowser v2.63.23's resource handler](https://github.com/filebrowser/filebrowser/blob/v2.63.23/http/resource.go)
showed that `override=false` checks for existence separately from opening
with truncation. It does not guarantee no overwrites under concurrent saves.
- The proposed direct path exposed the final filename before the write
completed. Both direct and namespace paths now finish a private temporary
file and publish it through a no-clobber hard link, retrying numbered names.
- Plain `ln` could place a temporary file inside an existing directory instead
of treating the destination as a collision. Use `ln -T`; existing directories
and dangling symlinks are conflicts, never replacement targets.
- Add filename and destination checks, unique temporary names, bounded name
retries, synchronization before publication, and exact input-length checks.
Truncated pipe input cannot become a completed purchased file.
- Files storage remains optional. An unavailable copy destination does not
undo the purchase or create a fake FileBrowser installation; the durable
purchased-content cache remains primary.
## Additional verification on the development node
Used disposable scratch directories only, then removed them:
- Reproduced a FileBrowser-style rootless-owned 0640 upload. The host backend
UID could not read it. `podman unshare cat` returned identical bytes without
changing its 0640 mode.
- Ran the exact namespace writer script with four concurrent writers against
a directory owned by the container UID range. Every file had unique naming,
exact bytes, the expected owner and mode, and no remaining temporary file.
- Sent truncated input to the namespace writer and verified refusal, no final
file and temporary-file cleanup.
The isolated tests additionally exercised 24 simultaneous direct writes,
existing-file preservation, symlink/directory conflicts, collision exhaustion,
root-independent permission failures, read-before-redemption ordering,
authorization, redirects and peer disconnects.
Logs on the development box:
`/tmp/archy-pr161-tests.log`, `/tmp/archy-pr162-tests.log`,
`/tmp/archy-pr-integration-tests.log`, `/tmp/archy-pr161-check.log`,
`/tmp/archy-pr162-check.log`, `/tmp/archy-pr-userns-scratch-test.log`.
## Next-release acceptance and limits
- Integrate the reviewed branches and repeat the release gates against the
final release commit if additional code changes land.
- Perform funded peer-to-peer acceptance on the candidate build, including a
Tor-only purchase and a purchase requiring change, before the next release.
The new review branches were not deployed to funded live wallets here.
- These PRs do not implement durable payment receipts. If a seller redeems a
payment and the connection subsequently loses the response, the buyer may
receive an unconfirmed-refund warning. Do not represent that warning as proof
of a refund or automatically charge the buyer again. Receipt-based recovery
remains separate follow-up work.
- Abrupt process termination can leave a hidden namespace temporary file;
ordinary write failures and truncated input are tested to clean up. The final
filename is published only after complete input, and existing files remain
protected.
- The separately reported X250 kiosk version-selector rendering issue remains
open in `TODO.md` and requires validation on the actual kiosk.
## Authorized merge — 2026-09-30
The operator explicitly requested normal merged/closed PR status after review.
Re-read both PRs and verified their heads still exactly matched the reviewed
commits. Changes from the integration-test base to main were documentation only.
Gitea normal merges completed and read-back confirmed `merged=true`, `state=closed`:
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
Local next-release lifecycle work will be integrated with this main before the
next release. Funded release acceptance and the documented delivery-receipt
limitation remain as recorded above; merging does not claim a new release.
-39
View File
@@ -356,42 +356,3 @@ Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
still verified. No temporary graceful-stop overrides remain. Catalog signature still verified. No temporary graceful-stop overrides remain. Catalog signature
verifies against the pinned release root; final 1.8.21 artifact validator passes. verifies against the pinned release root; final 1.8.21 artifact validator passes.
The candidate is ready for the user's local OTA signing ceremony. The candidate is ready for the user's local OTA signing ceremony.
### 1.8.21 publication completed — 2026-09-30
The operator signed the OTA manifest and subsequently the ISO checksum JSON.
Both signatures verified against the pinned release root. The signed OTA was
published on git/ngit, and the operator confirmed that Framework could see the
update. Source main and the annotated `v1.8.21-alpha` tag were published.
Raw ISO:
`archipelago-installer-1.8.21-alpha-unbundled-x86_64_RC1.iso`
- Size: 2,682,419,200 bytes.
- SHA256: `8667b5522c476a40e29abba19df4180086191527a194a88765aa70ed527f9406`.
- Build and ISO smoke checks passed. The mounted backend matched the staged
OTA backend hash, and the full dashboard/AIUI tree matched the fresh build.
- An isolated UEFI QEMU guest, with no network or host disks attached, booted to
the installer prompt. The VM was stopped and the ISO unmounted afterward.
This was an installer boot check, not a full installation onto hardware.
- Uploaded the raw ISO, plain SHA256 sidecar and signed checksum JSON to the
[1.8.21 release](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.21-alpha).
The stored server file hashes matched, the public ISO headers and first/last
byte samples matched, and both public checksum files matched byte-for-byte.
- The ngit downloader's full-ISO acquisition exceeded its fixed 30-minute
deadline on the available connection. Published Nostr asset records using
the already verified hashes, sizes and public URLs with the existing ngit
signer; both repository relays acknowledged them. Ngit then accepted those
records and final readback resolved all five release assets with the expected
hashes and sizes. No new release-root signing was performed by the assistant.
Final publication evidence: `/tmp/archy-1821-finish-events.log`,
`/tmp/archy-ngit-1821-complete-view.json`, and
`/tmp/archy-1821-verified-asset-events.log` on the development box.
Subsequent review of PRs #161/#162 found additional delivery and concurrent
file-save edge cases. Their repaired, tested branches are recorded in
[the next-release review](pr-review-20260930.md); those changes are not in the
signed 1.8.21 artifacts. The X250 kiosk selector report is also tracked for the
next release. No claim of exhaustive hardware or network-failure coverage is
made for this release.
@@ -2607,14 +2607,21 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
echo " ✅ Bundled image-versions.sh" echo " ✅ Bundled image-versions.sh"
fi fi
# Build-source apps need their complete contexts even on unbundled ISOs. # Bundle docker UI source files for building custom UIs on first boot
# Keep this identical to the OTA runtime payload; a per-app allowlist silently # Always bundle — these are tiny HTML/CSS files, not container images
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%. if true; then
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker" DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; } if [ -d "$DOCKER_UI_DIR" ]; then
mkdir -p "$ARCH_DIR/docker" echo " Bundling docker UI source files..."
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/" mkdir -p "$ARCH_DIR/docker"
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR" for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
if [ -d "$DOCKER_UI_DIR/$ui_dir" ]; then
cp -r "$DOCKER_UI_DIR/$ui_dir" "$ARCH_DIR/docker/"
echo " ✅ Bundled $ui_dir source"
fi
done
fi
fi
if [ "$UNBUNDLED" = "1" ]; then if [ "$UNBUNDLED" = "1" ]; then
echo " ✅ Unbundled build ready (Tor setup included, no container images)" echo " ✅ Unbundled build ready (Tor setup included, no container images)"
@@ -25,22 +25,13 @@
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div> <div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<fieldset class="space-y-2"> <label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<legend class="text-white/60 text-sm mb-2">{{ t('appDetails.selectVersion') }}</legend> <select
<!-- Inline options avoid native popup rendering in the kiosk WebView. v-model="selected"
They stay in document flow above the pruning explanation. --> class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
<div class="max-h-40 overflow-y-auto space-y-2 rounded-lg">
<label
v-for="v in versions"
:key="v.version"
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 text-sm text-white cursor-pointer"
:class="selected === v.version ? 'border-blue-400/60 bg-slate-800' : 'border-white/10 bg-slate-900'"
> >
<input v-model="selected" type="radio" :name="`install-version-${appId}`" :value="v.version" class="shrink-0 accent-blue-400" /> <option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
<span>{{ optionLabel(v) }}</span> </select>
</label>
</div>
</fieldset>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p> <p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div> </div>
@@ -122,7 +113,6 @@ async function load() {
} catch (err) { } catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err) if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed. // Fall back to the floating "latest" so the install can still proceed.
versions.value = [{ version: 'latest' } as CatalogVersionInfo]
selected.value = 'latest' selected.value = 'latest'
} finally { } finally {
loading.value = false loading.value = false
@@ -16,14 +16,12 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] }) versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] })
const wrapper = modal(id) const wrapper = modal(id)
await flushPromises() await flushPromises()
await wrapper.get('input[type=radio][value="28.4"]').setValue(true) await wrapper.get('select').setValue('28.4')
await wrapper.get('input[type=checkbox]').setValue(true) await wrapper.get('input[type=checkbox]').setValue(true)
await wrapper.get('button').trigger('click') await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['28.4', true]]) expect(wrapper.emitted('confirm')).toEqual([['28.4', true]])
expect(wrapper.text()).toContain('automatic pruning') expect(wrapper.text()).toContain('automatic pruning')
expect(wrapper.text()).toContain('Mempool') expect(wrapper.text()).toContain('Mempool')
expect(wrapper.find('select').exists()).toBe(false)
expect(wrapper.findAll('input[type=radio]')).toHaveLength(2)
}) })
it('keeps automatic disk selection by default and resets on reopening', async () => { it('keeps automatic disk selection by default and resets on reopening', async () => {
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] }) versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
@@ -31,17 +29,17 @@ describe('Bitcoin install storage choice', () => {
await flushPromises() await flushPromises()
await wrapper.get('button').trigger('click') await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', false]]) expect(wrapper.emitted('confirm')).toEqual([['latest', false]])
await wrapper.get('input[type=checkbox]').setValue(true) await wrapper.get('input').setValue(true)
await wrapper.setProps({ show: false }) await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true }) await wrapper.setProps({ show: true })
await flushPromises() await flushPromises()
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(false) expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(false)
}) })
it('still allows choosing pruning when version lookup fails', async () => { it('still allows choosing pruning when version lookup fails', async () => {
versions.mockRejectedValue(new Error('offline')) versions.mockRejectedValue(new Error('offline'))
const wrapper = modal() const wrapper = modal()
await flushPromises() await flushPromises()
await wrapper.get('input[type=checkbox]').setValue(true) await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click') await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', true]]) expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
}) })
@@ -49,7 +47,7 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] }) versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] })
const wrapper = modal('bitcoin-knots') const wrapper = modal('bitcoin-knots')
await flushPromises() await flushPromises()
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true) expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
await wrapper.get('button').trigger('click') await wrapper.get('button').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['latest', true]]) expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
}) })
@@ -64,6 +62,6 @@ describe('Bitcoin install storage choice', () => {
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] }) versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
const wrapper = modal('other') const wrapper = modal('other')
await flushPromises() await flushPromises()
expect(wrapper.find('input[type=checkbox]').exists()).toBe(false) expect(wrapper.find('input').exists()).toBe(false)
}) })
}) })
@@ -34,7 +34,6 @@ vi.mock('@/api/rpc-client', () => ({
vi.stubGlobal('open', mockWindowOpen) vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher' import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
import { useAppStore } from '../app'
describe('useAppLauncherStore', () => { describe('useAppLauncherStore', () => {
beforeEach(() => { beforeEach(() => {
@@ -55,25 +54,6 @@ describe('useAppLauncherStore', () => {
}) })
}) })
it('blocks both browser and embedded launch while HTTP is unready', () => {
const app = useAppStore()
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
const launcher = useAppLauncherStore()
launcher.openSession('gitea')
expect(launcher.panelAppId).toBeNull()
launcher.open({ url: 'http://192.0.2.10:3001/', title: 'Gitea', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('also gates a dynamic app resolved through its runtime URL', () => {
useAppStore().data = { 'package-data': { custom: { state: 'running', 'ui-ready': false, manifest: { id: 'custom', title: 'Custom' }, installed: { 'interface-addresses': { main: { 'lan-address': 'http://localhost:18993/' } } } } } } as never
const launcher = useAppLauncherStore()
launcher.open({ url: 'http://192.0.2.10:18993/', title: 'Custom', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('starts closed with empty state', () => { it('starts closed with empty state', () => {
const store = useAppLauncherStore() const store = useAppLauncherStore()
expect(store.isOpen).toBe(false) expect(store.isOpen).toBe(false)
@@ -1,42 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { reactive, nextTick } from 'vue'
const fake = reactive<{ packages: Record<string, unknown> }>({ packages: {} })
vi.mock('../sync', () => ({ useSyncStore: () => fake }))
vi.mock('../../api/rpc-client', () => ({ rpcClient: {} }))
import { useServerStore } from '../server'
function installing(phase = 'preparing-app') {
return { state: 'installing', manifest: { title: 'Git Workshop' }, 'install-progress': { phase, size: 0, downloaded: 0 } }
}
describe('installation state after hard refresh', () => {
beforeEach(() => {
setActivePinia(createPinia())
fake.packages = {}
})
it('restores an in-flight install from an already-loaded server snapshot', () => {
fake.packages = { 'archipelago-source': installing() }
const store = useServerStore()
expect(store.isInstalling('archipelago-source')).toBe(true)
expect(store.installingApps.get('archipelago-source')).toMatchObject({
progress: 20,
message: 'Downloading, building and starting app…',
})
})
it('keeps a long download visible and clears it on terminal success', async () => {
const store = useServerStore()
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(true)
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.installingApps.get('nginx-proxy-manager')?.progress).toBe(20)
fake.packages = { 'nginx-proxy-manager': { state: 'running' } }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(false)
})
})
+1 -23
View File
@@ -239,11 +239,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelPath = ref<string | null>(null) const panelPath = ref<string | null>(null)
function openSessionNow(appId: string, opts: LaunchOptions = {}) { function openSessionNow(appId: string, opts: LaunchOptions = {}) {
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || appId} is not ready to open yet`)
return
}
recordAppLaunch(appId) recordAppLaunch(appId)
const mobile = isMobileViewport() const mobile = isMobileViewport()
@@ -300,7 +295,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Apply the same readiness gate here so a container that has just entered // Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user. // `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId] const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) { if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`) useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return return
} }
@@ -399,12 +394,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId) let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
const pkg = resolvedId ? useAppStore().data?.['package-data']?.[resolvedId] : undefined
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || resolvedId} is not ready to open yet`)
return
}
// Scheme discipline for everything launched on this host. Ports fronted // Scheme discipline for everything launched on this host. Ports fronted
// by the node's app gate (manifest auth gated/open) serve TLS on the same // by the node's app gate (manifest auth gated/open) serve TLS on the same
// port — on an HTTPS connection those must open over https. Ports that // port — on an HTTPS connection those must open over https. Ports that
@@ -483,17 +472,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Check /app/{id}/ path-style routes first (HTTPS proxy mode) // Check /app/{id}/ path-style routes first (HTTPS proxy mode)
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i) const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
if (m?.[1]) return m[1].toLowerCase() if (m?.[1]) return m[1].toLowerCase()
// Dynamic/sideloaded apps have no entry in the static port map.
if (u.hostname === window.location.hostname && u.port) {
for (const [id, pkg] of Object.entries(useAppStore().data?.['package-data'] || {})) {
const address = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (!address) continue
try {
const runtime = new URL(address)
if (runtime.port === u.port && ['localhost', '127.0.0.1', window.location.hostname].includes(runtime.hostname)) return id
} catch { /* malformed runtime address is not a launch target */ }
}
}
// Check port-based apps // Check port-based apps
const appId = PORT_TO_APP_ID[u.port] const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId if (appId) return appId
+1 -2
View File
@@ -25,7 +25,6 @@ import type { InstallPhase } from '../types/api'
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = { const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' }, 'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' }, 'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
'preparing-app': { progress: 20, message: 'Downloading, building and starting app…', status: 'downloading' },
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' }, 'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' }, 'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' }, 'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
@@ -150,7 +149,7 @@ export const useServerStore = defineStore('server', () => {
uninstallingApps.value.delete(appId) uninstallingApps.value.delete(appId)
} }
} }
}, { deep: true, immediate: true }) }, { deep: true })
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) { function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
const existing = installingApps.value.get(appId) const existing = installingApps.value.get(appId)
-2
View File
@@ -88,7 +88,6 @@ export const PackageState = {
export type PackageState = typeof PackageState[keyof typeof PackageState] export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry { export interface PackageDataEntry {
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
state: PackageState state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null health?: string | null // "healthy", "unhealthy", "starting", or null
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash 'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
@@ -181,7 +180,6 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
export type InstallPhase = export type InstallPhase =
| 'preparing' | 'preparing'
| 'pulling-image' | 'pulling-image'
| 'preparing-app'
| 'creating-container' | 'creating-container'
| 'starting-container' | 'starting-container'
| 'waiting-healthy' | 'waiting-healthy'
+1 -1
View File
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id) const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl) const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy' return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
}) })
const features = computed(() => { const features = computed(() => {
-18
View File
@@ -39,7 +39,6 @@
:must-open-new-tab="mustOpenNewTab" :must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount" :auto-retry-count="autoRetryCount"
:refresh-key="refreshKey" :refresh-key="refreshKey"
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
:blocked-reason="blockedReason" :blocked-reason="blockedReason"
:blocked-title="blockedTitle" :blocked-title="blockedTitle"
:warming-up="warmingUp" :warming-up="warmingUp"
@@ -376,20 +375,6 @@ const panelClasses = computed(() => {
return `${base} app-session-overlay` return `${base} app-session-overlay`
}) })
// A cold/restarting upstream is held outside the iframe. Start one fresh
// load when the scanner observes HTTP readiness; no manual refresh required.
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
if (ready === false) {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
loading.value = false
} else if (previous === false && ready === true) {
autoRetryCount.value = 0
refresh()
}
})
// --- Lifecycle handlers --- // --- Lifecycle handlers ---
function onLoad() { function onLoad() {
@@ -448,7 +433,6 @@ function refresh() {
function startLoadTimeout() { function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId) if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (packageEntry.value?.['ui-ready'] === false) return
loadTimeoutId = setTimeout(() => { loadTimeoutId = setTimeout(() => {
if (loading.value) { if (loading.value) {
loading.value = false loading.value = false
@@ -458,13 +442,11 @@ function startLoadTimeout() {
} }
function openNewTabAndBack() { function openNewTabAndBack() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value) if (appUrl.value) openExternalUrl(appUrl.value)
closeSession() closeSession()
} }
function openNewTab() { function openNewTab() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value) if (appUrl.value) openExternalUrl(appUrl.value)
} }
@@ -6,7 +6,7 @@
first, then sync status arrives), and the sync screen is strictly first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two more informative, so it takes precedence instead of the two
rendering on top of each other. --> rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" /> <AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition> </Transition>
<!-- ElectrumX sync screen — shown before the real UI while the on-chain <!-- ElectrumX sync screen — shown before the real UI while the on-chain
@@ -43,7 +43,7 @@
</Transition> </Transition>
<div <div
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)" v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host" class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1" tabindex="-1"
@pointerdown="focusIframe" @pointerdown="focusIframe"
@@ -66,7 +66,7 @@
reachable yet, so the "App not reachable / retry" overlay would just reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. --> paint over the sync progress and read as a hard error. -->
<Transition name="content-fade"> <Transition name="content-fade">
<div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center"> <div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8"> <div class="text-center px-8">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock: <!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. --> the padlock reads as "blocked/denied" and this state is neither. -->
@@ -78,8 +78,7 @@
</div> </div>
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3> <h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6"> <p class="text-white/50 text-sm mb-6">
<template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</template> <template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template> <template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template> <template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template> <template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
@@ -96,7 +95,6 @@
Retry now Retry now
</button> </button>
<button <button
v-if="!uiReadyBlocked"
@click="$emit('openNewTabAndBack')" @click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2" class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
> >
@@ -110,7 +108,7 @@
</div> </div>
</Transition> </Transition>
<div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center"> <div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8"> <div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3> <h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p> <p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
@@ -135,7 +133,6 @@ const props = defineProps<{
mustOpenNewTab: boolean mustOpenNewTab: boolean
autoRetryCount: number autoRetryCount: number
refreshKey: number refreshKey: number
uiReadyBlocked?: boolean
blockedReason?: string blockedReason?: string
blockedTitle?: string blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the // True while the container is up but its probe hasn't answered yet and the
@@ -66,19 +66,3 @@ describe('AppSessionFrame warm-up state', () => {
expect(text).toContain('This app opens in a new tab') expect(text).toContain('This app opens in a new tab')
}) })
}) })
describe('HTTP readiness gate', () => {
it('does not show a missing-configuration error during initial installation', () => {
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
expect(frame.text()).not.toContain('App not configured')
expect(frame.find('iframe').exists()).toBe(false)
})
it('does not mount an iframe before readiness, then opens automatically', async () => {
const frame = mountFrame({ iframeBlocked: false, uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…', blockedTitle: 'App not ready' })
expect(frame.find('iframe').exists()).toBe(false)
expect(frame.text()).toContain('opens automatically')
expect(frame.text()).not.toContain('Open in new tab')
await frame.setProps({ uiReadyBlocked: false, blockedReason: '' })
expect(frame.find('iframe').exists()).toBe(true)
})
})
@@ -184,24 +184,3 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(pkg)).toBe(true) expect(canLaunch(pkg)).toBe(true)
}) })
}) })
describe('HTTP readiness independent of container health', () => {
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
const pkg = makePkg(id, id, 'other')
pkg['ui-ready'] = false
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(false)
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
}
})
it('allows a ready companion while its backend is syncing', () => {
const pkg = makePkg('lnd', 'Lightning', 'bitcoin')
pkg.health = 'starting'
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
})
})
-6
View File
@@ -244,7 +244,6 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true if (isWebOnlyApp(pkg.manifest.id)) return true
if (pkg['ui-ready'] === false) return false
// Headless backends never get a Launch button, even with a published port. // Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
@@ -278,7 +277,6 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
* health check retain the legacy state/port behaviour. * health check retain the legacy state/port behaviour.
*/ */
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean { export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
if (pkg['ui-ready'] !== undefined) return pkg['ui-ready']
const manifest = pkg.manifest as unknown as Record<string, unknown> const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check']) const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy' if (!hasHealthCheck) return pkg.health !== 'unhealthy'
@@ -287,10 +285,6 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string { export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id const appId = pkg?.manifest?.id || id
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
return 'Waiting for the app to be ready…'
}
if ( if (
(appId === 'fedimint' || appId === 'fedimintd') && (appId === 'fedimint' || appId === 'fedimintd') &&
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting')) (pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
"""Validate build-source apps against an OTA/ISO runtime payload before shipping."""
import sys
from pathlib import Path
import yaml
def check(root: Path) -> int:
root = root.resolve()
manifests = sorted((root / 'apps').glob('*/manifest.y*ml'))
if not manifests:
raise ValueError(f'No app manifests in {root / "apps"}')
count = 0
for manifest in manifests:
app = yaml.safe_load(manifest.read_text())['app']
build = app.get('container', {}).get('build')
if not build:
continue
context = Path(build['context'])
if context.is_absolute():
context = root / context.relative_to('/opt/archipelago')
else:
context = manifest.parent / context
context = context.resolve()
if not context.is_relative_to(root) or not context.is_dir():
raise ValueError(f'{app["id"]}: missing or out-of-payload build context: {context}')
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
count += 1
return count
if __name__ == '__main__':
try:
count = check(Path(sys.argv[1] if len(sys.argv) > 1 else '.'))
except (ValueError, KeyError, OSError, yaml.YAMLError) as error:
sys.exit(f'Invalid app build payload: {error}')
print(f'Validated {count} app build contexts and Dockerfiles.')
-1
View File
@@ -101,7 +101,6 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path" cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
fi fi
done done
python3 "$PROJECT_ROOT/scripts/check-app-build-contexts.py" "$RUNTIME_DIR"
# KEEP IN SYNC with the `for unit in [...]` array in # KEEP IN SYNC with the `for unit in [...]` array in
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that # core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
# bootstrap installs but this list does not ship simply never reaches a # bootstrap installs but this list does not ship simply never reaches a
-7
View File
@@ -64,13 +64,6 @@ for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
fi fi
done done
# Verify the mounted artifact carries every manifest-declared build source.
if python3 "$REPO/scripts/check-app-build-contexts.py" "$MNT/archipelago"; then
ok "app build contexts and Dockerfiles"
else
bad "incomplete app build payload"
fi
# ── GRUB must boot the live system ─────────────────────────────────── # ── GRUB must boot the live system ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live" ok "grub.cfg has boot=live"
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""Exercise release payload checks with complete, incomplete and escaping contexts."""
import importlib.util
import shutil
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('contexts', REPO / 'scripts/check-app-build-contexts.py')
contexts = importlib.util.module_from_spec(spec)
spec.loader.exec_module(contexts)
class BuildPayloadTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
shutil.copytree(REPO / 'apps', self.root / 'apps')
shutil.copytree(REPO / 'docker', self.root / 'docker')
def test_complete_payload(self):
self.assertGreaterEqual(contexts.check(self.root), 6)
def test_iso_old_allowlist_rejected(self):
shutil.rmtree(self.root / 'docker/archipelago-source')
with self.assertRaisesRegex(ValueError, 'archipelago-source.*missing'):
contexts.check(self.root)
def test_missing_dockerfile_rejected(self):
(self.root / 'docker/archipelago-source/Dockerfile').unlink()
with self.assertRaisesRegex(ValueError, 'archipelago-source.*Dockerfile'):
contexts.check(self.root)
def test_context_symlink_cannot_escape_payload(self):
target = self.root / 'docker/archipelago-source'
shutil.rmtree(target)
target.symlink_to(REPO / 'docker/archipelago-source', target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
contexts.check(self.root)
def test_empty_payload_rejected(self):
shutil.rmtree(self.root / 'apps')
with self.assertRaisesRegex(ValueError, 'No app manifests'):
contexts.check(self.root)
if __name__ == '__main__':
unittest.main()
-1
View File
@@ -71,7 +71,6 @@ summary() {
# ── Stage 1: static ────────────────────────────────────────────────── # ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py stage "manifest-shell" python3 scripts/check-manifest-shell.py
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py