From cd795c98ea343b5cfeb1139fa7b7adf01f062346 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 10 Aug 2026 14:31:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(container):=20pasta=20apps=20join=20the=20?= =?UTF-8?q?Quadlet=20migration=20=E2=80=94=20units=20over=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pasta exclusion paired with the transient-scope machinery: daemon-started pasta/conmon inherit the starter's cgroup, so scopes kept app networking alive across daemon restarts. A quadlet unit provides the same independence (user.slice parentage) with supervision on top — Restart=always and RestartSec=10, which also spaces restarts past pasta's port teardown (the race observed live on strfry, 2026-08-10). Renderer needed nothing: Network=pasta, ports and RestartSec were already in place. Lifted in all four places: migrate_to_quadlet_if_needed, install_fresh, and the missing-container unit-start path; the scoped start/restart helpers are now unit-aware — quadlet-managed pasta routes through systemctl --user (bare podman would fight systemd over an --rm container), legacy pasta keeps its scope, so mixed fleets stay coherent during rollout. Container suite 221/221. Co-Authored-By: Claude Fable 5 --- .../src/container/prod_orchestrator.rs | 41 ++++++++++++++----- core/archipelago/src/container/quadlet.rs | 13 ++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index a7ead553..1ea169ee 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1039,7 +1039,9 @@ async fn repair_manifest_host_ports_after_stability( container = %name, "host listener disappeared after startup; restarting container" ); - if uses_pasta_network(manifest) { + if uses_pasta_network(manifest) && !quadlet::unit_exists(name).await { + // Legacy (pre-quadlet) pasta app: no unit owns it, so a transient + // scope keeps its networking's cgroup independent of the daemon. podman_user_scope(&["restart", name]) .await .with_context(|| format!("podman restart {name}"))?; @@ -1085,9 +1087,16 @@ async fn start_container_scoped_if_pasta( name: &str, ) -> Result<()> { if uses_pasta_network(manifest) { - // Rootless pasta/conmon inherit the cgroup of the process that starts - // them. Starting through archipelago.service lets backend restarts kill - // app networking; a transient user scope keeps app daemons independent. + // Quadlet-managed pasta app: the unit owns the cgroup and the + // container is rendered --rm — bare `podman start` would fight + // systemd over it. Restart-through-the-unit starts a stopped one. + if quadlet::unit_exists(name).await { + return quadlet::restart_service(&format!("{name}.service")).await; + } + // Legacy pasta app: rootless pasta/conmon inherit the cgroup of the + // process that starts them. Starting through archipelago.service lets + // backend restarts kill app networking; a transient user scope keeps + // app daemons independent. podman_user_scope(&["start", name]).await } else { runtime.start_container(name).await @@ -1100,6 +1109,9 @@ async fn restart_container_scoped_if_pasta( name: &str, ) -> Result<()> { if uses_pasta_network(manifest) { + if quadlet::unit_exists(name).await { + return quadlet::restart_service(&format!("{name}.service")).await; + } podman_user_scope(&["restart", name]).await } else { let _ = runtime.stop_container(name).await; @@ -2267,7 +2279,15 @@ impl ProdContainerOrchestrator { // after proving the container exists. Boot reconciliation must // not create every catalog app just because a Quadlet unit is // absent. - if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) { + // + // Pasta apps included since 2026-08-10: the old exclusion + // paired with the transient-scope machinery (daemon-started + // pasta died with the daemon's cgroup). A quadlet unit gives + // pasta the same independence with systemd supervision on top + // — Restart=always + RestartSec=10, which also spaces restarts + // past pasta's port teardown. The scoped start/restart helpers + // now defer to the unit whenever one exists. + if self.use_quadlet_backends { if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? { return Ok(action); } @@ -2539,10 +2559,7 @@ impl ProdContainerOrchestrator { // lost the container record after a crash/reboot. Sync the unit // bytes first (clears stale Notify=healthy/nc probes), then ask // user systemd to start the generated service. - if self.use_quadlet_backends - && !uses_pasta_network(&resolved_manifest) - && self.quadlet_unit_exists(&name).await? - { + if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? { self.prepare_for_start(&resolved_manifest).await?; self.sync_quadlet_unit(lm, &name).await?; self.ensure_resolved_source_available(lm).await?; @@ -2725,11 +2742,13 @@ impl ProdContainerOrchestrator { self.prepare_for_start(&resolved_manifest).await?; self.ensure_container_network(&resolved_manifest).await?; - if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) { + if self.use_quadlet_backends { // Phase 3.2 path: declarative .container unit + systemctl. // Containers parented under user.slice instead of // archipelago.service's cgroup → no FM3 cascade SIGKILL on - // archipelago restart. + // archipelago restart. Pasta apps included since 2026-08-10 — + // the unit gives them the same cgroup independence the transient + // scopes provided, plus Restart=always supervision. self.install_via_quadlet(&resolved_manifest, &name).await?; } else { self.remove_quadlet_unit_if_present(&name).await?; diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 04ab7f63..00a5b7fc 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -661,6 +661,19 @@ fn parse_memory_mib(raw: &str) -> Option { num_part.trim().parse::().ok()?.checked_mul(mul) } +/// Does a quadlet `.container` unit exist for this container name? +/// Errors count as "unknown" and return false — callers use this to decide +/// whether systemd owns the container, and claiming ownership on an +/// unreadable answer would route lifecycle ops around a live unit. +pub async fn unit_exists(name: &str) -> bool { + let Ok(dir) = unit_dir().await else { + return false; + }; + tokio::fs::try_exists(dir.join(format!("{name}.container"))) + .await + .unwrap_or(false) +} + /// Resolve the per-user quadlet dir under $HOME. Created if missing. pub async fn unit_dir() -> Result { let home = std::env::var_os("HOME")