Preserve apostrophes in Quadlet commands and record funded acceptance

This commit is contained in:
archipelago
2026-09-30 12:08:35 -04:00
parent 169bf77de6
commit 5ab65f7581
11 changed files with 343 additions and 104 deletions
@@ -25,8 +25,12 @@ fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
// Registry-only apps need the same guard as OTA-bundled manifests. Honor // Registry-only apps need the same guard as OTA-bundled manifests. Honor
// the verified catalog's effective manifest before the disk fallback. // the verified catalog's effective manifest before the disk fallback.
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values() if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
.into_iter().find(|(id, _)| id == package_id) { .into_iter()
if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) { .find(|(id, _)| id == package_id)
{
if let Some(manifest) =
crate::container::app_catalog::catalog_manifest_overlay(package_id, value)
{
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies); return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
} }
} }
@@ -1063,9 +1067,14 @@ mod tests {
// edit to `requires_unpruned_bitcoin`. // edit to `requires_unpruned_bitcoin`.
assert!(manifest_declares_archival_bitcoin("electrumx")); assert!(manifest_declares_archival_bitcoin("electrumx"));
assert!(manifest_declares_archival_bitcoin("mempool")); assert!(manifest_declares_archival_bitcoin("mempool"));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
"/../../apps/angor-indexer/manifest.yml"))).unwrap(); env!("CARGO_MANIFEST_DIR"),
assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies)); "/../../apps/angor-indexer/manifest.yml"
)))
.unwrap();
assert!(dependency_list_declares_archival_bitcoin(
&angor.app.dependencies
));
// An app whose manifest exists but never declares the marker. // An app whose manifest exists but never declares the marker.
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots")); assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
// An id with no manifest on disk at all. // An id with no manifest on disk at all.
+13 -5
View File
@@ -117,9 +117,12 @@ pub struct CatalogManifestVariant {
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> { fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
// Never let an unknown future requirement become an unsafe partial match. // Never let an unknown future requirement become an unsafe partial match.
for variant in entry.manifest_variants.into_iter().rev() { for variant in entry.manifest_variants.into_iter().rev() {
if !variant.requires.is_empty() && variant.requires.iter().all(|capability| { if !variant.requires.is_empty()
capability == "runtime-migration-backup-v1" && variant
}) { .requires
.iter()
.all(|capability| capability == "runtime-migration-backup-v1")
{
return Some(variant.manifest); return Some(variant.manifest);
} }
} }
@@ -587,7 +590,9 @@ mod tests {
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}] "manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
}); });
#[derive(Deserialize)] #[derive(Deserialize)]
struct OldEntry { manifest: serde_json::Value } struct OldEntry {
manifest: serde_json::Value,
}
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap(); let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
assert!(old.manifest["app"]["container"].get("network").is_none()); assert!(old.manifest["app"]["container"].get("network").is_none());
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap(); let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
@@ -595,7 +600,10 @@ mod tests {
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns"); assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
assert_eq!(chosen["app"]["backup_before_runtime_change"], true); assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
let mut future = raw; let mut future = raw;
future["manifest_variants"][0]["requires"].as_array_mut().unwrap().push(serde_json::json!("unknown-next-capability")); future["manifest_variants"][0]["requires"]
.as_array_mut()
.unwrap()
.push(serde_json::json!("unknown-next-capability"));
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap(); let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
assert!(chosen["app"]["container"].get("network").is_none()); assert!(chosen["app"]["container"].get("network").is_none());
} }
@@ -107,7 +107,9 @@ fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool { fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
expected.iter().any(|required| { expected.iter().any(|required| {
let required = required.strip_prefix("CAP_").unwrap_or(required); let required = required.strip_prefix("CAP_").unwrap_or(required);
!actual.iter().any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required) !actual
.iter()
.any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required)
}) })
} }
@@ -2484,7 +2486,8 @@ impl ProdContainerOrchestrator {
.await .await
{ {
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating"); tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?; self.backup_runtime_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?;
@@ -2520,7 +2523,8 @@ 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_runtime_change(&name, &resolved_manifest).await?; self.backup_runtime_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?;
@@ -2577,7 +2581,8 @@ 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_runtime_change(&name, &resolved_manifest).await?; self.backup_runtime_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);
@@ -2634,7 +2639,8 @@ impl ProdContainerOrchestrator {
self.prepare_for_start(&resolved_manifest).await?; self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await { if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating"); tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?; self.backup_runtime_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);
@@ -3899,7 +3905,9 @@ impl ProdContainerOrchestrator {
// opted-in manifests identify their persistent state through bind mounts. // opted-in manifests identify their persistent state through bind mounts.
let output = tokio::process::Command::new("podman") let output = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"]) .args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output().await.context("inspect network before migration backup")?; .output()
.await
.context("inspect network before migration backup")?;
let present = if output.status.success() { let present = if output.status.success() {
true true
} else { } else {
@@ -3907,7 +3915,9 @@ impl ProdContainerOrchestrator {
// leave only its data and old unit. Prove absence before snapshotting // leave only its data and old unit. Prove absence before snapshotting
// stopped state; an inspect/Podman failure is not proof of absence. // stopped state; an inspect/Podman failure is not proof of absence.
let exists = tokio::process::Command::new("podman") let exists = tokio::process::Command::new("podman")
.args(["container", "exists", name]).status().await?; .args(["container", "exists", name])
.status()
.await?;
if exists.code() != Some(1) { if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before runtime migration backup"); anyhow::bail!("cannot verify existing container before runtime migration backup");
} }
@@ -3916,7 +3926,10 @@ impl ProdContainerOrchestrator {
let service = format!("{name}.service"); let service = format!("{name}.service");
let managed = quadlet::unit_exists(name).await; let managed = quadlet::unit_exists(name).await;
let previous_unit = if managed { let previous_unit = if managed {
Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?) Some(
tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container")))
.await?,
)
} else { } else {
None None
}; };
@@ -3925,7 +3938,13 @@ impl ProdContainerOrchestrator {
} else if present { } else if present {
self.runtime.stop_container(name).await?; self.runtime.stop_container(name).await?;
} }
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await { match crate::container::migration_backup::snapshot(
manifest,
&self.data_dir,
previous_unit.as_deref(),
)
.await
{
Ok(archive) => { Ok(archive) => {
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration"); tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
Ok(()) Ok(())
@@ -3961,11 +3980,13 @@ impl ProdContainerOrchestrator {
if unmanaged && !manifest.app.security.capabilities.is_empty() { if unmanaged && !manifest.app.security.capabilities.is_empty() {
if let Ok(output) = tokio::process::Command::new("podman") if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{json .BoundingCaps}}"]) .args(["inspect", name, "--format", "{{json .BoundingCaps}}"])
.output().await .output()
.await
{ {
if output.status.success() { if output.status.success() {
if let Ok(actual) = serde_json::from_slice::<Vec<String>>(&output.stdout) { if let Ok(actual) = serde_json::from_slice::<Vec<String>>(&output.stdout) {
if missing_declared_capability(&manifest.app.security.capabilities, &actual) { if missing_declared_capability(&manifest.app.security.capabilities, &actual)
{
return true; return true;
} }
} }
@@ -3975,16 +3996,23 @@ impl ProdContainerOrchestrator {
// Quadlet handles declarative Network= drift above. Legacy rootless // Quadlet handles declarative Network= drift above. Legacy rootless
// Podman containers need the same convergence when no unit owns them. // Podman containers need the same convergence when no unit owns them.
if unmanaged && matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) { if unmanaged
&& matches!(
manifest.app.container.network.as_deref(),
Some("slirp4netns" | "pasta")
)
{
if let Ok(output) = tokio::process::Command::new("podman") if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"]) .args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output() .output()
.await .await
{ {
if output.status.success() && rootless_network_mode_drifted( if output.status.success()
&& rootless_network_mode_drifted(
manifest.app.container.network.as_deref(), manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout), &String::from_utf8_lossy(&output.stdout),
) { )
{
return true; return true;
} }
} }
@@ -4560,21 +4588,38 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
// Optional shared-service preconditions are checked before recording // Optional shared-service preconditions are checked before recording
// installation or creating anything. A headless adapter must not claim // installation or creating anything. A headless adapter must not claim
// successful installation against a missing indexing stack. // successful installation against a missing indexing stack.
if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites") if let Some(required) = lm
.and_then(|value| value.as_sequence()) { .manifest
let present = self.runtime.list_containers().await .app
.extensions
.get("install_prerequisites")
.and_then(|value| value.as_sequence())
{
let present = self
.runtime
.list_containers()
.await
.context("check installed prerequisite services")?; .context("check installed prerequisite services")?;
for id in required.iter().filter_map(|value| value.as_str()) { for id in required.iter().filter_map(|value| value.as_str()) {
let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError( let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError(
format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.", format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.",
lm.manifest.app.name)))?; lm.manifest.app.name)))?;
let name = compute_container_name(&dependency.manifest); let name = compute_container_name(&dependency.manifest);
if !present.iter().any(|container| container.name.trim_start_matches('/') == name) { if !present
.iter()
.any(|container| container.name.trim_start_matches('/') == name)
{
let owner = crate::app_ops::owning_package(id); let owner = crate::app_ops::owning_package(id);
let title = self.loaded(owner).await.map(|app| app.manifest.app.name) let title = self
.loaded(owner)
.await
.map(|app| app.manifest.app.name)
.unwrap_or(dependency.manifest.app.name); .unwrap_or(dependency.manifest.app.name);
return Err(InstallPrerequisiteError(format!( return Err(InstallPrerequisiteError(format!(
"Install {title} first, then install {}.", lm.manifest.app.name)).into()); "Install {title} first, then install {}.",
lm.manifest.app.name
))
.into());
} }
} }
} }
@@ -5055,37 +5100,71 @@ mod tests {
/// is not a stack member at all. /// is not a stack member at all.
#[tokio::test] #[tokio::test]
async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() { async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() {
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); let manifest =
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
let seed = &manifest.app.files[0]; let seed = &manifest.app.files[0];
assert!(!seed.overwrite); assert!(!seed.overwrite);
let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1"); let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1");
assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/")); assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/"));
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fresh/app.ini"); let path = dir.path().join("fresh/app.ini");
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Rewritten); assert_eq!(
assert!(tokio::fs::read_to_string(&path).await.unwrap().contains("ROOT_URL")); ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
let custom = "[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n"; .await
.unwrap(),
HookOutcome::Rewritten
);
assert!(tokio::fs::read_to_string(&path)
.await
.unwrap()
.contains("ROOT_URL"));
let custom =
"[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n";
tokio::fs::write(&path, custom).await.unwrap(); tokio::fs::write(&path, custom).await.unwrap();
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Unchanged); assert_eq!(
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
.await
.unwrap(),
HookOutcome::Unchanged
);
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom); assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom);
let impossible = path.join("app.ini"); let impossible = path.join("app.ini");
assert!(ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite).await.is_err()); assert!(
ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite)
.await
.is_err()
);
} }
#[test] #[test]
fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() { fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() {
let required = vec!["CHOWN".into(), "SYS_CHROOT".into()]; let required = vec!["CHOWN".into(), "SYS_CHROOT".into()];
assert!(missing_declared_capability(&required, &["CAP_CHOWN".into()])); assert!(missing_declared_capability(
assert!(!missing_declared_capability(&required, &["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()])); &required,
assert!(!missing_declared_capability(&required, &["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()])); &["CAP_CHOWN".into()]
));
assert!(!missing_declared_capability(
&required,
&["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()]
));
assert!(!missing_declared_capability(
&required,
&["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()]
));
} }
#[test] #[test]
fn explicit_rootless_network_change_converges_without_guessing_defaults() { fn explicit_rootless_network_change_converges_without_guessing_defaults() {
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta")); assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge")); assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns")); assert!(!rootless_network_mode_drifted(
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true")); 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(None, "pasta"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "")); assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge")); assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
@@ -5777,19 +5856,31 @@ app:
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await; let orch = orch_with(rt.clone()).await;
let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20"); let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20");
app.app.extensions.insert("install_prerequisites".into(), app.app.extensions.insert(
serde_yaml::to_value(vec!["shared-index"]).unwrap()); "install_prerequisites".into(),
orch.insert_manifest_for_test(app, PathBuf::from("/tmp")).await; serde_yaml::to_value(vec!["shared-index"]).unwrap(),
orch.insert_manifest_for_test(pull_manifest("shared-index", "index:1"), PathBuf::from("/tmp")).await; );
orch.insert_manifest_for_test(app, PathBuf::from("/tmp"))
.await;
orch.insert_manifest_for_test(
pull_manifest("shared-index", "index:1"),
PathBuf::from("/tmp"),
)
.await;
let error = orch.install("indexer-adapter").await.unwrap_err(); let error = orch.install("indexer-adapter").await.unwrap_err();
assert!(error.downcast_ref::<InstallPrerequisiteError>().is_some()); assert!(error.downcast_ref::<InstallPrerequisiteError>().is_some());
assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir).await.contains("indexer-adapter")); assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir)
.await
.contains("indexer-adapter"));
assert_eq!(rt.calls(), vec!["list_containers"]); assert_eq!(rt.calls(), vec!["list_containers"]);
// An installed prerequisite satisfies the guard; it is never recreated // An installed prerequisite satisfies the guard; it is never recreated
// or reconfigured as part of installing this adapter. // or reconfigured as part of installing this adapter.
rt.set_state("shared-index", ContainerState::Running); rt.set_state("shared-index", ContainerState::Running);
orch.install("indexer-adapter").await.unwrap(); orch.install("indexer-adapter").await.unwrap();
assert!(!rt.calls().iter().any(|c| c.starts_with("create_container:shared-index"))); assert!(!rt
.calls()
.iter()
.any(|c| c.starts_with("create_container:shared-index")));
} }
fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest { fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest {
+46 -13
View File
@@ -390,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
.iter() .iter()
.map(|p| { .map(|p| {
let p = p.replace(['\r', '\n'], " ").replace('%', "%%"); let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) { if p.is_empty()
|| p.chars()
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = p let escaped = p
.replace('\\', "\\\\") .replace('\\', "\\\\")
.replace('"', "\\\"") .replace('"', "\\\"")
@@ -410,7 +413,7 @@ fn quote_environment(env: &str) -> String {
if env.is_empty() if env.is_empty()
|| env || env
.chars() .chars()
.any(|c| c.is_whitespace() || "\"\\$`".contains(c)) .any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{ {
let escaped = env let escaped = env
.replace('\\', "\\\\") .replace('\\', "\\\\")
@@ -992,11 +995,19 @@ pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
} }
pub fn security_changed(old_body: &str, new_body: &str) -> bool { pub fn security_changed(old_body: &str, new_body: &str) -> bool {
["AddCapability=", "DropCapability=", "NoNewPrivileges=", "ReadOnly=", "User="] [
.iter().any(|directive| { "AddCapability=",
"DropCapability=",
"NoNewPrivileges=",
"ReadOnly=",
"User=",
]
.iter()
.any(|directive| {
let mut old = directive_values(old_body, directive); let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive); let mut new = directive_values(new_body, directive);
old.sort(); new.sort(); old.sort();
new.sort();
old != new old != new
}) })
} }
@@ -1386,6 +1397,18 @@ app:
); );
} }
#[test]
fn apostrophes_survive_quadlet_argument_and_environment_parsing() {
// A whitespace-free Node script reproduced this in a real Quadlet:
// unquoted apostrophes were consumed by the parser, changing JS strings
// into identifiers and preventing the app from starting.
assert_eq!(
shell_join(&["require('http')".into()]),
"\"require('http')\""
);
assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\"");
}
#[test] #[test]
fn quote_environment_quotes_values_with_spaces() { fn quote_environment_quotes_values_with_spaces() {
assert_eq!( assert_eq!(
@@ -1600,9 +1623,7 @@ app:
#[test] #[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() { fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!( let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
"../../../../apps/portainer/manifest.yml"
))
.expect("shipped Portainer manifest must parse"); .expect("shipped Portainer manifest must parse");
let new = QuadletUnit::from_manifest(&manifest, "portainer").render(); let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
assert!(new.contains("Network=slirp4netns\n")); assert!(new.contains("Network=slirp4netns\n"));
@@ -1983,9 +2004,15 @@ app:
// Simulate systemctl failure or daemon interruption after unit rewrite. // Simulate systemctl failure or daemon interruption after unit rewrite.
drop(pending); drop(pending);
let retry = RestartObligation::prepare(&unit, false).await.unwrap(); let retry = RestartObligation::prepare(&unit, false).await.unwrap();
assert!(retry.is_pending(), "matching unit must not discard failed restart"); assert!(
retry.is_pending(),
"matching unit must not discard failed restart"
);
retry.complete().await.unwrap(); retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending()); assert!(!RestartObligation::prepare(&unit, false)
.await
.unwrap()
.is_pending());
} }
#[tokio::test] #[tokio::test]
@@ -1995,20 +2022,26 @@ app:
assert!(RestartObligation::prepare(&missing, true).await.is_err()); assert!(RestartObligation::prepare(&missing, true).await.is_err());
let unit = dir.path().join("app.container"); let unit = dir.path().join("app.container");
let pending = RestartObligation::prepare(&unit, true).await.unwrap(); let pending = RestartObligation::prepare(&unit, true).await.unwrap();
tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap(); tokio::fs::remove_file(unit.with_extension("restart-pending"))
.await
.unwrap();
assert!(pending.complete().await.is_err()); assert!(pending.complete().await.is_err());
} }
#[test] #[test]
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() { fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap(); let manifest =
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
manifest.validate().unwrap(); manifest.validate().unwrap();
let new = QuadletUnit::from_manifest(&manifest, "gitea").render(); let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
assert!(new.contains("AddCapability=SYS_CHROOT\n")); assert!(new.contains("AddCapability=SYS_CHROOT\n"));
let old = new.replace("AddCapability=SYS_CHROOT\n", ""); let old = new.replace("AddCapability=SYS_CHROOT\n", "");
assert!(security_changed(&old, &new)); assert!(security_changed(&old, &new));
assert!(!security_changed(&new, &new)); assert!(!security_changed(&new, &new));
assert!(!security_changed("AddCapability=CHOWN\nAddCapability=SETUID\n", "AddCapability=SETUID\nAddCapability=CHOWN\n")); assert!(!security_changed(
"AddCapability=CHOWN\nAddCapability=SETUID\n",
"AddCapability=SETUID\nAddCapability=CHOWN\n"
));
} }
#[test] #[test]
+1
View File
@@ -5,6 +5,7 @@
//! are reachable over the mesh; ports of apps that aren't installed have //! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert. //! no listener, so allowing them is inert.
#[rustfmt::skip]
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2283,
2342, 2342,
+50 -19
View File
@@ -990,14 +990,20 @@ impl AppManifest {
validate_ports(&self.app.ports)?; validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?; validate_interfaces(&self.app.interfaces)?;
if let Some(value) = self.app.extensions.get("install_prerequisites") { if let Some(value) = self.app.extensions.get("install_prerequisites") {
let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid( let items = value.as_sequence().ok_or_else(|| {
"install_prerequisites must be a list of app ids".into()))?; ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
})?;
for item in items { for item in items {
let id = item.as_str().unwrap_or_default(); let id = item.as_str().unwrap_or_default();
if id.is_empty() || id == self.app.id || !id.bytes().all(|b| if id.is_empty()
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { || id == self.app.id
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(ManifestError::Invalid( return Err(ManifestError::Invalid(
"install_prerequisites must contain valid other app ids".into())); "install_prerequisites must contain valid other app ids".into(),
));
} }
} }
} }
@@ -1088,7 +1094,9 @@ impl AppManifest {
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") { if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
if value.as_bool().is_none() { if value.as_bool().is_none() {
return Err(ManifestError::Invalid("backup_before_runtime_change must be boolean".into())); return Err(ManifestError::Invalid(
"backup_before_runtime_change must be boolean".into(),
));
} }
} }
@@ -1769,18 +1777,38 @@ app:
// disappeared; Cuprate restricted RPC moved from none to gate-open. // disappeared; Cuprate restricted RPC moved from none to gate-open.
// Compare exact endpoints, not just a count that can hide substitutions. // Compare exact endpoints, not just a count that can hide substitutions.
let expected = [ let expected = [
("bitcoin-core", 8333), ("bitcoin-knots", 8333), ("bitcoin-core", 8333),
("core-lightning", 9736), ("core-lightning", 9835), ("bitcoin-knots", 8333),
("cuprate", 18183), ("electrumx", 50001), ("core-lightning", 9736),
("fedimint", 8173), ("fedimint", 8174), ("core-lightning", 9835),
("fedimint-gateway", 8176), ("fedimint-gateway", 9737), ("cuprate", 18183),
("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080), ("electrumx", 50001),
("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086), ("fedimint", 8173),
("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400), ("fedimint", 8174),
("pine-piper", 10200), ("pine-whisper", 10300), ("fedimint-gateway", 8176),
("router", 1900), ("router", 5353), ("fedimint-gateway", 9737),
].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>(); ("gitea", 2222),
assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption"); ("lnd", 9735),
("lnd", 10009),
("lnd", 18080),
("netbird", 8087),
("netbird-server", 3478),
("netbird-server", 8086),
("phoenixd", 9740),
("pine", 10381),
("pine-openwakeword", 10400),
("pine-piper", 10200),
("pine-whisper", 10300),
("router", 1900),
("router", 5353),
]
.into_iter()
.map(|(id, port)| (id.to_owned(), port))
.collect::<Vec<_>>();
assert_eq!(
exempt, expected,
"unauthenticated endpoint set changed; review each exemption"
);
} }
/// `auth: open` ports are served by the gate WITHOUT its login challenge, /// `auth: open` ports are served by the gate WITHOUT its login challenge,
@@ -1839,7 +1867,10 @@ app:
fn invalid_install_prerequisites_are_rejected() { fn invalid_install_prerequisites_are_rejected() {
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] { for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n"); let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites")); assert!(AppManifest::parse(&yaml)
.unwrap_err()
.to_string()
.contains("install_prerequisites"));
} }
} }
+12 -3
View File
@@ -1080,10 +1080,19 @@ mod tests {
#[test] #[test]
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() { fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap(); 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!(
assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({ 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" "container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
})); })
);
} }
#[test] #[test]
+29 -7
View File
@@ -621,7 +621,11 @@ impl DockerRuntime {
// Docker is a development fallback. Refuse Podman-only network modes instead // Docker is a development fallback. Refuse Podman-only network modes instead
// of silently installing a different topology; still honor binds for other apps. // of silently installing a different topology; still honor binds for other apps.
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> { fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
let network = manifest.app.container.network.as_deref() let network = manifest
.app
.container
.network
.as_deref()
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
.unwrap_or(&manifest.app.security.network_policy); .unwrap_or(&manifest.app.security.network_policy);
if matches!(network, "slirp4netns" | "pasta") { if matches!(network, "slirp4netns" | "pasta") {
@@ -632,10 +636,24 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
args.extend(["--network".to_owned(), network.to_owned()]); args.extend(["--network".to_owned(), network.to_owned()]);
} }
for port in &manifest.app.ports { for port in &manifest.app.ports {
let host = port.host.checked_add(offset).context("published port offset overflow")?; let host = port
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) }; .host
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol }; .checked_add(offset)
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]); .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) Ok(args)
} }
@@ -1041,12 +1059,16 @@ mod tests {
#[test] #[test]
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() { fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap(); let mut m =
AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert!(docker_network_and_ports(&m, 0).is_err()); assert!(docker_network_and_ports(&m, 0).is_err());
m.app.container.network = Some("bridge".into()); m.app.container.network = Some("bridge".into());
m.app.ports[0].protocol = "udp".into(); m.app.ports[0].protocol = "udp".into();
let args = docker_network_and_ports(&m, 1).unwrap(); let args = docker_network_and_ports(&m, 1).unwrap();
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]); assert_eq!(
args,
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
);
assert!(docker_network_and_ports(&m, u16::MAX).is_err()); assert!(docker_network_and_ports(&m, u16::MAX).is_err());
} }
+9 -4
View File
@@ -39,8 +39,10 @@ Release status and acceptance gates: [execution checklist](next-release-20260930
whether an existing first-class relay meets Angor's requirements or a relay whether an existing first-class relay meets Angor's requirements or a relay
must be packaged with the indexer, and use the Angor logo from angor.io for its must be packaged with the indexer, and use the Angor logo from angor.io for its
service icon. Official current deployment documentation located and reviewed: stock Mempool service icon. Official current deployment documentation located and reviewed: stock Mempool
plus an optional strfry relay. Reuse of existing indexing services is the plus an optional strfry relay. Both headless services and the dependency guard
proposed approach; implementation and acceptance remain pending. Include this service in the next-release scope. are implemented; API outage/recovery and five relay lifecycle cycles passed.
Final candidate install/lifecycle checks and signed delivery remain pending.
Install on the development box; Bitcoin must finish syncing for indexed queries.
- [ ] **App lifecycle: keep installed apps visible through restart and hard - [ ] **App lifecycle: keep installed apps visible through restart and hard
refresh; gate embedded/browser launches on actual web and listener readiness.** refresh; gate embedded/browser launches on actual web and listener readiness.**
@@ -55,11 +57,14 @@ Release status and acceptance gates: [execution checklist](next-release-20260930
rootless file permissions in disposable scratch storage. Combined result: rootless file permissions in disposable scratch storage. Combined result:
1,585 passed, zero failed, four existing tests ignored. See the 1,585 passed, zero failed, four existing tests ignored. See the
[review evidence and remaining acceptance work](pr-review-20260930.md). [review evidence and remaining acceptance work](pr-review-20260930.md).
- [ ] Integrate the reviewed PR branches into the next release and run funded - [x] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change. candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on Operator authorized completing the normal merge/closure workflow on
2026-09-30. Both PRs are now merged and closed through Gitea; integrate 2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets. local repair commits and sync git/ngit before release. The combined candidate
is deployed on both test endpoints. Funded Tor-only purchase with change,
confirmed refund, exact Files bytes and zero-cost repeat delivery passed.
The updated source still needs inclusion in signed OTA/ISO artifacts.
- [ ] Design durable recovery for an accepted payment whose response is lost. - [ ] Design durable recovery for an accepted payment whose response is lost.
Preserve the truthful unconfirmed-refund warning and prevent automatic Preserve the truthful unconfirmed-refund warning and prevent automatic
duplicate payment while that recovery work is outstanding. duplicate payment while that recovery work is outstanding.
+32 -3
View File
@@ -34,7 +34,7 @@ See the Framework incident and 1.8.21 execution records for evidence/limits.
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks | | X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate | | App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts | | X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Candidate funded Tor-only purchase, change and Files acceptance | | PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts |
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; still need final candidate reboot convergence and signed delivery | | Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; still need final candidate reboot convergence and signed delivery |
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync | | Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync |
@@ -82,5 +82,34 @@ ignored tests. This is one layer of evidence, not a substitute for live gates.
- Delivery target is the development box, as clarified by the operator. Do not - Delivery target is the development box, as clarified by the operator. Do not
install Angor on the separate Portainer node. Full indexer availability still install Angor on the separate Portainer node. Full indexer availability still
requires the dev box's Bitcoin sync and Mempool/Electrum indexing to finish. requires the dev box's Bitcoin sync and Mempool/Electrum indexing to finish.
- Funded PR acceptance remains pending spendable test ecash. No spent proofs - Funded PR acceptance passed after the operator funded the dev Cashu wallet
were reactivated and no native wallet funds were moved for these checks. with 16 sats. Exact net payment was 1 sat; underpayment refunded in full;
repeat delivery cost zero. Both endpoints ran the combined candidate.
No spent proofs were reactivated and no native Bitcoin/LND funds were moved.
## Development candidate and cleanup
The combined optimized backend and production UI are deployed on the development
box with a private rollback copy. Native Bitcoin/LND containers were unchanged
during deployment. The operator separately uninstalled/reinstalled Bitcoin Core
to select an unpruned node; RPC confirmed `pruned=false`, and a separate baseline
was recorded after that operator action. Do not compare subsequent checks with
the pre-reinstall container start times.
Completed Gitea setup/private-repository and Portainer integration fixtures were
uninstalled through the supported lifecycle and removed from installed inventory.
Their private evidence/data were retained outside the active manifests. The old
Cuprate UI review container was also removed. Active Angor acceptance fixtures
must be removed on completion; the requested Angor services remain installed.
Funded acceptance used Tor-only peer-file transport, verified exact delivery
bytes and compatibility response fields, and read the result back through
FileBrowser. The original transport preference was restored, and temporary
seller catalog entries/files and the exact buyer test document were removed.
Financial receipt history was retained.
The final managed-install fixture exposed a separate Quadlet quoting defect:
whitespace-free command arguments containing apostrophes lost those characters
in the generated service. The renderer now quotes these arguments and
environment values; the updated isolated backend suite passed (1,607 passed, four opt-in tests
ignored), and the final candidate rebuild is in progress. Do not tag a release before this live regression is verified.
+1
View File
@@ -208,6 +208,7 @@ def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
"//! are reachable over the mesh; ports of apps that aren\'t installed have", "//! are reachable over the mesh; ports of apps that aren\'t installed have",
"//! no listener, so allowing them is inert.", "//! no listener, so allowing them is inert.",
"", "",
"#[rustfmt::skip]",
"pub const APP_LAUNCH_PORTS: &[u16] = &[", "pub const APP_LAUNCH_PORTS: &[u16] = &[",
] ]
lines.extend(f" {port}," for port in distinct) lines.extend(f" {port}," for port in distinct)