From 5b2e02bd4332fdcb6dd6bafff3eb2c88680fe97f Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 1 May 2026 17:22:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20Phase=203.2=20=E2=80=94?= =?UTF-8?q?=20wire=20Quadlet=20path=20behind=20feature=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod_orchestrator::install_fresh now branches on the new Config::use_quadlet_backends flag (default false): * off (today's production behavior) — unchanged: runtime.create_container + start_container, container parented under archipelago.service's cgroup, FM3 cascade SIGKILL on every archipelago restart. * on — install_via_quadlet renders the manifest as a Quadlet unit via QuadletUnit::from_manifest, writes it atomically into ~/.config/containers/systemd/, calls daemon-reload, and starts the generated .service. Container ends up under user.slice — no more cgroup parented under archipelago, so archipelago restarts don't touch the container's lifetime. Default off so this commit is structurally safe to ship: nothing changes at runtime until an operator opts in. Flip the default once tests/lifecycle/run-20x.sh has gone green against the new path on .228 + .198 (the v1.7.52 release gate). Plumbing: * config.rs — `use_quadlet_backends: bool` w/ Default false * prod_orchestrator.rs — flag stored on the struct, threaded through new(), with set_use_quadlet_backends(bool) test setter * prod_orchestrator.rs — install_via_quadlet helper * dropped the Phase-3.1 #[allow(dead_code)] markers on from_manifest / parse_memory_mib / RestartPolicy::OnFailure now that the call path exists; if a future revert removes the wiring, the warnings come back. Tests: 624 passing, cargo check clean (0 warnings). Existing companion behavior unaffected — render_skips_backend_directives_when_default still passes byte-equal to before quadlet.rs grew the new fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/archipelago/src/config.rs | 9 +++ .../src/container/prod_orchestrator.rs | 66 +++++++++++++++++-- core/archipelago/src/container/quadlet.rs | 13 ++-- tests/lifecycle/TESTING.md | 2 +- 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index 246443b5..d09481bc 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -62,6 +62,14 @@ pub struct Config { /// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor. #[serde(default)] pub nostr_tor_proxy: Option, + /// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs + /// through Quadlet (`.container` units in ~/.config/containers/systemd + /// + systemctl --user start) instead of `podman create + start`. Default + /// off so the legacy path stays the production path until the harness + /// at tests/lifecycle/run-20x.sh has gone green against the new path + /// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`. + #[serde(default)] + pub use_quadlet_backends: bool, } impl Config { @@ -221,6 +229,7 @@ impl Default for Config { "wss://relay.nostr.info".into(), ], nostr_tor_proxy: Some("127.0.0.1:9050".into()), + use_quadlet_backends: false, } } } diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 3776af26..8a2d1d95 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -38,6 +38,7 @@ use tokio::sync::{Mutex, RwLock}; use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime}; use crate::container::bitcoin_ui; use crate::container::filebrowser; +use crate::container::quadlet; use crate::container::traits::ContainerOrchestrator; use crate::update::host_sudo; @@ -140,6 +141,12 @@ pub struct ProdContainerOrchestrator { /// Root directory for secret files referenced by /// `container.secret_env[*].secret_file`. secrets_dir: PathBuf, + /// Phase 3.2 feature flag: when true, `install_fresh` writes a + /// Quadlet `.container` unit and starts it via systemctl --user + /// instead of shelling out to `podman create + start`. Default + /// false so the legacy path remains the production path until the + /// 20× lifecycle harness goes green against the new path. + use_quadlet_backends: bool, } struct FileSecretsProvider { @@ -184,6 +191,7 @@ impl ProdContainerOrchestrator { bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), filebrowser_paths: filebrowser::EnsurePaths::default(), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), + use_quadlet_backends: config.use_quadlet_backends, }) } @@ -198,9 +206,18 @@ impl ProdContainerOrchestrator { bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), filebrowser_paths: filebrowser::EnsurePaths::default(), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), + use_quadlet_backends: false, } } + /// Test-only setter for the Phase 3.2 feature flag, so unit tests + /// can exercise the Quadlet-backend install path without going + /// through the full Config plumbing. + #[cfg(test)] + pub fn set_use_quadlet_backends(&mut self, on: bool) { + self.use_quadlet_backends = on; + } + /// Override the bitcoin-ui render paths (secret + output). Only used /// by tests that exercise the bitcoin-ui pre-start hook — the /// default `/var/lib/archipelago/...` paths are correct for prod. @@ -466,15 +483,50 @@ impl ProdContainerOrchestrator { self.run_pre_start_hooks(&lm.manifest.app.id).await?; self.apply_data_uid(&resolved_manifest).await?; self.ensure_container_network(&resolved_manifest).await?; - // Production orchestrator: no port offset. - self.runtime - .create_container(&resolved_manifest, &name, 0) + + 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. + self.install_via_quadlet(&resolved_manifest, &name).await?; + } else { + // Legacy path. Production until tests/lifecycle/run-20x.sh + // goes green against the Quadlet path. + self.runtime + .create_container(&resolved_manifest, &name, 0) + .await + .with_context(|| format!("create_container {name}"))?; + self.runtime + .start_container(&name) + .await + .with_context(|| format!("start_container {name}"))?; + } + Ok(()) + } + + /// Phase 3.2 install path. Renders the manifest as a Quadlet unit, + /// writes it atomically into ~/.config/containers/systemd/, asks + /// systemd to reload, and starts the generated service. Errors at + /// any step propagate as install_fresh failures — no half-state. + async fn install_via_quadlet( + &self, + resolved_manifest: &AppManifest, + name: &str, + ) -> Result<()> { + let unit = quadlet::QuadletUnit::from_manifest(resolved_manifest, name); + let dir = quadlet::unit_dir() .await - .with_context(|| format!("create_container {name}"))?; - self.runtime - .start_container(&name) + .context("locate user quadlet unit dir")?; + quadlet::write_if_changed(&unit, &dir) .await - .with_context(|| format!("start_container {name}"))?; + .with_context(|| format!("write quadlet unit for {name}"))?; + quadlet::daemon_reload_user() + .await + .context("systemctl --user daemon-reload after writing quadlet unit")?; + quadlet::enable_now(&unit.service_name()) + .await + .with_context(|| format!("systemctl --user start {}", unit.service_name()))?; Ok(()) } diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 0c8080cd..fd1af134 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -66,9 +66,8 @@ pub enum NetworkMode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RestartPolicy { Always, - /// Used by `from_manifest` for backend manifests. Wired into the - /// orchestrator in Phase 3.2 (see `project_v1_7_52_phase3_quadlet_design`). - #[allow(dead_code)] + /// Used by `from_manifest` for backend manifests. Wired through + /// `install_via_quadlet` (gated by `Config::use_quadlet_backends`). OnFailure, } @@ -253,10 +252,9 @@ fn shell_join(parts: &[String]) -> String { impl QuadletUnit { /// Build a backend-flavour QuadletUnit from a parsed AppManifest. - /// Wired into the orchestrator in Phase 3.2 (see - /// `project_v1_7_52_phase3_quadlet_design`); marked allow(dead_code) - /// here so the warning resurfaces if 3.2 doesn't actually call this. - #[allow(dead_code)] + /// Wired through `prod_orchestrator::install_via_quadlet`, gated by + /// `Config::use_quadlet_backends`. + /// /// `name` is the on-disk container name (typically the manifest's /// `app.id`, but the orchestrator may rename — see /// `compute_container_name`). The returned unit is NOT yet written; @@ -329,7 +327,6 @@ impl QuadletUnit { /// Parse the manifest's memory_limit string into MiB. Recognises the /// forms our manifests actually use: "", "m"/"M", "g"/"G". /// Returns None for anything else; the caller treats None as unlimited. -#[allow(dead_code)] // called only from from_manifest (also dead until Phase 3.2) fn parse_memory_mib(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { diff --git a/tests/lifecycle/TESTING.md b/tests/lifecycle/TESTING.md index 2c71bdf8..7d26f005 100644 --- a/tests/lifecycle/TESTING.md +++ b/tests/lifecycle/TESTING.md @@ -96,7 +96,7 @@ Goal: minimum-viable container subsystem. | `core/container/src/bitcoin_simulator.rs` | 219 | 0 | -219 | ○ couples with dev_orchestrator | | `core/container/src/port_manager.rs` | 175 | 0 | -175 | ○ couples with dev_orchestrator | | `core/archipelago/src/api/rpc/package/install.rs::install_bitcoincoin_rpc_repair` | ~150 | 0 | -150 | ◐ pending fold into orchestrator pre-start | -| imperative `install_fresh` in prod_orchestrator | ~120 | 0 | -120 | ○ pending Phase 3.2 Quadlet renderer | +| imperative `install_fresh` in prod_orchestrator | ~120 | 0 | -120 | ◐ Phase 3.2 wired behind `use_quadlet_backends` flag (default off); flip default after 20× green | **Today: -270 LoC committed. Outstanding deletes possible: ~1,616 LoC** (if Phase 3 ships fully + dev_mode resolved).