fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -118,10 +118,12 @@ 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")
|
||||
&& variant.requires.iter().all(|capability| {
|
||||
matches!(
|
||||
capability.as_str(),
|
||||
"runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some(variant.manifest);
|
||||
}
|
||||
@@ -468,6 +470,12 @@ pub struct CatalogRefresh {
|
||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> {
|
||||
// Explicit operator-only qualification of a signed candidate on selected
|
||||
// nodes. Never change fleet mirrors or fall back to an older public catalog
|
||||
// while a candidate is selected. Normal signature enforcement still applies.
|
||||
if let Some(path) = std::env::var_os("ARCHY_APP_CATALOG_CANDIDATE") {
|
||||
return refresh_candidate_catalog(data_dir, Path::new(&path)).await;
|
||||
}
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -514,6 +522,49 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn refresh_candidate_catalog(data_dir: &Path, path: &Path) -> anyhow::Result<CatalogRefresh> {
|
||||
anyhow::ensure!(
|
||||
path.is_absolute(),
|
||||
"candidate catalog path must be absolute"
|
||||
);
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.context("inspect candidate catalog")?;
|
||||
anyhow::ensure!(
|
||||
metadata.is_file() && metadata.len() <= 4 * 1024 * 1024,
|
||||
"candidate catalog must be a file no larger than 4 MiB"
|
||||
);
|
||||
let body = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.context("read candidate catalog")?;
|
||||
anyhow::ensure!(
|
||||
body.len() <= 4 * 1024 * 1024,
|
||||
"candidate catalog exceeds 4 MiB"
|
||||
);
|
||||
let raw: serde_json::Value = serde_json::from_str(&body)?;
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
crate::trust::verify_detached(&raw)?,
|
||||
crate::trust::SignatureStatus::Verified { anchored: true, .. }
|
||||
),
|
||||
"candidate catalog requires a signature anchored to the release root"
|
||||
);
|
||||
let catalog: AppCatalog = serde_json::from_value(raw)?;
|
||||
let changed = write_cache(data_dir, &body)?;
|
||||
if changed {
|
||||
*CACHE.lock().unwrap() = None;
|
||||
}
|
||||
info!(
|
||||
apps = catalog.apps.len(),
|
||||
changed,
|
||||
"app-catalog: using explicitly selected signed candidate; public catalog refresh paused"
|
||||
);
|
||||
Ok(CatalogRefresh {
|
||||
apps: catalog.apps.len(),
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -582,6 +633,77 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_candidate(key_byte: u8) -> serde_json::Value {
|
||||
// Same test anchor as trust::signed_doc tests; never a production key.
|
||||
let anchor = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
std::env::set_var(
|
||||
"ARCHY_RELEASE_ROOT_PUBKEY",
|
||||
hex::encode(anchor.verifying_key().to_bytes()),
|
||||
);
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[key_byte; 32]);
|
||||
let mut value = serde_json::json!({"schema":1,"apps":{"demo":{"version":"2"}},"future_field":{"retain":true}});
|
||||
let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &value).unwrap();
|
||||
value["signature"] = sig.into();
|
||||
value["signed_by"] = did.into();
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_catalog_preserves_signed_bytes_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let body = serde_json::to_string_pretty(&signed_candidate(7)).unwrap();
|
||||
std::fs::write(&path, &body).unwrap();
|
||||
let first = refresh_candidate_catalog(dir.path(), &path).await.unwrap();
|
||||
assert!(first.changed);
|
||||
assert_eq!(first.apps, 1);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
body
|
||||
);
|
||||
assert!(
|
||||
!refresh_candidate_catalog(dir.path(), &path)
|
||||
.await
|
||||
.unwrap()
|
||||
.changed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_candidate_never_replaces_previous_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let previous = "previous cached bytes";
|
||||
write_cache(dir.path(), previous).unwrap();
|
||||
let mut tampered = signed_candidate(7);
|
||||
tampered["apps"]["demo"]["version"] = "tampered".into();
|
||||
for body in [
|
||||
"malformed".into(),
|
||||
r#"{"schema":1,"apps":{}}"#.into(),
|
||||
signed_candidate(11).to_string(),
|
||||
tampered.to_string(),
|
||||
" ".repeat(4 * 1024 * 1024 + 1),
|
||||
] {
|
||||
std::fs::write(&path, body).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert!(
|
||||
refresh_candidate_catalog(dir.path(), Path::new("relative.json"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||
let raw = serde_json::json!({
|
||||
@@ -608,6 +730,25 @@ mod tests {
|
||||
assert!(chosen["app"]["container"].get("network").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_gateway_variant_requires_explicit_runtime_support() {
|
||||
let mut raw = serde_json::json!({
|
||||
"version": "2.12.1", "manifest": {"network":"pasta"},
|
||||
"manifest_variants": [{"requires":["runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"],
|
||||
"manifest":{"network":"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"}}]
|
||||
});
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw.clone()).unwrap()).unwrap()["network"],
|
||||
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
// A runtime missing any required capability must retain the base.
|
||||
raw["manifest_variants"][0]["requires"][1] = serde_json::json!("unknown-gateway-v2");
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw).unwrap()).unwrap()["network"],
|
||||
"pasta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -24,6 +24,7 @@ fn canonical_package_id(name: &str) -> &str {
|
||||
"immich-postgres" => "immich_postgres",
|
||||
"immich-redis" => "immich_redis",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
"btcpay" | "btcpayserver" => "btcpay-server",
|
||||
name => name,
|
||||
}
|
||||
}
|
||||
@@ -445,6 +446,15 @@ mod lifecycle_regression_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
||||
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
||||
assert_eq!(canonical_package_id(name), "btcpay-server");
|
||||
}
|
||||
assert_eq!(canonical_package_id("archy-btcpay-db"), "btcpay-db");
|
||||
assert_eq!(canonical_package_id("archy-nbxplorer"), "nbxplorer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
||||
for id in [
|
||||
|
||||
@@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi
|
||||
const DEFAULT_CONFIG_JSON: &str =
|
||||
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
|
||||
|
||||
/// One atomically published record shared by setup, Cloud and credentials UI.
|
||||
/// Deliberately has no Debug implementation: the password must never be logged.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CloudCredentials {
|
||||
pub schema: u32,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn cloud_credentials(directory: &Path) -> Result<CloudCredentials> {
|
||||
let path = directory.join("credentials.json");
|
||||
match fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let value: CloudCredentials = serde_json::from_slice(&bytes)
|
||||
.context("Invalid private File Browser credential record")?;
|
||||
let suffix = value.username.strip_prefix("archy-").unwrap_or("");
|
||||
anyhow::ensure!(
|
||||
value.schema == 1
|
||||
&& suffix.len() == 32
|
||||
&& suffix.bytes().all(|c| c.is_ascii_hexdigit())
|
||||
&& value.password.len() == 64
|
||||
&& value.password.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid managed File Browser credentials"
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Compatibility during staged upgrades only. Never invent admin/admin
|
||||
// when a secret is missing; the pre-start provisioner repairs legacy DBs.
|
||||
let password = fs::read_to_string(directory.join("password"))
|
||||
.await
|
||||
.context("File Browser secure Cloud login has not been provisioned")?;
|
||||
let password = password.trim().to_owned();
|
||||
anyhow::ensure!(
|
||||
!password.is_empty() && password != "admin",
|
||||
"File Browser default credentials must be migrated before Cloud login"
|
||||
);
|
||||
Ok(CloudCredentials {
|
||||
schema: 0,
|
||||
username: "admin".into(),
|
||||
password,
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error).context("Cannot read private File Browser credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a stopped server using the same helper used by Quadlet and the ISO.
|
||||
pub async fn prepare_credentials(
|
||||
paths: &EnsurePaths,
|
||||
secret_dir: &Path,
|
||||
image: &str,
|
||||
runtime: &str,
|
||||
) -> Result<()> {
|
||||
let output = tokio::process::Command::new("python3")
|
||||
.args([
|
||||
"-c",
|
||||
include_str!("../../../../scripts/filebrowser-credentials.py"),
|
||||
"--image",
|
||||
image,
|
||||
"--runtime",
|
||||
runtime,
|
||||
"--data-dir",
|
||||
&paths.data_dir.to_string_lossy(),
|
||||
"--srv-root",
|
||||
&paths.srv_root.to_string_lossy(),
|
||||
"--secrets-dir",
|
||||
&secret_dir.to_string_lossy(),
|
||||
])
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
.await
|
||||
.context("Running File Browser credential setup")?;
|
||||
anyhow::ensure!(output.status.success(),
|
||||
"File Browser secure login setup failed; existing state and private rollback backup retained");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub srv_root: PathBuf,
|
||||
@@ -82,7 +160,18 @@ async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> {
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let tmp = paths.config_path.with_extension("tmp");
|
||||
match fs::write(&tmp, DEFAULT_CONFIG_JSON).await {
|
||||
let legacy = paths.data_dir.join("database.db").exists();
|
||||
let canonical = paths.data_dir.join("filebrowser.db").exists();
|
||||
anyhow::ensure!(
|
||||
!(legacy && canonical),
|
||||
"Multiple File Browser databases need explicit config selection"
|
||||
);
|
||||
let config = if legacy {
|
||||
DEFAULT_CONFIG_JSON.replace("/data/filebrowser.db", "/data/database.db")
|
||||
} else {
|
||||
DEFAULT_CONFIG_JSON.to_string()
|
||||
};
|
||||
match fs::write(&tmp, &config).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.config_path)
|
||||
.await
|
||||
@@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
|
||||
shell_quote(&paths.config_path.to_string_lossy()),
|
||||
DEFAULT_CONFIG_JSON
|
||||
config
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
@@ -312,6 +401,62 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_credentials_use_unique_record_and_never_default_password() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "admin")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "legacy-unique-password")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cloud_credentials(dir.path()).await.unwrap().schema, 0);
|
||||
let value = serde_json::json!({"schema":1,"username":format!("archy-{}", "a".repeat(32)),"password":"b".repeat(64)});
|
||||
fs::write(dir.path().join("credentials.json"), value.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = cloud_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.username, value["username"].as_str().unwrap());
|
||||
assert_eq!(loaded.password, value["password"].as_str().unwrap());
|
||||
fs::write(dir.path().join("credentials.json"), "{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
cloud_credentials(dir.path()).await.is_err(),
|
||||
"damaged managed record must not fall back to old credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_config_preserves_legacy_database_and_refuses_ambiguity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
srv_root: tmp.path().join("srv"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
config_path: tmp.path().join("data/.filebrowser.json"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(paths.data_dir.join("database.db"), b"legacy fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_config(&paths).await.unwrap();
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&paths.config_path).await.unwrap()).unwrap();
|
||||
assert_eq!(config["database"], "/data/database.db");
|
||||
fs::remove_file(&paths.config_path).await.unwrap();
|
||||
fs::write(paths.data_dir.join("filebrowser.db"), b"other fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ensure_config(&paths).await.is_err());
|
||||
assert!(!paths.config_path.exists());
|
||||
assert_eq!(
|
||||
fs::read(paths.data_dir.join("database.db")).await.unwrap(),
|
||||
b"legacy fixture"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_creates_dirs_and_file() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod migration_backup;
|
||||
pub mod npm;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Preserve NPM's active persistent mounts across installer and runtime paths.
|
||||
use anyhow::{bail, Context, Result};
|
||||
use archipelago_container::AppManifest;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Storage {
|
||||
pub data: String,
|
||||
pub certificates: String,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn bind_mounts(&self) -> Vec<String> {
|
||||
vec![
|
||||
format!("{}:/data", self.data),
|
||||
format!("{}:/etc/letsencrypt", self.certificates),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn apply(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
for (target, source) in [
|
||||
("/data", &self.data),
|
||||
("/etc/letsencrypt", &self.certificates),
|
||||
] {
|
||||
let matching: Vec<_> = manifest
|
||||
.app
|
||||
.volumes
|
||||
.iter_mut()
|
||||
.filter(|volume| volume.target == target)
|
||||
.collect();
|
||||
if matching.len() != 1 {
|
||||
bail!("NPM requires one persistent mount per storage target");
|
||||
}
|
||||
let volume = matching.into_iter().next().unwrap();
|
||||
if volume.volume_type != "bind" {
|
||||
bail!("NPM persistent state requires bind mounts");
|
||||
}
|
||||
volume.source.clone_from(source);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_storage(bytes: &[u8]) -> Result<Storage> {
|
||||
let storage: Storage =
|
||||
serde_json::from_slice(bytes).context("invalid NPM storage resolution")?;
|
||||
for value in [&storage.data, &storage.certificates] {
|
||||
if !Path::new(value).is_absolute() || value.chars().any(|c| c.is_control() || c == ':') {
|
||||
bail!("invalid NPM persistent storage path");
|
||||
}
|
||||
}
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
pub async fn resolve_storage() -> Result<Storage> {
|
||||
// Verify live mounts/database before any caller can stop or recreate NPM.
|
||||
// Remember only validated mount paths so later recreation, after the inspect
|
||||
// record is removed, still uses the operator's original storage.
|
||||
let mut command = tokio::process::Command::new("python3");
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
include_str!("../../../../scripts/npm-public-bridge.py"),
|
||||
"--resolve",
|
||||
"--remember",
|
||||
"--prepare-realip",
|
||||
])
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(Duration::from_secs(75), command.output())
|
||||
.await
|
||||
.context("NPM storage resolution timed out; existing state preserved")??;
|
||||
if !output.status.success() {
|
||||
bail!("NPM storage resolution failed; inspect mount/database ambiguity or permissions before migration");
|
||||
}
|
||||
parse_storage(&output.stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolved_mounts_preserve_custom_and_legacy_paths() {
|
||||
for data in [
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data",
|
||||
"/srv/operator npm",
|
||||
] {
|
||||
let bytes = serde_json::to_vec(
|
||||
&serde_json::json!({"data": data, "certificates": "/srv/certificates"}),
|
||||
)
|
||||
.unwrap();
|
||||
let storage = parse_storage(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
storage.bind_mounts(),
|
||||
[
|
||||
format!("{data}:/data"),
|
||||
"/srv/certificates:/etc/letsencrypt".into(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_resolution_cannot_become_a_container_mount() {
|
||||
for data in ["relative", "/srv/data:ro", "/srv/data\nother"] {
|
||||
let bytes =
|
||||
serde_json::to_vec(&serde_json::json!({"data": data, "certificates": "/certs"}))
|
||||
.unwrap();
|
||||
assert!(parse_storage(&bytes).is_err());
|
||||
}
|
||||
assert!(parse_storage(b"{}").is_err());
|
||||
}
|
||||
}
|
||||
@@ -92,16 +92,71 @@ fn is_restart_sensitive_app(app_id: &str) -> bool {
|
||||
fn is_builtin_network_mode(network: &str) -> bool {
|
||||
matches!(
|
||||
network,
|
||||
"host" | "bridge" | "none" | "slirp4netns" | "pasta"
|
||||
"host"
|
||||
| "bridge"
|
||||
| "none"
|
||||
| "slirp4netns"
|
||||
| "slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
| "pasta"
|
||||
)
|
||||
}
|
||||
|
||||
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||
matches!(expected, Some("slirp4netns" | "pasta"))
|
||||
&& !actual.trim().is_empty()
|
||||
&& actual.trim().split(':').next() != expected
|
||||
let actual = actual.trim();
|
||||
if actual.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match expected {
|
||||
Some(
|
||||
expected @ ("slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"),
|
||||
) => {
|
||||
let (mode, options) = actual.split_once(':').unwrap_or((actual, ""));
|
||||
let required = expected.split_once(':').unwrap().1;
|
||||
mode != "slirp4netns"
|
||||
|| required.split(',').any(|wanted| {
|
||||
let key = wanted.split_once('=').unwrap().0;
|
||||
!options.split(',').any(|option| option == wanted)
|
||||
|| options.split(',').any(|option| {
|
||||
option.split_once('=').is_some_and(|(name, _)| name == key)
|
||||
&& option != wanted
|
||||
})
|
||||
})
|
||||
}
|
||||
Some(mode @ ("slirp4netns" | "pasta")) => actual.split(':').next() != Some(mode),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// API-created containers may omit rootless options from HostConfig.NetworkMode.
|
||||
// generate spec retains them; inspect alone would cause an endless repair loop.
|
||||
fn rootless_network_from_spec(bytes: &[u8]) -> Option<String> {
|
||||
let spec: serde_json::Value = serde_json::from_slice(bytes).ok()?;
|
||||
let mode = spec.get("netns")?.get("nsmode")?.as_str()?;
|
||||
if !matches!(mode, "slirp4netns" | "pasta") {
|
||||
return None;
|
||||
}
|
||||
let options = spec
|
||||
.get("network_options")
|
||||
.and_then(|options| options.get(mode));
|
||||
match options {
|
||||
None => Some(mode.to_string()),
|
||||
Some(options) => {
|
||||
let options = options
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|value| value.as_str())
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if options.is_empty() {
|
||||
Some(mode.to_string())
|
||||
} else {
|
||||
Some(format!("{mode}:{}", options.join(",")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
|
||||
@@ -175,6 +230,11 @@ fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 {
|
||||
// First NPM initialization generates keys and migrates its database before
|
||||
// exposing nginx. Its readiness budget must not depend on the network driver.
|
||||
if manifest.app.id == "nginx-proxy-manager" {
|
||||
return 180;
|
||||
}
|
||||
if manifest.app.id == "uptime-kuma" {
|
||||
return 420;
|
||||
}
|
||||
@@ -2425,6 +2485,49 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
match status.state {
|
||||
ContainerState::Running => {
|
||||
// Legacy runtime path: migrate credentials once without
|
||||
// recreating accounts or changing operator passwords.
|
||||
// Quadlet installations receive the same helper through
|
||||
// the required ExecStartPre drift/restart above.
|
||||
if app_id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||
let secrets = self.secrets_dir.join("filebrowser");
|
||||
let managed = filebrowser::cloud_credentials(&secrets)
|
||||
.await
|
||||
.map(|value| value.schema == 1)
|
||||
.unwrap_or(false);
|
||||
if !managed {
|
||||
if !self.should_attempt_repair(&name).await {
|
||||
return Ok(ReconcileAction::Left(
|
||||
"filebrowser-credential-repair-budget-exhausted".into(),
|
||||
));
|
||||
}
|
||||
let unit_managed = self.runtime.cli_name() == "podman"
|
||||
&& quadlet::unit_exists(&name).await;
|
||||
let service = format!("{name}.service");
|
||||
if unit_managed {
|
||||
quadlet::stop_service(&service).await?;
|
||||
} else {
|
||||
self.runtime.stop_container(&name).await?;
|
||||
}
|
||||
let prepared = filebrowser::prepare_credentials(
|
||||
&self.filebrowser_paths,
|
||||
&secrets,
|
||||
&status.image,
|
||||
self.runtime.cli_name(),
|
||||
)
|
||||
.await;
|
||||
// Restore service availability even when setup
|
||||
// rolled back. Preserve the setup failure itself.
|
||||
let started = if unit_managed {
|
||||
quadlet::restart_service(&service).await
|
||||
} else {
|
||||
self.runtime.start_container(&name).await
|
||||
};
|
||||
prepared?;
|
||||
started?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
}
|
||||
// Zombie guard: podman can report a container "running"
|
||||
// after its process has died (conmon SIGKILLed in a
|
||||
// cgroup cascade on archipelago restart, etc.). Such a
|
||||
@@ -2971,6 +3074,18 @@ impl ProdContainerOrchestrator {
|
||||
self.ensure_manifest_files(manifest).await?;
|
||||
self.apply_data_uid(manifest).await?;
|
||||
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
||||
if manifest.app.id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||
let image = manifest.app.container.image.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("File Browser needs a pinned image for credential setup")
|
||||
})?;
|
||||
filebrowser::prepare_credentials(
|
||||
&self.filebrowser_paths,
|
||||
&self.secrets_dir.join("filebrowser"),
|
||||
image,
|
||||
self.runtime.cli_name(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3068,6 +3183,11 @@ impl ProdContainerOrchestrator {
|
||||
container = %name,
|
||||
"Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit"
|
||||
);
|
||||
// Resolve active persistent mounts before the old inspect record is
|
||||
// removed. NPM may use a legacy or operator-selected data directory.
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved).await?;
|
||||
self.backup_runtime_change(name, &resolved).await?;
|
||||
// Stop+remove the old container record. Volumes survive (host
|
||||
// bind mounts are not touched by podman rm).
|
||||
self.runtime
|
||||
@@ -3077,8 +3197,6 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
// Re-render the manifest with dynamic env baked in, then go
|
||||
// through the same install path a fresh install would.
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved).await?;
|
||||
self.install_via_quadlet(&resolved, name)
|
||||
.await
|
||||
.with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?;
|
||||
@@ -3797,6 +3915,11 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
if manifest.app.id == "nginx-proxy-manager" {
|
||||
crate::container::npm::resolve_storage()
|
||||
.await?
|
||||
.apply(manifest)?;
|
||||
}
|
||||
// Idempotency guard: partitioning already ran on this instance.
|
||||
// Re-running would re-taint against an environment that no longer
|
||||
// contains the composite entries and silently drop them. Callers
|
||||
@@ -4068,7 +4191,12 @@ impl ProdContainerOrchestrator {
|
||||
if unmanaged
|
||||
&& matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
Some("slirp4netns" | "pasta")
|
||||
Some(
|
||||
"slirp4netns"
|
||||
| "slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
| "pasta"
|
||||
)
|
||||
)
|
||||
{
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
@@ -4076,13 +4204,36 @@ impl ProdContainerOrchestrator {
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success()
|
||||
&& rootless_network_mode_drifted(
|
||||
if output.status.success() {
|
||||
let mut actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
)
|
||||
{
|
||||
return true;
|
||||
Some(
|
||||
"slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
)
|
||||
) && actual == "slirp4netns"
|
||||
{
|
||||
let spec = tokio::process::Command::new("podman")
|
||||
.args(["generate", "spec", name])
|
||||
.output()
|
||||
.await;
|
||||
actual = match spec {
|
||||
Ok(spec) if spec.status.success() => {
|
||||
rootless_network_from_spec(&spec.stdout).unwrap_or_default()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if actual.is_empty() {
|
||||
tracing::warn!(app = %name, "Could not verify rootless network options; retaining the existing container");
|
||||
}
|
||||
}
|
||||
if rootless_network_mode_drifted(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&actual,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5242,8 +5393,68 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_legacy_gateway_converges_and_rejects_conflicting_network_options() {
|
||||
let expected = Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24");
|
||||
assert!(is_builtin_network_mode(expected.unwrap()));
|
||||
for actual in [
|
||||
"pasta",
|
||||
"slirp4netns:allow_host_loopback=true",
|
||||
"slirp4netns:allow_host_loopback=true,cidr=10.0.2.0/24",
|
||||
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24,cidr=10.0.2.0/24",
|
||||
] {
|
||||
assert!(rootless_network_mode_drifted(expected, actual), "{actual}");
|
||||
}
|
||||
let actual = "slirp4netns:cidr=169.254.1.0/24,allow_host_loopback=true,mtu=65520";
|
||||
assert!(!rootless_network_mode_drifted(expected, actual));
|
||||
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["cidr=169.254.1.0/24","allow_host_loopback=true"]}}"#;
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
expected,
|
||||
&rootless_network_from_spec(spec).unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_initialization_budget_survives_network_migration() {
|
||||
let mut manifest = AppManifest::parse(include_str!(
|
||||
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))
|
||||
.unwrap();
|
||||
for network in ["pasta", "slirp4netns:allow_host_loopback=true"] {
|
||||
manifest.app.container.network = Some(network.to_string());
|
||||
assert_eq!(host_port_wait_timeout_secs(&manifest), 180);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_created_rootless_options_do_not_cause_repeated_recreation() {
|
||||
let expected = Some("slirp4netns:allow_host_loopback=true");
|
||||
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["allow_host_loopback=true"]}}"#;
|
||||
let actual = rootless_network_from_spec(spec).unwrap();
|
||||
assert!(!rootless_network_mode_drifted(expected, &actual));
|
||||
let plain = rootless_network_from_spec(br#"{"netns":{"nsmode":"slirp4netns"}}"#).unwrap();
|
||||
assert!(rootless_network_mode_drifted(expected, &plain));
|
||||
assert!(rootless_network_from_spec(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||
let npm = Some("slirp4netns:allow_host_loopback=true");
|
||||
assert!(is_builtin_network_mode(npm.unwrap()));
|
||||
for actual in [
|
||||
"pasta",
|
||||
"bridge",
|
||||
"slirp4netns",
|
||||
"slirp4netns:allow_host_loopback=false",
|
||||
] {
|
||||
assert!(rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||
}
|
||||
for actual in [
|
||||
"slirp4netns:allow_host_loopback=true",
|
||||
"slirp4netns:mtu=65520,allow_host_loopback=true",
|
||||
] {
|
||||
assert!(!rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||
}
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
|
||||
@@ -68,6 +68,10 @@ pub enum NetworkMode {
|
||||
/// Rootless slirp4netns networking. Podman rejects network aliases with
|
||||
/// this mode, so render only Network=slirp4netns.
|
||||
Slirp4netns,
|
||||
/// Permit explicit host aliases as well as LAN upstreams for NPM.
|
||||
Slirp4netnsHostLoopback,
|
||||
/// Preserve existing NPM upstreams using pasta's former host gateway.
|
||||
Slirp4netnsLegacyGateway,
|
||||
/// Rootless pasta networking. This is more reliable than slirp4netns for
|
||||
/// host port forwarding on long-running web apps.
|
||||
Pasta,
|
||||
@@ -229,6 +233,15 @@ impl QuadletUnit {
|
||||
NetworkMode::Host => {
|
||||
let _ = writeln!(s, "Network=host");
|
||||
}
|
||||
NetworkMode::Slirp4netnsHostLoopback => {
|
||||
let _ = writeln!(s, "Network=slirp4netns:allow_host_loopback=true");
|
||||
}
|
||||
NetworkMode::Slirp4netnsLegacyGateway => {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
}
|
||||
NetworkMode::Slirp4netns => {
|
||||
let _ = writeln!(s, "Network=slirp4netns");
|
||||
}
|
||||
@@ -348,6 +361,26 @@ impl QuadletUnit {
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "[Service]");
|
||||
if self.name == "filebrowser" {
|
||||
// Runs while the managed server is stopped, before it can expose
|
||||
// a default account. The helper verifies real login and root access.
|
||||
let mut argv = vec![
|
||||
"/usr/bin/python3".to_string(),
|
||||
"/opt/archipelago/scripts/filebrowser-credentials.py".to_string(),
|
||||
"--image".to_string(),
|
||||
self.image.clone(),
|
||||
];
|
||||
for (target, flag) in [("/data", "--data-dir"), ("/srv", "--srv-root")] {
|
||||
if let Some(mount) = self
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.find(|m| m.container == Path::new(target))
|
||||
{
|
||||
argv.extend([flag.to_string(), mount.host.display().to_string()]);
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, "ExecStartPre={}", shell_join(&argv));
|
||||
}
|
||||
// Dependency-gated apps may legitimately keep their container entrypoint
|
||||
// in a wait loop before the actual daemon binds ports. Fedimint waits
|
||||
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
|
||||
@@ -447,6 +480,12 @@ impl QuadletUnit {
|
||||
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
||||
_ => match app.container.network.as_deref() {
|
||||
Some("slirp4netns") => NetworkMode::Slirp4netns,
|
||||
Some("slirp4netns:allow_host_loopback=true") => {
|
||||
NetworkMode::Slirp4netnsHostLoopback
|
||||
}
|
||||
Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24") => {
|
||||
NetworkMode::Slirp4netnsLegacyGateway
|
||||
}
|
||||
Some("pasta") => NetworkMode::Pasta,
|
||||
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
||||
_ => NetworkMode::Default,
|
||||
@@ -1071,7 +1110,8 @@ pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
||||
// Entrypoint= and Exec= together define what the container runs, so a drift
|
||||
// in either must recreate the container (e.g. when this renderer first
|
||||
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
|
||||
directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
directive_values(old_body, "ExecStartPre=") != directive_values(new_body, "ExecStartPre=")
|
||||
|| directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
|
||||
}
|
||||
|
||||
@@ -1142,6 +1182,28 @@ pub async fn is_active(service: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn filebrowser_prestart_credentials_are_a_required_runtime_change() {
|
||||
let unit = super::QuadletUnit {
|
||||
name: "filebrowser".into(),
|
||||
image: "registry.example/filebrowser:v2.63.23".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let rendered = unit.render();
|
||||
assert!(rendered.contains("ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/filebrowser-credentials.py --image registry.example/filebrowser:v2.63.23"));
|
||||
let old = rendered
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("ExecStartPre="))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(super::exec_changed(&old, &rendered));
|
||||
assert!(!super::exec_changed(&rendered, &rendered));
|
||||
let other = super::QuadletUnit {
|
||||
name: "other-app".into(),
|
||||
..unit
|
||||
};
|
||||
assert!(!other.render().contains("filebrowser-credentials.py"));
|
||||
}
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -1709,6 +1771,24 @@ app:
|
||||
assert!(!s.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_network_preserves_same_node_upstreams_without_aliases() {
|
||||
let m = AppManifest::parse(include_str!(
|
||||
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))
|
||||
.unwrap();
|
||||
let rendered = QuadletUnit::from_manifest(&m, "nginx-proxy-manager").render();
|
||||
assert_eq!(
|
||||
rendered
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Network="))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"]
|
||||
);
|
||||
assert!(!rendered.contains("NetworkAlias="));
|
||||
assert!(!rendered.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_pasta_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
|
||||
Reference in New Issue
Block a user