diff --git a/lynx/agent/setup-agent.sh b/lynx/agent/setup-agent.sh index 4d9ff4f..9744107 100644 --- a/lynx/agent/setup-agent.sh +++ b/lynx/agent/setup-agent.sh @@ -204,20 +204,11 @@ done _check_remove firewalld "$_REASON_FW" _check_remove ufw "$_REASON_FW" -# iptables — incompatible with Lynx when netavark 1.10+ uses the nftables driver. -# Any iptables binary on the host signals an external firewall manager or a stale -# package. Remove it; netavark 1.15.2 + firewall_driver=nftables does not call it. -if command -v iptables &>/dev/null; then - _incompatible_found=true - log_warn "Removing incompatible: iptables (netavark now uses nftables driver)" - log_info " Reason: ${_REASON_FW}" - case "$DISTRO" in - debian) apt-get purge -y iptables 2>/dev/null || true ;; - rhel) { dnf remove -y iptables 2>/dev/null || yum remove -y iptables 2>/dev/null; } || true ;; - *) log_warn "Unknown distro — remove iptables manually" ;; - esac - log_ok "Removed: iptables" -fi +# iptables package must NOT be removed — netavark 1.15.2 still calls the iptables +# binary internally even when firewall_driver = nftables is configured. On Ubuntu +# 24.04+ the 'iptables' package is actually iptables-nft which routes all calls +# through nftables; no legacy kernel module is involved. What is incompatible is +# software that *manages* iptables rules (Docker, ufw, firewalld), not the binary. if $_incompatible_found; then if command -v iptables-legacy &>/dev/null; then diff --git a/lynx/dashboard/setup-dashboard.sh b/lynx/dashboard/setup-dashboard.sh index 78f33fa..2666eef 100644 --- a/lynx/dashboard/setup-dashboard.sh +++ b/lynx/dashboard/setup-dashboard.sh @@ -242,20 +242,11 @@ done _check_remove firewalld "$_REASON_FW" _check_remove ufw "$_REASON_FW" -# iptables — incompatible with Lynx when netavark 1.10+ uses the nftables driver. -# Any iptables binary on the host signals an external firewall manager or a stale -# package. Remove it; netavark 1.15.2 + firewall_driver=nftables does not call it. -if command -v iptables &>/dev/null; then - _incompatible_found=true - log_warn "Removing incompatible: iptables (netavark now uses nftables driver)" - log_info " Reason: ${_REASON_FW}" - case "$DISTRO" in - debian) apt-get purge -y iptables 2>/dev/null || true ;; - rhel) { dnf remove -y iptables 2>/dev/null || yum remove -y iptables 2>/dev/null; } || true ;; - *) log_warn "Unknown distro — remove iptables manually" ;; - esac - log_ok "Removed: iptables" -fi +# iptables package must NOT be removed — netavark 1.15.2 still calls the iptables +# binary internally even when firewall_driver = nftables is configured. On Ubuntu +# 24.04+ the 'iptables' package is actually iptables-nft which routes all calls +# through nftables; no legacy kernel module is involved. What is incompatible is +# software that *manages* iptables rules (Docker, ufw, firewalld), not the binary. if $_incompatible_found; then # Flush residual kernel rules left behind by Docker / ufw / iptables. @@ -1069,7 +1060,7 @@ log_ok "PostgreSQL app user initialized" # 2. Valkey log_info "Starting Valkey..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up -d valkey +"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d valkey log_info "Waiting for Valkey to be healthy..." for i in $(seq 1 30); do @@ -1122,7 +1113,7 @@ log_ok "WireGuard interface up: wg-lynx-dash (10.100.0.1/16)" # 3. Backend log_info "Starting backend..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up -d backend +"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d backend log_info "Waiting for backend to be healthy..." for i in $(seq 1 40); do @@ -1141,7 +1132,7 @@ done # 4. Frontend log_info "Starting frontend..." -"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up -d frontend +"$BIN_DIR/lynx-compose" -p lynx-dashboard -f "$COMPOSE_FILE" up --no-recreate -d frontend log_info "Waiting for frontend to be healthy..." for i in $(seq 1 40); do diff --git a/lynx/translators/compose/internal/engine/health.rs b/lynx/translators/compose/internal/engine/health.rs index fdb8c32..2cfad5b 100644 --- a/lynx/translators/compose/internal/engine/health.rs +++ b/lynx/translators/compose/internal/engine/health.rs @@ -24,7 +24,16 @@ impl Engine { .unwrap_or(30); for _ in 0..retries { - let info = self.docker.inspect_container(container_name, None).await?; + let info = match self.docker.inspect_container(container_name, None).await { + Ok(i) => i, + Err(e) => { + // Podman uses "stopped" for exited containers; Bollard can't + // deserialize it. Treat any inspect error as "not healthy yet". + tracing::debug!("inspect_container error (will retry): {e}"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + continue; + } + }; if let Some(state) = info.state { if let Some(health) = state.health { if health.status == Some(HealthStatusEnum::HEALTHY) { @@ -44,7 +53,14 @@ impl Engine { /// exits with a non-zero code or if the deadline is exceeded. pub(super) async fn wait_completed(&self, container_name: &str) -> Result<()> { for _ in 0..600 { - let info = self.docker.inspect_container(container_name, None).await?; + let info = match self.docker.inspect_container(container_name, None).await { + Ok(i) => i, + Err(e) => { + tracing::debug!("inspect_container error (will retry): {e}"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + }; if let Some(state) = info.state { let status = state.status.map(|s| format!("{s:?}").to_lowercase()); if status.as_deref() == Some("exited") { diff --git a/lynx/translators/compose/internal/engine/mod.rs b/lynx/translators/compose/internal/engine/mod.rs index 995fd49..627a981 100644 --- a/lynx/translators/compose/internal/engine/mod.rs +++ b/lynx/translators/compose/internal/engine/mod.rs @@ -60,7 +60,7 @@ impl Engine { // ----------------------------------------------------------------------- pub async fn up(&self, file: &ComposeFile) -> Result<()> { - self.up_with_options(file, false, &[], &[]).await + self.up_with_options(file, false, &[], &[], false).await } pub async fn up_with_options( @@ -69,6 +69,7 @@ impl Engine { _detach: bool, active_profiles: &[String], target_services: &[String], + no_recreate: bool, ) -> Result<()> { let order = crate::compose::resolve_order(file)?; let active = active_profiles_set(active_profiles); @@ -163,6 +164,10 @@ impl Engine { } else { format!("{}-{i}", self.container_name(name, service)) }; + if no_recreate && self.is_container_running(&container_name).await { + info!("{container_name} already running — skipping recreate"); + continue; + } self.create_and_start(&container_name, name, service, file) .await?; self.connect_extra_networks(&container_name, service, file) @@ -600,6 +605,22 @@ impl Engine { Ok(()) } + async fn is_container_running(&self, container_name: &str) -> bool { + // Use list_containers (not inspect_container) to avoid Bollard + // deserialization failures when Podman returns "stopped" state. + let mut filters = HashMap::new(); + filters.insert("name".to_string(), vec![container_name.to_string()]); + self.docker + .list_containers(Some(ListContainersOptions { + all: false, + filters: Some(filters), + ..Default::default() + })) + .await + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + fn container_name(&self, service_name: &str, service: &Service) -> String { service .container_name diff --git a/lynx/translators/compose/internal/main.rs b/lynx/translators/compose/internal/main.rs index b2d025b..5c7c07b 100644 --- a/lynx/translators/compose/internal/main.rs +++ b/lynx/translators/compose/internal/main.rs @@ -44,6 +44,9 @@ enum Commands { /// Remove containers for services not defined in the compose file. #[arg(long)] remove_orphans: bool, + /// Do not recreate containers that are already running. + #[arg(long)] + no_recreate: bool, /// Bring up only these services (and their transitive depends_on). /// If omitted, brings up every service in the compose file. #[arg(trailing_var_arg = true)] @@ -116,13 +119,14 @@ async fn main() -> anyhow::Result<()> { detach, watch, remove_orphans, + no_recreate, services, } => { if remove_orphans { engine.remove_orphans(&file).await?; } engine - .up_with_options(&file, detach, &cli.profile, &services) + .up_with_options(&file, detach, &cli.profile, &services, no_recreate) .await?; if watch { engine.watch(&file).await?;