Preserve apostrophes in Quadlet commands and record funded acceptance

This commit is contained in:
archipelago
2026-09-30 12:08:35 -04:00
parent 169bf77de6
commit 5ab65f7581
11 changed files with 343 additions and 104 deletions
@@ -25,8 +25,12 @@ fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
// Registry-only apps need the same guard as OTA-bundled manifests. Honor
// the verified catalog's effective manifest before the disk fallback.
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
.into_iter().find(|(id, _)| id == package_id) {
if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) {
.into_iter()
.find(|(id, _)| id == package_id)
{
if let Some(manifest) =
crate::container::app_catalog::catalog_manifest_overlay(package_id, value)
{
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
}
}
@@ -1063,9 +1067,14 @@ mod tests {
// edit to `requires_unpruned_bitcoin`.
assert!(manifest_declares_archival_bitcoin("electrumx"));
assert!(manifest_declares_archival_bitcoin("mempool"));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),
"/../../apps/angor-indexer/manifest.yml"))).unwrap();
assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../apps/angor-indexer/manifest.yml"
)))
.unwrap();
assert!(dependency_list_declares_archival_bitcoin(
&angor.app.dependencies
));
// An app whose manifest exists but never declares the marker.
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
// An id with no manifest on disk at all.
+13 -5
View File
@@ -117,9 +117,12 @@ pub struct CatalogManifestVariant {
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
// Never let an unknown future requirement become an unsafe partial match.
for variant in entry.manifest_variants.into_iter().rev() {
if !variant.requires.is_empty() && variant.requires.iter().all(|capability| {
capability == "runtime-migration-backup-v1"
}) {
if !variant.requires.is_empty()
&& variant
.requires
.iter()
.all(|capability| capability == "runtime-migration-backup-v1")
{
return Some(variant.manifest);
}
}
@@ -587,7 +590,9 @@ mod tests {
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
});
#[derive(Deserialize)]
struct OldEntry { manifest: serde_json::Value }
struct OldEntry {
manifest: serde_json::Value,
}
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
assert!(old.manifest["app"]["container"].get("network").is_none());
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
@@ -595,7 +600,10 @@ mod tests {
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
let mut future = raw;
future["manifest_variants"][0]["requires"].as_array_mut().unwrap().push(serde_json::json!("unknown-next-capability"));
future["manifest_variants"][0]["requires"]
.as_array_mut()
.unwrap()
.push(serde_json::json!("unknown-next-capability"));
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
assert!(chosen["app"]["container"].get("network").is_none());
}
@@ -107,7 +107,9 @@ fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
expected.iter().any(|required| {
let required = required.strip_prefix("CAP_").unwrap_or(required);
!actual.iter().any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required)
!actual
.iter()
.any(|cap| cap.strip_prefix("CAP_").unwrap_or(cap) == required)
})
}
@@ -2484,7 +2486,8 @@ impl ProdContainerOrchestrator {
.await
{
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?;
self.backup_runtime_change(&name, &resolved_manifest)
.await?;
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
@@ -2520,7 +2523,8 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::NoOp);
}
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?;
self.backup_runtime_change(&name, &resolved_manifest)
.await?;
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
@@ -2577,7 +2581,8 @@ impl ProdContainerOrchestrator {
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?;
self.backup_runtime_change(&name, &resolved_manifest)
.await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -2634,7 +2639,8 @@ impl ProdContainerOrchestrator {
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating");
self.backup_runtime_change(&name, &resolved_manifest).await?;
self.backup_runtime_change(&name, &resolved_manifest)
.await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -3899,7 +3905,9 @@ impl ProdContainerOrchestrator {
// opted-in manifests identify their persistent state through bind mounts.
let output = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output().await.context("inspect network before migration backup")?;
.output()
.await
.context("inspect network before migration backup")?;
let present = if output.status.success() {
true
} else {
@@ -3907,7 +3915,9 @@ impl ProdContainerOrchestrator {
// leave only its data and old unit. Prove absence before snapshotting
// stopped state; an inspect/Podman failure is not proof of absence.
let exists = tokio::process::Command::new("podman")
.args(["container", "exists", name]).status().await?;
.args(["container", "exists", name])
.status()
.await?;
if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before runtime migration backup");
}
@@ -3916,7 +3926,10 @@ impl ProdContainerOrchestrator {
let service = format!("{name}.service");
let managed = quadlet::unit_exists(name).await;
let previous_unit = if managed {
Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?)
Some(
tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container")))
.await?,
)
} else {
None
};
@@ -3925,7 +3938,13 @@ impl ProdContainerOrchestrator {
} else if present {
self.runtime.stop_container(name).await?;
}
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await {
match crate::container::migration_backup::snapshot(
manifest,
&self.data_dir,
previous_unit.as_deref(),
)
.await
{
Ok(archive) => {
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
Ok(())
@@ -3961,11 +3980,13 @@ impl ProdContainerOrchestrator {
if unmanaged && !manifest.app.security.capabilities.is_empty() {
if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{json .BoundingCaps}}"])
.output().await
.output()
.await
{
if output.status.success() {
if let Ok(actual) = serde_json::from_slice::<Vec<String>>(&output.stdout) {
if missing_declared_capability(&manifest.app.security.capabilities, &actual) {
if missing_declared_capability(&manifest.app.security.capabilities, &actual)
{
return true;
}
}
@@ -3975,16 +3996,23 @@ impl ProdContainerOrchestrator {
// Quadlet handles declarative Network= drift above. Legacy rootless
// Podman containers need the same convergence when no unit owns them.
if unmanaged && matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) {
if unmanaged
&& matches!(
manifest.app.container.network.as_deref(),
Some("slirp4netns" | "pasta")
)
{
if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output()
.await
{
if output.status.success() && rootless_network_mode_drifted(
manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout),
) {
if output.status.success()
&& rootless_network_mode_drifted(
manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout),
)
{
return true;
}
}
@@ -4560,21 +4588,38 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
// Optional shared-service preconditions are checked before recording
// installation or creating anything. A headless adapter must not claim
// successful installation against a missing indexing stack.
if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites")
.and_then(|value| value.as_sequence()) {
let present = self.runtime.list_containers().await
if let Some(required) = lm
.manifest
.app
.extensions
.get("install_prerequisites")
.and_then(|value| value.as_sequence())
{
let present = self
.runtime
.list_containers()
.await
.context("check installed prerequisite services")?;
for id in required.iter().filter_map(|value| value.as_str()) {
let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError(
format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.",
lm.manifest.app.name)))?;
let name = compute_container_name(&dependency.manifest);
if !present.iter().any(|container| container.name.trim_start_matches('/') == name) {
if !present
.iter()
.any(|container| container.name.trim_start_matches('/') == name)
{
let owner = crate::app_ops::owning_package(id);
let title = self.loaded(owner).await.map(|app| app.manifest.app.name)
let title = self
.loaded(owner)
.await
.map(|app| app.manifest.app.name)
.unwrap_or(dependency.manifest.app.name);
return Err(InstallPrerequisiteError(format!(
"Install {title} first, then install {}.", lm.manifest.app.name)).into());
"Install {title} first, then install {}.",
lm.manifest.app.name
))
.into());
}
}
}
@@ -5055,37 +5100,71 @@ mod tests {
/// is not a stack member at all.
#[tokio::test]
async fn gitea_fresh_url_seed_preserves_operator_config_and_reports_write_failure() {
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
let seed = &manifest.app.files[0];
assert!(!seed.overwrite);
let content = seed.content.replace("{{HOST_IP}}", "192.0.2.1");
assert!(content.contains("ROOT_URL = http://192.0.2.1:3001/"));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fresh/app.ini");
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Rewritten);
assert!(tokio::fs::read_to_string(&path).await.unwrap().contains("ROOT_URL"));
let custom = "[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n";
assert_eq!(
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
.await
.unwrap(),
HookOutcome::Rewritten
);
assert!(tokio::fs::read_to_string(&path)
.await
.unwrap()
.contains("ROOT_URL"));
let custom =
"[server]\nROOT_URL = https://git.example.test/\n[database]\nDB_TYPE = postgres\n";
tokio::fs::write(&path, custom).await.unwrap();
assert_eq!(ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite).await.unwrap(), HookOutcome::Unchanged);
assert_eq!(
ensure_rendered_file(path.to_str().unwrap(), &content, seed.overwrite)
.await
.unwrap(),
HookOutcome::Unchanged
);
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), custom);
let impossible = path.join("app.ini");
assert!(ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite).await.is_err());
assert!(
ensure_rendered_file(impossible.to_str().unwrap(), &content, seed.overwrite)
.await
.is_err()
);
}
#[test]
fn ssh_sandbox_capability_repair_uses_bounding_set_and_preserves_extra_overrides() {
let required = vec!["CHOWN".into(), "SYS_CHROOT".into()];
assert!(missing_declared_capability(&required, &["CAP_CHOWN".into()]));
assert!(!missing_declared_capability(&required, &["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()]));
assert!(!missing_declared_capability(&required, &["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()]));
assert!(missing_declared_capability(
&required,
&["CAP_CHOWN".into()]
));
assert!(!missing_declared_capability(
&required,
&["CAP_CHOWN".into(), "CAP_SYS_CHROOT".into()]
));
assert!(!missing_declared_capability(
&required,
&["CHOWN".into(), "SYS_CHROOT".into(), "CAP_KILL".into()]
));
}
#[test]
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true"));
assert!(!rootless_network_mode_drifted(
Some("slirp4netns"),
"slirp4netns"
));
assert!(!rootless_network_mode_drifted(
Some("slirp4netns"),
"slirp4netns:allow_host_loopback=true"
));
assert!(!rootless_network_mode_drifted(None, "pasta"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
@@ -5777,19 +5856,31 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20");
app.app.extensions.insert("install_prerequisites".into(),
serde_yaml::to_value(vec!["shared-index"]).unwrap());
orch.insert_manifest_for_test(app, PathBuf::from("/tmp")).await;
orch.insert_manifest_for_test(pull_manifest("shared-index", "index:1"), PathBuf::from("/tmp")).await;
app.app.extensions.insert(
"install_prerequisites".into(),
serde_yaml::to_value(vec!["shared-index"]).unwrap(),
);
orch.insert_manifest_for_test(app, PathBuf::from("/tmp"))
.await;
orch.insert_manifest_for_test(
pull_manifest("shared-index", "index:1"),
PathBuf::from("/tmp"),
)
.await;
let error = orch.install("indexer-adapter").await.unwrap_err();
assert!(error.downcast_ref::<InstallPrerequisiteError>().is_some());
assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir).await.contains("indexer-adapter"));
assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir)
.await
.contains("indexer-adapter"));
assert_eq!(rt.calls(), vec!["list_containers"]);
// An installed prerequisite satisfies the guard; it is never recreated
// or reconfigured as part of installing this adapter.
rt.set_state("shared-index", ContainerState::Running);
orch.install("indexer-adapter").await.unwrap();
assert!(!rt.calls().iter().any(|c| c.starts_with("create_container:shared-index")));
assert!(!rt
.calls()
.iter()
.any(|c| c.starts_with("create_container:shared-index")));
}
fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest {
+51 -18
View File
@@ -390,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
if p.is_empty()
|| p.chars()
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = p
.replace('\\', "\\\\")
.replace('"', "\\\"")
@@ -410,7 +413,7 @@ fn quote_environment(env: &str) -> String {
if env.is_empty()
|| env
.chars()
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = env
.replace('\\', "\\\\")
@@ -992,13 +995,21 @@ pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
}
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
["AddCapability=", "DropCapability=", "NoNewPrivileges=", "ReadOnly=", "User="]
.iter().any(|directive| {
let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive);
old.sort(); new.sort();
old != new
})
[
"AddCapability=",
"DropCapability=",
"NoNewPrivileges=",
"ReadOnly=",
"User=",
]
.iter()
.any(|directive| {
let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive);
old.sort();
new.sort();
old != new
})
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
@@ -1386,6 +1397,18 @@ app:
);
}
#[test]
fn apostrophes_survive_quadlet_argument_and_environment_parsing() {
// A whitespace-free Node script reproduced this in a real Quadlet:
// unquoted apostrophes were consumed by the parser, changing JS strings
// into identifiers and preventing the app from starting.
assert_eq!(
shell_join(&["require('http')".into()]),
"\"require('http')\""
);
assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\"");
}
#[test]
fn quote_environment_quotes_values_with_spaces() {
assert_eq!(
@@ -1600,10 +1623,8 @@ app:
#[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!(
"../../../../apps/portainer/manifest.yml"
))
.expect("shipped Portainer manifest must parse");
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
.expect("shipped Portainer manifest must parse");
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
assert!(new.contains("Network=slirp4netns\n"));
assert!(!new.contains("NetworkAlias="));
@@ -1983,9 +2004,15 @@ app:
// Simulate systemctl failure or daemon interruption after unit rewrite.
drop(pending);
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
assert!(retry.is_pending(), "matching unit must not discard failed restart");
assert!(
retry.is_pending(),
"matching unit must not discard failed restart"
);
retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
assert!(!RestartObligation::prepare(&unit, false)
.await
.unwrap()
.is_pending());
}
#[tokio::test]
@@ -1995,20 +2022,26 @@ app:
assert!(RestartObligation::prepare(&missing, true).await.is_err());
let unit = dir.path().join("app.container");
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap();
tokio::fs::remove_file(unit.with_extension("restart-pending"))
.await
.unwrap();
assert!(pending.complete().await.is_err());
}
#[test]
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
let manifest = AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
manifest.validate().unwrap();
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
assert!(security_changed(&old, &new));
assert!(!security_changed(&new, &new));
assert!(!security_changed("AddCapability=CHOWN\nAddCapability=SETUID\n", "AddCapability=SETUID\nAddCapability=CHOWN\n"));
assert!(!security_changed(
"AddCapability=CHOWN\nAddCapability=SETUID\n",
"AddCapability=SETUID\nAddCapability=CHOWN\n"
));
}
#[test]
+1
View File
@@ -5,6 +5,7 @@
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
#[rustfmt::skip]
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283,
2342,
+50 -19
View File
@@ -990,14 +990,20 @@ impl AppManifest {
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
if let Some(value) = self.app.extensions.get("install_prerequisites") {
let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid(
"install_prerequisites must be a list of app ids".into()))?;
let items = value.as_sequence().ok_or_else(|| {
ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
})?;
for item in items {
let id = item.as_str().unwrap_or_default();
if id.is_empty() || id == self.app.id || !id.bytes().all(|b|
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
if id.is_empty()
|| id == self.app.id
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(ManifestError::Invalid(
"install_prerequisites must contain valid other app ids".into()));
"install_prerequisites must contain valid other app ids".into(),
));
}
}
}
@@ -1088,7 +1094,9 @@ impl AppManifest {
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
if value.as_bool().is_none() {
return Err(ManifestError::Invalid("backup_before_runtime_change must be boolean".into()));
return Err(ManifestError::Invalid(
"backup_before_runtime_change must be boolean".into(),
));
}
}
@@ -1769,18 +1777,38 @@ app:
// disappeared; Cuprate restricted RPC moved from none to gate-open.
// Compare exact endpoints, not just a count that can hide substitutions.
let expected = [
("bitcoin-core", 8333), ("bitcoin-knots", 8333),
("core-lightning", 9736), ("core-lightning", 9835),
("cuprate", 18183), ("electrumx", 50001),
("fedimint", 8173), ("fedimint", 8174),
("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080),
("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086),
("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400),
("pine-piper", 10200), ("pine-whisper", 10300),
("router", 1900), ("router", 5353),
].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>();
assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption");
("bitcoin-core", 8333),
("bitcoin-knots", 8333),
("core-lightning", 9736),
("core-lightning", 9835),
("cuprate", 18183),
("electrumx", 50001),
("fedimint", 8173),
("fedimint", 8174),
("fedimint-gateway", 8176),
("fedimint-gateway", 9737),
("gitea", 2222),
("lnd", 9735),
("lnd", 10009),
("lnd", 18080),
("netbird", 8087),
("netbird-server", 3478),
("netbird-server", 8086),
("phoenixd", 9740),
("pine", 10381),
("pine-openwakeword", 10400),
("pine-piper", 10200),
("pine-whisper", 10300),
("router", 1900),
("router", 5353),
]
.into_iter()
.map(|(id, port)| (id.to_owned(), port))
.collect::<Vec<_>>();
assert_eq!(
exempt, expected,
"unauthenticated endpoint set changed; review each exemption"
);
}
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
@@ -1839,7 +1867,10 @@ app:
fn invalid_install_prerequisites_are_rejected() {
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites"));
assert!(AppManifest::parse(&yaml)
.unwrap_err()
.to_string()
.contains("install_prerequisites"));
}
}
+13 -4
View File
@@ -1080,10 +1080,19 @@ mod tests {
#[test]
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert_eq!(podman_network_settings(m.app.container.network.as_deref(), &m.app.security.network_policy), ("slirp4netns", None));
assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
}));
assert_eq!(
podman_network_settings(
m.app.container.network.as_deref(),
&m.app.security.network_policy
),
("slirp4netns", None)
);
assert_eq!(
podman_publish_mapping(&m.app.ports[0]),
serde_json::json!({
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
})
);
}
#[test]
+29 -7
View File
@@ -621,7 +621,11 @@ impl DockerRuntime {
// Docker is a development fallback. Refuse Podman-only network modes instead
// of silently installing a different topology; still honor binds for other apps.
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
let network = manifest.app.container.network.as_deref()
let network = manifest
.app
.container
.network
.as_deref()
.filter(|v| !v.is_empty())
.unwrap_or(&manifest.app.security.network_policy);
if matches!(network, "slirp4netns" | "pasta") {
@@ -632,10 +636,24 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
args.extend(["--network".to_owned(), network.to_owned()]);
}
for port in &manifest.app.ports {
let host = port.host.checked_add(offset).context("published port offset overflow")?;
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) };
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol };
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]);
let host = port
.host
.checked_add(offset)
.context("published port offset overflow")?;
let bind = if port.bind.is_empty() {
String::new()
} else {
format!("{}:", port.bind)
};
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
&port.protocol
};
args.extend([
"-p".to_owned(),
format!("{bind}{host}:{}/{protocol}", port.container),
]);
}
Ok(args)
}
@@ -1041,12 +1059,16 @@ mod tests {
#[test]
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
let mut m =
AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert!(docker_network_and_ports(&m, 0).is_err());
m.app.container.network = Some("bridge".into());
m.app.ports[0].protocol = "udp".into();
let args = docker_network_and_ports(&m, 1).unwrap();
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]);
assert_eq!(
args,
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
);
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
}