feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge, fedimint_client, fedimint RPC, wallet wiring - Paid peer content: content invoices + streaming content server + content RPCs - Seed-phrase ceremony/reveal RPCs and CLI ceremony tool - LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and decoupled-update wiring; Fedimint Client core app in catalog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c10f2ac22e
commit
bd567cd165
@@ -281,6 +281,109 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
/// For each writable bind mount, write-probe as the container's own process
|
||||
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
|
||||
/// as root) to that service uid:gid. Because the chown runs in the container's
|
||||
/// user namespace, podman translates it to the correct host owner regardless of
|
||||
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
|
||||
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
|
||||
/// This is the durable replacement for the per-app hardcoded host chowns.
|
||||
///
|
||||
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
|
||||
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
|
||||
/// returns true if it repaired something; never fails reconcile (a degraded app
|
||||
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
|
||||
async fn ensure_running_container_ownership(name: &str) -> bool {
|
||||
async fn podman_stdout(args: &[&str]) -> Option<String> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
// The uid:gid the container's main process actually runs as.
|
||||
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false, // can't exec (no shell / not running) — nothing to do
|
||||
};
|
||||
let gid = podman_stdout(&["exec", name, "id", "-g"])
|
||||
.await
|
||||
.filter(|g| !g.is_empty())
|
||||
.unwrap_or_else(|| uid.clone());
|
||||
|
||||
// Writable bind-mount destinations only.
|
||||
let dests = match podman_stdout(&[
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let mut repaired = false;
|
||||
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
// Never touch system / socket bind mounts.
|
||||
if dest == "/"
|
||||
|| dest.starts_with("/proc")
|
||||
|| dest.starts_with("/sys")
|
||||
|| dest.starts_with("/dev")
|
||||
|| dest.starts_with("/run")
|
||||
|| dest.starts_with("/etc")
|
||||
|| dest.ends_with(".sock")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drift check: can the service user write here already?
|
||||
let probe = format!(
|
||||
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
|
||||
);
|
||||
let writable = tokio::process::Command::new("podman")
|
||||
.args(["exec", name, "sh", "-c", &probe])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if writable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Repair inside the container's userns — podman maps to the right host uid.
|
||||
let chown = tokio::process::Command::new("podman")
|
||||
.args(["exec", "-u", "0", name, "chown", "-R", &format!("{uid}:{gid}"), dest])
|
||||
.output()
|
||||
.await;
|
||||
match chown {
|
||||
Ok(o) if o.status.success() => {
|
||||
repaired = true;
|
||||
tracing::warn!(
|
||||
container = %name, dest, uid = %uid,
|
||||
"repaired unwritable volume ownership (in-container chown)"
|
||||
);
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
container = %name, dest,
|
||||
"volume ownership repair failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}"),
|
||||
}
|
||||
}
|
||||
repaired
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1155,6 +1258,30 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
|
||||
// (registry/manifest apps AND legacy compose stacks like immich) and
|
||||
// repair any that can't write their bind mounts — the durable, app-
|
||||
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
|
||||
// so steady state is just cheap in-container write-probes; only a broken
|
||||
// volume is chowned (in-userns, mapping-proof) and its container
|
||||
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
|
||||
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
|
||||
if let Ok(containers) = self.runtime.list_containers().await {
|
||||
for c in containers
|
||||
.iter()
|
||||
.filter(|c| matches!(c.state, ContainerState::Running))
|
||||
{
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &c.name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
@@ -1180,6 +1307,33 @@ impl ProdContainerOrchestrator {
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
self.ensure_app_secrets(&app_id).await?;
|
||||
|
||||
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
|
||||
// bitcoin-knots share port 8332, so if the *other* variant is already
|
||||
// running the inactive one can never start — the reconciler would just
|
||||
// churn "address already in use" and report a reconcile failure. Skip
|
||||
// it, mirroring the health monitor's same skip. (#47)
|
||||
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
|
||||
"bitcoin-core" => Some("bitcoin-knots"),
|
||||
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
|
||||
_ => None,
|
||||
} {
|
||||
if let Ok(list) = self.runtime.list_containers().await {
|
||||
let other_running = list.iter().any(|c| {
|
||||
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
|
||||
&& matches!(c.state, ContainerState::Running)
|
||||
});
|
||||
if other_running {
|
||||
tracing::debug!(
|
||||
app_id = %app_id,
|
||||
conflict,
|
||||
"skipping reconcile — the other Bitcoin implementation is running"
|
||||
);
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
|
||||
Reference in New Issue
Block a user