feat(bitcoin): multi-version support for Core & Knots (install/switch/pin/auto-update)

Lets a node runner choose which Bitcoin Core / Knots version to install
(latest pre-selected), then switch, pin, or opt into auto-update from the
app's interface — all manifest/catalog-driven, rootless, signed-registry,
zero-data-loss. Motivated by upcoming BIP-110 signalling: runners need a
real choice of software version.

Backend:
- version_config.rs: per-app pin + auto-update persistence (atomic, merge-
  preserving), downgrade detection, auto-update enumeration (+ unit tests).
- app_catalog.rs: CatalogVersion / versions[] schema, catalog_versions(),
  catalog_image_for_version() (same-repo guard); a pin suppresses the update
  badge.
- prod_orchestrator.rs: pinned version wins over the catalog default on every
  install/recreate.
- install.rs: install-time `version` param persisted (default = unpinned).
- set_config.rs: package.versions (read) + package.set-config (write) RPCs;
  downgrade is gated behind explicit confirm (warn + confirm + allow).
- update.rs/main.rs: hourly per-app auto-update tick via the orchestrator
  (opt-in, pin-respecting); fix handle_package_update to be non-fatal for
  orchestrator-managed apps lacking a catalog primary image (bitcoin-core).

UI:
- MarketplaceAppDetails.vue: install-time version selector (shown when an app
  offers >=2 versions).
- appDetails/AppSidebar.vue: "Version & Updates" card (switch / pin / auto-
  update toggle / downgrade warning), per app.
- rpc-client.ts + en.json: RPC methods, types, strings.

Phase 0 image pipeline:
- scripts/build-bitcoin-image.sh: download official tarball + SHA256SUMS(.asc),
  verify SHA-256 + pinned-maintainer OpenPGP signature (fail-closed), build a
  minimal rootless image, smoke-test, tag + push.
- apps/bitcoin-core/Dockerfile rewritten (drops stale community base);
  apps/bitcoin-knots/Dockerfile added.
- generate-app-catalog.sh: emit curated versions[]; published + catalog now
  offers Core 25.2/26.2/27.2/28.4/29.3/30.2/31.0 + Knots 29.3.knots20260508.

