chore(release): stage v1.7.54-alpha
This commit is contained in:
@@ -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