mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
484 lines
16 KiB
Go
484 lines
16 KiB
Go
package install
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Pool and dataset names. rpool matches Proxmox, which is what every ZFS
|
|
// recovery guide and forum answer assumes — the name an operator will be
|
|
// reading off a serial console at 3am.
|
|
const (
|
|
ZFSPoolName = "rpool"
|
|
ZFSRootDataset = ZFSPoolName + "/ROOT/spinifex"
|
|
)
|
|
|
|
// FSType selects the root filesystem and, for ZFS, the vdev topology.
|
|
type FSType string
|
|
|
|
const (
|
|
FSExt4 FSType = "ext4"
|
|
FSZFSRAID0 FSType = "zfs-raid0"
|
|
FSZFSRAID1 FSType = "zfs-raid1"
|
|
FSZFSRAID10 FSType = "zfs-raid10"
|
|
FSZFSRAIDZ1 FSType = "zfs-raidz1"
|
|
FSZFSRAIDZ2 FSType = "zfs-raidz2"
|
|
FSZFSRAIDZ3 FSType = "zfs-raidz3"
|
|
)
|
|
|
|
// AllFSTypes is the selector order presented by the UI. ext4 is first and is
|
|
// the default, so ZFS — which holds the kernel at its shipped version and
|
|
// reserves RAM for the ARC — is always an explicit choice.
|
|
var AllFSTypes = []FSType{
|
|
FSExt4, FSZFSRAID0, FSZFSRAID1, FSZFSRAID10, FSZFSRAIDZ1, FSZFSRAIDZ2, FSZFSRAIDZ3,
|
|
}
|
|
|
|
// fsMeta carries the per-topology facts that drive display and validation.
|
|
var fsMeta = map[FSType]struct {
|
|
label string
|
|
minDisks int
|
|
redundant bool // subject to the same-size rule
|
|
vdev string // zpool create keyword; empty means a plain stripe
|
|
}{
|
|
FSExt4: {"ext4", 1, false, ""},
|
|
FSZFSRAID0: {"zfs (RAID0)", 1, false, ""},
|
|
FSZFSRAID1: {"zfs (RAID1)", 2, true, "mirror"},
|
|
FSZFSRAID10: {"zfs (RAID10)", 4, true, "mirror"},
|
|
FSZFSRAIDZ1: {"zfs (RAIDZ-1)", 3, true, "raidz1"},
|
|
FSZFSRAIDZ2: {"zfs (RAIDZ-2)", 4, true, "raidz2"},
|
|
FSZFSRAIDZ3: {"zfs (RAIDZ-3)", 5, true, "raidz3"},
|
|
}
|
|
|
|
// IsZFS reports whether this mode creates a pool rather than a plain filesystem.
|
|
func (f FSType) IsZFS() bool { return strings.HasPrefix(string(f), "zfs-") }
|
|
|
|
// Label is the display name, matching proxinstall's wording so the screen is
|
|
// recognisable to anyone who has installed Proxmox.
|
|
func (f FSType) Label() string { return fsMeta[f].label }
|
|
|
|
// MinDisks is the smallest member count the topology can be built from.
|
|
func (f FSType) MinDisks() int { return fsMeta[f].minDisks }
|
|
|
|
// Redundant reports whether the topology stores parity or mirrors, and so
|
|
// requires members of matching size. A RAID0 stripe over unequal disks is
|
|
// legitimate and is deliberately not covered.
|
|
func (f FSType) Redundant() bool { return fsMeta[f].redundant }
|
|
|
|
// vdevKeyword is the zpool create keyword introducing each vdev.
|
|
func (f FSType) vdevKeyword() string { return fsMeta[f].vdev }
|
|
|
|
// ParseFSType maps a SPINIFEX_FS value to an FSType.
|
|
func ParseFSType(s string) (FSType, error) {
|
|
f := FSType(strings.ToLower(strings.TrimSpace(s)))
|
|
if _, ok := fsMeta[f]; !ok {
|
|
names := make([]string, 0, len(AllFSTypes))
|
|
for _, t := range AllFSTypes {
|
|
names = append(names, string(t))
|
|
}
|
|
return "", fmt.Errorf("unknown filesystem %q — must be one of: %s", s, strings.Join(names, ", "))
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// DiskRole is the purpose a selected drive serves on an ext4 install. A ZFS
|
|
// pool carries every workload on one set of members, so roles do not apply
|
|
// there — the topology already spans all the disks.
|
|
type DiskRole string
|
|
|
|
const (
|
|
RoleOS DiskRole = "os"
|
|
RoleSpinifex DiskRole = "spinifex"
|
|
RolePredastore DiskRole = "predastore"
|
|
)
|
|
|
|
// AllDiskRoles is canonical order, outermost mountpoint first. Mounting, fstab
|
|
// emission and rsync protect filters all walk it forwards; teardown walks it
|
|
// back, because predastore nests inside spinifex.
|
|
var AllDiskRoles = []DiskRole{RoleOS, RoleSpinifex, RolePredastore}
|
|
|
|
// roleMountpoints is where each role lands on the installed system. A role left
|
|
// unassigned simply stays on the filesystem above it.
|
|
var roleMountpoints = map[DiskRole]string{
|
|
RoleOS: "/",
|
|
RoleSpinifex: "/var/lib/spinifex",
|
|
RolePredastore: "/var/lib/spinifex/predastore",
|
|
}
|
|
|
|
// Label is the role name as shown in the UI and written to partition labels.
|
|
func (r DiskRole) Label() string { return string(r) }
|
|
|
|
// Mountpoint is the role's path on the installed system.
|
|
func (r DiskRole) Mountpoint() string { return roleMountpoints[r] }
|
|
|
|
// RoleMount binds one drive to one role.
|
|
type RoleMount struct {
|
|
Role DiskRole
|
|
Disk Disk
|
|
}
|
|
|
|
// Mountpoint is where this drive's filesystem is mounted on the target.
|
|
func (r RoleMount) Mountpoint() string { return r.Role.Mountpoint() }
|
|
|
|
// ZFSOpts are the advanced tunables. A zero value means "use the computed
|
|
// default", which is why Ashift and ARCMaxMiB are plain ints — 0 is not legal
|
|
// for either, so it is unambiguous as a sentinel.
|
|
type ZFSOpts struct {
|
|
Ashift int
|
|
Compress string
|
|
Checksum string
|
|
Copies int
|
|
ARCMaxMiB int
|
|
}
|
|
|
|
// DiskConfig is the storage half of the installer's configuration. Disks is
|
|
// ordered and the order is significant: RAID10 pairs members two at a time, and
|
|
// on ext4 the first entry is the OS drive.
|
|
type DiskConfig struct {
|
|
FS FSType
|
|
Disks []Disk
|
|
ZFS ZFSOpts
|
|
|
|
// Roles assigns ext4 drives to mountpoints, in AllDiskRoles order. Empty
|
|
// means a single-disk install with everything on the root filesystem.
|
|
Roles []RoleMount
|
|
}
|
|
|
|
// WithRoles returns a copy with ext4 drive roles assigned and Disks rebuilt
|
|
// from them in canonical order, so the list of drives to erase and the list of
|
|
// roles cannot drift apart — there is one place a drive can be named, not two.
|
|
func (d DiskConfig) WithRoles(roles []RoleMount) DiskConfig {
|
|
d.Roles, d.Disks = nil, nil
|
|
add := func(rm RoleMount) {
|
|
d.Roles = append(d.Roles, rm)
|
|
d.Disks = append(d.Disks, rm.Disk)
|
|
}
|
|
for _, want := range AllDiskRoles {
|
|
for _, rm := range roles {
|
|
if rm.Role == want {
|
|
add(rm)
|
|
}
|
|
}
|
|
}
|
|
// Anything with an unknown or duplicate role is kept so Validate can name
|
|
// it, rather than being dropped into a silently smaller selection.
|
|
for _, rm := range roles {
|
|
if _, known := roleMountpoints[rm.Role]; !known {
|
|
add(rm)
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// RolesForDisks assigns roles positionally, which is the default the UI offers:
|
|
// first disk picked is the OS, second is spinifex, third is predastore.
|
|
func RolesForDisks(disks []Disk) []RoleMount {
|
|
out := make([]RoleMount, 0, len(disks))
|
|
for i, disk := range disks {
|
|
var role DiskRole
|
|
if i < len(AllDiskRoles) {
|
|
role = AllDiskRoles[i]
|
|
}
|
|
out = append(out, RoleMount{Role: role, Disk: disk})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DataMounts is every role except the OS drive, shallowest mountpoint first.
|
|
// This is the traversal every stage of the install shares.
|
|
func (d DiskConfig) DataMounts() []RoleMount {
|
|
var out []RoleMount
|
|
for _, rm := range d.Roles {
|
|
if rm.Role != RoleOS {
|
|
out = append(out, rm)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bootDisks are the drives a bootloader is installed to. Every ZFS member gets
|
|
// its own ESP; on ext4 only the OS drive does, because a data drive holding a
|
|
// bootloader that nothing points at is a trap during recovery.
|
|
func (d DiskConfig) bootDisks() []Disk {
|
|
if d.FS.IsZFS() || len(d.Disks) == 0 {
|
|
return d.Disks
|
|
}
|
|
return d.Disks[:1]
|
|
}
|
|
|
|
// Primary is the first selected disk — the OS drive in ext4 mode, and the
|
|
// disk shown in single-disk summaries.
|
|
func (d DiskConfig) Primary() Disk {
|
|
if len(d.Disks) == 0 {
|
|
return Disk{}
|
|
}
|
|
return d.Disks[0]
|
|
}
|
|
|
|
// Paths lists the selected device paths, for logs and confirmation screens.
|
|
func (d DiskConfig) Paths() []string {
|
|
out := make([]string, len(d.Disks))
|
|
for i, disk := range d.Disks {
|
|
out[i] = disk.Path
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Buildable reports whether the selection has enough members, of the right
|
|
// count, to form the topology at all. Capacity and fault tolerance are
|
|
// meaningless until it does — a 3-disk RAIDZ-3 has no geometry to describe.
|
|
func (d DiskConfig) Buildable() bool {
|
|
if _, ok := fsMeta[d.FS]; !ok {
|
|
return false
|
|
}
|
|
if len(d.Disks) < d.FS.MinDisks() {
|
|
return false
|
|
}
|
|
return d.FS != FSZFSRAID10 || len(d.Disks)%2 == 0
|
|
}
|
|
|
|
// Requirement describes what the topology still needs, for the preview line
|
|
// shown in place of a capacity that cannot be computed yet.
|
|
func (d DiskConfig) Requirement() string {
|
|
req := fmt.Sprintf("needs %d disks", d.FS.MinDisks())
|
|
if d.FS == FSZFSRAID10 {
|
|
req = "needs an even number of disks, at least 4"
|
|
}
|
|
if d.FS.Redundant() {
|
|
req += " of the same size"
|
|
}
|
|
return req
|
|
}
|
|
|
|
// UsableBytes estimates post-parity capacity, for the geometry preview. It is
|
|
// an operator-facing approximation and deliberately ignores metadata overhead.
|
|
// Returns 0 for a selection that cannot form the topology, since the parity
|
|
// arithmetic below goes to zero and then negative as members run out.
|
|
func (d DiskConfig) UsableBytes() int64 {
|
|
n := int64(len(d.Disks))
|
|
if n == 0 || !d.Buildable() {
|
|
return 0
|
|
}
|
|
smallest := d.Disks[0].Bytes
|
|
for _, disk := range d.Disks {
|
|
smallest = min(smallest, disk.Bytes)
|
|
}
|
|
switch d.FS {
|
|
case FSExt4, FSZFSRAID0:
|
|
var total int64
|
|
for _, disk := range d.Disks {
|
|
total += disk.Bytes
|
|
}
|
|
return total
|
|
case FSZFSRAID1:
|
|
return smallest
|
|
case FSZFSRAID10:
|
|
return smallest * (n / 2)
|
|
case FSZFSRAIDZ1:
|
|
return smallest * (n - 1)
|
|
case FSZFSRAIDZ2:
|
|
return smallest * (n - 2)
|
|
case FSZFSRAIDZ3:
|
|
return smallest * (n - 3)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Tolerated returns the number of simultaneous disk failures the topology
|
|
// survives, for the geometry preview.
|
|
func (d DiskConfig) Tolerated() int {
|
|
if !d.Buildable() {
|
|
return 0
|
|
}
|
|
switch d.FS {
|
|
case FSZFSRAID1:
|
|
return max(len(d.Disks)-1, 0)
|
|
case FSZFSRAID10:
|
|
return 1 // one per mirror pair; the honest worst case is one
|
|
case FSZFSRAIDZ1:
|
|
return 1
|
|
case FSZFSRAIDZ2:
|
|
return 2
|
|
case FSZFSRAIDZ3:
|
|
return 3
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// sizeTolerance is the fraction by which pool members may differ in size.
|
|
// Matches Proxmox's zfs_mirror_size_check: anything beyond 10% and the pool
|
|
// silently truncates every member to the smallest.
|
|
const sizeTolerance = 0.10
|
|
|
|
// bootedEFI reports whether firmware booted this machine in EFI mode. GRUB's
|
|
// i386-pc target cannot read a 4Kn drive, so legacy BIOS constrains selection.
|
|
func bootedEFI() bool {
|
|
_, err := os.Stat("/sys/firmware/efi")
|
|
return err == nil
|
|
}
|
|
|
|
// Validate checks a disk selection against every rule that must hold before any
|
|
// destructive command runs. The UI calls it on each keystroke so failures block
|
|
// Continue, rather than aborting an install that has already repartitioned.
|
|
func (d DiskConfig) Validate() error {
|
|
if _, ok := fsMeta[d.FS]; !ok {
|
|
return fmt.Errorf("no filesystem selected")
|
|
}
|
|
if len(d.Disks) == 0 {
|
|
return fmt.Errorf("%s: select at least one disk", d.FS.Label())
|
|
}
|
|
if err := d.validateRoles(); err != nil {
|
|
return err
|
|
}
|
|
if n := len(d.Disks); n < d.FS.MinDisks() {
|
|
return fmt.Errorf("%s: needs at least %d disks, %d selected", d.FS.Label(), d.FS.MinDisks(), n)
|
|
}
|
|
if d.FS == FSZFSRAID10 && len(d.Disks)%2 != 0 {
|
|
return fmt.Errorf("%s: needs an even number of disks, %d selected", d.FS.Label(), len(d.Disks))
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, disk := range d.Disks {
|
|
if seen[disk.Path] {
|
|
return fmt.Errorf("%s is selected more than once", disk.Path)
|
|
}
|
|
seen[disk.Path] = true
|
|
|
|
if disk.LiveMedia {
|
|
return fmt.Errorf("%s is the installer's own boot media and cannot be erased", disk.Path)
|
|
}
|
|
if disk.Bytes < minRootBytes {
|
|
return fmt.Errorf("%s is too small (%s, need at least %dGiB)",
|
|
disk.Path, disk.SizeHuman(), minRootBytes>>30)
|
|
}
|
|
}
|
|
|
|
// GRUB's i386-pc target cannot read a 4Kn drive, so on legacy BIOS a drive
|
|
// holding a bootloader would leave the installed system unreachable. Only
|
|
// boot drives are constrained: a data role is never booted from.
|
|
for _, disk := range d.bootDisks() {
|
|
if !bootedEFI() && disk.LogicalBlockSize == 4096 {
|
|
return fmt.Errorf("%s is a 4Kn drive (4096-byte sectors) and this machine booted in legacy BIOS mode — booting from it is not supported; enable UEFI in firmware",
|
|
disk.Path)
|
|
}
|
|
}
|
|
|
|
return d.validateSizes()
|
|
}
|
|
|
|
// validateRoles checks the ext4 role assignment. Roles are the only way to use
|
|
// more than one disk without ZFS, so a multi-disk selection without them is
|
|
// rejected rather than quietly installed onto the first drive.
|
|
func (d DiskConfig) validateRoles() error {
|
|
if d.FS.IsZFS() {
|
|
if len(d.Roles) > 0 {
|
|
return fmt.Errorf("%s: drive roles apply to ext4 only — a pool spans every member already", d.FS.Label())
|
|
}
|
|
return nil
|
|
}
|
|
if len(d.Roles) == 0 {
|
|
if len(d.Disks) > 1 {
|
|
return fmt.Errorf("%s: %d disks selected but no roles assigned — assign os, spinifex and predastore, or choose a zfs mode",
|
|
d.FS.Label(), len(d.Disks))
|
|
}
|
|
return nil
|
|
}
|
|
if len(d.Roles) > len(AllDiskRoles) {
|
|
return fmt.Errorf("%s: at most %d disks, %d selected — the roles are os, spinifex and predastore",
|
|
d.FS.Label(), len(AllDiskRoles), len(d.Roles))
|
|
}
|
|
|
|
seen := map[DiskRole]bool{}
|
|
for _, rm := range d.Roles {
|
|
if _, ok := roleMountpoints[rm.Role]; !ok {
|
|
return fmt.Errorf("%s has no role assigned — every selected disk needs one", rm.Disk.Path)
|
|
}
|
|
if seen[rm.Role] {
|
|
return fmt.Errorf("two disks are assigned the %s role", rm.Role)
|
|
}
|
|
seen[rm.Role] = true
|
|
}
|
|
if !seen[RoleOS] {
|
|
return fmt.Errorf("%s: no disk assigned the os role — one disk must hold the operating system", d.FS.Label())
|
|
}
|
|
if len(d.Roles) != len(d.Disks) {
|
|
return fmt.Errorf("internal: %d roles for %d disks", len(d.Roles), len(d.Disks))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// minRootBytes matches Proxmox's hard floor for a root disk.
|
|
const minRootBytes = 2 << 30
|
|
|
|
// validateSizes enforces the same-size rule where the topology needs it:
|
|
// across all members for mirrors and raidz, and per-pair for RAID10 — a
|
|
// striped mirror only requires each pair to match, not every disk in the pool.
|
|
func (d DiskConfig) validateSizes() error {
|
|
if !d.FS.Redundant() {
|
|
return nil
|
|
}
|
|
if d.FS == FSZFSRAID10 {
|
|
for i := 0; i+1 < len(d.Disks); i += 2 {
|
|
if err := sizesMatch(d.Disks[i], d.Disks[i+1]); err != nil {
|
|
return fmt.Errorf("%s mirror pair %d: %w", d.FS.Label(), i/2+1, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for _, disk := range d.Disks[1:] {
|
|
if err := sizesMatch(d.Disks[0], disk); err != nil {
|
|
return fmt.Errorf("%s: %w", d.FS.Label(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SizesWithinTolerance reports whether two disks could be members of the same
|
|
// redundant pool. The UI uses it to decide whether suggesting ZFS is honest.
|
|
func SizesWithinTolerance(a, b Disk) bool { return sizesMatch(a, b) == nil }
|
|
|
|
// sizesMatch reports whether two members are within the tolerance, naming both
|
|
// disks and their sizes — that message is the operator's only diagnostic.
|
|
func sizesMatch(a, b Disk) error {
|
|
if math.Abs(float64(a.Bytes-b.Bytes)) > float64(a.Bytes)*sizeTolerance {
|
|
return fmt.Errorf("disks must be the same size within %.0f%%: %s is %s, %s is %s",
|
|
sizeTolerance*100, a.Path, a.SizeHuman(), b.Path, b.SizeHuman())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Warnings returns non-blocking advice for a valid selection. A RAID0 stripe
|
|
// over mismatched disks is legal but almost always a mistake, so it is
|
|
// surfaced without preventing the install — which is what Proxmox does.
|
|
func (d DiskConfig) Warnings() []string {
|
|
var out []string
|
|
if d.FS == FSZFSRAID0 && len(d.Disks) > 1 {
|
|
for _, disk := range d.Disks[1:] {
|
|
if sizesMatch(d.Disks[0], disk) != nil {
|
|
out = append(out, "striping disks of different sizes — capacity is usable but a failure of any disk loses the pool")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if d.FS.IsZFS() && d.FS != FSZFSRAID0 && d.Buildable() && d.Tolerated() == 0 {
|
|
out = append(out, "this topology stores no redundancy")
|
|
}
|
|
// A three-drive role layout looks like an array and is not one: each drive
|
|
// is an independent point of failure, so the node loses whatever sat on it.
|
|
if len(d.DataMounts()) > 0 {
|
|
out = append(out, "role-assigned drives store no redundancy — losing any one drive loses what it held")
|
|
}
|
|
for _, rm := range d.DataMounts() {
|
|
if rm.Role == RolePredastore && rm.Disk.Bytes < smallPredastoreBytes {
|
|
out = append(out, fmt.Sprintf("%s is small for object storage (%s) — predastore will fill quickly",
|
|
rm.Disk.Path, rm.Disk.SizeHuman()))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// smallPredastoreBytes is where a dedicated object-store drive stops being
|
|
// worth the drive. Advisory only: the install is legal below it.
|
|
const smallPredastoreBytes = 64 << 30
|