feat: preserve volumes on failed install + migrate ext4 to btrfs

- COW snapshot (cp --reflink=always) of package volumes before
  install/update; restore on failure, remove on success
- Automatic ext4→btrfs conversion via btrfs-convert during disk attach
  with e2fsck pre-check and post-conversion defrag
- Probe package-data filesystem during setup.disk.list (on both disk
  and partition level) so the UI can warn about ext4 conversion
- Setup wizard preserve-overwrite dialog shows ext4 warning with
  backup acknowledgment checkbox before allowing preserve
This commit is contained in:
Aiden McClelland
2026-03-17 15:10:03 -06:00
parent c1a328e5ca
commit 900d86ab83
15 changed files with 386 additions and 42 deletions

View File

@@ -1,12 +1,30 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiDialogContext } from '@taiga-ui/core'
import {
TuiButton,
TuiCheckbox,
TuiDialogContext,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
export interface PreserveOverwriteData {
isExt4: boolean
}
@Component({
imports: [TuiButton, TuiHeader, TuiTitle, i18nPipe],
imports: [
FormsModule,
TuiButton,
TuiCheckbox,
TuiHeader,
TuiNotification,
TuiTitle,
i18nPipe,
],
template: `
<header tuiHeader>
<hgroup tuiTitle>
@@ -24,6 +42,18 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
{{ 'to discard' | i18n }}
</li>
</ul>
@if (context.data.isExt4) {
<p tuiNotification appearance="warning" size="m">
{{
'This drive uses ext4 and will be automatically converted to btrfs. A backup is strongly recommended before proceeding.'
| i18n
}}
</p>
<label>
<input tuiCheckbox type="checkbox" [(ngModel)]="backupAck" />
{{ 'I have a backup of my data' | i18n }}
</label>
}
<footer>
<button
tuiButton
@@ -36,6 +66,7 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
tuiButton
appearance=""
[style.background]="'var(--tui-status-positive)'"
[disabled]="context.data.isExt4 && !backupAck"
(click)="context.completeWith(true)"
>
{{ 'Preserve' | i18n }}
@@ -44,7 +75,9 @@ import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
`,
})
export class PreserveOverwriteDialog {
protected readonly context = injectContext<TuiDialogContext<boolean>>()
protected readonly context =
injectContext<TuiDialogContext<boolean, PreserveOverwriteData>>()
protected backupAck = false
}
export const PRESERVE_OVERWRITE = new PolymorpheusComponent(

View File

@@ -292,8 +292,18 @@ export default class DrivesPage {
private showPreserveOverwriteDialog() {
let selectionMade = false
const drive = this.selectedDataDrive
const filesystem =
drive?.filesystem ||
drive?.partitions.find(p => p.guid)?.filesystem ||
null
const isExt4 = filesystem === 'ext2'
this.dialogs.openComponent<boolean>(PRESERVE_OVERWRITE).subscribe({
this.dialogs
.openComponent<boolean>(PRESERVE_OVERWRITE, {
data: { isExt4 },
})
.subscribe({
next: preserve => {
selectionMade = true
this.preserveData = preserve

View File

@@ -203,6 +203,7 @@ const MOCK_DISKS: DiskInfo[] = [
partitions: [],
capacity: 0,
guid: null,
filesystem: null,
},
// 10 GiB - too small for OS and data; also tests both vendor+model null
{
@@ -217,10 +218,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 10 * GiB,
guid: null,
filesystem: null,
},
// 18 GiB - exact OS boundary; tests vendor null with model present
{
@@ -235,10 +238,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 18 * GiB,
guid: null,
filesystem: null,
},
// 20 GiB - exact data boundary; tests vendor present with model null
{
@@ -253,10 +258,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 20 * GiB,
guid: null,
filesystem: null,
},
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
{
@@ -271,10 +278,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 30 * GiB,
guid: null,
filesystem: null,
},
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
{
@@ -298,10 +307,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: 'small-existing-guid',
filesystem: 'ext2',
},
],
capacity: 30 * GiB,
guid: 'small-existing-guid',
filesystem: 'ext2',
},
// 500 GB - large, always OK
{
@@ -316,10 +327,12 @@ const MOCK_DISKS: DiskInfo[] = [
used: null,
startOs: {},
guid: null,
filesystem: null,
},
],
capacity: 500000000000,
guid: null,
filesystem: null,
},
// 1 TB with existing StartOS data
{
@@ -343,10 +356,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: 'existing-guid',
filesystem: 'btrfs',
},
],
capacity: 1000000000000,
guid: 'existing-guid',
filesystem: 'btrfs',
},
// 2 TB
{
@@ -370,10 +385,12 @@ const MOCK_DISKS: DiskInfo[] = [
},
},
guid: null,
filesystem: null,
},
],
capacity: 2000000000000,
guid: null,
filesystem: null,
},
]