feat: container orchestration, branding overhaul, onboarding logging

Container orchestration:
- Health monitor with crash recovery and auto-restart
- Doctor service (periodic health checks via systemd timer)
- Reconcile service (desired-state convergence)
- Stack-aware install/uninstall with dependency tracking

Branding:
- Custom GRUB background (designer artwork, 1024x768)
- ISOLINUX boot menu: centered, orange accents, clean labels
- Terminal banners: adaptive width, basic ANSI colors, fits 80-col
- Removed auto-generated splash scripts (designer provides assets)
- GRUB theme: lowercase branding

Frontend:
- 401 handler clears localStorage immediately (prevents cascade)

Backend:
- Onboarding/auth logging ([onboarding] tag in journalctl)
- Cookie Secure flag logging for debugging HTTP/HTTPS issues

ISO fixes:
- Install log saved before unmount (was silently failing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-28 11:34:29 +00:00
co-authored by Claude Opus 4.6
parent 6311aa563e
commit 99400a7165
22 changed files with 626 additions and 763 deletions
+12
View File
@@ -16,8 +16,10 @@ impl RpcHandler {
if !is_setup {
// Dev mode: allow default password so UI can log in without running setup
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
tracing::info!("[onboarding] login via dev default password");
return Ok(serde_json::Value::Null);
}
tracing::warn!("[onboarding] login attempt before setup complete");
return Err(anyhow::anyhow!(
"User not set up. Please complete setup first."
));
@@ -25,13 +27,16 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
tracing::warn!("[onboarding] login failed — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
tracing::info!("[onboarding] login successful");
Ok(serde_json::Value::Null)
}
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
tracing::info!("[onboarding] logout");
Ok(serde_json::Value::Null)
}
@@ -78,6 +83,7 @@ impl RpcHandler {
// Prevent re-setup if already set up
let is_setup = self.auth_manager.is_setup().await?;
if is_setup {
tracing::warn!("[onboarding] setup rejected — already set up");
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
}
@@ -88,20 +94,24 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing password"))?;
if password.len() < 8 {
tracing::warn!("[onboarding] setup rejected — password too short");
return Err(anyhow::anyhow!("Password must be at least 8 characters"));
}
self.auth_manager.setup_user(password).await?;
tracing::info!("[onboarding] user setup complete");
Ok(serde_json::json!(true))
}
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
self.auth_manager.complete_onboarding().await?;
tracing::info!("[onboarding] onboarding marked complete");
Ok(serde_json::json!(true))
}
pub(super) async fn handle_auth_is_onboarding_complete(&self) -> Result<serde_json::Value> {
let complete = self.auth_manager.is_onboarding_complete().await?;
tracing::debug!("[onboarding] isOnboardingComplete={}", complete);
Ok(serde_json::json!(complete))
}
@@ -117,10 +127,12 @@ impl RpcHandler {
let valid = self.auth_manager.verify_password(password).await?;
if !valid {
tracing::warn!("[onboarding] reset rejected — wrong password");
return Err(anyhow::anyhow!("Password Incorrect"));
}
self.auth_manager.reset_onboarding().await?;
tracing::info!("[onboarding] onboarding reset");
Ok(serde_json::json!(true))
}
}
+2
View File
@@ -153,9 +153,11 @@ impl RpcHandler {
}
if let Some(proto) = headers.get("x-forwarded-proto") {
if proto.as_bytes() == b"https" {
tracing::debug!("[onboarding] cookie: Secure (X-Forwarded-Proto: https)");
return "; Secure";
}
}
tracing::debug!("[onboarding] cookie: no Secure flag (HTTP or no X-Forwarded-Proto)");
""
}
@@ -228,6 +228,11 @@ impl RpcHandler {
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
// Rollback: remove partially created container
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
}
@@ -235,6 +240,43 @@ impl RpcHandler {
.trim()
.to_string();
// Post-start health verification: wait up to 30s for container to be running
for i in 0..6u32 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "running" {
break;
}
if state == "exited" {
// Container crashed immediately — get logs for diagnosis
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "20", container_name])
.output()
.await;
let log_output = logs
.map(|o| String::from_utf8_lossy(&o.stderr).to_string())
.unwrap_or_default();
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
return Err(anyhow::anyhow!(
"Container {} exited immediately after start. Logs: {}",
container_name,
log_output.chars().take(500).collect::<String>()
));
}
}
if i == 5 {
debug!("Container {} health check timeout (30s) — continuing anyway", container_name);
}
}
// Post-install hooks
self.run_post_install_hooks(package_id).await;
@@ -301,11 +343,43 @@ impl RpcHandler {
Ok(has_local_fallback)
}
/// Stream `podman pull` while updating install progress state.
/// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s).
async fn pull_image_with_progress(
&self,
package_id: &str,
docker_image: &str,
) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
for attempt in 1..=MAX_ATTEMPTS {
match self.do_pull_image(package_id, docker_image).await {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
let delay = BACKOFF_SECS[(attempt - 1) as usize];
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
docker_image, attempt, MAX_ATTEMPTS, e, delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
Err(e) => {
self.clear_install_progress(package_id).await;
return Err(e.context(format!(
"Failed to pull {} after {} attempts",
docker_image, MAX_ATTEMPTS
)));
}
}
}
unreachable!()
}
/// Single image pull attempt with progress streaming.
async fn do_pull_image(
&self,
package_id: &str,
docker_image: &str,
) -> Result<()> {
debug!("Pulling image: {}", docker_image);
self.set_install_progress(package_id, 0, 0).await;
@@ -336,8 +410,20 @@ impl RpcHandler {
.await
.context("Failed to wait for image pull")?;
if !status.success() {
self.clear_install_progress(package_id).await;
return Err(anyhow::anyhow!("Failed to pull image"));
return Err(anyhow::anyhow!("podman pull exited with non-zero status"));
}
// Verify image exists locally after pull
let verify = tokio::process::Command::new("podman")
.args(["images", "-q", docker_image])
.output()
.await
.context("Failed to verify pulled image")?;
if String::from_utf8_lossy(&verify.stdout).trim().is_empty() {
return Err(anyhow::anyhow!(
"Image {} not found locally after pull",
docker_image
));
}
self.set_install_progress(package_id, 100, 100).await;
@@ -4,6 +4,22 @@ use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
/// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
fn stop_timeout_secs(container_name: &str) -> &'static str {
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
match id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
"lnd" => "330",
"electrumx" | "electrs" | "mempool-electrs" => "300",
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
| "nextcloud-db" | "endurain-db" => "120",
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
_ => "30",
}
}
impl RpcHandler {
/// Start a package: start all containers in dependency order.
pub(in crate::api::rpc) async fn handle_package_start(
@@ -56,7 +72,7 @@ impl RpcHandler {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
.await;
let _ = tokio::process::Command::new("podman")
.args(["stop", &container_name])
.args(["stop", "-t", stop_timeout_secs(&container_name), &container_name])
.output()
.await;
return Ok(serde_json::Value::Null);
@@ -67,7 +83,7 @@ impl RpcHandler {
}
for name in containers {
let _ = tokio::process::Command::new("podman")
.args(["stop", &name])
.args(["stop", "-t", stop_timeout_secs(&name), &name])
.output()
.await;
}
@@ -135,7 +151,7 @@ impl RpcHandler {
for name in &containers_to_remove {
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", "10", name])
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
match stop_out {
@@ -344,7 +360,7 @@ impl RpcHandler {
validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman")
.args(["stop", app_id])
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
.output()
.await
.context("Failed to stop container")?;
+37 -8
View File
@@ -7,6 +7,41 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::info;
/// Pull an image with retry and exponential backoff (3 attempts).
async fn pull_image_with_retry(image: &str) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
for attempt in 1..=MAX_ATTEMPTS {
let output = tokio::process::Command::new("podman")
.args(["pull", image])
.output()
.await
.context("Failed to execute podman pull")?;
if output.status.success() {
return Ok(());
}
if attempt < MAX_ATTEMPTS {
let delay = BACKOFF_SECS[(attempt - 1) as usize];
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
image, attempt, MAX_ATTEMPTS, stderr.trim(), delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Failed to pull {} after {} attempts: {}",
image, MAX_ATTEMPTS, stderr.trim()
));
}
}
unreachable!()
}
impl RpcHandler {
/// Install Immich stack (postgres + redis + server).
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
@@ -38,10 +73,7 @@ impl RpcHandler {
"80.71.235.15:3000/archipelago/immich-server:release",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
pull_image_with_retry(img).await?;
}
let _ = tokio::process::Command::new("sudo")
@@ -168,10 +200,7 @@ impl RpcHandler {
"80.71.235.15:3000/archipelago/penpot-frontend:2.4",
];
for img in &images {
let _ = tokio::process::Command::new("podman")
.args(["pull", img])
.output()
.await;
pull_image_with_retry(img).await?;
}
let _ = tokio::process::Command::new("sudo")