mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
900287e54a05641290f221245a504013e106abc955cdf009a03361e274c55770
|
||||
1221dec8316a1292bbeaec68dd06fe0b385a01bdc649fa24f52ef99fec0d4078
|
||||
|
||||
@@ -992,6 +992,24 @@ function InspectModal({
|
||||
stagingPath: string
|
||||
pathsAvailable: string[]
|
||||
rollbackPlan?: RollbackPlan
|
||||
// Cross-kernel awareness passed through to RestoreOptionsModal:
|
||||
// when direction === "bk_older" the modal shows the safe-subset
|
||||
// banner and grays out any path prefixed by anything in
|
||||
// blockedPaths. "bk_newer" and "same" pass through as no-ops.
|
||||
crossKernel?: {
|
||||
direction: "same" | "bk_older" | "bk_newer"
|
||||
backupKernel: string
|
||||
targetKernel: string
|
||||
blockedPaths: string[]
|
||||
}
|
||||
// Kernel-agnostic hydration preview (bk_older only): what the
|
||||
// restore will re-apply to the target via merge instead of
|
||||
// whole-file copy — IOMMU cmdline tokens, VFIO modules,
|
||||
// operator's GRUB keys, whitelisted vfio/nvidia files.
|
||||
hydration?: {
|
||||
applies: boolean
|
||||
actions: string[]
|
||||
}
|
||||
} | null>(null)
|
||||
const [restoreTerminal, setRestoreTerminal] = useState<{
|
||||
stagingPath: string
|
||||
@@ -999,27 +1017,6 @@ function InspectModal({
|
||||
paths: string[]
|
||||
rollbackExecute?: boolean
|
||||
} | null>(null)
|
||||
// Cross-kernel modal state:
|
||||
// - refuse: backup kernel is neither installed nor available in
|
||||
// Proxmox repos. Restore cannot proceed cleanly; the modal
|
||||
// mirrors the CLI msgbox and tells the operator what to do.
|
||||
// - notice: backup kernel is installed (or installable). Restore
|
||||
// will proceed but the operator sees a plain heads-up so they
|
||||
// know why the flow will pin a different kernel + reboot.
|
||||
const [crossKernelRefuse, setCrossKernelRefuse] = useState<{
|
||||
backupKernel: string
|
||||
targetKernel: string
|
||||
reason: string
|
||||
} | null>(null)
|
||||
const [crossKernelNotice, setCrossKernelNotice] = useState<{
|
||||
backupKernel: string
|
||||
targetKernel: string
|
||||
action: "installed" | "installable"
|
||||
pkg: string
|
||||
stagingPath: string
|
||||
pathsAvailable: string[]
|
||||
rollbackPlan?: RollbackPlan
|
||||
} | null>(null)
|
||||
|
||||
const beginRestore = async () => {
|
||||
if (!archive) return
|
||||
@@ -1045,44 +1042,25 @@ function InspectModal({
|
||||
})
|
||||
if (!r?.staging_path) throw new Error("backend did not return a staging path")
|
||||
const ck = r.cross_kernel || {}
|
||||
// Refuse path: kernel unreachable, do not open the restore
|
||||
// options dialog. The operator sees the modal explaining why.
|
||||
if (ck?.detected && ck?.action === "unavailable") {
|
||||
setCrossKernelRefuse({
|
||||
backupKernel: ck.backup_kernel || "",
|
||||
targetKernel: ck.target_kernel || "",
|
||||
reason: ck.reason || "unknown",
|
||||
})
|
||||
// Clean up the staging tree the backend just created — the
|
||||
// restore is not going to run against it.
|
||||
try {
|
||||
await fetchApi("/api/host-backups/restore/cleanup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ staging_path: r.staging_path }),
|
||||
})
|
||||
} catch { /* best effort */ }
|
||||
return
|
||||
}
|
||||
// Notice path: heads-up modal, then continue to the standard
|
||||
// restore options once the operator acknowledges.
|
||||
if (ck?.detected && (ck?.action === "installed" || ck?.action === "installable")) {
|
||||
setCrossKernelNotice({
|
||||
backupKernel: ck.backup_kernel || "",
|
||||
targetKernel: ck.target_kernel || "",
|
||||
action: ck.action,
|
||||
pkg: ck.pkg || "",
|
||||
stagingPath: r.staging_path,
|
||||
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
|
||||
rollbackPlan: r.rollback_plan,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Same-kernel: straight to the options dialog.
|
||||
const hyd = r.hydration || {}
|
||||
setRestoreOptions({
|
||||
stagingPath: r.staging_path,
|
||||
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
|
||||
rollbackPlan: r.rollback_plan,
|
||||
crossKernel: ck.direction
|
||||
? {
|
||||
direction: ck.direction,
|
||||
backupKernel: ck.backup_kernel || "",
|
||||
targetKernel: ck.target_kernel || "",
|
||||
blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [],
|
||||
}
|
||||
: undefined,
|
||||
hydration: hyd.applies
|
||||
? {
|
||||
applies: true,
|
||||
actions: Array.isArray(hyd.actions) ? hyd.actions : [],
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
||||
@@ -1540,131 +1518,6 @@ function InspectModal({
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ── Cross-kernel refuse modal ───────────────────────────
|
||||
Backend detected the backup was taken on a kernel major.minor
|
||||
different from the target's, and that kernel is neither
|
||||
installed nor pullable from Proxmox repos. Restoring anyway
|
||||
would leave drivers/DKMS pinned to a kernel that isn't on
|
||||
the box; we refuse and tell the operator what to do. Copy
|
||||
mirrors the CLI msgbox in backup_host.sh so a user reading
|
||||
both surfaces gets the exact same instructions. */}
|
||||
{crossKernelRefuse && (
|
||||
<Dialog open={true} onOpenChange={(o) => !o && setCrossKernelRefuse(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Restore refused — incompatible kernel
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
This backup was taken on kernel{" "}
|
||||
<code className="font-mono text-red-300">{crossKernelRefuse.backupKernel}</code>.
|
||||
</p>
|
||||
<p>
|
||||
That kernel is <span className="font-medium">{crossKernelRefuse.reason}</span>{" "}
|
||||
(current host runs{" "}
|
||||
<code className="font-mono">{crossKernelRefuse.targetKernel}</code>).
|
||||
</p>
|
||||
<p>
|
||||
Restoring without a matching kernel would leave drivers and modules built against a
|
||||
kernel that is not present on this host — the system may fail to boot or run degraded.
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 space-y-2">
|
||||
<p className="font-medium">How to proceed</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-xs">
|
||||
<li>Install Proxmox VE from an ISO whose kernel matches the backup.</li>
|
||||
<li>
|
||||
Or fetch the kernel manually:{" "}
|
||||
<code className="font-mono">
|
||||
apt install proxmox-kernel-{crossKernelRefuse.backupKernel}
|
||||
</code>
|
||||
</li>
|
||||
<li>Then relaunch the restore from this dialog.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCrossKernelRefuse(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ── Cross-kernel notice modal ──────────────────────────
|
||||
Backend detected cross-kernel but the correct kernel is
|
||||
available (already installed or pullable). The restore
|
||||
flow will handle the pin + reboot automatically; this
|
||||
just tells the operator what to expect before they see
|
||||
the standard restore options dialog. */}
|
||||
{crossKernelNotice && (
|
||||
<Dialog open={true} onOpenChange={(o) => !o && setCrossKernelNotice(null)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-blue-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Cross-kernel restore
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
This backup was taken on kernel{" "}
|
||||
<code className="font-mono">{crossKernelNotice.backupKernel}</code>. The host
|
||||
currently runs <code className="font-mono">{crossKernelNotice.targetKernel}</code>.
|
||||
</p>
|
||||
{crossKernelNotice.action === "installed" ? (
|
||||
<p>
|
||||
<code className="font-mono">{crossKernelNotice.pkg}</code> is already installed on
|
||||
this host. The restore will pin it as the default boot kernel and reboot into it
|
||||
once so DKMS drivers are rebuilt against the correct ABI.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<code className="font-mono">{crossKernelNotice.pkg}</code> is available from
|
||||
Proxmox repos. The restore will install it, pin it as the default boot kernel and
|
||||
reboot into it once so DKMS drivers are rebuilt against the correct ABI.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
An extra reboot will happen automatically as part of the restore. You do not need to
|
||||
do anything.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const sp = crossKernelNotice.stagingPath
|
||||
setCrossKernelNotice(null)
|
||||
if (sp) {
|
||||
fetchApi("/api/host-backups/restore/cleanup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ staging_path: sp }),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRestoreOptions({
|
||||
stagingPath: crossKernelNotice.stagingPath,
|
||||
pathsAvailable: crossKernelNotice.pathsAvailable,
|
||||
rollbackPlan: crossKernelNotice.rollbackPlan,
|
||||
})
|
||||
setCrossKernelNotice(null)
|
||||
}}
|
||||
>
|
||||
Understood — continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ── Restore options modal: opens only AFTER the prepare step,
|
||||
so the Custom checklist already knows what's inside. ──── */}
|
||||
<RestoreOptionsModal
|
||||
@@ -1672,6 +1525,8 @@ function InspectModal({
|
||||
stagingPath={restoreOptions?.stagingPath ?? ""}
|
||||
pathsAvailable={restoreOptions?.pathsAvailable ?? []}
|
||||
rollbackPlan={restoreOptions?.rollbackPlan}
|
||||
crossKernel={restoreOptions?.crossKernel}
|
||||
hydration={restoreOptions?.hydration}
|
||||
display_id={archive?.display_id}
|
||||
onClose={() => {
|
||||
const sp = restoreOptions?.stagingPath
|
||||
@@ -7485,6 +7340,8 @@ function RestoreOptionsModal({
|
||||
stagingPath,
|
||||
pathsAvailable,
|
||||
rollbackPlan,
|
||||
crossKernel,
|
||||
hydration,
|
||||
display_id,
|
||||
onLaunch,
|
||||
}: {
|
||||
@@ -7493,9 +7350,27 @@ function RestoreOptionsModal({
|
||||
stagingPath: string
|
||||
pathsAvailable: string[]
|
||||
rollbackPlan?: RollbackPlan
|
||||
crossKernel?: {
|
||||
direction: "same" | "bk_older" | "bk_newer"
|
||||
backupKernel: string
|
||||
targetKernel: string
|
||||
blockedPaths: string[]
|
||||
}
|
||||
hydration?: {
|
||||
applies: boolean
|
||||
actions: string[]
|
||||
}
|
||||
display_id?: string
|
||||
onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void
|
||||
}) {
|
||||
// When direction === "bk_older" the CLI-side filter (RS_SKIP_PATHS)
|
||||
// will drop these prefixes from any restore. We mirror that
|
||||
// behavior in the picker so the operator can see what will be
|
||||
// skipped rather than being surprised at run time.
|
||||
const isBkOlder = crossKernel?.direction === "bk_older"
|
||||
const blockedPrefixes = isBkOlder ? (crossKernel?.blockedPaths ?? []) : []
|
||||
const isPathBlocked = (p: string) =>
|
||||
blockedPrefixes.some((b) => p === b || p.startsWith(b.endsWith("/") ? b : `${b}/`))
|
||||
const [step, setStep] = useState<"choose" | "custom">("choose")
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -7524,6 +7399,7 @@ function RestoreOptionsModal({
|
||||
}, [open])
|
||||
|
||||
const togglePath = (p: string) => {
|
||||
if (isPathBlocked(p)) return
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(p)) next.delete(p)
|
||||
@@ -7533,16 +7409,18 @@ function RestoreOptionsModal({
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === filteredPaths.length) {
|
||||
const selectable = filteredPaths.filter((p) => !isPathBlocked(p))
|
||||
if (selected.size === selectable.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(filteredPaths))
|
||||
setSelected(new Set(selectable))
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPaths = filter
|
||||
? pathsAvailable.filter((p) => p.toLowerCase().includes(filter.toLowerCase()))
|
||||
: pathsAvailable
|
||||
const selectableCount = filteredPaths.filter((p) => !isPathBlocked(p)).length
|
||||
|
||||
const launch = (mode: "full" | "custom") => {
|
||||
if (mode === "custom" && selected.size === 0) {
|
||||
@@ -7568,6 +7446,56 @@ function RestoreOptionsModal({
|
||||
|
||||
{step === "choose" && (
|
||||
<div className="space-y-3">
|
||||
{isBkOlder && (
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Cross-kernel restore — safe subset only
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
|
||||
<div>
|
||||
Backup kernel <code className="font-mono">{crossKernel?.backupKernel}</code> is older than target kernel <code className="font-mono">{crossKernel?.targetKernel}</code>.
|
||||
</div>
|
||||
<div>
|
||||
Both Complete and Custom restore will automatically skip kernel/boot-tied paths to avoid a kernel panic on next boot. Everything else (VMs, LXCs, network, components, custom paths) restores normally.
|
||||
</div>
|
||||
{blockedPrefixes.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-amber-400/90 hover:text-amber-300">
|
||||
Paths that will be skipped ({blockedPrefixes.length})
|
||||
</summary>
|
||||
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
|
||||
{blockedPrefixes.map((p) => (
|
||||
<li key={p} className="font-mono text-[10.5px] text-muted-foreground/80 break-all">{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBkOlder && hydration?.applies && hydration.actions.length > 0 && (
|
||||
<div className="rounded-md border border-emerald-500/50 bg-emerald-500/5 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Operator config re-applied via kernel-agnostic merge
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
|
||||
<div>
|
||||
The following operator-authored settings will be merged into the target automatically, without touching kernel-tied defaults. Runs on both Complete and Custom restore.
|
||||
</div>
|
||||
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
|
||||
{hydration.actions.map((a, i) => (
|
||||
<li key={`${a}-${i}`} className="text-[10.5px] text-emerald-300/90 break-all">
|
||||
<span className="text-emerald-500 mr-1">•</span>{a}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Surface destructive deltas BEFORE the operator picks
|
||||
Complete. Custom-by-paths can't trigger these
|
||||
(paths-only restore doesn't touch the guest list or
|
||||
@@ -7666,29 +7594,44 @@ function RestoreOptionsModal({
|
||||
onClick={toggleAll}
|
||||
className="h-8 text-xs whitespace-nowrap"
|
||||
>
|
||||
{selected.size === filteredPaths.length && filteredPaths.length > 0 ? "Clear" : "All"}
|
||||
{selected.size === selectableCount && selectableCount > 0 ? "Clear" : "All"}
|
||||
</Button>
|
||||
</div>
|
||||
{isBkOlder && (
|
||||
<div className="text-[10.5px] text-amber-400/90 px-2 py-1.5 rounded border border-amber-500/30 bg-amber-500/5">
|
||||
Kernel/boot-tied paths are grayed out and cannot be selected — target runs a newer kernel.
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border bg-background/40 p-1 max-h-72 overflow-auto">
|
||||
<ul className="divide-y divide-border/40">
|
||||
{filteredPaths.map((p) => (
|
||||
<li key={p}>
|
||||
<label className="flex items-center gap-2 px-2 py-1 hover:bg-white/5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selected.has(p)}
|
||||
onCheckedChange={() => togglePath(p)}
|
||||
/>
|
||||
<code className="font-mono text-xs break-all flex-1">{p}</code>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{filteredPaths.map((p) => {
|
||||
const blocked = isPathBlocked(p)
|
||||
return (
|
||||
<li key={p}>
|
||||
<label
|
||||
className={`flex items-center gap-2 px-2 py-1 ${blocked ? "opacity-40 cursor-not-allowed" : "hover:bg-white/5 cursor-pointer"}`}
|
||||
title={blocked ? "Skipped: kernel-tied path, target runs a newer kernel" : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={!blocked && selected.has(p)}
|
||||
disabled={blocked}
|
||||
onCheckedChange={() => togglePath(p)}
|
||||
/>
|
||||
<code className={`font-mono text-xs break-all flex-1 ${blocked ? "line-through" : ""}`}>{p}</code>
|
||||
{blocked && (
|
||||
<span className="text-[10px] text-amber-400/80 font-normal shrink-0">skipped</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{filteredPaths.length === 0 && (
|
||||
<li className="text-[11px] text-muted-foreground italic px-2 py-2">No paths match the filter.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Selected: {selected.size} / {pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
|
||||
Selected: {selected.size} / {isBkOlder ? selectableCount : pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
|
||||
|
||||
@@ -16905,27 +16905,113 @@ def api_host_backups_restore_prepare():
|
||||
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Cross-kernel plan: shared with the CLI menu via
|
||||
# kernel_pin_plan.sh so both surfaces refuse the same restores.
|
||||
# `detected=true` + `action=unavailable` is what the frontend
|
||||
# treats as a hard refuse: it shows the "install a kernel first"
|
||||
# modal instead of proceeding to the safety checkboxes.
|
||||
cross_kernel: dict = {'detected': False}
|
||||
kernel_plan_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/kernel_pin_plan.sh'
|
||||
if os.path.isfile(kernel_plan_script):
|
||||
# Direction-aware cross-kernel signal for the Web restore UI.
|
||||
# We source the same logic the CLI uses: read the backup's
|
||||
# kernel from the staging's run_info.env, compare major.minor
|
||||
# to the target's current kernel, and classify the direction.
|
||||
# "bk_older" is the only mode that trims the picker; the
|
||||
# blocked list mirrors hb_unsafe_paths_cross_version so both
|
||||
# surfaces skip the exact same set. "bk_newer" and "same" send
|
||||
# empty lists and the Web treats them as a normal restore.
|
||||
cross_kernel: dict = {
|
||||
'direction': 'same',
|
||||
'backup_kernel': '',
|
||||
'target_kernel': '',
|
||||
'blocked_paths': [],
|
||||
}
|
||||
try:
|
||||
bk_kernel = ''
|
||||
run_info = os.path.join(staging, 'metadata', 'run_info.env')
|
||||
if os.path.isfile(run_info):
|
||||
with open(run_info, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('kernel='):
|
||||
bk_kernel = line.strip().split('=', 1)[1]
|
||||
break
|
||||
try:
|
||||
r = subprocess.run(['bash', kernel_plan_script, staging],
|
||||
capture_output=True, text=True, timeout=90)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
cross_kernel = json.loads(r.stdout.strip())
|
||||
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
tg_kernel = subprocess.check_output(['uname', '-r'], text=True).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
tg_kernel = ''
|
||||
cross_kernel['backup_kernel'] = bk_kernel
|
||||
cross_kernel['target_kernel'] = tg_kernel
|
||||
def _mm(k: str) -> tuple:
|
||||
parts = (k or '').split('.')
|
||||
try:
|
||||
return (int(parts[0]), int(parts[1]))
|
||||
except (ValueError, IndexError):
|
||||
return (0, 0)
|
||||
if bk_kernel and tg_kernel:
|
||||
bk_mm, tg_mm = _mm(bk_kernel), _mm(tg_kernel)
|
||||
if bk_mm != tg_mm and bk_mm != (0, 0) and tg_mm != (0, 0):
|
||||
if bk_mm < tg_mm:
|
||||
cross_kernel['direction'] = 'bk_older'
|
||||
# Pull the exact list the CLI uses.
|
||||
lib_sh = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh'
|
||||
if os.path.isfile(lib_sh):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['bash', '-c',
|
||||
f'source "{lib_sh}"; hb_unsafe_paths_cross_version'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
blocked = []
|
||||
for line in r.stdout.splitlines():
|
||||
parts = line.split('\t', 1)
|
||||
if parts and parts[0].strip():
|
||||
blocked.append(parts[0].strip())
|
||||
cross_kernel['blocked_paths'] = blocked
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
else:
|
||||
cross_kernel['direction'] = 'bk_newer'
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Hydration preview — only meaningful in bk_older direction.
|
||||
# Runs the same phases the CLI runs in "plan" mode: no
|
||||
# writes to the live target, just the list of would-be
|
||||
# actions. Same code path as the shell dialog, so the Web
|
||||
# banner and the CLI msgbox always list the same items.
|
||||
hydration = {'applies': False, 'actions': []}
|
||||
if cross_kernel['direction'] == 'bk_older':
|
||||
backup_sh = (
|
||||
f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/backup_host.sh'
|
||||
)
|
||||
if os.path.isfile(backup_sh):
|
||||
try:
|
||||
# Source lib+backup_host in a subshell, set the
|
||||
# direction so any inner guard passes, run the
|
||||
# orchestrator in plan mode, print RS_HYDRATION_ACTIONS
|
||||
# one per line. HB_LIB_ONLY/HB_NO_MAIN etc. are not
|
||||
# needed: sourcing backup_host.sh only defines
|
||||
# functions; nothing runs until we call one.
|
||||
script = (
|
||||
f'set +e; '
|
||||
f'source "{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh"; '
|
||||
f'source "{backup_sh}" >/dev/null 2>&1; '
|
||||
f'HB_COMPAT_KERNEL_DIRECTION=bk_older '
|
||||
f'_rs_apply_bk_older_hydration "{staging}" plan >/dev/null 2>&1; '
|
||||
f'printf "%s\\n" "${{RS_HYDRATION_ACTIONS[@]}}"'
|
||||
)
|
||||
r = subprocess.run(
|
||||
['bash', '-c', script],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode == 0:
|
||||
actions = [
|
||||
ln.strip() for ln in r.stdout.splitlines()
|
||||
if ln.strip()
|
||||
]
|
||||
hydration['actions'] = actions
|
||||
hydration['applies'] = bool(actions)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'staging_path': staging,
|
||||
'paths_available': paths_available,
|
||||
'rollback_plan': rollback_plan,
|
||||
'cross_kernel': cross_kernel,
|
||||
'hydration': hydration,
|
||||
})
|
||||
except Exception as e:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
@@ -40,9 +40,8 @@ fi
|
||||
|
||||
: "${HB_RESTORE_INCLUDE_ZFS:=0}"
|
||||
: "${HB_COMPAT_CROSS_VERSION:=0}"
|
||||
: "${HB_KERNEL_PIN_ACTION:=none}"
|
||||
: "${HB_KERNEL_PIN_KVER:=}"
|
||||
: "${HB_KERNEL_PIN_PKG:=}"
|
||||
: "${HB_COMPAT_KERNEL_DIRECTION:=same}"
|
||||
: "${HB_HYDRATION_APPLIED:=0}"
|
||||
|
||||
if [[ ! -f "$APPLY_LIST" ]]; then
|
||||
echo "Apply list missing: $APPLY_LIST"
|
||||
@@ -50,13 +49,11 @@ if [[ ! -f "$APPLY_LIST" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pending dir: $PENDING_DIR"
|
||||
echo "Apply list: $APPLY_LIST"
|
||||
echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
|
||||
echo "Cross-version: $HB_COMPAT_CROSS_VERSION"
|
||||
echo "Kernel pin act: $HB_KERNEL_PIN_ACTION"
|
||||
echo "Kernel pin ver: $HB_KERNEL_PIN_KVER"
|
||||
echo "Kernel pin pkg: $HB_KERNEL_PIN_PKG"
|
||||
echo "Pending dir: $PENDING_DIR"
|
||||
echo "Apply list: $APPLY_LIST"
|
||||
echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
|
||||
echo "Cross-version: $HB_COMPAT_CROSS_VERSION"
|
||||
echo "Kernel direction: $HB_COMPAT_KERNEL_DIRECTION"
|
||||
|
||||
# Hardware-drift skips persisted by _rs_prepare_pending_restore.
|
||||
# Each line is an absolute path; we drop any rel path that matches
|
||||
@@ -268,6 +265,16 @@ EOF
|
||||
NEEDS_GRUB=1 ;;
|
||||
esac
|
||||
done < "$APPLY_LIST"
|
||||
# bk_older hydration writes tokens/modules/files DIRECTLY to
|
||||
# the live target (bypassing the apply_list because the whole
|
||||
# paths are in RS_SKIP_PATHS). Those changes still need
|
||||
# update-initramfs + update-grub / proxmox-boot-tool refresh
|
||||
# to take effect on the next boot — force the flags so the
|
||||
# post-boot script picks them up.
|
||||
if [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then
|
||||
NEEDS_INITRAMFS=1
|
||||
NEEDS_GRUB=1
|
||||
fi
|
||||
echo "Post-boot maintenance flags: initramfs=$NEEDS_INITRAMFS grub=$NEEDS_GRUB"
|
||||
|
||||
# Marker as env-style key=value so the post-boot script
|
||||
@@ -310,60 +317,6 @@ UNITEOF
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable proxmenux-apply-cluster-postboot.service >/dev/null 2>&1 || true
|
||||
|
||||
# ── Cross-kernel: install (if needed) + pin backup's kernel ──
|
||||
# When the backup was taken on a different kernel major.minor
|
||||
# than the target, the restore flow pre-computed a plan telling
|
||||
# us whether the backup's kernel is already on the target or
|
||||
# can be pulled from Proxmox repos. Acting here — during the
|
||||
# onboot restore, before the cluster-postboot fires — gives us
|
||||
# a controlled reboot into the pinned kernel, so the DKMS
|
||||
# auto-reinstall in postboot builds every module against the
|
||||
# right ABI (the kernel the operator's backup actually expects).
|
||||
_kernel_pin_reboot_needed=0
|
||||
if [[ "$HB_KERNEL_PIN_ACTION" != "none" && -n "$HB_KERNEL_PIN_KVER" ]]; then
|
||||
echo ""
|
||||
echo "── Cross-kernel: preparing kernel $HB_KERNEL_PIN_KVER ──"
|
||||
if [[ "$HB_KERNEL_PIN_ACTION" == "installable" ]]; then
|
||||
echo "Installing $HB_KERNEL_PIN_PKG from Proxmox repositories..."
|
||||
apt-get update -qq 2>&1 | tail -5
|
||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$HB_KERNEL_PIN_PKG" 2>&1 | tail -10; then
|
||||
echo " ✓ $HB_KERNEL_PIN_PKG installed"
|
||||
else
|
||||
echo " ✗ apt install failed — pin will fall back to whatever kernels are present"
|
||||
fi
|
||||
fi
|
||||
if command -v proxmox-boot-tool >/dev/null 2>&1; then
|
||||
proxmox-boot-tool kernel pin "$HB_KERNEL_PIN_KVER" 2>&1 | sed 's/^/ pin: /'
|
||||
proxmox-boot-tool refresh 2>&1 | sed 's/^/ refresh: /'
|
||||
_kernel_pin_reboot_needed=1
|
||||
else
|
||||
echo " ! proxmox-boot-tool not available — kernel pin skipped"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( _kernel_pin_reboot_needed == 1 )); then
|
||||
# Do NOT start the cluster-postboot service in this boot.
|
||||
# We must reboot first so the kernel pin takes effect;
|
||||
# once the machine is back up on the pinned kernel, the
|
||||
# postboot service (enabled with a ConditionPathExists on
|
||||
# cluster-apply-pending) will fire automatically and its
|
||||
# DKMS reinstalls will target the correct kernel.
|
||||
echo ""
|
||||
echo "Rebooting into pinned kernel $HB_KERNEL_PIN_KVER..."
|
||||
echo "completed" >"$STATE_FILE" 2>/dev/null || true
|
||||
# Mark this restore as completed so the on-boot service
|
||||
# doesn't re-fire after we reboot.
|
||||
_rid="$(basename "$PENDING_DIR")"
|
||||
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${_rid}" >/dev/null 2>&1 || true
|
||||
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
|
||||
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
sync
|
||||
systemctl --no-block reboot
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Same-kernel path: fire cluster-postboot inline in this boot.
|
||||
# `systemctl enable` only adds the unit to multi-user.target.wants/.
|
||||
# It does NOT pull the unit into the currently-running boot
|
||||
# transaction — by the time we run, multi-user.target may have
|
||||
|
||||
@@ -2061,9 +2061,8 @@ CREATED_AT=${created_at}
|
||||
HB_RESTORE_INCLUDE_ZFS=${HB_RESTORE_INCLUDE_ZFS:-0}
|
||||
HB_ROLLBACK_EXECUTE=${HB_ROLLBACK_EXECUTE:-0}
|
||||
HB_COMPAT_CROSS_VERSION=${HB_COMPAT_CROSS_VERSION:-0}
|
||||
HB_KERNEL_PIN_ACTION=${HB_KERNEL_PIN_ACTION:-none}
|
||||
HB_KERNEL_PIN_KVER=${HB_KERNEL_PIN_KVER:-}
|
||||
HB_KERNEL_PIN_PKG=${HB_KERNEL_PIN_PKG:-}
|
||||
HB_COMPAT_KERNEL_DIRECTION=${HB_COMPAT_KERNEL_DIRECTION:-same}
|
||||
HB_HYDRATION_APPLIED=${HB_HYDRATION_APPLIED:-0}
|
||||
EOF
|
||||
# Persist hardware-drift skips so apply_pending_restore.sh can filter
|
||||
# them at boot. The RS_SKIP_PATHS env var only lives in the restore
|
||||
@@ -2124,6 +2123,328 @@ _rs_handle_ssh_network_risk() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Kernel-agnostic hydration for bk_older restores
|
||||
# ==========================================================
|
||||
# In bk_older direction we block whole-file restores of the
|
||||
# kernel/boot-tied paths (see hb_unsafe_paths_cross_version) to
|
||||
# stop the target's boot from being poisoned by systemd unit
|
||||
# overrides, APT sources, GRUB defaults, etc. from an older
|
||||
# base. But the operator's OWN customisations inside those
|
||||
# paths (IOMMU cmdline, VFIO modules, usb-storage.quirks, custom
|
||||
# GRUB_TIMEOUT, ...) are kernel-agnostic and should still land
|
||||
# on the target — otherwise a fresh install after a restore
|
||||
# forgets everything the operator tuned.
|
||||
#
|
||||
# The hydration functions below implement a merge-not-copy
|
||||
# strategy: they read the operator-authored bits from the
|
||||
# manifest (cmdline_extra, modules_loaded_at_boot) or from
|
||||
# specific whitelisted files in the staging rootfs, and merge
|
||||
# them into the target's live config without touching keys or
|
||||
# tokens the target already carries. All four phases are
|
||||
# idempotent and additive; running them twice is a no-op.
|
||||
# ==========================================================
|
||||
|
||||
_rs_hyd_manifest() { echo "$1/metadata/manifest.json"; }
|
||||
|
||||
# List operator-authored kernel cmdline tokens from the manifest,
|
||||
# one per line. Empty output = nothing to merge.
|
||||
_rs_hyd_cmdline_tokens() {
|
||||
local manifest="$1"
|
||||
[[ -f "$manifest" ]] || return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
jq -r '.kernel_params.cmdline_extra // [] | .[]' "$manifest" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# List modules the backup loaded at boot (from /etc/modules on
|
||||
# the source host), one per line.
|
||||
_rs_hyd_modules_at_boot() {
|
||||
local manifest="$1"
|
||||
[[ -f "$manifest" ]] || return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
jq -r '.kernel_params.modules_loaded_at_boot // [] | .[]' "$manifest" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Read a KEY=VALUE line from a `/etc/default/grub`-style file.
|
||||
# Prints the raw VALUE part (including surrounding quotes) if
|
||||
# present, empty otherwise. Handles both `KEY=value` and
|
||||
# `KEY="value with spaces"` forms.
|
||||
_rs_hyd_grub_read_key() {
|
||||
local file="$1" key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
# Grab the LAST assignment (later wins in shell-style config),
|
||||
# skip comment lines, tolerate leading whitespace.
|
||||
grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1 | sed -E "s/^[[:space:]]*${key}=//"
|
||||
}
|
||||
|
||||
# Write or replace a KEY=VALUE line in a `/etc/default/grub`-style
|
||||
# file. If the key already exists (uncommented), rewrites its
|
||||
# line in place; otherwise appends the new line at the end. Leaves
|
||||
# comment lines untouched.
|
||||
_rs_hyd_grub_write_key() {
|
||||
local file="$1" key="$2" value="$3"
|
||||
[[ -f "$file" ]] || return 1
|
||||
if grep -qE "^[[:space:]]*${key}=" "$file"; then
|
||||
# In-place rewrite of the last matching line. Use sed with
|
||||
# a temp file so partial failure doesn't corrupt the target.
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { pat = "^[[:space:]]*" k "=" }
|
||||
$0 ~ pat { print k "=" v; next }
|
||||
{ print }
|
||||
' "$file" > "$tmp" && mv "$tmp" "$file"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$value" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Strip surrounding double quotes and split a GRUB_CMDLINE_-style
|
||||
# value into whitespace-delimited tokens (one per line). Handles
|
||||
# escaped inner quotes conservatively.
|
||||
_rs_hyd_cmdline_split() {
|
||||
local raw="$1"
|
||||
raw="${raw%\"}"; raw="${raw#\"}"
|
||||
raw="${raw%\'}"; raw="${raw#\'}"
|
||||
# tr will collapse any run of whitespace; xargs normalises.
|
||||
tr -s '[:space:]' '\n' <<<"$raw" | sed '/^$/d'
|
||||
}
|
||||
|
||||
# Merge two cmdline strings: target wins on ties (same key), any
|
||||
# backup token whose KEY (or bare flag) is not in target is
|
||||
# appended. Prints the merged cmdline as a single line without
|
||||
# surrounding quotes. Also returns 0 if any token was added, 1
|
||||
# if nothing changed — useful for the caller to decide whether
|
||||
# an update-grub / proxmox-boot-tool refresh is needed.
|
||||
_rs_hyd_cmdline_merge() {
|
||||
local target_line="$1" backup_line="$2"
|
||||
local -A target_keys=()
|
||||
local -a merged=()
|
||||
local t bkey
|
||||
while IFS= read -r t; do
|
||||
[[ -z "$t" ]] && continue
|
||||
merged+=("$t")
|
||||
bkey="${t%%=*}"
|
||||
target_keys["$bkey"]=1
|
||||
done < <(_rs_hyd_cmdline_split "$target_line")
|
||||
local added=0
|
||||
while IFS= read -r t; do
|
||||
[[ -z "$t" ]] && continue
|
||||
bkey="${t%%=*}"
|
||||
if [[ -z "${target_keys[$bkey]:-}" ]]; then
|
||||
merged+=("$t")
|
||||
target_keys["$bkey"]=1
|
||||
added=1
|
||||
fi
|
||||
done < <(_rs_hyd_cmdline_split "$backup_line")
|
||||
printf '%s' "${merged[*]}"
|
||||
(( added )) && return 0 || return 1
|
||||
}
|
||||
|
||||
# Phase 1a — GRUB path
|
||||
# ────────────────────
|
||||
# Merge cmdline tokens from the backup's cmdline_extra into the
|
||||
# target's live GRUB_CMDLINE_LINUX_DEFAULT (never overwrite existing
|
||||
# tokens or keys). Also merge whitelisted GRUB_* keys from the
|
||||
# backup's /etc/default/grub whose values differ. Reports one
|
||||
# summary line per action into the global RS_HYDRATION_ACTIONS[].
|
||||
# $3=mode: "plan" reports would-be actions only, "commit" writes.
|
||||
_rs_hyd_grub() {
|
||||
local backup_rootfs="$1" manifest="$2" mode="${3:-commit}"
|
||||
local target="/etc/default/grub"
|
||||
local bk_file="$backup_rootfs/etc/default/grub"
|
||||
[[ -f "$target" ]] || return 0
|
||||
|
||||
local changed=0
|
||||
|
||||
# ── cmdline merge ──
|
||||
local bk_tokens
|
||||
bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')"
|
||||
if [[ -n "${bk_tokens// /}" ]]; then
|
||||
local live_default merged_default added_tokens
|
||||
live_default="$(_rs_hyd_grub_read_key "$target" GRUB_CMDLINE_LINUX_DEFAULT)"
|
||||
local stripped="${live_default%\"}"; stripped="${stripped#\"}"
|
||||
if merged_default="$(_rs_hyd_cmdline_merge "$stripped" "$bk_tokens")"; then
|
||||
added_tokens="$(comm -13 \
|
||||
<(printf '%s\n' $stripped | sort -u) \
|
||||
<(printf '%s\n' $merged_default | sort -u) 2>/dev/null | tr '\n' ' ')"
|
||||
[[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" GRUB_CMDLINE_LINUX_DEFAULT "\"${merged_default}\""
|
||||
RS_HYDRATION_ACTIONS+=("GRUB_CMDLINE_LINUX_DEFAULT — merge tokens: ${added_tokens}")
|
||||
changed=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── whitelisted GRUB_* keys from the backup file ──
|
||||
if [[ -f "$bk_file" ]]; then
|
||||
local key bk_val live_val
|
||||
while IFS= read -r key; do
|
||||
[[ -z "$key" ]] && continue
|
||||
[[ "$key" == "GRUB_CMDLINE_LINUX_DEFAULT" || "$key" == "GRUB_CMDLINE_LINUX" ]] && continue
|
||||
bk_val="$(_rs_hyd_grub_read_key "$bk_file" "$key")"
|
||||
[[ -z "$bk_val" ]] && continue
|
||||
live_val="$(_rs_hyd_grub_read_key "$target" "$key")"
|
||||
if [[ "$bk_val" != "$live_val" ]]; then
|
||||
[[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" "$key" "$bk_val"
|
||||
RS_HYDRATION_ACTIONS+=("${key} — restore operator value from backup")
|
||||
changed=1
|
||||
fi
|
||||
done < <(hb_grub_keys_whitelist)
|
||||
fi
|
||||
|
||||
(( changed )) && return 0 || return 1
|
||||
}
|
||||
|
||||
# Phase 1b — systemd-boot / ZFS path
|
||||
# ──────────────────────────────────
|
||||
# /etc/kernel/cmdline is a single-line file holding the ENTIRE
|
||||
# kernel cmdline (root=, boot=, rootflags=, plus operator tokens).
|
||||
# We MUST NOT copy it verbatim across kernels — the target's fresh
|
||||
# install references its own ZFS pool. Instead: keep the target's
|
||||
# boot boilerplate, append the operator tokens from cmdline_extra
|
||||
# that aren't already there.
|
||||
_rs_hyd_kernel_cmdline() {
|
||||
local manifest="$1" mode="${2:-commit}"
|
||||
local target="/etc/kernel/cmdline"
|
||||
[[ -f "$target" ]] || return 0
|
||||
|
||||
local bk_tokens
|
||||
bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')"
|
||||
[[ -z "${bk_tokens// /}" ]] && return 1
|
||||
|
||||
local live merged added_tokens
|
||||
live="$(cat "$target" 2>/dev/null | tr -d '\n')"
|
||||
if merged="$(_rs_hyd_cmdline_merge "$live" "$bk_tokens")"; then
|
||||
added_tokens="$(comm -13 \
|
||||
<(printf '%s\n' $live | sort -u) \
|
||||
<(printf '%s\n' $merged | sort -u) 2>/dev/null | tr '\n' ' ')"
|
||||
[[ "$mode" == "commit" ]] && printf '%s\n' "$merged" > "$target"
|
||||
RS_HYDRATION_ACTIONS+=("/etc/kernel/cmdline (systemd-boot/ZFS) — merge tokens: ${added_tokens}")
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Phase 2 — /etc/modules merge
|
||||
# ────────────────────────────
|
||||
# Append modules from modules_loaded_at_boot that are whitelisted
|
||||
# AND not already present in /etc/modules. Preserves comments and
|
||||
# existing entries.
|
||||
_rs_hyd_modules() {
|
||||
local manifest="$1" mode="${2:-commit}"
|
||||
local target="/etc/modules"
|
||||
[[ -f "$target" ]] || return 0
|
||||
|
||||
local -A live_modules=() whitelist=()
|
||||
local m line
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
m="$(printf '%s' "$line" | xargs)"
|
||||
[[ -n "$m" ]] && live_modules["$m"]=1
|
||||
done < "$target"
|
||||
while IFS= read -r m; do
|
||||
m="$(printf '%s' "$m" | xargs)"
|
||||
[[ -n "$m" ]] && whitelist["$m"]=1
|
||||
done < <(hb_hydration_module_whitelist)
|
||||
|
||||
local added=0
|
||||
while IFS= read -r m; do
|
||||
m="$(printf '%s' "$m" | xargs)"
|
||||
[[ -z "$m" ]] && continue
|
||||
[[ -z "${whitelist[$m]:-}" ]] && continue
|
||||
[[ -n "${live_modules[$m]:-}" ]] && continue
|
||||
[[ "$mode" == "commit" ]] && printf '%s\n' "$m" >> "$target"
|
||||
live_modules["$m"]=1
|
||||
RS_HYDRATION_ACTIONS+=("/etc/modules — add: $m")
|
||||
added=1
|
||||
done < <(_rs_hyd_modules_at_boot "$manifest")
|
||||
|
||||
(( added )) && return 0 || return 1
|
||||
}
|
||||
|
||||
# Phase 3 — whitelisted files
|
||||
# ───────────────────────────
|
||||
# For each pattern in hb_hydration_files_patterns, copy the
|
||||
# matching file from the staging rootfs to the live target IF the
|
||||
# backup carries it and its content differs from the live copy.
|
||||
_rs_hyd_files() {
|
||||
local backup_rootfs="$1" mode="${2:-commit}"
|
||||
local pattern src dst
|
||||
local copied=0
|
||||
while IFS= read -r pattern; do
|
||||
[[ -z "$pattern" ]] && continue
|
||||
while IFS= read -r src; do
|
||||
[[ -z "$src" || ! -f "$src" ]] && continue
|
||||
dst="${src#${backup_rootfs}}"
|
||||
[[ "$dst" == /* ]] || continue
|
||||
if ! cmp -s "$src" "$dst" 2>/dev/null; then
|
||||
if [[ "$mode" == "commit" ]]; then
|
||||
mkdir -p "$(dirname "$dst")" 2>/dev/null || true
|
||||
cp -a "$src" "$dst" 2>/dev/null || continue
|
||||
fi
|
||||
RS_HYDRATION_ACTIONS+=("${dst} — restore from backup")
|
||||
copied=1
|
||||
fi
|
||||
done < <(compgen -G "${backup_rootfs}${pattern}" 2>/dev/null || true)
|
||||
done < <(hb_hydration_files_patterns)
|
||||
(( copied )) && return 0 || return 1
|
||||
}
|
||||
|
||||
# Orchestrator for the 4 phases. Two modes:
|
||||
# plan — populates RS_HYDRATION_ACTIONS and RS_HYDRATION_SUMMARY
|
||||
# without writing to any live file. Safe to call before
|
||||
# the operator's final yes/no.
|
||||
# commit — actually writes to the live target and exports
|
||||
# HB_HYDRATION_APPLIED=1 if anything changed, so that
|
||||
# plan.env carries the flag and apply_pending_restore.sh
|
||||
# forces NEEDS_INITRAMFS/NEEDS_GRUB.
|
||||
# Both modes are safe to call multiple times: writes are additive
|
||||
# and idempotent, plan is side-effect free.
|
||||
_rs_apply_bk_older_hydration() {
|
||||
local staging_root="$1" mode="${2:-commit}"
|
||||
local backup_rootfs="$staging_root/rootfs"
|
||||
local manifest
|
||||
manifest="$(_rs_hyd_manifest "$staging_root")"
|
||||
|
||||
RS_HYDRATION_ACTIONS=()
|
||||
RS_HYDRATION_SUMMARY=""
|
||||
|
||||
[[ -d "$backup_rootfs" ]] || return 0
|
||||
|
||||
local bootloader
|
||||
bootloader="$(hb_detect_bootloader)"
|
||||
|
||||
local any=0
|
||||
case "$bootloader" in
|
||||
systemd-boot)
|
||||
_rs_hyd_kernel_cmdline "$manifest" "$mode" && any=1 || true
|
||||
;;
|
||||
grub|*)
|
||||
_rs_hyd_grub "$backup_rootfs" "$manifest" "$mode" && any=1 || true
|
||||
;;
|
||||
esac
|
||||
_rs_hyd_modules "$manifest" "$mode" && any=1 || true
|
||||
_rs_hyd_files "$backup_rootfs" "$mode" && any=1 || true
|
||||
|
||||
if (( any )); then
|
||||
if [[ "$mode" == "commit" ]]; then
|
||||
HB_HYDRATION_APPLIED=1
|
||||
export HB_HYDRATION_APPLIED
|
||||
fi
|
||||
local body a header
|
||||
if [[ "$mode" == "plan" ]]; then
|
||||
header="$(translate "Operator config that WILL be re-applied via kernel-agnostic merge")"
|
||||
else
|
||||
header="$(translate "Operator config re-applied via kernel-agnostic merge")"
|
||||
fi
|
||||
body="\Zb${header}\ZB"$'\n\n'
|
||||
for a in "${RS_HYDRATION_ACTIONS[@]}"; do
|
||||
body+=" \Z2•\Zn ${a}"$'\n'
|
||||
done
|
||||
RS_HYDRATION_SUMMARY="$body"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
_rs_run_complete_guided() {
|
||||
local staging_root="$1"
|
||||
local -a all_paths=()
|
||||
@@ -2218,15 +2539,12 @@ _rs_run_complete_guided() {
|
||||
fi
|
||||
|
||||
# ── Cross-version safe-restore filter ────────────────────
|
||||
# When the backup was taken on a different PVE major or a
|
||||
# different kernel major.minor, restoring boot/kernel/apt
|
||||
# config on top of the current install is what causes the
|
||||
# post-reboot kernel panic. Fold those paths into RS_SKIP_PATHS
|
||||
# so the same downstream machinery that already honours skips
|
||||
# (_rs_apply, _rs_collect_pending_paths, apply_pending_restore)
|
||||
# keeps them out of the restore.
|
||||
# Only activates on `bk_older` (backup kernel older than target).
|
||||
# The reverse direction (`bk_newer`) restores cleanly as-is —
|
||||
# verified empirically on multiple 9.2 → 9.1 restores with GPU
|
||||
# passthrough / IOMMU / DKMS drivers.
|
||||
RS_CROSS_VERSION_SKIPS=""
|
||||
if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
local -a cv_skipped=()
|
||||
local cv_line cv_path cv_reason cv_rel
|
||||
# The drift block above trims the trailing newline off
|
||||
@@ -2265,6 +2583,15 @@ _rs_run_complete_guided() {
|
||||
RS_SKIP_PATHS="${RS_SKIP_PATHS#$'\n'}"
|
||||
RS_SKIP_PATHS="${RS_SKIP_PATHS%$'\n'}"
|
||||
export RS_SKIP_PATHS
|
||||
|
||||
# Kernel-agnostic hydration of operator-authored bits that
|
||||
# would otherwise be lost with the whole-file skip above.
|
||||
# See _rs_apply_bk_older_hydration for the four merge phases.
|
||||
# Runs in "plan" mode BEFORE the confirm dialog so
|
||||
# RS_HYDRATION_SUMMARY can preview what will be re-applied;
|
||||
# the commit runs AFTER the operator says yes, right before
|
||||
# the hot apply.
|
||||
_rs_apply_bk_older_hydration "$staging_root" plan
|
||||
fi
|
||||
|
||||
# Build the rich confirmation body. Replaces the previous 4-strategy
|
||||
@@ -2334,6 +2661,12 @@ _rs_run_complete_guided() {
|
||||
if [[ -n "${RS_CROSS_VERSION_SKIPS:-}" ]]; then
|
||||
body+=$'\n'"${RS_CROSS_VERSION_SKIPS}"
|
||||
fi
|
||||
# And the counterpart: what gets re-applied via kernel-agnostic
|
||||
# merge. Rendered in green because it's what the operator gets
|
||||
# back for free even though the whole file was skipped above.
|
||||
if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then
|
||||
body+=$'\n'"${RS_HYDRATION_SUMMARY}"
|
||||
fi
|
||||
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
|
||||
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
|
||||
body+="\Zb$(translate "Continue?")\ZB"
|
||||
@@ -2385,6 +2718,15 @@ _rs_run_complete_guided() {
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying safe paths and preparing pending restore")"
|
||||
# Commit the kernel-agnostic hydration BEFORE hot apply so
|
||||
# /etc/modules, /etc/default/grub (or /etc/kernel/cmdline),
|
||||
# and whitelisted vfio/nvidia files carry the operator's
|
||||
# tokens by the time apply_pending_restore.sh reads its
|
||||
# pending list. plan.env picks up HB_HYDRATION_APPLIED=1
|
||||
# and forces NEEDS_INITRAMFS/NEEDS_GRUB post-boot.
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
_rs_apply_bk_older_hydration "$staging_root" commit
|
||||
fi
|
||||
[[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
|
||||
|
||||
local -a pending_paths=()
|
||||
@@ -2581,12 +2923,69 @@ _rs_select_component_paths() {
|
||||
|
||||
# ── Dialog checklist (one entry per real backup path) ──
|
||||
if [[ -z "$_rsp_selected" ]]; then
|
||||
# In bk_older restores, kernel-tied paths are excluded from
|
||||
# the list entirely (bash `dialog` has no gray-out primitive,
|
||||
# so we hide the unsafe entries and surface their names in a
|
||||
# msgbox up front). Uses the same list `hb_unsafe_paths_cross_version`
|
||||
# returns — single source of truth with the safe filter used
|
||||
# by Full restore.
|
||||
local -A _rsp_blocked=()
|
||||
local -a _rsp_blocked_list=()
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
local _rsp_bp _rsp_reason
|
||||
while IFS=$'\t' read -r _rsp_bp _rsp_reason; do
|
||||
[[ -z "$_rsp_bp" ]] && continue
|
||||
local _rsp_bp_rel="${_rsp_bp#/}"
|
||||
_rsp_blocked["$_rsp_bp_rel"]=1
|
||||
done < <(hb_unsafe_paths_cross_version)
|
||||
fi
|
||||
|
||||
local -a _rsp_checklist=()
|
||||
local _rsp_p
|
||||
for _rsp_p in "${_rsp_backup[@]}"; do
|
||||
# Skip paths whose parent (or itself) is on the blocked
|
||||
# list: `/etc/systemd/system/foo` counts as blocked when
|
||||
# `/etc/systemd/system` is on the list.
|
||||
local _rsp_hit="" _rsp_probe="$_rsp_p"
|
||||
while [[ -n "$_rsp_probe" ]]; do
|
||||
if [[ -n "${_rsp_blocked[$_rsp_probe]:-}" ]]; then
|
||||
_rsp_hit="/$_rsp_probe"
|
||||
break
|
||||
fi
|
||||
[[ "$_rsp_probe" != *"/"* ]] && break
|
||||
_rsp_probe="${_rsp_probe%/*}"
|
||||
done
|
||||
if [[ -n "$_rsp_hit" ]]; then
|
||||
_rsp_blocked_list+=("$_rsp_hit")
|
||||
continue
|
||||
fi
|
||||
_rsp_checklist+=("$_rsp_p" "/$_rsp_p" "off")
|
||||
done
|
||||
|
||||
# If any paths were dropped, show them in a msgbox before the
|
||||
# picker so the operator understands why they don't appear.
|
||||
if (( ${#_rsp_blocked_list[@]} > 0 )); then
|
||||
# Deduplicate — a folder can hide many child paths.
|
||||
local -A _rsp_seen=()
|
||||
local _rsp_list_str=""
|
||||
local _rsp_e
|
||||
for _rsp_e in "${_rsp_blocked_list[@]}"; do
|
||||
[[ -n "${_rsp_seen[$_rsp_e]:-}" ]] && continue
|
||||
_rsp_seen["$_rsp_e"]=1
|
||||
_rsp_list_str+=" • ${_rsp_e}"$'\n'
|
||||
done
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate "Cross-kernel — paths hidden from picker")" \
|
||||
--msgbox "$(translate "The following backup paths are kernel-tied and would break the target host if restored across kernel versions. They are excluded from the selection below:")"$'\n\n'"${_rsp_list_str}"$'\n'"$(translate "If you need any of these, restore this backup on a host with the same kernel major.minor.")" \
|
||||
20 84
|
||||
fi
|
||||
|
||||
if (( ${#_rsp_checklist[@]} == 0 )); then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Nothing selectable")" \
|
||||
--msgbox "$(translate "Every path in this backup is kernel-tied and cannot be restored across kernel versions.")" 9 78
|
||||
return 1
|
||||
fi
|
||||
|
||||
_rsp_selected=$(dialog --backtitle "ProxMenux" --separate-output \
|
||||
--title "$(translate "Custom restore by paths")" \
|
||||
--checklist "\n$(translate "Select the paths to restore (one per backup entry):")" \
|
||||
@@ -2655,6 +3054,15 @@ _rs_run_custom_restore() {
|
||||
local hot_count="$RS_SEL_HOT"
|
||||
local pending_count=$(( RS_SEL_REBOOT + RS_SEL_DANGEROUS ))
|
||||
|
||||
# In bk_older, preview the kernel-agnostic hydration so the
|
||||
# operator sees UPFRONT what will be re-applied automatically
|
||||
# (IOMMU cmdline, VFIO modules, custom GRUB_TIMEOUT, etc.)
|
||||
# even though the whole-file paths remain blocked from the
|
||||
# custom picker.
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
_rs_apply_bk_older_hydration "$staging_root" plan
|
||||
fi
|
||||
|
||||
local body
|
||||
body="\Zb$(translate "About to restore") ${RS_SEL_TOTAL} $(translate "selected path(s):")\ZB"$'\n\n'
|
||||
if (( hot_count > 0 )); then
|
||||
@@ -2663,6 +3071,9 @@ _rs_run_custom_restore() {
|
||||
if (( pending_count > 0 )); then
|
||||
body+=" • $(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "for next boot (needs reboot to take effect)")"$'\n'
|
||||
fi
|
||||
if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then
|
||||
body+=$'\n'"${RS_HYDRATION_SUMMARY}"
|
||||
fi
|
||||
if (( pending_count > 0 )); then
|
||||
body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n'
|
||||
fi
|
||||
@@ -2670,13 +3081,20 @@ _rs_run_custom_restore() {
|
||||
|
||||
if ! dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate "Confirm custom restore")" \
|
||||
--yesno "$body" 14 82; then
|
||||
--yesno "$body" 22 82; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Applying custom restore")"
|
||||
|
||||
# Commit the hydration BEFORE hot apply. Same rationale as in
|
||||
# the Complete flow: plan.env picks up HB_HYDRATION_APPLIED=1
|
||||
# so apply_pending_restore.sh forces initramfs+grub regen.
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
_rs_apply_bk_older_hydration "$staging_root" commit
|
||||
fi
|
||||
|
||||
if (( hot_count > 0 )); then
|
||||
_rs_apply "$staging_root" hot "${selected_paths[@]}"
|
||||
fi
|
||||
@@ -2687,6 +3105,24 @@ _rs_run_custom_restore() {
|
||||
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
|
||||
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
|
||||
fi
|
||||
elif [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then
|
||||
# Custom restore picked only hot paths, but hydration
|
||||
# merged tokens into /etc/default/grub or /etc/kernel/cmdline
|
||||
# and/or added modules to /etc/modules. Those need
|
||||
# initramfs + bootloader refresh before the next reboot to
|
||||
# be effective. No post-boot dispatcher runs in this
|
||||
# branch (plan.env is only written by
|
||||
# _rs_prepare_pending_restore), so we do the reflows here.
|
||||
msg_info "$(translate "Regenerating boot artifacts for the merged kernel-agnostic changes...")"
|
||||
update-initramfs -u -k all >/dev/null 2>&1 || true
|
||||
local _bl
|
||||
_bl="$(hb_detect_bootloader)"
|
||||
case "$_bl" in
|
||||
systemd-boot) proxmox-boot-tool refresh >/dev/null 2>&1 || true ;;
|
||||
grub|*) update-grub >/dev/null 2>&1 || true ;;
|
||||
esac
|
||||
stop_spinner 2>/dev/null || true
|
||||
msg_ok "$(translate "Boot artifacts regenerated — reboot the host to activate the merged config.")"
|
||||
fi
|
||||
|
||||
_rs_finish_flow
|
||||
@@ -2984,6 +3420,25 @@ _rs_apply_menu() {
|
||||
# confirmation dialog (option 1). It's still reachable on demand
|
||||
# from option 6 of this menu.
|
||||
|
||||
# ── Cross-kernel notice (only for bk_older) ───────────────
|
||||
# When the backup was taken on a kernel older than the target,
|
||||
# let the operator know BEFORE they see the mode menu that
|
||||
# Full restore will silently skip kernel-tied paths and that
|
||||
# Custom will present them as blocked. Nothing to click through
|
||||
# for the reverse direction (bk_newer) — those restores go
|
||||
# through as-is because empirical testing shows they work.
|
||||
if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
|
||||
local _bkk="" _cur_k _cur_mm
|
||||
[[ -f "$staging_root/metadata/run_info.env" ]] && \
|
||||
_bkk=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-)
|
||||
_cur_k=$(uname -r 2>/dev/null || echo "?")
|
||||
_cur_mm=$(echo "$_cur_k" | cut -d. -f1-2)
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate "Cross-kernel restore — safe subset only")" \
|
||||
--msgbox "$(translate "This backup was taken on kernel"): \Zb${_bkk}\ZB"$'\n'"$(translate "Target host runs"): \Zb${_cur_k}\ZB"$'\n\n'"\Zb$(translate "Only the essential configuration will be restored"):\ZB $(translate "/etc/pve, network, ssh, users, cron, ProxMenux state, etc.")"$'\n\n'"$(translate "Kernel-tied paths (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) will be skipped automatically to protect the target's boot.")"$'\n\n'"$(translate "If you need a complete bit-for-bit restore, use a backup taken on kernel") \Zb${_cur_mm}.x\ZB $(translate "or reinstall Proxmox with a version that ships kernel") \Zb${_bkk}\ZB." \
|
||||
22 84
|
||||
fi
|
||||
|
||||
while true; do
|
||||
local choice
|
||||
choice=$(dialog --backtitle "ProxMenux" \
|
||||
@@ -3071,39 +3526,6 @@ restore_menu() {
|
||||
# silent same-host path and the loud cross-host path.
|
||||
hb_compat_check "$staging_root"
|
||||
|
||||
# Cross-kernel restore requires the backup's kernel to be
|
||||
# bootable on the target — either already installed or
|
||||
# obtainable from the Proxmox repositories. When it's
|
||||
# neither, refuse cleanly: continuing would leave the
|
||||
# operator with drivers built against a kernel that isn't
|
||||
# present, and DKMS would silently pin the wrong ABI.
|
||||
if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then
|
||||
local _bk_kernel=""
|
||||
if [[ -f "$staging_root/metadata/run_info.env" ]]; then
|
||||
_bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-)
|
||||
fi
|
||||
if [[ -n "$_bk_kernel" ]]; then
|
||||
hb_kernel_pin_plan "$_bk_kernel"
|
||||
if [[ "$HB_KERNEL_PIN_ACTION" == "unavailable" ]]; then
|
||||
# Log so the operator can inspect after the fact.
|
||||
local _refuse_log="/var/log/proxmenux/restore-refused-$(date +%Y%m%d_%H%M%S).log"
|
||||
mkdir -p "$(dirname "$_refuse_log")" 2>/dev/null
|
||||
{
|
||||
echo "Refused at: $(date -Iseconds)"
|
||||
echo "Backup kernel: $_bk_kernel"
|
||||
echo "Target running: $(uname -r)"
|
||||
echo "Reason: $HB_KERNEL_PIN_REASON"
|
||||
} > "$_refuse_log"
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate "Restore refused — incompatible kernel")" \
|
||||
--msgbox "\Zb\Z1$(translate "This backup was taken on kernel"):\Zn \Zb${_bk_kernel}\ZB"$'\n\n'"$(translate "That kernel is") ${HB_KERNEL_PIN_REASON}."$'\n\n'"$(translate "Restoring without a matching kernel would leave drivers and modules built against a kernel that is not present on this host — the system may fail to boot or run degraded.")"$'\n\n'"\Zb$(translate "How to proceed"):\ZB"$'\n'" • $(translate "Install Proxmox VE from an ISO whose kernel matches")"$'\n'" • $(translate "Or fetch the kernel manually:") apt install proxmox-kernel-${_bk_kernel}"$'\n'" • $(translate "Then relaunch the restore.")"$'\n\n'"$(translate "Details logged at"): $_refuse_log" \
|
||||
22 84
|
||||
rm -rf "$staging_root"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if hb_show_compat_report; then
|
||||
if _rs_apply_menu "$staging_root"; then
|
||||
rm -rf "$staging_root"
|
||||
|
||||
@@ -2711,85 +2711,6 @@ hb_apply_nic_remaps() {
|
||||
# Output: one line per path, tab-separated: <abs_path>\t<reason>
|
||||
# The reason is a short label used verbatim in the confirm dialog.
|
||||
# ==========================================================
|
||||
# ==========================================================
|
||||
# KERNEL PIN PLAN — cross-kernel restore only
|
||||
# ==========================================================
|
||||
# When the backup was taken on a kernel major.minor different
|
||||
# from the target's running kernel, the safe and universally
|
||||
# correct action is to boot the target with the backup's kernel
|
||||
# after the restore. That way every driver / DKMS module that
|
||||
# the backup expects (nvidia, coral, intel_gpu, plus every
|
||||
# out-of-tree ZFS build) targets a kernel that actually exists
|
||||
# and matches, and the auto-reinstall on post-boot builds them
|
||||
# against the right ABI. Universal: works for ZFS root, LVM/ext4
|
||||
# root, BIOS or UEFI hosts alike.
|
||||
#
|
||||
# This function decides how to obtain that kernel on the target
|
||||
# and publishes the plan for the restore flow to act on.
|
||||
#
|
||||
# Input:
|
||||
# $1 bk_kernel — kernel release string from the backup
|
||||
# (e.g. "6.17.2-1-pve")
|
||||
# Output (env vars, all exported):
|
||||
# HB_KERNEL_PIN_ACTION installed | installable | unavailable
|
||||
# HB_KERNEL_PIN_PKG candidate package name to install (empty
|
||||
# when installed / unavailable)
|
||||
# HB_KERNEL_PIN_KVER the kernel release to pin (same as $1)
|
||||
# Also fills HB_KERNEL_PIN_REASON with a short human message
|
||||
# when action is "unavailable" (for the refuse dialog).
|
||||
# ==========================================================
|
||||
hb_kernel_pin_plan() {
|
||||
local bk_kernel="$1"
|
||||
export HB_KERNEL_PIN_ACTION="unavailable"
|
||||
export HB_KERNEL_PIN_PKG=""
|
||||
export HB_KERNEL_PIN_KVER="$bk_kernel"
|
||||
export HB_KERNEL_PIN_REASON=""
|
||||
|
||||
[[ -z "$bk_kernel" ]] && {
|
||||
HB_KERNEL_PIN_REASON="backup did not record its kernel release"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Signed package name convention on modern Proxmox: the actual
|
||||
# binary kernel image ships as `proxmox-kernel-<ver>-signed`, the
|
||||
# unsigned build is `proxmox-kernel-<ver>` (only in special cases).
|
||||
# `pve-kernel-<ver>` was the legacy name pre-Proxmox 8; try both
|
||||
# so this works whether the backup came from an older PVE.
|
||||
local -a candidates=(
|
||||
"proxmox-kernel-${bk_kernel}-signed"
|
||||
"proxmox-kernel-${bk_kernel}"
|
||||
"pve-kernel-${bk_kernel}"
|
||||
)
|
||||
local pkg
|
||||
for pkg in "${candidates[@]}"; do
|
||||
# Already installed → nothing to fetch, just pin later.
|
||||
if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
||||
| grep -q '^install ok installed$'; then
|
||||
HB_KERNEL_PIN_ACTION="installed"
|
||||
HB_KERNEL_PIN_PKG="$pkg"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Not installed. Refresh apt metadata quietly, then look each
|
||||
# candidate up. `apt-cache policy` shows a non-empty "Candidate"
|
||||
# only when there IS an installable version in the enabled repos.
|
||||
apt-get update -qq >/dev/null 2>&1 || true
|
||||
for pkg in "${candidates[@]}"; do
|
||||
local cand
|
||||
cand=$(apt-cache policy "$pkg" 2>/dev/null \
|
||||
| awk '/Candidate:/ {print $2; exit}')
|
||||
if [[ -n "$cand" && "$cand" != "(none)" ]]; then
|
||||
HB_KERNEL_PIN_ACTION="installable"
|
||||
HB_KERNEL_PIN_PKG="$pkg"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
HB_KERNEL_PIN_ACTION="unavailable"
|
||||
HB_KERNEL_PIN_REASON="not installed on this host and not available in the configured Proxmox repositories"
|
||||
return 0
|
||||
}
|
||||
|
||||
hb_unsafe_paths_cross_version() {
|
||||
cat <<'EOF'
|
||||
/etc/default/grub GRUB defaults tied to prior kernel order
|
||||
@@ -2811,15 +2732,116 @@ hb_unsafe_paths_cross_version() {
|
||||
EOF
|
||||
}
|
||||
|
||||
# HB_COMPAT_SAME_HOST → 1 if backup's hostname matches current
|
||||
# HB_COMPAT_ANY_FAIL → 1 if at least one FAIL was raised
|
||||
# HB_COMPAT_ANY_WARN → 1 if at least one WARN was raised
|
||||
# HB_COMPAT_CROSS_VERSION → 1 if PVE major OR kernel major.minor differs
|
||||
# (triggers the safe-restore path filter that
|
||||
# skips grub/kernel/apt/dkms/... — see
|
||||
# hb_unsafe_paths_cross_version)
|
||||
# HB_COMPAT_RESULTS[] → array of "STATUS|category|message" entries
|
||||
# where STATUS ∈ {PASS, INFO, WARN, FAIL}
|
||||
# ==========================================================
|
||||
# hb_detect_bootloader — "grub" | "systemd-boot" | "unknown"
|
||||
# ==========================================================
|
||||
# Proxmox uses systemd-boot on ZFS-on-root installs and GRUB on
|
||||
# ext4/lvm. `proxmox-boot-tool status` reports both cases when
|
||||
# the tool is present; on rescue paths without pbt we fall back
|
||||
# to the presence of /etc/kernel/cmdline (systemd-boot) or
|
||||
# /etc/default/grub (GRUB).
|
||||
hb_detect_bootloader() {
|
||||
if command -v proxmox-boot-tool >/dev/null 2>&1; then
|
||||
local pbt
|
||||
pbt="$(proxmox-boot-tool status 2>/dev/null || true)"
|
||||
if grep -qi 'systemd-boot' <<<"$pbt"; then
|
||||
echo "systemd-boot"; return 0
|
||||
elif grep -qiE 'grub|BIOS' <<<"$pbt"; then
|
||||
echo "grub"; return 0
|
||||
fi
|
||||
fi
|
||||
if [[ -f /etc/kernel/cmdline ]]; then
|
||||
echo "systemd-boot"
|
||||
elif [[ -f /etc/default/grub ]]; then
|
||||
echo "grub"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# hb_grub_keys_whitelist — GRUB /etc/default/grub keys that are
|
||||
# stable across PVE 9.x / kernel majors and worth merging from the
|
||||
# backup on a bk_older restore. Excludes GRUB_DISTRIBUTOR (changes
|
||||
# with the distro package) and keys whose values reference boot
|
||||
# entries the target install regenerates (GRUB_SAVEDEFAULT).
|
||||
hb_grub_keys_whitelist() {
|
||||
cat <<'EOF'
|
||||
GRUB_TIMEOUT
|
||||
GRUB_TIMEOUT_STYLE
|
||||
GRUB_DEFAULT
|
||||
GRUB_TERMINAL
|
||||
GRUB_TERMINAL_INPUT
|
||||
GRUB_TERMINAL_OUTPUT
|
||||
GRUB_DISABLE_OS_PROBER
|
||||
GRUB_SERIAL_COMMAND
|
||||
GRUB_CMDLINE_LINUX_DEFAULT
|
||||
GRUB_CMDLINE_LINUX
|
||||
GRUB_GFXMODE
|
||||
GRUB_GFXPAYLOAD_LINUX
|
||||
EOF
|
||||
}
|
||||
|
||||
# hb_hydration_module_whitelist — module names safe to add to the
|
||||
# target's /etc/modules from the backup's modules_loaded_at_boot
|
||||
# on a bk_older restore. All are kernel-agnostic in name across
|
||||
# 5.x → 7.x. Skips distro defaults (loop, dm-*) since a fresh
|
||||
# install already loads them.
|
||||
hb_hydration_module_whitelist() {
|
||||
cat <<'EOF'
|
||||
vfio
|
||||
vfio_pci
|
||||
vfio_iommu_type1
|
||||
vfio_virqfd
|
||||
kvm
|
||||
kvm_intel
|
||||
kvm_amd
|
||||
nvidia
|
||||
nvidia_drm
|
||||
nvidia_modeset
|
||||
nvidia_uvm
|
||||
i915
|
||||
xe
|
||||
EOF
|
||||
}
|
||||
|
||||
# hb_hydration_files_patterns — glob patterns (relative to root)
|
||||
# for files that are safe to copy verbatim from the backup rootfs
|
||||
# in a bk_older restore. The rule for inclusion: operator- or
|
||||
# ProxMenux-authored file whose content uses stable syntax across
|
||||
# kernel majors. Excludes distro-owned files (pve-blacklist.conf,
|
||||
# mdadm.conf, nvme.conf) whose contents can evolve between releases.
|
||||
hb_hydration_files_patterns() {
|
||||
cat <<'EOF'
|
||||
/etc/modprobe.d/proxmenux-*.conf
|
||||
/etc/modprobe.d/vfio.conf
|
||||
/etc/modprobe.d/vfio-pci.conf
|
||||
/etc/modprobe.d/kvm.conf
|
||||
/etc/modprobe.d/blacklist-nvidia.conf
|
||||
/etc/modprobe.d/blacklist-nouveau.conf
|
||||
/etc/modprobe.d/nvidia*.conf
|
||||
/etc/modules-load.d/proxmenux-*.conf
|
||||
/etc/modules-load.d/vfio*.conf
|
||||
/etc/modules-load.d/nvidia*.conf
|
||||
/etc/udev/rules.d/10-proxmenux-vfio-bind.rules
|
||||
/etc/udev/rules.d/70-nvidia.rules
|
||||
EOF
|
||||
}
|
||||
|
||||
# HB_COMPAT_SAME_HOST → 1 if backup's hostname matches current
|
||||
# HB_COMPAT_ANY_FAIL → 1 if at least one FAIL was raised
|
||||
# HB_COMPAT_ANY_WARN → 1 if at least one WARN was raised
|
||||
# HB_COMPAT_CROSS_VERSION → 1 if PVE major OR kernel major.minor differs
|
||||
# HB_COMPAT_KERNEL_DIRECTION → "same" / "bk_older" / "bk_newer" — only
|
||||
# "bk_older" activates the safe-subset
|
||||
# restore that skips kernel-tied paths
|
||||
# (grub, /etc/systemd/system, initramfs
|
||||
# config, apt sources, ZFS state, …).
|
||||
# "bk_newer" restores everything as
|
||||
# same-version because empirical tests
|
||||
# (9.2 → 9.1 with GPU passthrough +
|
||||
# IOMMU) prove that direction is safe.
|
||||
# HB_COMPAT_RESULTS[] → array of "STATUS|category|message"
|
||||
# entries where STATUS ∈ {PASS,INFO,WARN,FAIL}
|
||||
# Use hb_show_compat_report to surface the result and let the user
|
||||
# decide whether to continue.
|
||||
# ==========================================================
|
||||
@@ -2830,6 +2852,32 @@ hb_compat_check() {
|
||||
HB_COMPAT_ANY_FAIL=0
|
||||
HB_COMPAT_ANY_WARN=0
|
||||
HB_COMPAT_CROSS_VERSION=0
|
||||
# HB_COMPAT_KERNEL_DIRECTION captures which direction the kernel
|
||||
# major.minor drift goes:
|
||||
# same — backup and target on the same kernel major.minor
|
||||
# (or one side lacks a kernel string in the manifest).
|
||||
# bk_older — backup kernel is OLDER than target kernel. This is
|
||||
# the only direction that has been observed to break
|
||||
# things: restoring older-kernel-era userspace onto a
|
||||
# newer target can drag in old libzfs siblings, older
|
||||
# systemd unit overrides, and DKMS modules built
|
||||
# against a kernel that isn't the one now running.
|
||||
# The restore flow uses this to skip the kernel-tied
|
||||
# paths (grub, /etc/systemd/system, initramfs config,
|
||||
# etc.) so Full mode still runs but only over the
|
||||
# safe subset, and Custom mode grays out the unsafe
|
||||
# paths so the operator can't shoot themself in the
|
||||
# foot picking them.
|
||||
# bk_newer — backup kernel is NEWER than target kernel. Empirical
|
||||
# testing (multiple 9.2 → 9.1 restores with GPU
|
||||
# passthrough / IOMMU) shows this direction works
|
||||
# fine as-is: Proxmox repos don't ship future package
|
||||
# versions on old series, so APT silently skips the
|
||||
# backup's kernel-tied packages, and modules compile
|
||||
# against whatever kernel the target is running. No
|
||||
# safe-mode restrictions apply here; the restore runs
|
||||
# as though it were same-version.
|
||||
HB_COMPAT_KERNEL_DIRECTION="same"
|
||||
|
||||
local meta="$staging_root/metadata"
|
||||
local rootfs="$staging_root/rootfs"
|
||||
@@ -2898,10 +2946,28 @@ hb_compat_check() {
|
||||
if [[ "$bk_kmaj" == "$cur_kmaj" ]]; then
|
||||
HB_COMPAT_RESULTS+=("PASS|Kernel|$(hb_translate "Same major.minor:") $bk_kernel → $cur_kernel")
|
||||
else
|
||||
# Kernel major.minor drift — same reasoning as the
|
||||
# PVE case above. Safe filter handles it.
|
||||
HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Different kernel major.minor:") $bk_kernel → $cur_kernel $(hb_translate "— safe restore mode will skip kernel-tied config")")
|
||||
HB_COMPAT_CROSS_VERSION=1
|
||||
# Kernel major.minor drift — decide direction so the
|
||||
# rest of the restore knows whether to switch on the
|
||||
# safe-subset mode. Bash `[[ ... < ... ]]` is a string
|
||||
# compare, so we split into integers explicitly.
|
||||
local _bk_maj _bk_min _cur_maj _cur_min
|
||||
_bk_maj=${bk_kmaj%%.*}
|
||||
_bk_min=${bk_kmaj##*.}
|
||||
_cur_maj=${cur_kmaj%%.*}
|
||||
_cur_min=${cur_kmaj##*.}
|
||||
if (( _bk_maj < _cur_maj )) \
|
||||
|| (( _bk_maj == _cur_maj && _bk_min < _cur_min )); then
|
||||
HB_COMPAT_KERNEL_DIRECTION="bk_older"
|
||||
HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on older kernel:") $bk_kernel → $cur_kernel $(hb_translate "— Full/Custom restore will skip kernel-tied paths")")
|
||||
HB_COMPAT_CROSS_VERSION=1
|
||||
else
|
||||
# Backup NEWER than target — empirical evidence
|
||||
# (multiple GPU passthrough tests) shows this
|
||||
# direction restores cleanly with no filtering.
|
||||
HB_COMPAT_KERNEL_DIRECTION="bk_newer"
|
||||
HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on newer kernel:") $bk_kernel → $cur_kernel $(hb_translate "— restore proceeds as same-version (verified safe)")")
|
||||
HB_COMPAT_CROSS_VERSION=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# kernel_pin_plan.sh — JSON reporter for cross-kernel restore
|
||||
# ==========================================================
|
||||
# Usage: bash kernel_pin_plan.sh <staging_root>
|
||||
# Output: JSON on stdout — one of:
|
||||
#
|
||||
# {"detected":false}
|
||||
# Backup + target run the same kernel major.minor.
|
||||
# No action needed.
|
||||
#
|
||||
# {"detected":true,
|
||||
# "backup_kernel":"6.17.2-1-pve",
|
||||
# "target_kernel":"7.0.12-1-pve",
|
||||
# "action":"installed",
|
||||
# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"}
|
||||
# Cross-kernel. The backup's kernel is already installed on the
|
||||
# target; the restore flow will pin it.
|
||||
#
|
||||
# {"detected":true, ...,
|
||||
# "action":"installable",
|
||||
# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"}
|
||||
# Cross-kernel. The kernel is available in Proxmox repos and
|
||||
# will be installed before the pin.
|
||||
#
|
||||
# {"detected":true, ...,
|
||||
# "action":"unavailable",
|
||||
# "reason":"not installed on this host and not available in the
|
||||
# configured Proxmox repositories"}
|
||||
# Cross-kernel. The kernel is neither installed nor obtainable.
|
||||
# The restore flow must refuse.
|
||||
#
|
||||
# The heavy lifting is delegated to hb_kernel_pin_plan in
|
||||
# lib_host_backup_common.sh, so the CLI menu and the Web Monitor
|
||||
# agree byte-for-byte on which restores are safe to start.
|
||||
# ==========================================================
|
||||
set -euo pipefail
|
||||
|
||||
staging_root="${1:-}"
|
||||
[[ -z "$staging_root" || ! -d "$staging_root" ]] && {
|
||||
printf '{"detected":false,"error":"staging_root missing"}\n'
|
||||
exit 0
|
||||
}
|
||||
|
||||
LIB="/usr/local/share/proxmenux/scripts/backup_restore/lib_host_backup_common.sh"
|
||||
if [[ ! -f "$LIB" ]]; then
|
||||
printf '{"detected":false,"error":"lib_host_backup_common.sh not found"}\n'
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB"
|
||||
|
||||
# Read the backup's kernel release.
|
||||
bk_kernel=""
|
||||
if [[ -f "$staging_root/metadata/run_info.env" ]]; then
|
||||
bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null \
|
||||
| cut -d= -f2-)
|
||||
fi
|
||||
cur_kernel=$(uname -r 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$bk_kernel" || -z "$cur_kernel" ]]; then
|
||||
printf '{"detected":false}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compare major.minor — the same rule hb_compat_check uses.
|
||||
bk_mm=$(echo "$bk_kernel" | cut -d. -f1-2)
|
||||
cur_mm=$(echo "$cur_kernel" | cut -d. -f1-2)
|
||||
if [[ "$bk_mm" == "$cur_mm" ]]; then
|
||||
printf '{"detected":false}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cross-kernel: compute the pin plan.
|
||||
hb_kernel_pin_plan "$bk_kernel"
|
||||
|
||||
# Emit JSON. jq if we have it (safer with special chars), else printf.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn \
|
||||
--arg bkk "$bk_kernel" \
|
||||
--arg trk "$cur_kernel" \
|
||||
--arg act "${HB_KERNEL_PIN_ACTION:-unavailable}" \
|
||||
--arg pkg "${HB_KERNEL_PIN_PKG:-}" \
|
||||
--arg rsn "${HB_KERNEL_PIN_REASON:-}" \
|
||||
'{detected:true, backup_kernel:$bkk, target_kernel:$trk,
|
||||
action:$act, pkg:$pkg, reason:$rsn}'
|
||||
else
|
||||
printf '{"detected":true,"backup_kernel":"%s","target_kernel":"%s","action":"%s","pkg":"%s","reason":"%s"}\n' \
|
||||
"$bk_kernel" "$cur_kernel" \
|
||||
"${HB_KERNEL_PIN_ACTION:-unavailable}" \
|
||||
"${HB_KERNEL_PIN_PKG:-}" \
|
||||
"${HB_KERNEL_PIN_REASON:-}"
|
||||
fi
|
||||
Reference in New Issue
Block a user