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:
co-authored by
Claude Opus 4.8
parent
d7c6f8c348
commit
6aa74c7386
@@ -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
|
||||
|
||||
@@ -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 1k–500k, on-chain > 500k",
|
||||
"amountSats": "Amount (sats)",
|
||||
"lightningInvoice": "Lightning Invoice (BOLT11)",
|
||||
"bitcoinAddress": "Bitcoin Address",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user