fix: netavark GLIBC mismatch in ISO, container adopt, app updates
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 13m24s

ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41)
which broke container networking on Debian 12 targets. Rootfs already
installs netavark from Debian 12 repos — just configure the backend.

Install RPC now adopts existing containers (from first-boot) instead of
erroring on duplicates. Container scanner extracts real versions from
image tags and detects available updates against pinned versions.

Frontend shows update button with version info when updates are available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-09 11:47:35 +02:00
parent b94e1aa135
commit 56e04a9df8
21 changed files with 830 additions and 66 deletions

View File

@@ -545,6 +545,14 @@ class RPCClient {
})
}
async updatePackage(id: string): Promise<{ status: string }> {
return this.call({
method: 'package.update',
params: { id },
timeout: 660000, // Bitcoin Knots needs up to 600s for graceful shutdown
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
return this.call({
method: 'marketplace.get',

View File

@@ -59,6 +59,7 @@ export const useAppStore = defineStore('app', () => {
startPackage: server.startPackage,
stopPackage: server.stopPackage,
restartPackage: server.restartPackage,
updatePackage: server.updatePackage,
updateServer: server.updateServer,
restartServer: server.restartServer,
shutdownServer: server.shutdownServer,

View File

@@ -17,7 +17,7 @@ export const useServerStore = defineStore('server', () => {
watch(() => sync.packages, (packages) => {
if (!packages) return
for (const [appId, pkg] of Object.entries(packages)) {
if ((pkg.state as string) === 'installing') {
if ((pkg.state as string) === 'installing' || (pkg.state as string) === 'updating') {
// Backend confirms it's installing — update or create tracking entry
if (!installingApps.value.has(appId)) {
installingApps.value.set(appId, {
@@ -121,6 +121,10 @@ export const useServerStore = defineStore('server', () => {
return rpcClient.restartPackage(id)
}
async function updatePackage(id: string): Promise<{ status: string }> {
return rpcClient.updatePackage(id)
}
// Server actions
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return rpcClient.updateServer(marketplaceUrl)
@@ -169,6 +173,7 @@ export const useServerStore = defineStore('server', () => {
startPackage,
stopPackage,
restartPackage,
updatePackage,
updateServer,
restartServer,
shutdownServer,

View File

@@ -74,6 +74,7 @@ export const PackageState = {
Restoring: 'restoring-backup',
Removing: 'removing',
BackingUp: 'backing-up',
Updating: 'updating',
} as const
export type PackageState = typeof PackageState[keyof typeof PackageState]
@@ -90,6 +91,7 @@ export interface PackageDataEntry {
manifest: Manifest
installed?: InstalledPackageDataEntry
'install-progress'?: InstallProgress
'available-update'?: string | null
}
export interface Manifest {

View File

@@ -33,6 +33,7 @@
@stop="stopApp"
@restart="restartApp"
@uninstall="uninstallApp"
@update="updateApp"
@channels="router.push('/dashboard/apps/lnd/channels')"
/>
@@ -333,6 +334,14 @@ async function restartApp() {
}
}
async function updateApp() {
try {
await store.updatePackage(appId.value)
} catch (err) {
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
function showUninstallModal() {
if (!pkg.value) return
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }

View File

@@ -26,6 +26,28 @@
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center gap-2"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="packageKey === 'lnd'"
@click="$emit('channels')"
@@ -130,6 +152,28 @@
<!-- Action Buttons (Auto Grid) -->
<div class="grid grid-cols-2 gap-2">
<!-- Update available (mobile) -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress (mobile) -->
<span
v-if="pkg.state === 'updating'"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="canLaunch"
@click="$emit('launch')"
@@ -200,6 +244,7 @@ defineEmits<{
stop: []
restart: []
uninstall: []
update: []
channels: []
}>()

View File

@@ -6,7 +6,12 @@
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
<div class="text-right">
<span class="text-white font-medium">v{{ pkg.manifest.version }}</span>
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
v{{ pkg['available-update'] }} available
</span>
</div>
</div>
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>

View File

@@ -125,6 +125,8 @@ export function getStatusClass(state: PackageState, health?: string | null, exit
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
case PackageState.Updating:
return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
default:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
}
@@ -148,12 +150,15 @@ export function getStatusDotClass(state: PackageState, health?: string | null, e
return 'bg-yellow-400 animate-pulse'
case PackageState.Installing:
return 'bg-blue-400 animate-pulse'
case PackageState.Updating:
return 'bg-orange-400 animate-pulse'
default:
return 'bg-gray-400'
}
}
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
if (state === PackageState.Updating) return 'updating...'
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'

View File

@@ -44,6 +44,10 @@
class="tier-badge"
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tier }}</span>
<span
v-if="pkg['available-update']"
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
>Update</span>
</div>
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
@@ -250,6 +254,6 @@ const tier = computed(() => {
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || (s === 'running' && h === 'starting')
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
})
</script>

View File

@@ -138,6 +138,8 @@ export function getStatusClass(state: PackageState, health?: string | null, exit
return 'bg-yellow-500/20 text-yellow-200'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200'
case PackageState.Updating:
return 'bg-orange-500/20 text-orange-200'
default:
return 'bg-gray-500/20 text-gray-200'
}
@@ -147,6 +149,7 @@ export function getStatusLabel(state: PackageState, health?: string | null, exit
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Updating) return 'updating...'
if (state === PackageState.Running) return 'running'
if (state === PackageState.Exited || state === PackageState.Stopped) {
if (exitCode === 137) return 'killed (OOM)'