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
// the verified catalog's effective manifest before the disk fallback.
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
.into_iter().find(|(id, _)| id == package_id) {
if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) {
.into_iter()
.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);
}
}
@@ -1063,9 +1067,14 @@ mod tests {
// edit to `requires_unpruned_bitcoin`.
assert!(manifest_declares_archival_bitcoin("electrumx"));
assert!(manifest_declares_archival_bitcoin("mempool"));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),
"/../../apps/angor-indexer/manifest.yml"))).unwrap();
assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../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.
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
// 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> {
// Never let an unknown future requirement become an unsafe partial match.
for variant in entry.manifest_variants.into_iter().rev() {
if !variant.requires.is_empty() && variant.requires.iter().all(|capability| {
capability == "runtime-migration-backup-v1"
}) {
if !variant.requires.is_empty()
&& variant
.requires
.iter()
.all(|capability| capability == "runtime-migration-backup-v1")
{
return Some(variant.manifest);
}
}
@@ -587,7 +590,9 @@ mod tests {
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
});
#[derive(Deserialize)]
struct OldEntry { manifest: serde_json::Value }
struct OldEntry {
manifest: serde_json::Value,
}
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
assert!(old.manifest["app"]["container"].get("network").is_none());
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"]["backup_before_runtime_change"], true);
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();
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 {
expected.iter().any(|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
{
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.remove_container(&name).await;
self.install_fresh(lm).await?;
@@ -2520,7 +2523,8 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::NoOp);
}
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.remove_container(&name).await;
self.install_fresh(lm).await?;
@@ -2577,7 +2581,8 @@ impl ProdContainerOrchestrator {
.await
{
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;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -2634,7 +2639,8 @@ impl ProdContainerOrchestrator {
self.prepare_for_start(&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");
self.backup_runtime_change(&name, &resolved_manifest).await?;
self.backup_runtime_change(&name, &resolved_manifest)
.await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -3899,7 +3905,9 @@ impl ProdContainerOrchestrator {
// opted-in manifests identify their persistent state through bind mounts.
let output = tokio::process::Command::new("podman")
.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() {
true
} else {
@@ -3907,7 +3915,9 @@ impl ProdContainerOrchestrator {
// 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?;
.args(["container", "exists", name])
.status()
.await?;
if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before runtime migration backup");
}
@@ -3916,7 +3926,10 @@ impl ProdContainerOrchestrator {
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?)
Some(
tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container")))
.await?,
)
} else {
None
};
@@ -3925,7 +3938,13 @@ impl ProdContainerOrchestrator {
} else if present {
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) => {
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
Ok(())
@@ -3961,11 +3980,13 @@ impl ProdContainerOrchestrator {
if unmanaged && !manifest.app.security.capabilities.is_empty() {
if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{json .BoundingCaps}}"])
.output().await
.output()
.await
{
if output.status.success() {
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;
}
}
@@ -3975,16 +3996,23 @@ impl ProdContainerOrchestrator {
// Quadlet handles declarative Network= drift above. Legacy rootless
// 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")
.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),
) {
if output.status.success()
&& rootless_network_mode_drifted(
manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout),
)
{
return true;
}
}
@@ -4560,21 +4588,38 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
// Optional shared-service preconditions are checked before recording
// installation or creating anything. A headless adapter must not claim
// successful installation against a missing indexing stack.
if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites")
.and_then(|value| value.as_sequence()) {
let present = self.runtime.list_containers().await
if let Some(required) = lm
.manifest
.app
.extensions
.get("install_prerequisites")
.and_then(|value| value.as_sequence())
{
let present = self
.runtime
.list_containers()
.await
.context("check installed prerequisite services")?;
for id in required.iter().filter_map(|value| value.as_str()) {
let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError(
format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.",
lm.manifest.app.name)))?;
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 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);
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.
#[tokio::test]
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];
assert!(!seed.overwrite);
let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1");
assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/"));
let dir = tempfile::tempdir().unwrap();
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!(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";
assert_eq!(
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
.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();
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);
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]
fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() {
let required = vec!["CHOWN".into(), "SYS_CHROOT".into()];
assert!(missing_declared_capability(&required, &["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()]));
assert!(missing_declared_capability(
&required,
&["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]
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(
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"));
@@ -5777,19 +5856,31 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20");
app.app.extensions.insert("install_prerequisites".into(),
serde_yaml::to_value(vec!["shared-index"]).unwrap());
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;
app.app.extensions.insert(
"install_prerequisites".into(),
serde_yaml::to_value(vec!["shared-index"]).unwrap(),
);
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();
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"]);
// An installed prerequisite satisfies the guard; it is never recreated
// or reconfigured as part of installing this adapter.
rt.set_state("shared-index", ContainerState::Running);
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 {
+51 -18
View File
@@ -390,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
.iter()
.map(|p| {
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
.replace('\\', "\\\\")
.replace('"', "\\\"")
@@ -410,7 +413,7 @@ fn quote_environment(env: &str) -> String {
if env.is_empty()
|| env
.chars()
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = env
.replace('\\', "\\\\")
@@ -992,13 +995,21 @@ pub fn publish_ports_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| {
let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive);
old.sort(); new.sort();
old != new
})
[
"AddCapability=",
"DropCapability=",
"NoNewPrivileges=",
"ReadOnly=",
"User=",
]
.iter()
.any(|directive| {
let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive);
old.sort();
new.sort();
old != new
})
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
@@ -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]
fn quote_environment_quotes_values_with_spaces() {
assert_eq!(
@@ -1600,10 +1623,8 @@ app:
#[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 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="));
@@ -1983,9 +2004,15 @@ app:
// 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");
assert!(
retry.is_pending(),
"matching unit must not discard failed restart"
);
retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
assert!(!RestartObligation::prepare(&unit, false)
.await
.unwrap()
.is_pending());
}
#[tokio::test]
@@ -1995,20 +2022,26 @@ app:
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();
tokio::fs::remove_file(unit.with_extension("restart-pending"))
.await
.unwrap();
assert!(pending.complete().await.is_err());
}
#[test]
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();
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
assert!(security_changed(&old, &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]
+1
View File
@@ -5,6 +5,7 @@
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
#[rustfmt::skip]
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283,
2342,
+50 -19
View File
@@ -990,14 +990,20 @@ impl AppManifest {
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
if let Some(value) = self.app.extensions.get("install_prerequisites") {
let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid(
"install_prerequisites must be a list of app ids".into()))?;
let items = value.as_sequence().ok_or_else(|| {
ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
})?;
for item in items {
let id = item.as_str().unwrap_or_default();
if id.is_empty() || id == self.app.id || !id.bytes().all(|b|
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
if id.is_empty()
|| id == self.app.id
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
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 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.
// Compare exact endpoints, not just a count that can hide substitutions.
let expected = [
("bitcoin-core", 8333), ("bitcoin-knots", 8333),
("core-lightning", 9736), ("core-lightning", 9835),
("cuprate", 18183), ("electrumx", 50001),
("fedimint", 8173), ("fedimint", 8174),
("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
("gitea", 2222), ("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");
("bitcoin-core", 8333),
("bitcoin-knots", 8333),
("core-lightning", 9736),
("core-lightning", 9835),
("cuprate", 18183),
("electrumx", 50001),
("fedimint", 8173),
("fedimint", 8174),
("fedimint-gateway", 8176),
("fedimint-gateway", 9737),
("gitea", 2222),
("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,
@@ -1839,7 +1867,10 @@ app:
fn invalid_install_prerequisites_are_rejected() {
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");
assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites"));
assert!(AppManifest::parse(&yaml)
.unwrap_err()
.to_string()
.contains("install_prerequisites"));
}
}
+13 -4
View File
@@ -1080,10 +1080,19 @@ 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"
}));
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]
+29 -7
View File
@@ -621,7 +621,11 @@ 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()
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") {
@@ -632,10 +636,24 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
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)]);
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)
}
@@ -1041,12 +1059,16 @@ mod tests {
#[test]
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());
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_eq!(
args,
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
);
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
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
plus an optional strfry relay. Reuse of existing indexing services is the
proposed approach; implementation and acceptance remain pending. Include this service in the next-release scope.
plus an optional strfry relay. Both headless services and the dependency guard
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
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:
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
- [x] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets.
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.
Preserve the truthful unconfirmed-refund warning and prevent automatic
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 |
| 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 |
| 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 |
| 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
install Angor on the separate Portainer node. Full indexer availability still
requires the dev box's Bitcoin sync and Mempool/Electrum indexing to finish.
- Funded PR acceptance remains pending spendable test ecash. No spent proofs
were reactivated and no native wallet funds were moved for these checks.
- Funded PR acceptance passed after the operator funded the dev Cashu wallet
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",
"//! no listener, so allowing them is inert.",
"",
"#[rustfmt::skip]",
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
]
lines.extend(f" {port}," for port in distinct)