From 095a76cd20b6e5e86287012f47a786ab8de5410d Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 29 Jun 2026 05:46:04 -0400 Subject: [PATCH 1/4] fix(bitcoin): bulletproof multi-version switching (Knots & Core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stacked bugs made "switch version" silently fail / crash-loop, and the data-access mismatch corrupted a node's index during recovery attempts. Backend renderer: - sync_quadlet_unit ignored the per-app pinned version and re-rendered the quadlet with the manifest's :latest every reconcile tick, reverting any switch. Factor the install-time catalog/pin resolution into a shared resolve_catalog_image() and call it in BOTH install_fresh and sync_quadlet_unit. - The renderer folded manifest `entrypoint: ["sh","-lc"]` into Exec=, which only worked when the image entrypoint was a passthrough shell wrapper. The versioned images use ENTRYPOINT ["bitcoind"], so Exec=sh -lc ... became `bitcoind sh -lc ...` and crash-looped. Emit a real Entrypoint= override; exec_changed now also compares Entrypoint=. Images: - Build all bitcoin images (Core + Knots, every version) as container-root (USER removed) like the legacy :latest image. Chain data is owned by the data_uid (container uid 102); root reads it via CAP_DAC_OVERRIDE (granted in the manifest). A non-root USER (the previous uid 1000) can't read existing chain data → "Error initializing block database". Still fully rootless: container-root maps to the unprivileged host service user. Catalog: - bitcoin-knots versions[]: 29.3.knots20260508/20260507/20260210 + 29.2.knots20251110, "latest" tracking newest. - bitcoin-core versions[]: add 29.2 + a "latest" entry. All images rebuilt root and published to the mirror. Frontend: - AppSidebar version dropdown: rename the latest option to "Always use the latest version" (no v prefix), fix right padding, and guarantee the current selection matches a real option (was rendering blank). - New InstallVersionModal: full-screen version chooser shown from the App Store / Discover install button for multi-version apps (Bitcoin Knots/Core), app icon + "Install ", latest pre-selected. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/bitcoin-core/Dockerfile | 7 +- apps/bitcoin-knots/Dockerfile | 7 +- .../src/container/prod_orchestrator.rs | 89 +++++++------ core/archipelago/src/container/quadlet.rs | 34 +++-- .../src/components/InstallVersionModal.vue | 120 ++++++++++++++++++ neode-ui/src/locales/en.json | 3 + neode-ui/src/views/Discover.vue | 51 +++++++- neode-ui/src/views/appDetails/AppSidebar.vue | 40 ++++-- releases/app-catalog.json | 30 ++++- scripts/build-bitcoin-image.sh | 7 +- 10 files changed, 314 insertions(+), 74 deletions(-) create mode 100644 neode-ui/src/components/InstallVersionModal.vue diff --git a/apps/bitcoin-core/Dockerfile b/apps/bitcoin-core/Dockerfile index 883f977f..04df9fdf 100644 --- a/apps/bitcoin-core/Dockerfile +++ b/apps/bitcoin-core/Dockerfile @@ -22,7 +22,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/apps/bitcoin-knots/Dockerfile b/apps/bitcoin-knots/Dockerfile index 5f2afb4d..74025b9c 100644 --- a/apps/bitcoin-knots/Dockerfile +++ b/apps/bitcoin-knots/Dockerfile @@ -23,7 +23,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cd4fe297..e21fd021 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -929,6 +929,51 @@ pub struct AdoptionReport { pub adopted: Vec, } +/// Decouple the app image from the shipped manifest: prefer the remote app +/// catalog when it covers this app with a same-repo image, and let a +/// runner-pinned version win over the catalog default so "install/switch to +/// the version I chose" is honored across every install + recreate. Falls +/// through to the catalog default when unpinned or the pin can't be resolved +/// (unknown/repo-mismatch), and to the manifest image when the catalog is +/// absent/uncovered. +/// +/// Applied in-place to a cloned manifest. MUST run anywhere a quadlet is +/// rendered — both install_fresh (pull + create) and sync_quadlet_unit (the +/// reconciler's drift re-render); otherwise the reconciler rewrites the unit +/// back to the manifest's shipped `:latest` tag and silently reverts a pinned +/// version on the next tick. +fn resolve_catalog_image(resolved_manifest: &mut AppManifest) { + let Some(current) = resolved_manifest.app.container.image.clone() else { + return; + }; + let app_id = resolved_manifest.app.id.clone(); + let resolved_image = crate::container::version_config::pinned_version(&app_id) + .and_then(|v| { + crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t).or_else( + || { + tracing::warn!( + app_id = %app_id, + pinned = %v, + "version_config: pinned version not resolvable in catalog — using default" + ); + None + }, + ) + }) + .or_else(|| crate::container::app_catalog::catalog_image_override(&app_id, ¤t)); + if let Some(catalog_image) = resolved_image { + if catalog_image != current { + tracing::info!( + app_id = %app_id, + from = %current, + to = %catalog_image, + "app-catalog: overriding manifest image" + ); + resolved_manifest.app.container.image = Some(catalog_image); + } + } +} + /// Internal: track a manifest together with the absolute directory it was loaded /// from, so Build sources can resolve relative `context:` paths. #[derive(Debug, Clone)] @@ -1809,45 +1854,7 @@ impl ProdContainerOrchestrator { self.ensure_app_secrets(&lm.manifest.app.id).await?; let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest)?; - - // Decouple the app image from the shipped manifest: prefer the remote - // app catalog when it covers this app with a same-repo image. This makes - // both the pull below and create_container() below use the catalog tag, - // so an app update no longer requires a binary/runtime release. Falls - // back to the manifest image when the catalog is absent/uncovered. - if let Some(current) = resolved_manifest.app.container.image.clone() { - let app_id = resolved_manifest.app.id.clone(); - // Multi-version support: a runner-pinned version wins over the catalog - // default so "install/switch to the version I chose" is honored across - // every install + recreate. Falls through to the catalog default when - // unpinned or the pin can't be resolved (unknown/repo-mismatch). - let resolved_image = crate::container::version_config::pinned_version(&app_id) - .and_then(|v| { - crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t) - .or_else(|| { - tracing::warn!( - app_id = %app_id, - pinned = %v, - "version_config: pinned version not resolvable in catalog — using default" - ); - None - }) - }) - .or_else(|| { - crate::container::app_catalog::catalog_image_override(&app_id, ¤t) - }); - if let Some(catalog_image) = resolved_image { - if catalog_image != current { - tracing::info!( - app_id = %app_id, - from = %current, - to = %catalog_image, - "app-catalog: overriding manifest image" - ); - resolved_manifest.app.container.image = Some(catalog_image); - } - } - } + resolve_catalog_image(&mut resolved_manifest); let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| { anyhow::anyhow!( @@ -2206,6 +2213,10 @@ impl ProdContainerOrchestrator { let mut resolved = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved)?; + // Same catalog/pinned-version image resolution the installer applies, so + // the drift re-render doesn't revert a pinned version back to the + // manifest's shipped `:latest` tag on the next reconcile tick. + resolve_catalog_image(&mut resolved); let unit = quadlet::QuadletUnit::from_manifest(&resolved, name); let new_body = unit.render(); let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body); diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 9250e841..25747558 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -268,14 +268,21 @@ impl QuadletUnit { let _ = writeln!(s, "HealthTimeout={}", h.timeout); let _ = writeln!(s, "HealthRetries={}", h.retries); } - if let Some(ep) = &self.entrypoint { - // Quadlet's Exec= replaces the image entrypoint+cmd. When - // the manifest provides both entrypoint and command we - // concatenate; if only command is set we'll emit that on - // its own below. - let mut parts: Vec = ep.clone(); + if let Some((first, rest)) = self.entrypoint.as_deref().and_then(<[String]>::split_first) { + // Quadlet's Exec= sets only the command (the args passed to the + // image's ENTRYPOINT) — it does NOT replace the entrypoint. So a + // manifest entrypoint like `sh -lc` must be emitted as a real + // Entrypoint= override; otherwise it gets appended to whatever + // ENTRYPOINT the image baked in (e.g. the versioned bitcoind + // images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper + // into `bitcoind sh -lc ...` and crash-looped). Emitting + // Entrypoint= makes the unit independent of the image's entrypoint. + let _ = writeln!(s, "Entrypoint={first}"); + let mut parts: Vec = rest.to_vec(); parts.extend(self.command.iter().cloned()); - let _ = writeln!(s, "Exec={}", shell_join(&parts)); + if !parts.is_empty() { + let _ = writeln!(s, "Exec={}", shell_join(&parts)); + } } else if !self.command.is_empty() { let _ = writeln!(s, "Exec={}", shell_join(&self.command)); } @@ -769,9 +776,11 @@ pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool { } pub fn exec_changed(old_body: &str, new_body: &str) -> bool { - let old_exec = directive_values(old_body, "Exec="); - let new_exec = directive_values(new_body, "Exec="); - old_exec != new_exec + // Entrypoint= and Exec= together define what the container runs, so a drift + // in either must recreate the container (e.g. when this renderer first + // splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`). + directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=") + || directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=") } fn directive_values(unit_body: &str, prefix: &str) -> Vec { @@ -1063,7 +1072,10 @@ mod tests { assert!(s.contains("ReadOnly=true")); assert!(s.contains("NoNewPrivileges=true")); assert!(s.contains("PodmanArgs=--cpus=2")); - assert!(s.contains("Exec=/usr/local/bin/bitcoind -server=1 -rpcbind=0.0.0.0")); + // Manifest entrypoint becomes a real Entrypoint= override (not folded + // into Exec=), so the unit doesn't depend on the image's own ENTRYPOINT. + assert!(s.contains("Entrypoint=/usr/local/bin/bitcoind")); + assert!(s.contains("Exec=-server=1 -rpcbind=0.0.0.0")); assert!(s.contains("Restart=on-failure")); assert!(s.contains("Network=archy-net")); } diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue new file mode 100644 index 00000000..47edbc73 --- /dev/null +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index c8aa340a..c618d628 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -330,6 +330,8 @@ "marketplace": { "title": "App Store", "subtitle": "Discover and install apps for your new sovereign life", + "installModalTitle": "Install {name}", + "installModalHint": "Choose a version to install. The latest is recommended.", "curatedTab": "Curated", "communityTab": "Community", "nostrCommunityTab": "Nostr Community", @@ -572,6 +574,7 @@ "versionUpdates": "Version & Updates", "runningVersion": "Running version", "selectVersion": "Version", + "alwaysUseLatestVersion": "Always use the latest version", "autoUpdateLatest": "Auto-update to latest", "autoUpdatePinnedNote": "Auto-update is disabled while a version is pinned.", "confirmDowngrade": "Downgrade anyway", diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index 25dcbe84..7361d00c 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -207,6 +207,15 @@

// Cypherpunks write code. We run nodes.

+ + + @@ -228,6 +237,7 @@ import { APP_STORE_SECTIONS } from './appStoreCategories' import DiscoverHero from './discover/DiscoverHero.vue' import FeaturedApps from './discover/FeaturedApps.vue' import AppGrid from './discover/AppGrid.vue' +import InstallVersionModal from '@/components/InstallVersionModal.vue' import type { MarketplaceApp, FeaturedApp } from './discover/types' import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps' @@ -262,6 +272,10 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS) // been removed in favour of the store's phase-aware mapping. const installingApps = serverStore.installingApps +// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core) +const showInstallModal = ref(false) +const installModalApp = ref(null) + function navigateToMarketplace(categoryId: string) { router.push({ name: 'marketplace', query: { category: categoryId } }) } @@ -427,19 +441,42 @@ function launchInstalledApp(app: MarketplaceApp) { appLauncher.openSession(app.id) } -function handleInstall(app: MarketplaceApp) { +async function handleInstall(app: MarketplaceApp) { const blocked = installBlockedReason(app.id) if (blocked) { toast.error(blocked) return } + if (installingApps.has(app.id) || isInstalled(app.id)) return + // Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up + // front via a full-screen modal (latest pre-selected) instead of silently + // installing the default. Best-effort — if the lookup fails we install directly. + try { + const info = await rpcClient.getPackageVersions(app.id) + if (info.supportsVersions && info.versions.length > 1) { + installModalApp.value = app + showInstallModal.value = true + return + } + } catch { /* no catalog versions — fall through to direct install */ } + startInstall(app) +} + +function startInstall(app: MarketplaceApp, versionOverride?: string) { if (app.source === 'local') { - installApp(app) + installApp(app, versionOverride) } else { - installCommunityApp(app) + installCommunityApp(app, versionOverride) } } +function onInstallModalConfirm(version: string) { + const app = installModalApp.value + showInstallModal.value = false + installModalApp.value = null + if (app) startInstall(app, version) +} + function viewAppDetails(app: MarketplaceApp) { try { if (isInstalled(app.id)) { @@ -514,27 +551,27 @@ function failInstall(app: MarketplaceApp, err: unknown) { trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000) } -async function installApp(app: MarketplaceApp) { +async function installApp(app: MarketplaceApp, versionOverride?: string) { if (installingApps.has(app.id) || isInstalled(app.id)) return queueInstall(app) toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") router.push('/dashboard/apps').catch(() => {}) try { const installUrl = app.url || app.manifestUrl || app.s9pkUrl - await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 }) + await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 }) } catch (err) { if (import.meta.env.DEV) console.error('Installation failed:', err) failInstall(app, err) } } -async function installCommunityApp(app: MarketplaceApp) { +async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) { if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return queueInstall(app) toast.info("Installing " + (app.title ?? app.id) + " - check My Apps") router.push('/dashboard/apps').catch(() => {}) try { - const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: app.version } + const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version } if ((app as Record).containerConfig) { installParams.containerConfig = (app as Record).containerConfig } diff --git a/neode-ui/src/views/appDetails/AppSidebar.vue b/neode-ui/src/views/appDetails/AppSidebar.vue index 39dfb4ab..03eb2362 100644 --- a/neode-ui/src/views/appDetails/AppSidebar.vue +++ b/neode-ui/src/views/appDetails/AppSidebar.vue @@ -48,11 +48,9 @@ @@ -252,7 +250,8 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { AppCredentialsResponse } from '@/types/api' -import { rpcClient, type PackageVersionsResponse } from '../../api/rpc-client' +import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client' +import { displayVersion } from '@/utils/version' const { t } = useI18n() const copiedCredential = ref('') @@ -334,10 +333,33 @@ const isPinned = computed(() => !!versionInfo.value?.pinnedVersion) const versionDirty = computed(() => { const info = versionInfo.value if (!info) return false - const currentSelection = info.pinnedVersion || info.default || info.installedVersion || '' - return selectedVersion.value !== currentSelection || autoUpdate.value !== info.autoUpdate + return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate }) +// Option label: the floating "latest" entry reads as a sentence (no "v" +// prefix); every concrete version is normalized via $ver + status suffixes. +function versionOptionLabel(v: CatalogVersionInfo): string { + if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion') + let label = displayVersion(v.version) + if (v.deprecated) label += ' (deprecated)' + if (v.eol) label += ` · EOL ${v.eol}` + return label +} + +// Resolve the dropdown's current value and GUARANTEE it matches a real option, +// otherwise the native