feat(container): pasta apps join the Quadlet migration — units over scopes

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-10 14:31:34 -04:00
co-authored by Claude Fable 5
parent a0ffc5ca3d
commit cd795c98ea
2 changed files with 43 additions and 11 deletions
@@ -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?;
+13
View File
@@ -661,6 +661,19 @@ fn parse_memory_mib(raw: &str) -> Option<u32> {
num_part.trim().parse::<u32>().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<PathBuf> {
let home = std::env::var_os("HOME")