Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 77 additions & 21 deletions cmd/microcloud/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
}
}

lxd := sh.Services[types.LXD].(*service.LXDService)

availableDisks := map[string]map[string]api.ResourcesStorageDisk{}
for name, state := range c.state {
// Skip this system if it already has the local storage pool configured
Expand All @@ -342,11 +344,17 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
continue
}

if len(state.AvailableDisks) == 0 {
// Also offer non-pristine disks as they might contain an existing local storage pool which can be reused.
// This allows recovered systems to set up local storage without wiping their existing storage pool.
disks := make(map[string]api.ResourcesStorageDisk, len(state.AvailableDisks)+len(state.ReusableDisks))
maps.Copy(disks, state.AvailableDisks)
maps.Copy(disks, state.ReusableDisks)

if len(disks) == 0 {
continue
}

availableDisks[name] = state.AvailableDisks
availableDisks[name] = disks
}

// When adding new systems to a cluster which already has the local storage pool,
Expand Down Expand Up @@ -398,8 +406,8 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
}
}

lxd := sh.Services[types.LXD].(*service.LXDService)
toWipe := map[string]string{}
toRecover := map[string]bool{}
wipeable, err := lxd.HasExtension(context.Background(), lxd.Name(), lxd.Address(), nil, "storage_pool_source_wipe")
if err != nil {
return fmt.Errorf("Failed to check for source.wipe extension: %w", err)
Expand Down Expand Up @@ -435,28 +443,74 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
return errors.New("Failed to add local storage pool: Some peers don't have an available disk")
}

if wipeable {
newRows := make([][]string, len(answers))
for row := range answers {
newRows[row] = make([]string, len(header))
for j, h := range header {
newRows[row][j] = answers[row][h]
selectedInfo := map[string]api.ResourcesStorageDisk{}
for target, path := range selected {
for _, disk := range availableDisks[target] {
if service.FormatDiskPath(disk) == path {
selectedInfo[target] = disk
break
}
}
}

reuseDisks := map[string]bool{}
for _, target := range slices.Sorted(maps.Keys(selected)) {
if service.IsPristineDisk(selectedInfo[target]) {
continue
}

answers, err := table.Render(context.Background(), c.asker, "Select which disks to wipe:", newRows...)
reuse, err := c.asker.AskBool(fmt.Sprintf("Disk %q on %q is non-pristine. Reuse its existing local storage pool?", selected[target], target), true)
if err != nil {
return fmt.Errorf("Failed to confirm which disks to wipe: %w", err)
return err
}

reuseDisks[target] = reuse
}

wipe := map[string]string{}
if wipeable {
newRows := make([][]string, 0, len(answers))
for _, entry := range answers {
target := entry["LOCATION"]
path := entry["PATH"]
toWipe[target] = path
// Disks whose existing storage pool is reused must not be wiped.
if reuseDisks[entry["LOCATION"]] {
continue
}

row := make([]string, len(header))
for j, h := range header {
row[j] = entry[h]
}

newRows = append(newRows, row)
}

if len(newRows) > 0 {
answers, err := table.Render(context.Background(), c.asker, "Select which disks to wipe:", newRows...)
if err != nil {
return fmt.Errorf("Failed to confirm which disks to wipe: %w", err)
}

for _, entry := range answers {
target := entry["LOCATION"]
path := entry["PATH"]
wipe[target] = path
}
}
}

for _, target := range slices.Sorted(maps.Keys(selected)) {
if reuseDisks[target] || wipe[target] != "" {
continue
}

if len(selectedInfo[target].Partitions) != 0 {
return fmt.Errorf("Disk %q on %q has existing partitions. Either reuse its existing local storage pool or wipe it", selected[target], target)
}
}

selectedDisks = selected
toWipe = wipe
toRecover = reuseDisks

return nil
})
Expand All @@ -468,22 +522,18 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
return nil
}

if wipeable {
toWipe = selectedDisks
}

