chore(release): stage v1.7.54-alpha

This commit is contained in:
archipelago
2026-05-06 09:23:57 -04:00
parent 1a0d8a432c
commit c0751e2551
30 changed files with 1871 additions and 102 deletions
+10 -1
View File
@@ -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(());
+35
View File
@@ -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#"