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:
co-authored by
Claude Opus 4.6
parent
6311aa563e
commit
99400a7165
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user