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
|
// 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.
|
||||||
|
|||||||
@@ -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()
|
||||||
manifest.app.container.network.as_deref(),
|
&& rootless_network_mode_drifted(
|
||||||
&String::from_utf8_lossy(&output.stdout),
|
manifest.app.container.network.as_deref(),
|
||||||
) {
|
&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 {
|
||||||
|
|||||||
@@ -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,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 {
|
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
["AddCapability=", "DropCapability=", "NoNewPrivileges=", "ReadOnly=", "User="]
|
[
|
||||||
.iter().any(|directive| {
|
"AddCapability=",
|
||||||
let mut old = directive_values(old_body, directive);
|
"DropCapability=",
|
||||||
let mut new = directive_values(new_body, directive);
|
"NoNewPrivileges=",
|
||||||
old.sort(); new.sort();
|
"ReadOnly=",
|
||||||
old != new
|
"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 {
|
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]
|
#[test]
|
||||||
fn quote_environment_quotes_values_with_spaces() {
|
fn quote_environment_quotes_values_with_spaces() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1600,10 +1623,8 @@ 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"));
|
||||||
assert!(!new.contains("NetworkAlias="));
|
assert!(!new.contains("NetworkAlias="));
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
m.app.container.network.as_deref(),
|
||||||
}));
|
&m.app.security.network_policy
|
||||||
|
),
|
||||||
|
("slirp4netns", None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
podman_publish_mapping(&m.app.ports[0]),
|
||||||
|
serde_json::json!({
|
||||||
|
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user