fix(bitcoin): bulletproof multi-version switching (Knots & Core)

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 <name>", latest pre-selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-29 05:46:04 -04:00
parent 169ff2e2cd
commit 095a76cd20
10 changed files with 314 additions and 74 deletions

View File

@ -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

View File

@ -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

View File

@ -929,6 +929,51 @@ pub struct AdoptionReport {
pub adopted: Vec<String>,
}
/// 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, &current).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, &current));
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, &current)
.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, &current)
});
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);

View File

@ -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<String> = 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<String> = 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<String> {
@ -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"));
}

View File

@ -0,0 +1,120 @@
<template>
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
<div class="flex items-center gap-4 mb-5 -mt-2">
<img
v-if="app?.icon"
:src="app.icon"
:alt="app?.title || ''"
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
/>
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<h3 class="text-xl font-semibold text-white leading-snug">
{{ t('marketplace.installModalTitle', { name: app?.title || appId }) }}
</h3>
</div>
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2">
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
<template #footer>
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
:disabled="loading || !selected"
@click="confirm"
>
{{ t('common.install') }}
</button>
<button
type="button"
class="rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium px-5 py-2.5 transition-colors"
@click="emit('close')"
>
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version'
const props = defineProps<{
show: boolean
appId: string
app: { id: string; title?: string; icon?: string } | null
}>()
const emit = defineEmits<{
close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
confirm: [version: string]
}>()
const { t } = useI18n()
const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([])
const selected = ref('')
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
function optionLabel(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
}
async function load() {
loading.value = true
versions.value = []
selected.value = ''
try {
const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest pre-select it.
versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
selected.value = 'latest'
} finally {
loading.value = false
}
}
function confirm() {
if (!selected.value) return
emit('confirm', selected.value)
}
watch(
() => props.show,
(open) => {
if (open) void load()
},
{ immediate: true },
)
</script>

View File

@ -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",

View File

@ -207,6 +207,15 @@
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
</div>
<!-- First-install version chooser (Bitcoin Knots / Core) -->
<InstallVersionModal
:show="showInstallModal"
:app-id="installModalApp?.id || ''"
:app="installModalApp"
@close="showInstallModal = false; installModalApp = null"
@confirm="onInstallModalConfirm"
/>
</div>
</template>
@ -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<MarketplaceApp | null>(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<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
}

View File

@ -48,11 +48,9 @@
<select
v-model="selectedVersion"
:disabled="versionBusy"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}{{ v.eol ? ` · EOL ${v.eol}` : '' }}
</option>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
</select>
</div>
@ -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 <select> renders blank. Prefer the pin, then the catalog
// default, then the running version but fall back to the default/first option
// when none of those is actually in the list (e.g. a stale installedVersion the
// catalog no longer carries, which is what left the control blank).
function pickSelection(info: PackageVersionsResponse): string {
const options = info.versions.map((v) => v.version)
const preferred = info.pinnedVersion || info.default || info.installedVersion || ''
if (preferred && options.includes(preferred)) return preferred
return info.default && options.includes(info.default)
? info.default
: info.versions[0]?.version || ''
}
async function loadVersions(appId: string) {
versionInfo.value = null
versionError.value = ''
@ -346,7 +368,7 @@ async function loadVersions(appId: string) {
const info = await rpcClient.getPackageVersions(appId)
if (!info.supportsVersions || !info.versions.length) return
versionInfo.value = info
selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || info.versions[0]?.version || ''
selectedVersion.value = pickSelection(info)
autoUpdate.value = info.autoUpdate
} catch (err) {
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
@ -381,7 +403,7 @@ function cancelDowngrade() {
downgradeWarning.value = ''
// Reset the dropdown to the current selection.
const info = versionInfo.value
if (info) selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || ''
if (info) selectedVersion.value = pickSelection(info)
}
watch(

View File

@ -334,7 +334,7 @@
}
},
"bitcoin-core": {
"version": "28.4.0",
"version": "latest",
"manifest": {
"app": {
"id": "bitcoin-core",
@ -343,7 +343,7 @@
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"container_name": "bitcoin-core",
"container": {
"image": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"image": "146.59.87.168:3000/lfg2025/bitcoin:latest",
"pull_policy": "if-not-present",
"network": "archy-net",
"entrypoint": [
@ -433,6 +433,11 @@
}
},
"versions": [
{
"version": "latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin:latest",
"default": true
},
{
"version": "31.0",
"image": "146.59.87.168:3000/lfg2025/bitcoin:31.0"
@ -445,10 +450,13 @@
"version": "29.3",
"image": "146.59.87.168:3000/lfg2025/bitcoin:29.3"
},
{
"version": "29.2",
"image": "146.59.87.168:3000/lfg2025/bitcoin:29.2"
},
{
"version": "28.4.0",
"image": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"default": true
"image": "146.59.87.168:3000/lfg2025/bitcoin:28.4"
},
{
"version": "27.2",
@ -569,12 +577,24 @@
"versions": [
{
"version": "latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508",
"default": true
},
{
"version": "29.3.knots20260508",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508"
},
{
"version": "29.3.knots20260507",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260507"
},
{
"version": "29.3.knots20260210",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260210"
},
{
"version": "29.2.knots20251110",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.2.knots20251110"
}
]
},

View File

@ -144,7 +144,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, exactly like the legacy hand-built :latest image.
# Rootless Podman maps container-root to the unprivileged host service user, and
# 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 here 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