Preserve apostrophes in Quadlet commands and record funded acceptance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user