chore(release): stage v1.7.54-alpha
This commit is contained in:
@@ -192,7 +192,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
|
||||
}
|
||||
"grafana" => (
|
||||
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
@@ -292,7 +292,8 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
|
||||
"immich_postgres" | "penpot-postgres" => "256m",
|
||||
"immich_postgres" => "2g",
|
||||
"penpot-postgres" => "256m",
|
||||
"immich_redis" | "penpot-valkey" => "128m",
|
||||
// Default
|
||||
_ => "512m",
|
||||
@@ -428,7 +429,7 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_container_names;
|
||||
use super::{all_container_names, get_health_check_args};
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
@@ -440,6 +441,19 @@ mod tests {
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grafana_health_requires_writable_data_and_http_health() {
|
||||
let args = get_health_check_args("grafana", "unused");
|
||||
let health_cmd = args
|
||||
.iter()
|
||||
.find_map(|arg| arg.strip_prefix("--health-cmd="))
|
||||
.expect("grafana should have a health command");
|
||||
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana"));
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
|
||||
assert!(health_cmd.contains("http://localhost:3000/api/health"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
|
||||
@@ -669,6 +669,9 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
for name in to_start {
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
if to_start.iter().any(|name| name == "indeedhub") {
|
||||
super::install::patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -826,6 +829,9 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
}
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
if containers.iter().any(|name| name == "indeedhub") {
|
||||
super::install::patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
|
||||
}
|
||||
@@ -842,7 +848,10 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
|
||||
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
|
||||
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"grafana" => {
|
||||
repair_grafana_dirs().await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
@@ -943,6 +952,34 @@ async fn repair_btcpay_dirs() {
|
||||
repair_btcpay_database_password().await;
|
||||
}
|
||||
|
||||
async fn repair_grafana_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
|
||||
.output()
|
||||
.await;
|
||||
let podman_chown = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"unshare",
|
||||
"chown",
|
||||
"-R",
|
||||
"472:472",
|
||||
"/var/lib/archipelago/grafana",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"100471:100471",
|
||||
"/var/lib/archipelago/grafana",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_btcpay_database_password() {
|
||||
let Ok(db_pass) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
|
||||
|
||||
@@ -450,7 +450,7 @@ impl RpcHandler {
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--memory=2g",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U postgres || exit 1",
|
||||
"--health-interval=30s",
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//!
|
||||
//! Two things are synced on startup:
|
||||
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
|
||||
//! 2. An nginx `location /api/app-catalog` proxy block — required for
|
||||
//! the App Store catalog proxy to actually reach the backend.
|
||||
//! 2. Missing nginx backend proxy blocks required for frontend fetches to
|
||||
//! reach the backend instead of the SPA fallback.
|
||||
//!
|
||||
//! Idempotent: no-ops on boxes that are already in sync. All work is
|
||||
//! best-effort — failures are logged but never abort the backend.
|
||||
@@ -31,6 +31,7 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@@ -38,6 +39,8 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
/// image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -57,8 +60,8 @@ pub async fn ensure_doctor_installed() {
|
||||
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_nginx().await {
|
||||
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
|
||||
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
||||
Ok(true) => info!("Patched nginx config to proxy missing backend endpoints"),
|
||||
Ok(false) => debug!("Nginx backend endpoint proxy blocks already present"),
|
||||
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
@@ -444,13 +447,10 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
|
||||
/// it's missing. The original ISO shipped individual per-endpoint `location`
|
||||
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
|
||||
/// through to the SPA `index.html` and the frontend got HTML instead of
|
||||
/// JSON. We anchor the insert to the DWN comment that already sits right
|
||||
/// after the `/api/blob` block, so the new block lands in both the HTTP
|
||||
/// and HTTPS server blocks.
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
/// got HTML instead of JSON.
|
||||
///
|
||||
/// Validates via `nginx -t` before reloading. On failure the patch is
|
||||
/// rolled back from a backup written just before the write.
|
||||
@@ -465,51 +465,90 @@ async fn run_nginx() -> Result<bool> {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !Path::new(NGINX_CONF_PATH).exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH);
|
||||
return Ok(false);
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
continue;
|
||||
}
|
||||
let canonical = fs::canonicalize(candidate)
|
||||
.await
|
||||
.unwrap_or_else(|_| candidate.to_path_buf());
|
||||
if patched_paths.iter().any(|p| p == &canonical) {
|
||||
continue;
|
||||
}
|
||||
patched_paths.push(canonical);
|
||||
changed |= patch_nginx_conf(path).await?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(NGINX_CONF_PATH)
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
|
||||
if content.contains("location /api/app-catalog") {
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// The DWN comment sits at the same indent right after the `/api/blob`
|
||||
// block in both server blocks — a stable anchor that existed on every
|
||||
// ISO shipped to date. If it's absent (config got heavily customized),
|
||||
// we bail rather than guess where to splice.
|
||||
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
|
||||
if !content.contains(anchor) {
|
||||
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
|
||||
return Ok(false);
|
||||
let mut patched = content.clone();
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing electrs-status anchor — skipping /bitcoin-status patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_BITCOIN_STATUS_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
|
||||
let patched = content.replace(anchor, &replacement);
|
||||
if missing_app_catalog {
|
||||
// The DWN comment sits at the same indent right after the `/api/blob`
|
||||
// block in both server blocks — a stable anchor that existed on every
|
||||
// ISO shipped to date. If it's absent (config got heavily customized),
|
||||
// skip rather than guess where to splice.
|
||||
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if patched == content {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Write patched config via a user-owned tmp + sudo mv, after stashing
|
||||
// a backup so we can revert if `nginx -t` hates what we produced.
|
||||
// a backup outside nginx include dirs so validation cannot load it too.
|
||||
let pid = std::process::id();
|
||||
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
|
||||
fs::write(&tmp, &patched)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp))?;
|
||||
|
||||
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
|
||||
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
|
||||
let backup = format!(
|
||||
"/tmp/archipelago-nginx-backup-{}-{}.conf",
|
||||
pid,
|
||||
patched.len()
|
||||
);
|
||||
if let Err(e) = host_sudo(&["cp", path, &backup]).await {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
return Err(e.context("backup nginx conf"));
|
||||
}
|
||||
|
||||
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
|
||||
let mv = host_sudo(&["mv", &tmp, path]).await;
|
||||
match mv {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
anyhow::bail!("sudo mv nginx conf exited with {}", s);
|
||||
anyhow::bail!("sudo mv nginx conf to {} exited with {}", path, s);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
@@ -522,7 +561,7 @@ async fn run_nginx() -> Result<bool> {
|
||||
let valid = matches!(&test, Ok(s) if s.success());
|
||||
if !valid {
|
||||
warn!("nginx -t failed after patch — reverting");
|
||||
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
|
||||
let _ = host_sudo(&["mv", &backup, path]).await;
|
||||
if let Err(e) = test {
|
||||
return Err(e.context("nginx -t"));
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
// Prefer local build — companions can carry build-time customizations
|
||||
@@ -193,6 +194,9 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
}
|
||||
if image_exists(&local_image).await {
|
||||
return Ok(local_image);
|
||||
}
|
||||
@@ -335,13 +339,18 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
||||
}
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing OR the service is not active.
|
||||
/// the unit file is missing, stale, or the service is not active.
|
||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
let expected_image = ensure_image_present(spec).await?;
|
||||
let expected_unit = build_unit(spec, &expected_image);
|
||||
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||
return Ok(true);
|
||||
}
|
||||
let svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,118 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn patch_indeedhub_nostr_provider() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"/X-Frame-Options/d",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"cp",
|
||||
provider_src,
|
||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"grep",
|
||||
"-q",
|
||||
"nostr-provider",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if !already_patched {
|
||||
let cat_out = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(out) = cat_out {
|
||||
if out.status.success() {
|
||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
expires off;\n\
|
||||
}\n\n\
|
||||
location = /sw.js {",
|
||||
);
|
||||
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
conf
|
||||
};
|
||||
|
||||
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(tmp_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Outcome of `reconcile_all` for a single app.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReconcileAction {
|
||||
@@ -501,9 +613,10 @@ impl ProdContainerOrchestrator {
|
||||
let app_id = lm.manifest.app.id.clone();
|
||||
if app_id == "indeedhub" {
|
||||
// IndeedHub is a multi-container stack installed by the package
|
||||
// stack path. Reconciling its single manifest races stack installs
|
||||
// and can recreate a broken frontend container with the same name.
|
||||
return Ok(ReconcileAction::Left("stack-managed".to_string()));
|
||||
// stack path. Boot reconcile must not fresh-install the catalog
|
||||
// manifest, but it does need to start/repair an already-installed
|
||||
// stack and reapply the frontend's Nostr provider patch after boot.
|
||||
return self.reconcile_indeedhub_stack(mode).await;
|
||||
}
|
||||
let lock = self.app_lock(&app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
@@ -720,10 +833,24 @@ impl ProdContainerOrchestrator {
|
||||
async fn run_post_data_uid_hooks(&self, app_id: &str) -> Result<()> {
|
||||
match app_id {
|
||||
"fedimint" | "fedimint-gateway" => self.ensure_fedimint_dirs().await,
|
||||
"grafana" => self.ensure_grafana_dirs().await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_grafana_dirs(&self) -> Result<()> {
|
||||
let dir = "/var/lib/archipelago/grafana";
|
||||
let mkdir = host_sudo(&["mkdir", "-p", dir])
|
||||
.await
|
||||
.context("mkdir grafana data dir")?;
|
||||
if !mkdir.success() {
|
||||
return Err(anyhow::anyhow!("mkdir -p {dir} failed with status {mkdir}"));
|
||||
}
|
||||
chown_for_rootless_container("472:472", dir)
|
||||
.await
|
||||
.context("chown grafana data dir for rootless uid 472")
|
||||
}
|
||||
|
||||
/// Phase 3.3 in-place migration. When `use_quadlet_backends` flips
|
||||
/// from off → on, existing nodes have backend containers parented
|
||||
/// under archipelago.service's cgroup (the bad shape). They need to
|
||||
@@ -1138,6 +1265,59 @@ impl ProdContainerOrchestrator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_indeedhub_stack(&self, mode: ReconcileMode) -> Result<ReconcileAction> {
|
||||
let frontend_status = match self.runtime.get_container_status("indeedhub").await {
|
||||
Ok(status) => status,
|
||||
Err(_) => {
|
||||
if mode == ReconcileMode::ExistingOnly {
|
||||
return Ok(ReconcileAction::Left("absent".to_string()));
|
||||
}
|
||||
// Fresh stack creation is owned by package::stacks so we do not
|
||||
// create a single broken frontend container from the manifest.
|
||||
return Ok(ReconcileAction::Left("stack-managed".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
self.start_indeedhub_backends().await?;
|
||||
|
||||
let mut started = false;
|
||||
match frontend_status.state {
|
||||
ContainerState::Running => {}
|
||||
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created => {
|
||||
self.runtime
|
||||
.start_container("indeedhub")
|
||||
.await
|
||||
.context("start IndeedHub frontend during reconcile")?;
|
||||
started = true;
|
||||
}
|
||||
ContainerState::Paused => return Ok(ReconcileAction::Left("paused".to_string())),
|
||||
ContainerState::Unknown(s) => return Ok(ReconcileAction::Left(s)),
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
self.repair_indeedhub_network_aliases().await;
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
|
||||
if !wait_for_host_port(7778, 10).await {
|
||||
tracing::warn!(
|
||||
"IndeedHub frontend running but host port 7778 is not listening; restarting"
|
||||
);
|
||||
let _ = self.runtime.stop_container("indeedhub").await;
|
||||
self.runtime
|
||||
.start_container("indeedhub")
|
||||
.await
|
||||
.context("restart IndeedHub frontend after missing host port")?;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
if started {
|
||||
Ok(ReconcileAction::Started)
|
||||
} else {
|
||||
Ok(ReconcileAction::NoOp)
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network_aliases(&self) {
|
||||
for (container, alias) in [
|
||||
("indeedhub-postgres", "postgres"),
|
||||
@@ -1302,6 +1482,10 @@ impl ProdContainerOrchestrator {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.container_command_drifted(name, manifest).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
@@ -1334,6 +1518,52 @@ impl ProdContainerOrchestrator {
|
||||
})
|
||||
}
|
||||
|
||||
async fn container_command_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
|
||||
if manifest.app.container.entrypoint.is_none()
|
||||
&& manifest.app.container.custom_args.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"entry={{json .Config.Entrypoint}}\ncmd={{json .Config.Cmd}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = inspect else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let current_entry = text
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("entry="))
|
||||
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let current_cmd = text
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("cmd="))
|
||||
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let expected_entry = manifest
|
||||
.app
|
||||
.container
|
||||
.entrypoint
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
current_entry != expected_entry || current_cmd != manifest.app.container.custom_args
|
||||
}
|
||||
|
||||
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
|
||||
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
|
||||
return Ok(());
|
||||
|
||||
@@ -829,6 +829,41 @@ app:
|
||||
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: grafana
|
||||
name: Grafana
|
||||
version: 10.2.0
|
||||
container:
|
||||
image: grafana/grafana:10.2.0
|
||||
data_uid: "472:472"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/grafana
|
||||
target: /var/lib/grafana
|
||||
options: [rw]
|
||||
resources:
|
||||
memory_limit: 1g
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
assert_eq!(m.app.container.data_uid.as_deref(), Some("472:472"));
|
||||
|
||||
let u = QuadletUnit::from_manifest(&m, "grafana");
|
||||
assert_eq!(u.memory_mb, Some(1024));
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].host,
|
||||
PathBuf::from("/var/lib/archipelago/grafana")
|
||||
);
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].container,
|
||||
PathBuf::from("/var/lib/grafana")
|
||||
);
|
||||
assert!(!u.bind_mounts[0].read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_marks_ro_volumes_read_only() {
|
||||
let yaml = r#"
|
||||
|
||||
Reference in New Issue
Block a user