Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda28c4cd6 | ||
|
|
6ac26f637c | ||
|
|
27d81e956d | ||
|
|
eb39391223 | ||
|
|
d42f448e31 | ||
|
|
1566f1bb00 |
@@ -106,10 +106,3 @@ app:
|
|||||||
- Issue tracking and pull requests
|
- Issue tracking and pull requests
|
||||||
- CI/CD via Gitea Actions
|
- CI/CD via Gitea Actions
|
||||||
- Lightweight SQLite deployment
|
- Lightweight SQLite deployment
|
||||||
|
|
||||||
nginx_proxy:
|
|
||||||
listen: 3000
|
|
||||||
proxy_pass: http://127.0.0.1:3001
|
|
||||||
extra_headers:
|
|
||||||
- proxy_hide_header X-Frame-Options
|
|
||||||
- proxy_hide_header Content-Security-Policy
|
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ app:
|
|||||||
container:
|
container:
|
||||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
# Portainer fetches Git sources and images from services on this same node.
|
||||||
|
# Rootless pasta copies the host LAN address into its namespace, so a LAN
|
||||||
|
# URL points back at Portainer itself. Give it a private address with the
|
||||||
|
# supported rootless slirp backend; public app URLs still traverse the gate.
|
||||||
|
network: slirp4netns
|
||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
|
# Snapshot state before an upgrade recreates this app with new networking.
|
||||||
|
backup_on_network_change: true
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ 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
|
||||||
@@ -184,17 +193,20 @@ 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);
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
.state_manager
|
||||||
entry.state = PackageState::Stopped;
|
.mutate_data(|data| {
|
||||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||||
size: 0,
|
entry.state = PackageState::Stopped;
|
||||||
downloaded: 0,
|
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||||
phase: None,
|
size: 0,
|
||||||
message: Some(err_msg),
|
downloaded: 0,
|
||||||
});
|
phase: None,
|
||||||
handler.state_manager.update_data(data).await;
|
message: Some(err_msg),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -252,6 +264,11 @@ 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.
|
||||||
}
|
}
|
||||||
@@ -382,52 +399,56 @@ 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};
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| PackageDataEntry {
|
.entry(package_id.to_string())
|
||||||
state: PackageState::Installing,
|
.or_insert_with(|| PackageDataEntry {
|
||||||
health: None,
|
ui_ready: None,
|
||||||
exit_code: None,
|
state: PackageState::Installing,
|
||||||
static_files: StaticFiles {
|
health: None,
|
||||||
license: String::new(),
|
exit_code: None,
|
||||||
instructions: String::new(),
|
static_files: StaticFiles {
|
||||||
// Leave icon empty during the transient Installing window:
|
license: String::new(),
|
||||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
instructions: String::new(),
|
||||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
// Leave icon empty during the transient Installing window:
|
||||||
// the scanner refreshes the entry. The frontend's `icon`
|
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||||
// has the correct extensions for known apps.
|
// the scanner refreshes the entry. The frontend's `icon`
|
||||||
icon: String::new(),
|
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||||
},
|
// has the correct extensions for known apps.
|
||||||
manifest: Manifest {
|
icon: String::new(),
|
||||||
id: package_id.to_string(),
|
},
|
||||||
title: package_id.to_string(),
|
manifest: Manifest {
|
||||||
version: String::new(),
|
id: package_id.to_string(),
|
||||||
description: Description {
|
title: package_id.to_string(),
|
||||||
short: "Installing...".to_string(),
|
version: String::new(),
|
||||||
long: String::new(),
|
description: Description {
|
||||||
},
|
short: "Installing...".to_string(),
|
||||||
release_notes: String::new(),
|
long: String::new(),
|
||||||
license: String::new(),
|
},
|
||||||
wrapper_repo: String::new(),
|
release_notes: String::new(),
|
||||||
upstream_repo: String::new(),
|
license: String::new(),
|
||||||
support_site: String::new(),
|
wrapper_repo: String::new(),
|
||||||
marketing_site: String::new(),
|
upstream_repo: String::new(),
|
||||||
donation_url: None,
|
support_site: String::new(),
|
||||||
author: None,
|
marketing_site: String::new(),
|
||||||
website: None,
|
donation_url: None,
|
||||||
interfaces: None,
|
author: None,
|
||||||
tier: None,
|
website: None,
|
||||||
},
|
interfaces: None,
|
||||||
installed: None,
|
tier: None,
|
||||||
install_progress: None,
|
},
|
||||||
uninstall_stage: None,
|
installed: None,
|
||||||
available_update: None,
|
install_progress: None,
|
||||||
});
|
uninstall_stage: None,
|
||||||
entry.state = PackageState::Installing;
|
available_update: None,
|
||||||
state_manager.update_data(data).await;
|
});
|
||||||
|
entry.ui_ready = Some(false);
|
||||||
|
entry.state = PackageState::Installing;
|
||||||
|
})
|
||||||
|
.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
|
||||||
@@ -485,20 +506,23 @@ async fn remove_entry_with_notification(
|
|||||||
id_prefix: &str,
|
id_prefix: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
data.package_data.remove(package_id);
|
.state_manager
|
||||||
data.notifications.push(crate::data_model::Notification {
|
.mutate_data(|data| {
|
||||||
id: format!("{id_prefix}-{package_id}"),
|
data.package_data.remove(package_id);
|
||||||
level: crate::data_model::NotificationLevel::Error,
|
data.notifications.push(crate::data_model::Notification {
|
||||||
title: format!("Could not install {package_id}"),
|
id: format!("{id_prefix}-{package_id}"),
|
||||||
message: message.to_string(),
|
level: crate::data_model::NotificationLevel::Error,
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
title: format!("Could not install {package_id}"),
|
||||||
app_id: Some(package_id.to_string()),
|
message: message.to_string(),
|
||||||
});
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
while data.notifications.len() > 20 {
|
app_id: Some(package_id.to_string()),
|
||||||
data.notifications.remove(0);
|
});
|
||||||
}
|
while data.notifications.len() > 20 {
|
||||||
handler.state_manager.update_data(data).await;
|
data.notifications.remove(0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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
|
||||||
@@ -508,18 +532,23 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
} else {
|
entry.ui_ready = Some(false);
|
||||||
warn!(
|
}
|
||||||
"flip_package_state: no entry for {} — cannot flip",
|
entry.state = new_state;
|
||||||
package_id
|
} else {
|
||||||
);
|
warn!(
|
||||||
}
|
"flip_package_state: no entry for {} — cannot flip",
|
||||||
prev
|
package_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set state unconditionally (no-op if entry no longer exists).
|
/// Set state unconditionally (no-op if entry no longer exists).
|
||||||
@@ -528,13 +557,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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
|
||||||
@@ -545,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.uninstall_stage = None;
|
if new_state != PackageState::Running {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
entry.uninstall_stage = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
|||||||
@@ -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::CreatingContainer)
|
self.set_install_phase(package_id, InstallPhase::PreparingApp)
|
||||||
.await;
|
.await;
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
||||||
@@ -1699,32 +1699,10 @@ autopilot.active=false\n",
|
|||||||
patch_indeedhub_nostr_provider().await;
|
patch_indeedhub_nostr_provider().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
// Gitea owns its public URL and security settings in app.ini, including
|
||||||
// in a new tab on that direct port so absolute asset URLs must be
|
// values chosen in its first-run setup. Do not rewrite operator values
|
||||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
// or claim success from best-effort grep/sed commands. The app gate
|
||||||
if package_id == "gitea" {
|
// fronts its declared HTTP port and handles frame headers separately.
|
||||||
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
|
|
||||||
|
|
||||||
// Set ROOT_URL to the direct launch route so links/assets stay
|
|
||||||
// anchored under the same origin Gitea is launched from.
|
|
||||||
let host_ip = &self.config.host_ip;
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["exec", "gitea", "sh", "-c",
|
|
||||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
|
||||||
.args(["exec", "gitea", "sh", "-c",
|
|
||||||
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
|
|
||||||
host_ip
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if package_id == "nextcloud" {
|
if package_id == "nextcloud" {
|
||||||
let host_ip = &self.config.host_ip;
|
let host_ip = &self.config.host_ip;
|
||||||
@@ -2053,25 +2031,8 @@ 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) {
|
||||||
match package_id {
|
// Never kill by port: another app or the management gate may own it.
|
||||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
crate::container::ghost_reaper::reap_for_app(package_id).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(
|
||||||
@@ -2196,93 +2157,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
match package_id {
|
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
|
||||||
"grafana"
|
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
return true;
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
}
|
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,20 +14,23 @@ 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) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
entry.state = PackageState::Installing;
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
entry.ui_ready = Some(false);
|
||||||
entry.install_progress = Some(InstallProgress {
|
entry.state = PackageState::Installing;
|
||||||
size,
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: existing_phase,
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: existing_phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the install pipeline phase and broadcast. This is the
|
/// Set the install pipeline phase and broadcast. This is the
|
||||||
@@ -35,76 +38,86 @@ 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) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||||
// Updates use Updating state — the wrapper has already flipped
|
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||||
// state to Updating, so don't clobber it.
|
// Updates use Updating state — the wrapper has already flipped
|
||||||
if entry.state != PackageState::Updating {
|
// state to Updating, so don't clobber it.
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded) = entry
|
||||||
.map(|p| (p.size, p.downloaded))
|
.install_progress
|
||||||
.unwrap_or((0, 0));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded))
|
||||||
size,
|
.unwrap_or((0, 0));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: Some(phase),
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: Some(phase),
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
if entry.state != PackageState::Updating {
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded, phase) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded, phase) = entry
|
||||||
.map(|p| (p.size, p.downloaded, p.phase))
|
.install_progress
|
||||||
.unwrap_or((0, 0, None));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded, p.phase))
|
||||||
size,
|
.unwrap_or((0, 0, None));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase,
|
size,
|
||||||
message: Some(message.to_string()),
|
downloaded,
|
||||||
});
|
phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: Some(message.to_string()),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.install_progress = None;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
}
|
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) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.uninstall_stage = Some(stage.to_string());
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = crate::data_model::PackageState::Removing;
|
entry.uninstall_stage = Some(stage.to_string());
|
||||||
}
|
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).
|
||||||
@@ -114,25 +127,28 @@ impl RpcHandler {
|
|||||||
downloaded: u64,
|
downloaded: u64,
|
||||||
total: u64,
|
total: u64,
|
||||||
) {
|
) {
|
||||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.install_progress = Some(InstallProgress {
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
size: total,
|
entry.install_progress = Some(InstallProgress {
|
||||||
downloaded,
|
size: total,
|
||||||
phase: existing_phase,
|
downloaded,
|
||||||
message: None,
|
phase: existing_phase,
|
||||||
});
|
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,
|
||||||
|
|||||||
@@ -1431,10 +1431,9 @@ 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
|
||||||
// the port cleanup below: killing the owner is what actually frees the
|
// starting the replacement. A port sweep cannot distinguish a ghost
|
||||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
// from the dashboard gate or another live app and must never kill it.
|
||||||
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) {
|
||||||
@@ -1579,7 +1578,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1588,7 +1586,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;
|
||||||
cleanup_nginx_proxy_manager_ports().await;
|
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").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");
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1810,9 @@ 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"));
|
||||||
}
|
}
|
||||||
@@ -2032,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ports = runtime_host_ports(container_name);
|
// Only reap processes proven to belong to an absent container. The app
|
||||||
if !ports.is_empty() {
|
// gate shares the app's port on other addresses and lives in this daemon;
|
||||||
cleanup_ports(&ports).await;
|
// killing port owners (or matching argv with pkill) kills the dashboard.
|
||||||
return;
|
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -2095,13 +2055,16 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
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
|
||||||
@@ -2111,13 +2074,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||||
@@ -2185,6 +2153,20 @@ 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
|
||||||
|
|||||||
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
|
|||||||
app_id: &str,
|
app_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
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) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ 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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,34 @@ 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>,
|
||||||
@@ -162,11 +190,12 @@ pub async fn run(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {}
|
||||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
_ = REFRESH_KICK.notified() => {}
|
||||||
}
|
|
||||||
_ = 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,3 +490,19 @@ 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ 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,
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@ 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,8 +3,9 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use archipelago_container::{
|
use archipelago_container::{
|
||||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, 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};
|
||||||
@@ -25,8 +26,15 @@ impl DockerPackageScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan Docker containers and convert to package data
|
/// Scan Docker containers and convert to package data
|
||||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
pub async fn scan_containers(
|
||||||
let containers = self.runtime.list_containers().await?;
|
&self,
|
||||||
|
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());
|
||||||
|
|
||||||
@@ -139,6 +147,18 @@ 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
|
||||||
@@ -179,14 +199,22 @@ 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 = image_versions::extract_version_from_image(&container.image);
|
let running_version = if container.id.is_empty() {
|
||||||
|
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 =
|
let available_update = if container.id.is_empty() {
|
||||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
None
|
||||||
|
} 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 {
|
||||||
@@ -283,10 +311,215 @@ 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(¤t, *port))
|
||||||
|
};
|
||||||
|
if needs_refresh {
|
||||||
|
refresh_now().await;
|
||||||
|
}
|
||||||
|
let current = status.read().await;
|
||||||
|
for (id, port) in gated {
|
||||||
|
if !port_claimed(¤t, 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,
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! Consistent, private snapshots for declaratively opted-in network migrations.
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use archipelago_container::AppManifest;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
|
||||||
|
match manifest.app.extensions.get("backup_on_network_change") {
|
||||||
|
None => Ok(false),
|
||||||
|
Some(value) => value
|
||||||
|
.as_bool()
|
||||||
|
.context("backup_on_network_change must be boolean"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for volume in &manifest.app.volumes {
|
||||||
|
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A runtime socket is a connection, not application state.
|
||||||
|
if volume.source == "/run/user/1000/podman/podman.sock" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if volume.volume_type != "bind" {
|
||||||
|
bail!("network migration backup requires bind-mounted persistent state");
|
||||||
|
}
|
||||||
|
let path = Path::new(&volume.source);
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(data_dir)
|
||||||
|
.context("network migration state must be inside the node data directory")?;
|
||||||
|
if relative.as_os_str().is_empty()
|
||||||
|
|| relative
|
||||||
|
.components()
|
||||||
|
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||||
|
{
|
||||||
|
bail!("invalid network migration state path");
|
||||||
|
}
|
||||||
|
sources.push(relative.to_path_buf());
|
||||||
|
}
|
||||||
|
sources.sort();
|
||||||
|
sources.dedup();
|
||||||
|
let mut roots: Vec<PathBuf> = Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
if !roots.iter().any(|root| source.starts_with(root)) {
|
||||||
|
roots.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roots.is_empty() {
|
||||||
|
bail!("network migration backup has no persistent state mounts");
|
||||||
|
}
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller must gracefully stop the app before this function, and resume the old
|
||||||
|
/// service if it fails. No source files are changed or deleted by this operation.
|
||||||
|
pub async fn snapshot(
|
||||||
|
manifest: &AppManifest,
|
||||||
|
data_dir: &Path,
|
||||||
|
previous_unit: Option<&[u8]>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let mut command = tokio::process::Command::new("podman");
|
||||||
|
command.args(["unshare", "tar"]);
|
||||||
|
snapshot_with_command(manifest, data_dir, previous_unit, command).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot_with_command(
|
||||||
|
manifest: &AppManifest,
|
||||||
|
data_dir: &Path,
|
||||||
|
previous_unit: Option<&[u8]>,
|
||||||
|
mut command: tokio::process::Command,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let sources = relative_sources(manifest, data_dir)?;
|
||||||
|
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
|
||||||
|
for source in &sources {
|
||||||
|
let path = data_dir.join(source);
|
||||||
|
if tokio::fs::symlink_metadata(&path)
|
||||||
|
.await?
|
||||||
|
.file_type()
|
||||||
|
.is_symlink()
|
||||||
|
{
|
||||||
|
bail!("network migration state mount is a symlink; explicit backup required");
|
||||||
|
}
|
||||||
|
let canonical = tokio::fs::canonicalize(&path).await?;
|
||||||
|
if !canonical.starts_with(&canonical_root) {
|
||||||
|
bail!("network migration state path resolves outside node data directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = data_dir.join("migration-backups");
|
||||||
|
tokio::fs::create_dir_all(&root).await?;
|
||||||
|
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
|
||||||
|
let dir = root.join(uuid::Uuid::new_v4().to_string());
|
||||||
|
tokio::fs::create_dir(&dir).await?;
|
||||||
|
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
|
||||||
|
if let Some(unit) = previous_unit {
|
||||||
|
let path = dir.join("previous.container");
|
||||||
|
tokio::fs::write(&path, unit).await?;
|
||||||
|
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
|
||||||
|
tokio::fs::File::open(&path).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
let partial = dir.join("state.tar.partial");
|
||||||
|
let archive = dir.join("state.tar");
|
||||||
|
let output = command
|
||||||
|
.args([
|
||||||
|
"--create",
|
||||||
|
"--numeric-owner",
|
||||||
|
"--acls",
|
||||||
|
"--xattrs",
|
||||||
|
"--file",
|
||||||
|
])
|
||||||
|
.arg(&partial)
|
||||||
|
.arg("--directory")
|
||||||
|
.arg(data_dir)
|
||||||
|
.arg("--")
|
||||||
|
.args(&sources)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("start rootless migration snapshot")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
// No tar stderr in public logs: it can contain private filenames.
|
||||||
|
let _ = tokio::fs::remove_file(&partial).await;
|
||||||
|
bail!("persistent-state snapshot failed; original state was left intact");
|
||||||
|
}
|
||||||
|
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
|
||||||
|
tokio::fs::File::open(&partial).await?.sync_all().await?;
|
||||||
|
tokio::fs::rename(&partial, &archive).await?;
|
||||||
|
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
|
||||||
|
"network": manifest.app.container.network, "sources": sources});
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.join("metadata.json"),
|
||||||
|
serde_json::to_vec_pretty(&metadata)?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tokio::fs::File::open(&dir).await?.sync_all().await?;
|
||||||
|
Ok(archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn portainer() -> AppManifest {
|
||||||
|
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = dir.path().join("portainer");
|
||||||
|
tokio::fs::create_dir_all(state.join("compose"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("portainer.db"), b"fixture database")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = state.display().to_string();
|
||||||
|
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||||
|
let archive = snapshot_with_command(
|
||||||
|
&m,
|
||||||
|
dir.path(),
|
||||||
|
Some(b"old unit"),
|
||||||
|
tokio::process::Command::new("tar"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"old unit"
|
||||||
|
);
|
||||||
|
let restored = tempfile::tempdir().unwrap();
|
||||||
|
assert!(tokio::process::Command::new("tar")
|
||||||
|
.arg("-xf")
|
||||||
|
.arg(archive)
|
||||||
|
.arg("-C")
|
||||||
|
.arg(restored.path())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.success());
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(restored.path().join("portainer/portainer.db"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"fixture database"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
b"services: {}\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||||
|
b"fixture database"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = dir.path().join("portainer");
|
||||||
|
tokio::fs::create_dir_all(state.join("compose"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(state.join("portainer.db"), b"unchanged")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = state.display().to_string();
|
||||||
|
m.app.volumes[1].source = state.join("compose").display().to_string();
|
||||||
|
assert!(
|
||||||
|
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
|
||||||
|
b"unchanged"
|
||||||
|
);
|
||||||
|
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
|
||||||
|
assert!(!entry.unwrap().path().join("state.tar").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
|
||||||
|
let m = portainer();
|
||||||
|
assert!(enabled(&m).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
|
||||||
|
vec![PathBuf::from("portainer")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
|
||||||
|
let mut m = portainer();
|
||||||
|
m.app.volumes[0].source = "/other/operator/state".into();
|
||||||
|
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||||
|
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
|
||||||
|
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod hooks;
|
|||||||
pub mod image_policy;
|
pub mod image_policy;
|
||||||
pub mod image_versions;
|
pub mod image_versions;
|
||||||
pub mod lnd;
|
pub mod lnd;
|
||||||
|
pub mod migration_backup;
|
||||||
pub mod prod_orchestrator;
|
pub mod prod_orchestrator;
|
||||||
pub mod quadlet;
|
pub mod quadlet;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ fn is_builtin_network_mode(network: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||||
|
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||||
|
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||||
|
matches!(expected, Some("slirp4netns" | "pasta"))
|
||||||
|
&& !actual.trim().is_empty()
|
||||||
|
&& actual.trim().split(':').next() != expected
|
||||||
|
}
|
||||||
|
|
||||||
fn uses_pasta_network(manifest: &AppManifest) -> bool {
|
fn uses_pasta_network(manifest: &AppManifest) -> bool {
|
||||||
manifest.app.container.network.as_deref() == Some("pasta")
|
manifest.app.container.network.as_deref() == Some("pasta")
|
||||||
}
|
}
|
||||||
@@ -2499,6 +2507,7 @@ impl ProdContainerOrchestrator {
|
|||||||
return Ok(ReconcileAction::NoOp);
|
return Ok(ReconcileAction::NoOp);
|
||||||
}
|
}
|
||||||
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
||||||
|
self.backup_network_change(&name, &resolved_manifest).await?;
|
||||||
let _ = self.runtime.stop_container(&name).await;
|
let _ = self.runtime.stop_container(&name).await;
|
||||||
let _ = self.runtime.remove_container(&name).await;
|
let _ = self.runtime.remove_container(&name).await;
|
||||||
self.install_fresh(lm).await?;
|
self.install_fresh(lm).await?;
|
||||||
@@ -2555,6 +2564,7 @@ impl ProdContainerOrchestrator {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
|
||||||
|
self.backup_network_change(&name, &resolved_manifest).await?;
|
||||||
let _ = self.runtime.remove_container(&name).await;
|
let _ = self.runtime.remove_container(&name).await;
|
||||||
self.install_fresh(lm).await?;
|
self.install_fresh(lm).await?;
|
||||||
return Ok(ReconcileAction::Installed);
|
return Ok(ReconcileAction::Installed);
|
||||||
@@ -3080,13 +3090,9 @@ impl ProdContainerOrchestrator {
|
|||||||
/// app is a companion (companion.rs owns those units), or when no
|
/// app is a companion (companion.rs owns those units), or when no
|
||||||
/// unit file exists yet (install_via_quadlet handles first-write).
|
/// unit file exists yet (install_via_quadlet handles first-write).
|
||||||
///
|
///
|
||||||
/// We DON'T restart the .service when content changes — running
|
/// Ordinary metadata changes wait for an operator restart. Runtime-affecting
|
||||||
/// containers keep their current config until an operator-initiated
|
/// changes restart the service and retain a durable pending marker until
|
||||||
/// restart picks up the new file. That's the right tradeoff: file
|
/// that succeeds, including across daemon restarts and failed reloads.
|
||||||
/// updates are cheap and non-destructive; service restarts are
|
|
||||||
/// destructive (the SIGKILL cascade we're trying to eliminate).
|
|
||||||
/// systemctl --user daemon-reload runs only when content actually
|
|
||||||
/// changed, so steady-state reconcile ticks pay just one fs read.
|
|
||||||
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
|
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
|
||||||
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
||||||
// companion.rs renders these units with a different shape, syncing
|
// companion.rs renders these units with a different shape, syncing
|
||||||
@@ -3106,7 +3112,7 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
let old_body = tokio::fs::read_to_string(&unit_path)
|
let old_body = tokio::fs::read_to_string(&unit_path)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.with_context(|| format!("read existing quadlet for {name}"))?;
|
||||||
let restart_required = quadlet::contains_stale_health_gate(&old_body);
|
let restart_required = quadlet::contains_stale_health_gate(&old_body);
|
||||||
|
|
||||||
let mut resolved = lm.manifest.clone();
|
let mut resolved = lm.manifest.clone();
|
||||||
@@ -3122,49 +3128,47 @@ impl ProdContainerOrchestrator {
|
|||||||
quadlet::network_aliases_changed(&old_body, &new_body);
|
quadlet::network_aliases_changed(&old_body, &new_body);
|
||||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||||
|
let needs_restart = restart_required
|
||||||
|
|| restart_for_port_change
|
||||||
|
|| restart_for_network_alias_change
|
||||||
|
|| restart_for_exec_change
|
||||||
|
|| restart_for_health_change;
|
||||||
|
// Record the obligation BEFORE replacing the unit. A failed reload or
|
||||||
|
// restart must not become a no-op on the next tick just because the
|
||||||
|
// generated file already matches the manifest.
|
||||||
|
let pending = quadlet::RestartObligation::prepare(&unit_path, needs_restart).await?;
|
||||||
|
if pending.is_pending() {
|
||||||
|
self.ensure_resolved_source_available(lm).await?;
|
||||||
|
}
|
||||||
|
if restart_for_network_alias_change {
|
||||||
|
self.backup_network_change(name, &resolved).await?;
|
||||||
|
}
|
||||||
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
|
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
|
||||||
if changed {
|
if changed || pending.is_pending() {
|
||||||
quadlet::daemon_reload_user()
|
quadlet::daemon_reload_user()
|
||||||
.await
|
.await
|
||||||
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
|
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
|
||||||
tracing::info!(
|
|
||||||
app_id = %lm.manifest.app.id,
|
|
||||||
container = %name,
|
|
||||||
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if changed
|
if pending.is_pending() {
|
||||||
&& (restart_required
|
|
||||||
|| restart_for_port_change
|
|
||||||
|| restart_for_network_alias_change
|
|
||||||
|| restart_for_exec_change
|
|
||||||
|| restart_for_health_change)
|
|
||||||
{
|
|
||||||
self.ensure_resolved_source_available(lm).await?;
|
|
||||||
let service = unit.service_name();
|
let service = unit.service_name();
|
||||||
let reason = if restart_required {
|
|
||||||
"stale health gate"
|
|
||||||
} else if restart_for_port_change {
|
|
||||||
"port binding drift"
|
|
||||||
} else if restart_for_network_alias_change {
|
|
||||||
"network alias drift"
|
|
||||||
} else if restart_for_health_change {
|
|
||||||
"health command drift"
|
|
||||||
} else {
|
|
||||||
"exec drift"
|
|
||||||
};
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
app_id = %lm.manifest.app.id,
|
app_id = %lm.manifest.app.id,
|
||||||
container = %name,
|
container = %name,
|
||||||
service = %service,
|
service = %service,
|
||||||
reason = reason,
|
"Applying pending Quadlet runtime change"
|
||||||
"Quadlet unit rewrite requires service restart"
|
|
||||||
);
|
);
|
||||||
quadlet::restart_service(&service)
|
quadlet::restart_service(&service)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("restart drifted quadlet service {service}"))?;
|
.with_context(|| format!("restart drifted quadlet service {service}"))?;
|
||||||
|
pending.complete().await?;
|
||||||
|
} else if changed {
|
||||||
|
tracing::info!(
|
||||||
|
app_id = %lm.manifest.app.id,
|
||||||
|
container = %name,
|
||||||
|
"Quadlet metadata updated; operator restart will apply it"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -3483,11 +3487,9 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_grafana_port(&self) {
|
async fn cleanup_stale_grafana_port(&self) {
|
||||||
let _ = tokio::process::Command::new("pkill")
|
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a
|
||||||
.args(["-f", "pasta.*3001"])
|
// Grafana container proven absent from Podman's inventory.
|
||||||
.output()
|
crate::container::ghost_reaper::reap_for_app("grafana").await;
|
||||||
.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 {
|
||||||
@@ -3868,6 +3870,61 @@ impl ProdContainerOrchestrator {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn backup_network_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
|
||||||
|
if !crate::container::migration_backup::enabled(manifest)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Only back up an actual network migration, not ordinary env drift.
|
||||||
|
let output = tokio::process::Command::new("podman")
|
||||||
|
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||||
|
.output().await.context("inspect network before migration backup")?;
|
||||||
|
let present = if output.status.success() {
|
||||||
|
if !rootless_network_mode_drifted(manifest.app.container.network.as_deref(), &String::from_utf8_lossy(&output.stdout)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// A crash after gracefully stopping a --rm Quadlet container can
|
||||||
|
// leave only its data and old unit. Prove absence before snapshotting
|
||||||
|
// stopped state; an inspect/Podman failure is not proof of absence.
|
||||||
|
let exists = tokio::process::Command::new("podman")
|
||||||
|
.args(["container", "exists", name]).status().await?;
|
||||||
|
if exists.code() != Some(1) {
|
||||||
|
anyhow::bail!("cannot verify existing container before network migration backup");
|
||||||
|
}
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let service = format!("{name}.service");
|
||||||
|
let managed = quadlet::unit_exists(name).await;
|
||||||
|
let previous_unit = if managed {
|
||||||
|
Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if managed {
|
||||||
|
quadlet::stop_service(&service).await?;
|
||||||
|
} else if present {
|
||||||
|
self.runtime.stop_container(name).await?;
|
||||||
|
}
|
||||||
|
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await {
|
||||||
|
Ok(archive) => {
|
||||||
|
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before network migration");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
// The unit has not been rewritten yet. Restore its previous
|
||||||
|
// service on backup failure and report the migration failure.
|
||||||
|
let restored = if managed {
|
||||||
|
quadlet::enable_now(&service).await
|
||||||
|
} else {
|
||||||
|
self.runtime.start_container(name).await
|
||||||
|
};
|
||||||
|
restored.context("restore original app after failed migration snapshot")?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||||
if cfg!(test) {
|
if cfg!(test) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3877,6 +3934,23 @@ impl ProdContainerOrchestrator {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quadlet handles declarative Network= drift above. Legacy rootless
|
||||||
|
// Podman containers need the same convergence when no unit owns them.
|
||||||
|
if matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) {
|
||||||
|
if let Ok(output) = tokio::process::Command::new("podman")
|
||||||
|
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if output.status.success() && rootless_network_mode_drifted(
|
||||||
|
manifest.app.container.network.as_deref(),
|
||||||
|
&String::from_utf8_lossy(&output.stdout),
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let inspect = tokio::process::Command::new("podman")
|
let inspect = tokio::process::Command::new("podman")
|
||||||
.args([
|
.args([
|
||||||
"inspect",
|
"inspect",
|
||||||
@@ -4919,6 +4993,17 @@ mod tests {
|
|||||||
/// recovered when its siblings have live containers (the stack is
|
/// recovered when its siblings have live containers (the stack is
|
||||||
/// installed), and left alone when the whole stack is gone or the app
|
/// installed), and left alone when the whole stack is gone or the app
|
||||||
/// is not a stack member at all.
|
/// is not a stack member at all.
|
||||||
|
#[test]
|
||||||
|
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||||
|
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||||
|
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||||
|
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns"));
|
||||||
|
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true"));
|
||||||
|
assert!(!rootless_network_mode_drifted(None, "pasta"));
|
||||||
|
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
|
||||||
|
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||||
|
|||||||
@@ -938,6 +938,53 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
|||||||
!= directive_values(new_body, "HealthRetries=")
|
!= directive_values(new_body, "HealthRetries=")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A unit rewrite and a successful systemd restart are separate operations.
|
||||||
|
/// Keep the restart obligation across errors or a management-daemon restart.
|
||||||
|
pub struct RestartObligation {
|
||||||
|
marker: PathBuf,
|
||||||
|
pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartObligation {
|
||||||
|
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
|
||||||
|
let marker = unit_path.with_extension("restart-pending");
|
||||||
|
if newly_required {
|
||||||
|
// Contents contain no manifest environment or credentials. sync_all
|
||||||
|
// makes the obligation durable before the subsequent unit rename.
|
||||||
|
let file = tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(&marker)
|
||||||
|
.await
|
||||||
|
.context("record pending Quadlet restart")?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
if let Some(parent) = marker.parent() {
|
||||||
|
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pending = tokio::fs::try_exists(&marker).await?;
|
||||||
|
Ok(Self { marker, pending })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_pending(&self) -> bool {
|
||||||
|
self.pending
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call only after systemd accepted the replacement service successfully.
|
||||||
|
pub async fn complete(self) -> Result<()> {
|
||||||
|
if self.pending {
|
||||||
|
tokio::fs::remove_file(&self.marker)
|
||||||
|
.await
|
||||||
|
.context("clear completed Quadlet restart")?;
|
||||||
|
if let Some(parent) = self.marker.parent() {
|
||||||
|
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
let old_ports = directive_values(old_body, "PublishPort=");
|
let old_ports = directive_values(old_body, "PublishPort=");
|
||||||
let new_ports = directive_values(new_body, "PublishPort=");
|
let new_ports = directive_values(new_body, "PublishPort=");
|
||||||
@@ -1541,6 +1588,28 @@ app:
|
|||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||||
|
let manifest = AppManifest::parse(include_str!(
|
||||||
|
"../../../../apps/portainer/manifest.yml"
|
||||||
|
))
|
||||||
|
.expect("shipped Portainer manifest must parse");
|
||||||
|
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
|
||||||
|
assert!(new.contains("Network=slirp4netns\n"));
|
||||||
|
assert!(!new.contains("NetworkAlias="));
|
||||||
|
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
|
||||||
|
assert!(!new.contains("PublishPort=0.0.0.0"));
|
||||||
|
// The upgrade changes networking only: retain both state mounts and the
|
||||||
|
// existing rootless socket, without an app.ini or repository rewrite.
|
||||||
|
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
|
||||||
|
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
|
||||||
|
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
|
||||||
|
let old = new.replace("Network=slirp4netns\n", "");
|
||||||
|
assert!(network_aliases_changed(&old, &new));
|
||||||
|
assert!(!network_aliases_changed(&new, &new));
|
||||||
|
assert!(!publish_ports_changed(&old, &new));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_manifest_slirp4netns_omits_network_alias() {
|
fn from_manifest_slirp4netns_omits_network_alias() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
@@ -1891,6 +1960,35 @@ app:
|
|||||||
assert!(!network_aliases_changed(new, new));
|
assert!(!network_aliases_changed(new, new));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let unit = dir.path().join("portainer.container");
|
||||||
|
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
|
||||||
|
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||||
|
assert!(pending.is_pending());
|
||||||
|
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Simulate systemctl failure or daemon interruption after unit rewrite.
|
||||||
|
drop(pending);
|
||||||
|
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
|
||||||
|
assert!(retry.is_pending(), "matching unit must not discard failed restart");
|
||||||
|
retry.complete().await.unwrap();
|
||||||
|
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_runtime_change_errors_are_not_reported_as_success() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let missing = dir.path().join("missing/app.container");
|
||||||
|
assert!(RestartObligation::prepare(&missing, true).await.is_err());
|
||||||
|
let unit = dir.path().join("app.container");
|
||||||
|
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
|
||||||
|
tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap();
|
||||||
|
assert!(pending.complete().await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_aliases_changed_detects_network_mode_drift() {
|
fn network_aliases_changed_detects_network_mode_drift() {
|
||||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
let old = "[Container]\nNetwork=slirp4netns\n";
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ 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> {
|
||||||
@@ -220,12 +221,23 @@ 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 _ = fs::write(&path, json).await;
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
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;
|
||||||
@@ -235,6 +247,7 @@ 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;
|
||||||
@@ -252,6 +265,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1497,3 +1511,26 @@ 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ 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")]
|
||||||
@@ -297,6 +301,8 @@ 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,
|
||||||
|
|||||||
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
|
|||||||
};
|
};
|
||||||
|
|
||||||
crate::data_model::PackageDataEntry {
|
crate::data_model::PackageDataEntry {
|
||||||
state,
|
state: state.clone(),
|
||||||
// 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(),
|
||||||
@@ -1809,7 +1814,10 @@ 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 mut packages = scanner.scan_containers().await?;
|
let (before_scan, _) = state.get_snapshot().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) {
|
||||||
@@ -1870,11 +1878,14 @@ 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 {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
.mutate_data(|data| {
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
data.server_info.status_info.updated = update_available;
|
data.server_info.node_address =
|
||||||
state.update_data(data).await;
|
tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
|
data.server_info.status_info.updated = update_available;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1899,6 +1910,13 @@ 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 {
|
||||||
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if changed || tor_changed || first_scan || update_changed {
|
if changed || tor_changed || first_scan || update_changed {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.package_data = merged;
|
.mutate_data(|data| {
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
// A lifecycle operation may have started/finished while this scan
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
// awaited probes or disk I/O. Never overwrite that newer entry or
|
||||||
data.server_info.status_info.containers_scanned = true;
|
// resurrect one that an uninstall removed in the meantime.
|
||||||
data.server_info.status_info.updated = update_available;
|
apply_scanned_packages(&mut data.package_data, ¤t_data.package_data, &merged);
|
||||||
state.update_data(data).await;
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
debug!(
|
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
|
data.server_info.status_info.containers_scanned = true;
|
||||||
changed, tor_changed, first_scan, update_changed
|
data.server_info.status_info.updated = update_available;
|
||||||
);
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>,
|
||||||
) {
|
) {
|
||||||
@@ -2268,6 +2304,7 @@ 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,
|
||||||
@@ -2280,6 +2317,37 @@ 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
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ 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;
|
||||||
@@ -190,3 +205,29 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1746,40 +1746,28 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
exempt.sort();
|
exempt.sort();
|
||||||
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
|
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
|
||||||
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
|
// disappeared; Cuprate restricted RPC moved from none to gate-open.
|
||||||
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
|
// Compare exact endpoints, not just a count that can hide substitutions.
|
||||||
// upstream's own safe-for-public
|
let expected = [
|
||||||
// subset that wallets connect to directly as a "remote node" over
|
("bitcoin-core", 8333), ("bitcoin-knots", 8333),
|
||||||
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
|
("core-lightning", 9736), ("core-lightning", 9835),
|
||||||
// cuprate's unrestricted RPC (full node control) stays loopback-only
|
("cuprate", 18183), ("electrumx", 50001),
|
||||||
// (auth: local), not in this set.
|
("fedimint", 8173), ("fedimint", 8174),
|
||||||
//
|
("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
|
||||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080),
|
||||||
// loopback-only JSON API whose own generated http password
|
("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086),
|
||||||
// authenticates every request (added with the phoenixd onboarding,
|
("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400),
|
||||||
// which did not update this count — exactly the drift this test
|
("pine-piper", 10200), ("pine-whisper", 10300),
|
||||||
// exists to catch).
|
("router", 1900), ("router", 5353),
|
||||||
//
|
].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>();
|
||||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption");
|
||||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
|
||||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
|
||||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
|
||||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
|
||||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
|
||||||
// deliberate, rationale-carrying exemption; the release-gate test
|
|
||||||
// stage timed out that cycle, so the count here lagged at 17.
|
|
||||||
assert_eq!(
|
|
||||||
exempt.len(),
|
|
||||||
28,
|
|
||||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||||
/// same review guard as `auth: none`. Each one must be an app that
|
/// same review guard as `auth: none`. Each must enforce its own login or
|
||||||
/// enforces a real login of its own.
|
/// have an explicitly reviewed public protocol purpose.
|
||||||
#[test]
|
#[test]
|
||||||
fn gate_open_ports_are_all_accounted_for() {
|
fn gate_open_ports_are_all_accounted_for() {
|
||||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||||
@@ -1801,6 +1789,8 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
open.sort();
|
open.sort();
|
||||||
|
// Cuprate 18090 is its deliberately public restricted RPC subset;
|
||||||
|
// unrestricted node-control RPC remains container-loopback-only.
|
||||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
|
||||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||||
// by anonymous payers), and — since the v1.8.7 platform round — the
|
// by anonymous payers), and — since the v1.8.7 platform round — the
|
||||||
@@ -1812,11 +1802,12 @@ app:
|
|||||||
open,
|
open,
|
||||||
vec![
|
vec![
|
||||||
("btcpay-server".to_string(), 23000u16),
|
("btcpay-server".to_string(), 23000u16),
|
||||||
|
("cuprate".to_string(), 18090u16),
|
||||||
("gitea".to_string(), 3001u16),
|
("gitea".to_string(), 3001u16),
|
||||||
("nginx-proxy-manager".to_string(), 8081u16),
|
("nginx-proxy-manager".to_string(), 8081u16),
|
||||||
("tailscale".to_string(), 8240u16),
|
("tailscale".to_string(), 8240u16),
|
||||||
],
|
],
|
||||||
"gate-open port set changed — every entry must be an app with its own login"
|
"gate-open port set changed — review login or intentional public protocol purpose"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -310,59 +310,7 @@ impl PodmanClient {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
port_mappings.push(podman_publish_mapping(port));
|
||||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
|
||||||
// silently break relay discovery.
|
|
||||||
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
|
||||||
"udp" => "udp",
|
|
||||||
"sctp" => "sctp",
|
|
||||||
_ => "tcp",
|
|
||||||
};
|
|
||||||
// Effective bind. A gated port with no declared bind would
|
|
||||||
// publish 0.0.0.0 — the app would own every host address, which
|
|
||||||
// is both the exposure itself and the reason the daemon's app
|
|
||||||
// gate cannot bind those addresses to authenticate them. Pin it
|
|
||||||
// to loopback so the gate can take the external addresses.
|
|
||||||
//
|
|
||||||
// Doing it HERE, at container creation, is the point: the pin and
|
|
||||||
// the gate's takeover then both come from the daemon and cannot
|
|
||||||
// disagree. The earlier attempt put this decision in manifest
|
|
||||||
// data instead, and a node whose manifests lagged the binary
|
|
||||||
// published Bitcoin's loopback-only RPC across the LAN
|
|
||||||
// (test node, 2026-08-03).
|
|
||||||
//
|
|
||||||
// A port that already declares a bind is never overridden — that
|
|
||||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
|
||||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
|
||||||
// published as they are, so remote wallets keep working.
|
|
||||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
|
||||||
// published port to loopback is how an app hands its external
|
|
||||||
// addresses to the gate, but it belongs in the manifest, not in
|
|
||||||
// daemon-side inference:
|
|
||||||
//
|
|
||||||
// * `bind` is already honoured by every publish path (here and
|
|
||||||
// in package::install), so a manifest edit needs no code.
|
|
||||||
// * inference here would cover only THIS path — proven on
|
|
||||||
// a test node, where a recreate went through another one and
|
|
||||||
// the pin never applied.
|
|
||||||
// * and inferring from an ABSENT field is what republished
|
|
||||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
|
||||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
|
||||||
// every remote wallet.
|
|
||||||
//
|
|
||||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
|
||||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
|
||||||
// overridden by the catalog, which is precisely why the catalog is
|
|
||||||
// the right and only place to carry it.
|
|
||||||
let mut mapping = serde_json::json!({
|
|
||||||
"container_port": port.container,
|
|
||||||
"host_port": port.host,
|
|
||||||
"protocol": protocol,
|
|
||||||
});
|
|
||||||
if !port.bind.is_empty() {
|
|
||||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
|
||||||
}
|
|
||||||
port_mappings.push(mapping);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = Vec::new();
|
let mut mounts = Vec::new();
|
||||||
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
|
|||||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the explicitly declared bind and transport identical to Quadlet. The
|
||||||
|
// app gate owns external listeners; container publication must not bypass it.
|
||||||
|
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
|
||||||
|
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
|
||||||
|
"udp" => "udp",
|
||||||
|
"sctp" => "sctp",
|
||||||
|
_ => "tcp",
|
||||||
|
};
|
||||||
|
let mut mapping = serde_json::json!({
|
||||||
|
"container_port": port.container,
|
||||||
|
"host_port": port.host,
|
||||||
|
"protocol": protocol,
|
||||||
|
});
|
||||||
|
if !port.bind.is_empty() {
|
||||||
|
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||||
|
}
|
||||||
|
mapping
|
||||||
|
}
|
||||||
|
|
||||||
fn podman_network_settings(
|
fn podman_network_settings(
|
||||||
network: Option<&str>,
|
network: Option<&str>,
|
||||||
network_policy: &str,
|
network_policy: &str,
|
||||||
@@ -1110,6 +1077,15 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||||
|
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||||
|
assert_eq!(podman_network_settings(m.app.container.network.as_deref(), &m.app.security.network_policy), ("slirp4netns", None));
|
||||||
|
assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({
|
||||||
|
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -618,6 +618,28 @@ impl DockerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker is a development fallback. Refuse Podman-only network modes instead
|
||||||
|
// of silently installing a different topology; still honor binds for other apps.
|
||||||
|
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
|
||||||
|
let network = manifest.app.container.network.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or(&manifest.app.security.network_policy);
|
||||||
|
if matches!(network, "slirp4netns" | "pasta") {
|
||||||
|
anyhow::bail!("this app requires rootless Podman networking ({network})");
|
||||||
|
}
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if !network.is_empty() && network != "isolated" {
|
||||||
|
args.extend(["--network".to_owned(), network.to_owned()]);
|
||||||
|
}
|
||||||
|
for port in &manifest.app.ports {
|
||||||
|
let host = port.host.checked_add(offset).context("published port offset overflow")?;
|
||||||
|
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) };
|
||||||
|
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol };
|
||||||
|
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]);
|
||||||
|
}
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ContainerRuntime for DockerRuntime {
|
impl ContainerRuntime for DockerRuntime {
|
||||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||||
@@ -657,25 +679,7 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
cmd.arg("--read-only");
|
cmd.arg("--read-only");
|
||||||
}
|
}
|
||||||
|
|
||||||
match manifest.app.security.network_policy.as_str() {
|
cmd.args(docker_network_and_ports(manifest, port_offset)?);
|
||||||
"host" => {
|
|
||||||
cmd.arg("--network").arg("host");
|
|
||||||
}
|
|
||||||
"isolated" => {
|
|
||||||
// Docker uses bridge network by default
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
cmd.arg("--network")
|
|
||||||
.arg(&manifest.app.security.network_policy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Port mappings with offset
|
|
||||||
for port in &manifest.app.ports {
|
|
||||||
let host_port = port.host + port_offset;
|
|
||||||
cmd.arg("-p")
|
|
||||||
.arg(format!("{}:{}", host_port, port.container));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volumes
|
// Volumes
|
||||||
for volume in &manifest.app.volumes {
|
for volume in &manifest.app.volumes {
|
||||||
@@ -1035,6 +1039,17 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
|
||||||
|
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||||
|
assert!(docker_network_and_ports(&m, 0).is_err());
|
||||||
|
m.app.container.network = Some("bridge".into());
|
||||||
|
m.app.ports[0].protocol = "udp".into();
|
||||||
|
let args = docker_network_and_ports(&m, 1).unwrap();
|
||||||
|
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]);
|
||||||
|
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||||
// podman 5.x `inspect` phrasing for a missing container.
|
// podman 5.x `inspect` phrasing for a missing container.
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ 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. The resulting clean
|
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`
|
||||||
install reports zero npm advisories; its type-check, 152 unit tests, and
|
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
|
||||||
Archipelago subpath production build pass. Keeping this mechanical security
|
installs failed the retained dependency audit. The resulting clean install
|
||||||
|
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..0933917 100644
|
index 20631bb..86b6f86 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..0933917 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.9",
|
+ "version": "5.0.12",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -678,9 +678,9 @@ index 20631bb..0933917 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.18",
|
+ "version": "1.1.21",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -833,9 +833,9 @@ index 20631bb..0933917 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.7",
|
+ "version": "3.1.8",
|
||||||
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -859,9 +859,9 @@ index 20631bb..0933917 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.9",
|
+ "version": "5.0.12",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -893,9 +893,9 @@ index 20631bb..0933917 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.0",
|
+ "version": "10.7.2",
|
||||||
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -1445,4 +1445,3 @@ index bd7190c..6aa5a6f 100644
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
// Mock window.matchMedia
|
// Mock window.matchMedia
|
||||||
|
|
||||||
|
|||||||
+20
-8
@@ -14,6 +14,17 @@ 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
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
PRs remain open; the reviewed code has not 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.
|
||||||
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
||||||
and appears underneath the pruning information.** Operator reports white
|
and appears underneath the pruning information.** Operator reports white
|
||||||
styling with invisible text on the actual kiosk; the same flow works in remote
|
styling with invisible text on the actual kiosk; the same flow works in remote
|
||||||
@@ -28,20 +39,21 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
|||||||
alone do not establish that the kiosk rendering is fixed. Track for the next
|
alone do not establish that the kiosk rendering is fixed. Track for the next
|
||||||
release; the signed 1.8.21 artifacts remain unchanged.
|
release; the signed 1.8.21 artifacts remain unchanged.
|
||||||
|
|
||||||
## Current repair and release tasks — 2026-09-29
|
## 1.8.21 repair and release tasks — completed 2026-09-30
|
||||||
|
|
||||||
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
See the [execution record](repair-release-20260929.md) for evidence and limits.
|
||||||
|
|
||||||
- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
- [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.
|
||||||
- [ ] Complete the remaining Framework incident verification and evidence.
|
- [x] Record Framework verification and the operator's acceptance of the
|
||||||
- [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
|
remaining display check before release.
|
||||||
|
- [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.
|
||||||
- [ ] Offer pruning in the Bitcoin installation version modal, using the same
|
- [x] 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.
|
||||||
- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
- [x] 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.
|
||||||
- [ ] Test the completed changes on this development box, then publish a new
|
- [x] 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)
|
||||||
|
|||||||
@@ -290,3 +290,15 @@ app:
|
|||||||
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
|
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
|
||||||
with `scripts/generate-app-catalog.py` (drift-checked in CI by
|
with `scripts/generate-app-catalog.py` (drift-checked in CI by
|
||||||
`scripts/check-app-catalog-drift.py`).
|
`scripts/check-app-catalog-drift.py`).
|
||||||
|
|
||||||
|
### Persistent-state backup for network migrations
|
||||||
|
|
||||||
|
`app.backup_on_network_change: true` opts an app into a stopped-state snapshot
|
||||||
|
before an explicitly selected rootless network mode is migrated. The orchestrator
|
||||||
|
archives writable persistent bind mounts under the node data directory, collapses
|
||||||
|
nested mounts, excludes the runtime Podman socket, and preserves the previous
|
||||||
|
Quadlet definition for rollback. Named volumes, outside-data-root state and
|
||||||
|
symlinked mount roots fail closed rather than silently producing an incomplete
|
||||||
|
backup. A failed snapshot resumes the original service and leaves migration
|
||||||
|
pending. Private archives are retained under `migration-backups/`; fresh installs
|
||||||
|
and unchanged network configurations do not create migration snapshots.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Same-node Gitea sources in Portainer
|
||||||
|
|
||||||
|
Status: root cause reproduced and network repair verified in disposable Portainer
|
||||||
|
instances; final migration integration and release acceptance remain in progress.
|
||||||
|
This change belongs to the next signed catalog, OTA and ISO. It does not modify
|
||||||
|
published 1.8.21 artifacts.
|
||||||
|
|
||||||
|
## Confirmed cause
|
||||||
|
|
||||||
|
On the affected X250, Gitea 1.27.3 and Portainer 2.45.0 run in rootless Podman
|
||||||
|
5.4.2, managed by user Quadlet services. Gitea publishes HTTP on loopback and the
|
||||||
|
Archipelago app gate serves its public port. Gitea's public ROOT_URL already
|
||||||
|
matches that gate URL.
|
||||||
|
|
||||||
|
Portainer had no explicit network selection and Podman selected pasta. Its
|
||||||
|
network namespace contained the host's LAN address. A Git request to that same
|
||||||
|
LAN address therefore reached Portainer's namespace rather than the host gate:
|
||||||
|
connection refused before authentication. The exact smart-HTTP request from the
|
||||||
|
host returned 200 with `application/x-git-upload-pack-advertisement`. From
|
||||||
|
Portainer's actual namespace the LAN request was refused, while its host mapping
|
||||||
|
returned a Git advertisement and the expected branch tip. Direct container-IP
|
||||||
|
requests timed out. Container health and host-only HTTP checks missed the defect.
|
||||||
|
|
||||||
|
A disposable Portainer using `slirp4netns` successfully created a Source through
|
||||||
|
Portainer's own API, using the original LAN clone URL. Returning that fixture to
|
||||||
|
pasta reproduced the refusal; recreating with slirp repaired it while preserving
|
||||||
|
its account and saved Source. Restart also passed. The requested branch tip and
|
||||||
|
Compose file were read from that actual Portainer network namespace. No user
|
||||||
|
stack was deployed. Deployment addresses and repository details are kept outside
|
||||||
|
this public record.
|
||||||
|
|
||||||
|
## Source changes
|
||||||
|
|
||||||
|
- Declare Portainer's rootless `slirp4netns` mode in its manifest. No shared static
|
||||||
|
container IP, host networking, all-interface backend publication or auth bypass.
|
||||||
|
- Keep Gitea's loopback HTTP backend and gate port; machine Git uses Gitea's
|
||||||
|
authentication. Remove obsolete port-3000 nginx metadata/template and the old
|
||||||
|
best-effort installer commands which silently rewrote app.ini and falsely
|
||||||
|
claimed success. Gitea owns first-run setup and operator configuration.
|
||||||
|
- Existing Quadlet reconciliation applies Network= drift. Record a durable
|
||||||
|
pending restart before updating the unit and clear it only after a successful
|
||||||
|
restart, so failed reloads/restarts and management interruptions retry.
|
||||||
|
- Detect explicit rootless network-mode drift in the older Podman runtime too.
|
||||||
|
Unspecified networks do not trigger inferred changes to unrelated apps.
|
||||||
|
- Portainer opts into `backup_on_network_change`. Before recreation, gracefully
|
||||||
|
stop the app and archive its writable persistent bind mounts, including nested
|
||||||
|
Compose state, once each. Runtime sockets are excluded. Save the previous
|
||||||
|
Quadlet definition, where present. Archives live under the node data directory's
|
||||||
|
private `migration-backups/<id>/` directory; state is never deleted. Backup
|
||||||
|
failures resume the original service and fail the migration visibly.
|
||||||
|
- Keep Podman API and Quadlet bind/network behavior covered by actual-manifest
|
||||||
|
tests. Docker remains a development fallback: it now preserves bind/protocol
|
||||||
|
declarations and rejects Podman-only networking instead of silently changing it.
|
||||||
|
|
||||||
|
## Operator use and diagnostics
|
||||||
|
|
||||||
|
Use Gitea's advertised HTTP(S) clone URL in Portainer Sources, with the Gitea
|
||||||
|
username and token in the credential fields. On first-run Gitea setup, the public
|
||||||
|
base URL must match the origin opened through Archipelago (including its port).
|
||||||
|
Keep a deliberately configured HTTPS/domain origin when one exists. Do not use a
|
||||||
|
container IP or put a token into the URL. A private repository requires repository
|
||||||
|
read permission. A successful Source check fetches Git refs; it does not deploy
|
||||||
|
a stack or establish that a Compose build uses a desired application revision.
|
||||||
|
|
||||||
|
`scripts/check-portainer-git-source.py` calls Portainer's own read-only Source
|
||||||
|
connection test. Supply a private mode-600 JSON credential file containing
|
||||||
|
`api_key` or `jwt`, and optionally `git: {username, password}`. Pass
|
||||||
|
`--portainer-url`, `--repository-url` and `--credentials-file`. It does not create
|
||||||
|
Sources or stacks and prints no credentials or raw server errors. It distinguishes
|
||||||
|
Portainer login/API failures from Git connection refusal, timeout, DNS/TLS
|
||||||
|
failure, HTML/login interception and repository authentication failure. TLS
|
||||||
|
verification stays enabled and API redirects are refused.
|
||||||
|
|
||||||
|
## Upgrade and rollback
|
||||||
|
|
||||||
|
The signed catalog embeds manifests and overrides installed disk copies. A disk
|
||||||
|
edit alone cannot deliver this fix. Publish the matching catalog with the tested
|
||||||
|
runtime, then verify the generated unit, actual network mode and Source API.
|
||||||
|
Expect a Portainer interruption while the snapshot and recreation run; duration
|
||||||
|
depends on its saved state size.
|
||||||
|
Gitea does not need recreation or an app.ini rewrite for this repair.
|
||||||
|
|
||||||
|
Keep the previous trusted catalog/runtime for rollback. Restore that catalog
|
||||||
|
before restoring the saved `previous.container`, reloading user systemd and
|
||||||
|
starting Portainer; otherwise reconciliation will correctly reapply the new
|
||||||
|
manifest. The archive is a stopped-state emergency backup, not an instruction to
|
||||||
|
roll back a live database automatically. Restore it only with Portainer stopped
|
||||||
|
and after preserving any newer state. Do not replace Gitea data/config, keys,
|
||||||
|
repositories or the production Portainer database with disposable test data.
|
||||||
|
|
||||||
|
## Validation and remaining gates
|
||||||
|
|
||||||
|
- Disposable X250 Portainer Source API: old mode refuses; repaired mode succeeds;
|
||||||
|
saved account/Source survive recreation; restart succeeds.
|
||||||
|
- Invalid Git credentials produce a repository-authentication error, distinct
|
||||||
|
from TCP refusal. Requested branch and Compose file read from Portainer context.
|
||||||
|
- Final expanded backend suite: 1,575 passed, zero failed, four existing ignored
|
||||||
|
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
|
||||||
|
Five diagnostic regression tests passed. Combined tests with the merged
|
||||||
|
paid-download PRs remain pending.
|
||||||
|
- Still required before release: live automatic migration with the new runtime,
|
||||||
|
snapshot/rollback verification, private-repository and install-order acceptance,
|
||||||
|
lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
|
||||||
|
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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.
|
||||||
@@ -356,3 +356,42 @@ 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,21 +2607,14 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
|
|||||||
echo " ✅ Bundled image-versions.sh"
|
echo " ✅ Bundled image-versions.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bundle docker UI source files for building custom UIs on first boot
|
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||||
# Always bundle — these are tiny HTML/CSS files, not container images
|
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||||
if true; then
|
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
|
||||||
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
||||||
if [ -d "$DOCKER_UI_DIR" ]; then
|
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; }
|
||||||
echo " Bundling docker UI source files..."
|
mkdir -p "$ARCH_DIR/docker"
|
||||||
mkdir -p "$ARCH_DIR/docker"
|
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/"
|
||||||
for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
|
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR"
|
||||||
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)"
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
|
|
||||||
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
|
|
||||||
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
|
|
||||||
server {
|
|
||||||
listen 3000;
|
|
||||||
server_name _;
|
|
||||||
client_max_body_size 1G;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3001;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_hide_header X-Frame-Options;
|
|
||||||
proxy_hide_header Content-Security-Policy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,13 +25,22 @@
|
|||||||
<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">
|
||||||
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
|
<fieldset class="space-y-2">
|
||||||
<select
|
<legend class="text-white/60 text-sm mb-2">{{ t('appDetails.selectVersion') }}</legend>
|
||||||
v-model="selected"
|
<!-- Inline options avoid native popup rendering in the kiosk WebView.
|
||||||
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"
|
They stay in document flow above the pruning explanation. -->
|
||||||
>
|
<div class="max-h-40 overflow-y-auto space-y-2 rounded-lg">
|
||||||
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
|
<label
|
||||||
</select>
|
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" />
|
||||||
|
<span>{{ optionLabel(v) }}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
@@ -113,6 +122,7 @@ 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,12 +16,14 @@ 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('select').setValue('28.4')
|
await wrapper.get('input[type=radio][value="28.4"]').setValue(true)
|
||||||
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' }] })
|
||||||
@@ -29,17 +31,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').setValue(true)
|
await wrapper.get('input[type=checkbox]').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').element as HTMLInputElement).checked).toBe(false)
|
expect((wrapper.get('input[type=checkbox]').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').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([['latest', true]])
|
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||||
})
|
})
|
||||||
@@ -47,7 +49,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').element as HTMLInputElement).checked).toBe(true)
|
expect((wrapper.get('input[type=checkbox]').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]])
|
||||||
})
|
})
|
||||||
@@ -62,6 +64,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').exists()).toBe(false)
|
expect(wrapper.find('input[type=checkbox]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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(() => {
|
||||||
@@ -54,6 +55,25 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -239,6 +239,11 @@ 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()
|
||||||
|
|
||||||
@@ -295,7 +300,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.state === 'running' && !isAppReadyForLaunch(pkg)) {
|
if (pkg && (pkg['ui-ready'] === false || (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
|
||||||
}
|
}
|
||||||
@@ -394,6 +399,12 @@ 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
|
||||||
@@ -472,6 +483,17 @@ 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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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' },
|
||||||
@@ -149,7 +150,7 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
uninstallingApps.value.delete(appId)
|
uninstallingApps.value.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true, immediate: 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)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ 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
|
||||||
@@ -180,6 +181,7 @@ 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'
|
||||||
|
|||||||
@@ -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.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||||
})
|
})
|
||||||
|
|
||||||
const features = computed(() => {
|
const features = computed(() => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
: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"
|
||||||
@@ -375,6 +376,20 @@ 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() {
|
||||||
@@ -433,6 +448,7 @@ 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
|
||||||
@@ -442,11 +458,13 @@ 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 && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(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 && (!electrsSync || electrsSync.stale)"
|
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!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 && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
<div v-if="(iframeBlocked || uiReadyBlocked) && !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,7 +78,8 @@
|
|||||||
</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="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
<template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</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>
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
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"
|
||||||
>
|
>
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
|
<div v-if="!appUrl && !uiReadyBlocked" 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>
|
||||||
@@ -133,6 +135,7 @@ 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,3 +66,19 @@ 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,3 +184,24 @@ 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ 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']
|
||||||
@@ -277,6 +278,7 @@ 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'
|
||||||
@@ -285,6 +287,10 @@ 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'))
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/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.')
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test Git from Portainer's server context without creating a Source or stack."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify(error):
|
||||||
|
text = error.lower()
|
||||||
|
for category, patterns in (
|
||||||
|
('connection-refused', ('connection refused',)),
|
||||||
|
('dns-failure', ('no such host', 'name resolution', 'server misbehaving')),
|
||||||
|
('timeout', ('timeout', 'timed out', 'deadline exceeded')),
|
||||||
|
('tls-failure', ('x509:', 'certificate', 'tls handshake')),
|
||||||
|
('proxy-or-login-interception', ('text/html', '<html', '<!doctype', 'unexpected content-type', 'invalid pkt-len')),
|
||||||
|
('repository-authentication', ('authentication required', 'authentication failed', 'authorization failed', '401', '403')),
|
||||||
|
('repository-not-found-or-private', ('repository not found', '404')),
|
||||||
|
):
|
||||||
|
if any(pattern in text for pattern in patterns):
|
||||||
|
return category
|
||||||
|
return 'git-error'
|
||||||
|
|
||||||
|
|
||||||
|
def safe_url(value):
|
||||||
|
parsed = urllib.parse.urlsplit(value)
|
||||||
|
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
|
||||||
|
raise ValueError('Use an HTTP(S) URL')
|
||||||
|
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError('URLs must not contain credentials, query parameters or fragments')
|
||||||
|
return value.rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def private_json(path):
|
||||||
|
path = pathlib.Path(path)
|
||||||
|
if stat.S_IMODE(path.stat().st_mode) & 0o077:
|
||||||
|
raise ValueError('Credential file must be private (chmod 600)')
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def check(base, repository, credentials, opener=None):
|
||||||
|
base, repository = safe_url(base), safe_url(repository)
|
||||||
|
# JWT/API keys and Git credentials travel in headers/body, never URLs or logs.
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if credentials.get('api_key'):
|
||||||
|
headers['X-API-Key'] = credentials['api_key']
|
||||||
|
elif credentials.get('jwt'):
|
||||||
|
headers['Authorization'] = 'Bearer ' + credentials['jwt']
|
||||||
|
else:
|
||||||
|
raise ValueError('Credential file needs api_key or jwt')
|
||||||
|
payload = {'url': repository, 'tlsSkipVerify': False, 'interval': '5m'}
|
||||||
|
if credentials.get('git'):
|
||||||
|
payload['authentication'] = credentials['git']
|
||||||
|
request = urllib.request.Request(base + '/api/gitops/sources/test',
|
||||||
|
data=json.dumps(payload).encode(), headers=headers)
|
||||||
|
opener = opener or urllib.request.build_opener(NoRedirect())
|
||||||
|
try:
|
||||||
|
with opener.open(request, timeout=45) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
return {'success': False, 'category': 'portainer-authentication' if error.code in (401, 403) else 'portainer-api-error', 'http_status': error.code}
|
||||||
|
except (urllib.error.URLError, TimeoutError, socket.timeout) as error:
|
||||||
|
return {'success': False, 'category': 'portainer-api-' + classify(str(error))}
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return {'success': False, 'category': 'portainer-api-invalid-response'}
|
||||||
|
if not isinstance(result, dict) or not isinstance(result.get('success'), bool):
|
||||||
|
return {'success': False, 'category': 'portainer-api-invalid-response'}
|
||||||
|
return {'success': result['success'], 'category': 'git-refs-readable' if result['success'] else classify(str(result.get('error', '')))}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--portainer-url', required=True, help='Reachable Portainer origin, without /api')
|
||||||
|
parser.add_argument('--repository-url', required=True, help='The same clone URL entered in Portainer')
|
||||||
|
parser.add_argument('--credentials-file', required=True, help='Mode 600 JSON: api_key or jwt; optional git: {username,password}')
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
result = check(args.portainer_url, args.repository_url, private_json(args.credentials_file))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
parser.exit(2, 'Invalid URL or private credential file; no credentials were printed.\n')
|
||||||
|
print(json.dumps(result))
|
||||||
|
return 0 if result['success'] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -101,6 +101,7 @@ 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
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ 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"
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ command -v setpriv >/dev/null
|
|||||||
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
|
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
|
||||||
metadata=$(mktemp)
|
metadata=$(mktemp)
|
||||||
trap 'rm -f "$metadata"' EXIT
|
trap 'rm -f "$metadata"' EXIT
|
||||||
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \
|
case "${ARCHY_TEST_PACKAGE:-archipelago}" in
|
||||||
|
archipelago) test_target=(-p archipelago --bin archipelago) ;;
|
||||||
|
archipelago-container) test_target=(-p archipelago-container --lib) ;;
|
||||||
|
*) echo 'Unsupported isolated test package' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \
|
||||||
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
|
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
|
||||||
python3 - "$metadata" <<'PYDIAG'
|
python3 - "$metadata" <<'PYDIAG'
|
||||||
import json,sys
|
import json,sys
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
|
spec = importlib.util.spec_from_file_location('diagnostic', ROOT / 'scripts/check-portainer-git-source.py')
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAPI:
|
||||||
|
def __init__(self, result=None, error=None):
|
||||||
|
self.result, self.error, self.request = result, error, None
|
||||||
|
def open(self, request, timeout):
|
||||||
|
self.request = request
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return Response(json.dumps(self.result).encode())
|
||||||
|
|
||||||
|
|
||||||
|
class Diagnostics(unittest.TestCase):
|
||||||
|
def test_server_context_credentials_not_in_url_and_tls_stays_enabled(self):
|
||||||
|
api = FakeAPI({'success': True})
|
||||||
|
result = m.check('http://localhost:9000', 'http://node:3001/user/repo',
|
||||||
|
{'jwt': 'test-jwt', 'git': {'username': 'test-user', 'password': 'test-secret'}}, api)
|
||||||
|
self.assertTrue(result['success'])
|
||||||
|
self.assertEqual(api.request.full_url, 'http://localhost:9000/api/gitops/sources/test')
|
||||||
|
payload = json.loads(api.request.data)
|
||||||
|
self.assertFalse(payload['tlsSkipVerify'])
|
||||||
|
self.assertEqual(payload['authentication']['password'], 'test-secret')
|
||||||
|
self.assertNotIn('test-secret', json.dumps(result))
|
||||||
|
|
||||||
|
def test_failure_categories_from_source_api(self):
|
||||||
|
cases = [('dial tcp: connection refused', 'connection-refused'),
|
||||||
|
('lookup node: no such host', 'dns-failure'),
|
||||||
|
('context deadline exceeded', 'timeout'),
|
||||||
|
('unexpected content-type text/html', 'proxy-or-login-interception'),
|
||||||
|
('authentication required', 'repository-authentication'),
|
||||||
|
('x509: certificate signed by unknown authority', 'tls-failure'),
|
||||||
|
('repository not found', 'repository-not-found-or-private')]
|
||||||
|
for error, expected in cases:
|
||||||
|
with self.subTest(error=error):
|
||||||
|
result = m.check('http://localhost:9000', 'http://node/repo', {'jwt': 'test'}, FakeAPI({'success': False, 'error': error}))
|
||||||
|
self.assertEqual(result['category'], expected)
|
||||||
|
self.assertFalse(result['success'])
|
||||||
|
|
||||||
|
def test_portainer_auth_is_distinct_from_repository_auth(self):
|
||||||
|
api = FakeAPI(error=urllib.error.HTTPError('http://localhost', 401, 'Unauthorized', {}, None))
|
||||||
|
self.assertEqual(m.check('http://localhost', 'http://node/repo', {'jwt': 'bad'}, api)['category'], 'portainer-authentication')
|
||||||
|
|
||||||
|
def test_html_or_malformed_api_response_never_proves_git_success(self):
|
||||||
|
for value in ({'status': 1}, {'success': 'true'}, [], '<html>login</html>'):
|
||||||
|
self.assertFalse(m.check('http://localhost', 'http://node/repo', {'jwt': 'test'}, FakeAPI(value))['success'])
|
||||||
|
|
||||||
|
def test_credential_urls_rejected_before_request(self):
|
||||||
|
for value in ('http://user:secret@node/repo', 'http://node/repo?token=secret', 'file:///data/repo'):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
m.check('http://localhost', value, {'jwt': 'test'}, FakeAPI())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -71,6 +71,7 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user