docs/bitcoin-multi-version-design.md: live progress tracker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-28 18:46:17 -04:00
co-authored by Claude Opus 4.8
parent d7c6f8c348
commit 6aa74c7386
21 changed files with 1454 additions and 30 deletions
+59
View File
@@ -15,6 +15,40 @@ export interface RPCResponse<T> {
}
}
// Multi-version support (docs/bitcoin-multi-version-design.md). Mirrors the
// `package.versions` / `package.set-config` RPC payloads.
export interface CatalogVersionInfo {
version: string
default: boolean
deprecated: boolean
eol: string | null
}
export interface PackageVersionsResponse {
id: string
supportsVersions: boolean
default: string | null
installedVersion: string | null
pinnedVersion: string | null
autoUpdate: boolean
versions: CatalogVersionInfo[]
}
export interface SetPackageConfigResponse {
status: 'ok' | 'confirm_required'
id: string
// present on status === 'ok'
pinnedVersion?: string | null
autoUpdate?: boolean
versionChanged?: boolean
recreating?: boolean
// present on status === 'confirm_required'
kind?: string
currentVersion?: string
targetVersion?: string
warning?: string
}
/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend.
export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired'
@@ -586,6 +620,31 @@ class RPCClient {
})
}
// Multi-version support (docs/bitcoin-multi-version-design.md): list the
// versions a runner may install / switch to for an app, plus the current pin
// / auto-update preference and the version actually running.
async getPackageVersions(id: string): Promise<PackageVersionsResponse> {
return this.call({
method: 'package.versions',
params: { id },
timeout: 15000,
})
}
// Persist a version pin (or un-pin to track latest) and/or the auto-update
// toggle. A DOWNGRADE returns { status: 'confirm_required', warning } unless
// confirm:true is passed — the UI warns first, then re-calls with confirm.
async setPackageConfig(
id: string,
opts: { version?: string; autoUpdate?: boolean; confirm?: boolean },
): Promise<SetPackageConfigResponse> {
return this.call({
method: 'package.set-config',
params: { id, ...opts },
timeout: 600000,
})
}
async checkPackageUpdates(): Promise<{
status: string
refreshed: boolean
+13 -4
View File
@@ -568,8 +568,16 @@
"notFoundTitle": "App Not Found",
"notFoundMessage": "The requested application could not be found",
"installed": "Installed",
"channels": "Channels",
"noLaunchUrl": "No launch URL available for this app yet"
"noLaunchUrl": "No launch URL available for this app yet",
"versionUpdates": "Version & Updates",
"runningVersion": "Running version",
"selectVersion": "Version",
"autoUpdateLatest": "Auto-update to latest",
"autoUpdatePinnedNote": "Auto-update is disabled while a version is pinned.",
"confirmDowngrade": "Downgrade anyway",
"applyingVersion": "Applying…",
"applyVersion": "Apply",
"downgradeGeneric": "This is a downgrade and may require a re-sync. Re-confirm to proceed."
},
"containerDetails": {
"back": "Back",
@@ -608,7 +616,8 @@
"installFailed": "Installation Failed",
"depRunning": "Running",
"depStopped": "Installed but stopped",
"depNotInstalled": "Not installed"
"depNotInstalled": "Not installed",
"selectVersion": "Select version"
},
"goalDetail": {
"backToGoals": "Back to Goals",
@@ -761,7 +770,7 @@
"auto": "Auto",
"lightning": "Lightning",
"ecash": "Ecash",
"autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k\u2013500k, on-chain > 500k",
"autoMethodDesc": "Auto-selects method based on amount: ecash < 1k sats, Lightning 1k500k, on-chain > 500k",
"amountSats": "Amount (sats)",
"lightningInvoice": "Lightning Invoice (BOLT11)",
"bitcoinAddress": "Bitcoin Address",
+54 -3
View File
@@ -60,8 +60,19 @@
</svg>
{{ t('marketplaceDetails.open') }}
</button>
<select
v-if="!isInstalled && installVersions.length > 1"
v-model="selectedInstallVersion"
:disabled="installing"
:aria-label="t('marketplaceDetails.selectVersion')"
class="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"
>
<option v-for="v in installVersions" :key="v.version" :value="v.version">
{{ $ver(v.version) }}{{ v.default ? ' latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
</option>
</select>
<button
v-else
v-if="!isInstalled"
@click="installApp"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
@@ -114,6 +125,19 @@
</div>
</div>
<!-- Install-time version selector (multi-version apps) -->
<select
v-if="!isInstalled && installVersions.length > 1"
v-model="selectedInstallVersion"
:disabled="installing"
:aria-label="t('marketplaceDetails.selectVersion')"
class="w-full mb-2 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"
>
<option v-for="v in installVersions" :key="v.version" :value="v.version">
{{ $ver(v.version) }}{{ v.default ? ' latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
</option>
</select>
<!-- Bottom: Action Buttons -->
<div class="grid grid-cols-2 gap-2">
<button
@@ -375,6 +399,12 @@ const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
// Multi-version support: install-time version choice. Populated from the signed
// catalog for apps that offer multiple versions (e.g. Bitcoin Core / Knots).
// Hidden when an app offers only one version — install stays one-click.
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
const selectedInstallVersion = ref('')
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
@@ -511,8 +541,24 @@ onMounted(() => {
}, 500)
}
loadBitcoinPruneStatus()
void loadInstallVersions()
})
// Fetch the catalog's selectable versions so the install panel can offer a
// choice (latest pre-selected). Best-effort: on any failure the selector stays
// hidden and install proceeds at the catalog default.
async function loadInstallVersions() {
installVersions.value = []
try {
const info = await rpcClient.getPackageVersions(appId.value)
if (!info.supportsVersions || info.versions.length < 2) return
installVersions.value = info.versions
selectedInstallVersion.value = info.default || info.versions.find(v => v.default)?.version || info.versions[0]?.version || ''
} catch (err) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] loadInstallVersions failed:', err)
}
}
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
@@ -603,13 +649,18 @@ async function installApp() {
installing.value = true
installError.value = null
// Multi-version: a runner-chosen version (when offered) overrides the default.
const chosenVersion = (installVersions.value.length > 1 && selectedInstallVersion.value)
? selectedInstallVersion.value
: app.value.version
try {
if (app.value.dockerImage) {
// Docker-based app installation
const installParams: Record<string, unknown> = {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
version: chosenVersion,
}
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
await rpcClient.call({
@@ -625,7 +676,7 @@ async function installApp() {
params: {
id: app.value.id,
url: installUrl,
version: app.value.version,
version: chosenVersion,
},
timeout: 600000,
})
+134 -1
View File
@@ -32,6 +32,67 @@
</div>
</div>
<!-- Version & Updates Card (multi-version apps: Bitcoin Core / Knots).
Lets a runner switch versions, pin, and opt into auto-update. See
docs/bitcoin-multi-version-design.md §3 Phase 3. -->
<div v-if="versionInfo && versionInfo.supportsVersions && versionInfo.versions.length" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.versionUpdates') }}</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-white/60 text-sm">{{ t('appDetails.runningVersion') }}</span>
<span class="text-white font-medium">{{ versionInfo.installedVersion || $ver(pkg.manifest.version) }}</span>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">{{ t('appDetails.selectVersion') }}</label>
<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"
>
<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>
</select>
</div>
<label class="flex items-center justify-between gap-3 cursor-pointer">
<span class="text-white/80 text-sm">{{ t('appDetails.autoUpdateLatest') }}</span>
<input
type="checkbox"
v-model="autoUpdate"
:disabled="versionBusy || isPinned"
class="h-4 w-4 accent-blue-500"
/>
</label>
<p v-if="isPinned" class="text-white/40 text-xs -mt-2">{{ t('appDetails.autoUpdatePinnedNote') }}</p>
<!-- Downgrade confirmation -->
<div v-if="downgradeWarning" class="rounded-lg border border-orange-400/40 bg-orange-500/10 p-3">
<p class="text-orange-200 text-xs leading-relaxed"> {{ downgradeWarning }}</p>
<div class="flex gap-2 mt-3">
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-orange-500/80 hover:bg-orange-500 text-white" :disabled="versionBusy" @click="applyVersionConfig(true)">
{{ t('appDetails.confirmDowngrade') }}
</button>
<button type="button" class="text-xs px-3 py-1.5 rounded-md bg-white/10 hover:bg-white/20 text-white" :disabled="versionBusy" @click="cancelDowngrade">
{{ t('common.cancel') }}
</button>
</div>
</div>
<button
v-else
type="button"
class="w-full rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium py-2 transition-colors"
:disabled="versionBusy || !versionDirty"
@click="applyVersionConfig(false)"
>
{{ versionBusy ? t('appDetails.applyingVersion') : t('appDetails.applyVersion') }}
</button>
<p v-if="versionError" class="text-red-300 text-xs">{{ versionError }}</p>
</div>
</div>
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.services') }}</h3>
@@ -188,9 +249,10 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
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'
const { t } = useI18n()
const copiedCredential = ref('')
@@ -258,4 +320,75 @@ const setupInstructions = computed(() => {
const instructions = typeof raw === 'string' ? raw.trim() : ''
return instructions ? instructions : ''
})
// ---- Version & Updates (multi-version support) -----------------------------
const versionInfo = ref<PackageVersionsResponse | null>(null)
const selectedVersion = ref('')
const autoUpdate = ref(false)
const versionBusy = ref(false)
const versionError = ref('')
const downgradeWarning = ref('')
const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
// "Apply" is enabled when the runner changed the version or the toggle.
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
})
async function loadVersions(appId: string) {
versionInfo.value = null
versionError.value = ''
downgradeWarning.value = ''
try {
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 || ''
autoUpdate.value = info.autoUpdate
} catch (err) {
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
}
}
async function applyVersionConfig(confirm: boolean) {
if (!versionInfo.value) return
versionBusy.value = true
versionError.value = ''
try {
const res = await rpcClient.setPackageConfig(versionInfo.value.id, {
version: selectedVersion.value,
autoUpdate: autoUpdate.value,
confirm,
})
if (res.status === 'confirm_required') {
downgradeWarning.value = res.warning || t('appDetails.downgradeGeneric')
return
}
downgradeWarning.value = ''
// Refresh so the card reflects the new pin / running version.
await loadVersions(versionInfo.value.id)
} catch (err: unknown) {
versionError.value = err instanceof Error ? err.message : String(err)
} finally {
versionBusy.value = false
}
}
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 || ''
}
watch(
() => props.packageKey,
(key) => {
if (key) void loadVersions(key)
},
{ immediate: true },
)
</script>