var joinConfigs map[string][]api.ClusterMemberConfigKey
var finalConfigs []api.StoragePoolsPost
var targetConfigs map[string][]api.StoragePoolsPost
if useJoinConfig {
joinConfigs = map[string][]api.ClusterMemberConfigKey{}
for target, path := range selectedDisks {
joinConfigs[target] = lxd.DefaultZFSStoragePoolJoinConfig(wipeable && toWipe[target] != "", path)
joinConfigs[target] = lxd.DefaultZFSStoragePoolJoinConfig(wipeable && toWipe[target] != "", toRecover[target], path)
}
} else {
targetConfigs = map[string][]api.StoragePoolsPost{}
for target, path := range selectedDisks {
targetConfigs[target] = []api.StoragePoolsPost{lxd.DefaultPendingZFSStoragePool(wipeable && toWipe[target] != "", path)}
targetConfigs[target] = []api.StoragePoolsPost{lxd.DefaultPendingZFSStoragePool(wipeable && toWipe[target] != "", toRecover[target], path)}
}

if len(targetConfigs) > 0 {
Expand All @@ -497,6 +547,11 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
}

for target, path := range selectedDisks {
if toRecover[target] {
fmt.Println(tui.SummarizeResult("Reusing existing local storage pool on %s on %s", path, target))
continue
}

fmt.Println(tui.SummarizeResult("Using %s on %s for local storage pool", path, target))
}

Expand All @@ -508,7 +563,8 @@ func (c *initConfig) askLocalPool(sh *service.Handler) error {
newAvailableDisks := map[string]map[string]api.ResourcesStorageDisk{}
for target, path := range selectedDisks {
newAvailableDisks[target] = map[string]api.ResourcesStorageDisk{}
for id, disk := range availableDisks[target] {
// Only pass on the available disks so that reusable disks are not offered for remote storage.
for id, disk := range c.state[target].AvailableDisks {
if service.FormatDiskPath(disk) != path {
newAvailableDisks[target][id] = disk
}
Expand Down
28 changes: 24 additions & 4 deletions cmd/microcloud/preseed.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ type DirectStorage struct {
Path string `yaml:"path"`
Wipe bool `yaml:"wipe"`
Encrypt bool `yaml:"encrypt"`

// Recover reuses an existing local storage pool on the disk.
// It is only supported for local storage.
Recover bool `yaml:"recover"`
}

// InitNetwork represents the structure of the network config in the preseed yaml.
Expand Down Expand Up @@ -401,6 +405,22 @@ func (p *Preseed) validate(name string, bootstrap bool) error {
directCephCount++
}

for _, disk := range system.Storage.Ceph {
if disk.Recover {
return fmt.Errorf("Cannot recover Ceph disks of system %q", system.Name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this path be actually triggered?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes; DirectStorage is shared by both the local and Ceph entries, so the following configuration parses successfully:

storage:
  ceph:
    - path: /dev/sdc
      recover: true

without the check, recover would simply be silently ignored for Ceph disks.

that said, I noticed that encrypt is also silently ignored for local disks today, so silently ignoring unsupported fields would at least be consistent with the existing behavior.

would you prefer to keep the explicit validation error or remove it for consistency with encrypt? alternatively, I could introduce a separate struct for local storage so that recover is only available there.

}
}

if system.Storage.Local.Recover {
if system.Storage.Local.Path == "" {
return fmt.Errorf("Missing the local storage disk path to recover on system %q", system.Name)
}

if system.Storage.Local.Wipe {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about not asking to wipe if the user already selected to recover the disk?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interactive flow already excludes reused disks from the wipe selection. In preseed, both flags can be set explicitly, so I added an error to prevent conflicting actions and unintended data loss. Alternatively, I can make recover take precedence over wipe.

return fmt.Errorf("Cannot both wipe and recover the local storage disk of system %q", system.Name)
}
}

if system.Storage.Local.Path != "" {
directLocalCount++
}
Expand Down Expand Up @@ -923,12 +943,12 @@ func (p *Preseed) Parse(s *service.Handler, c *initConfig, installedServices map
// Setup directly specified disks for ZFS pool.
if directLocal.Path != "" {
if c.bootstrap {
system.TargetStoragePools = append(system.TargetStoragePools, lxd.DefaultPendingZFSStoragePool(directLocal.Wipe, directLocal.Path))
system.TargetStoragePools = append(system.TargetStoragePools, lxd.DefaultPendingZFSStoragePool(directLocal.Wipe, directLocal.Recover, directLocal.Path))
if s.Name == peer {
system.StoragePools = append(system.StoragePools, lxd.DefaultZFSStoragePool())
}
} else {
system.JoinConfig = append(system.JoinConfig, lxd.DefaultZFSStoragePoolJoinConfig(directLocal.Wipe, directLocal.Path)...)
system.JoinConfig = append(system.JoinConfig, lxd.DefaultZFSStoragePoolJoinConfig(directLocal.Wipe, directLocal.Recover, directLocal.Path)...)
}

directZFSMatches[peer] = directZFSMatches[peer] + 1
Expand Down Expand Up @@ -1102,12 +1122,12 @@ func (p *Preseed) Parse(s *service.Handler, c *initConfig, installedServices map
if len(matched) > 0 {
zfsMachines[peer] = true
if c.bootstrap {
system.TargetStoragePools = append(system.TargetStoragePools, lxd.DefaultPendingZFSStoragePool(filter.Wipe, service.FormatDiskPath(matched[0])))
system.TargetStoragePools = append(system.TargetStoragePools, lxd.DefaultPendingZFSStoragePool(filter.Wipe, false, service.FormatDiskPath(matched[0])))
if s.Name == peer {
system.StoragePools = append(system.StoragePools, lxd.DefaultZFSStoragePool())
}
} else {
system.JoinConfig = append(system.JoinConfig, lxd.DefaultZFSStoragePoolJoinConfig(filter.Wipe, service.FormatDiskPath(matched[0]))...)
system.JoinConfig = append(system.JoinConfig, lxd.DefaultZFSStoragePoolJoinConfig(filter.Wipe, false, service.FormatDiskPath(matched[0]))...)
}

zfsMatches[filter.Find] = zfsMatches[filter.Find] + 1
Expand Down
58 changes: 58 additions & 0 deletions cmd/microcloud/preseed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,64 @@ func (s *preseedSuite) Test_preseedValidateInvalid() {
}
}

func (s *preseedSuite) Test_preseedValidateRecover() {
systems := func(n2 InitStorage) []System {
return []System{
{Name: "n1", Address: "1.0.0.1", Storage: InitStorage{Local: DirectStorage{Path: "/dev/sdb"}}},
{Name: "n2", Address: "1.0.0.2", Storage: n2},
}
}

cases := []struct {
desc string
bootstrap bool
storage InitStorage
err error
}{
{
desc: "Recover local disk when adding",
storage: InitStorage{Local: DirectStorage{Path: "/dev/sdb", Recover: true}},
err: nil,
},
{
desc: "Recover local disk when initializing",
bootstrap: true,
storage: InitStorage{Local: DirectStorage{Path: "/dev/sdb", Recover: true}},
err: nil,
},
{
desc: "Recover and wipe local disk",
storage: InitStorage{Local: DirectStorage{Path: "/dev/sdb", Recover: true, Wipe: true}},
err: errors.New(`Cannot both wipe and recover the local storage disk of system "n2"`),
},
{
desc: "Recover local disk without path",
storage: InitStorage{Local: DirectStorage{Recover: true}},
err: errors.New(`Missing the local storage disk path to recover on system "n2"`),
},
{
desc: "Recover Ceph disk",
storage: InitStorage{
Local: DirectStorage{Path: "/dev/sdb"},
Ceph: []DirectStorage{{Path: "/dev/sdc", Recover: true}},
},
err: errors.New(`Cannot recover Ceph disks of system "n2"`),
},
}

for _, c := range cases {
s.T().Log(c.desc)

p := Preseed{SessionPassphrase: "foo", InitiatorAddress: "1.0.0.1", Systems: systems(c.storage)}
err := p.validate("n1", c.bootstrap)
if c.err == nil {
s.NoError(err)
} else {
s.EqualError(err, c.err.Error())
}
}
}

func (s *preseedSuite) Test_preseedMatchDisksMemory() {
unit1, err := units.ParseByteSizeString("1MiB")
s.NoError(err)
Expand Down
2 changes: 2 additions & 0 deletions doc/reference/preseed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ session_timeout: 300
# `ovn_uplink_interface` is optional and represents the name of the interface reserved for use with OVN.
# `ovn_underlay_ip` is optional and represents the Geneve Encap IP for each system.
# `storage` is optional and represents explicit paths to disks for each system.
# `local.recover` is optional and reuses the existing local storage pool on a non-pristine disk instead of creating a new one.
# It cannot be combined with `wipe`.
systems:
- name: micro01
address: 10.0.0.1
Expand Down
4 changes: 4 additions & 0 deletions doc/reference/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ Local storage is fast and convenient. It is commonly used in production and is t

The default file system type for local storage is ZFS.

MicroCloud also offers unmounted non-pristine disks for local storage.
A non-pristine disk has existing partitions or a file system, for example a disk that still contains the local storage pool after reinstalling a machine.
If you select a non-pristine disk, MicroCloud asks whether to reuse the existing local storage pool on it instead of creating a new one.

(reference-requirements-storage-distributed)=
### Distributed storage

Expand Down
21 changes: 19 additions & 2 deletions service/lxd_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,17 @@ func (s LXDService) DefaultOVNNetwork(ipv4Gateway string, ipv4Range string, ipv6

// DefaultPendingZFSStoragePool returns the default local storage configuration when
// creating a pending pool on a specific cluster member target.
func (s LXDService) DefaultPendingZFSStoragePool(wipe bool, path string) api.StoragePoolsPost {
// If recoverSource is set, an existing storage pool on the source is reused instead of creating a new one.
func (s LXDService) DefaultPendingZFSStoragePool(wipe bool, recoverSource bool, path string) api.StoragePoolsPost {
cfg := map[string]string{"source": path}
if wipe {
cfg["source.wipe"] = strconv.FormatBool(wipe)
}

if recoverSource {
cfg["source.recover"] = strconv.FormatBool(recoverSource)
}

return api.StoragePoolsPost{
Name: DefaultZFSPool,
Driver: "zfs",
Expand All @@ -204,14 +209,22 @@ func (s LXDService) DefaultZFSStoragePool() api.StoragePoolsPost {

// DefaultZFSStoragePoolJoinConfig returns the default local storage configuration when
// joining an existing cluster.
func (s LXDService) DefaultZFSStoragePoolJoinConfig(wipe bool, path string) []api.ClusterMemberConfigKey {
// If recoverSource is set, an existing storage pool on the source is reused instead of creating a new one.
func (s LXDService) DefaultZFSStoragePoolJoinConfig(wipe bool, recoverSource bool, path string) []api.ClusterMemberConfigKey {
wipeDisk := api.ClusterMemberConfigKey{
Entity: "storage-pool",
Name: DefaultZFSPool,
Key: "source.wipe",
Value: "true",
}

recoverDisk := api.ClusterMemberConfigKey{
Entity: "storage-pool",
Name: DefaultZFSPool,
Key: "source.recover",
Value: "true",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this value need to be based on recoverSource? Otherwise it's always true.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key is only appended when go recoverSource is true:

if recoverSource {
    joinConfig = append(joinConfig, recoverDisk)
}

So it can never end up in the config as true unless recovery was explicitly requested. This follows the same pattern as the existing wipeDisk key just above it.

If the logic is confusing, I'm happy to build the key inside the if block instead to make the conditional behavior more obvious.

}

sourceTemplate := api.ClusterMemberConfigKey{
Entity: "storage-pool",
Name: DefaultZFSPool,
Expand All @@ -224,6 +237,10 @@ func (s LXDService) DefaultZFSStoragePoolJoinConfig(wipe bool, path string) []ap
joinConfig = append(joinConfig, wipeDisk)
}

if recoverSource {
joinConfig = append(joinConfig, recoverDisk)
}

return joinConfig
}

Expand Down
Loading