diff --git a/docs/explanation/architecture/components.rst b/docs/explanation/architecture/components.rst index 27c6bdcaf..91c992c15 100644 --- a/docs/explanation/architecture/components.rst +++ b/docs/explanation/architecture/components.rst @@ -258,8 +258,9 @@ used for temporary storage during workshop rebuild operations. Storage backends ~~~~~~~~~~~~~~~~ -|ws_markup| uses ZFS for storage on Linux, -with automatic Btrfs fallback on Windows Subsystem for Linux (WSL). +|ws_markup| uses ZFS for storage where it detects that ZFS is available, +with automatic Btrfs fallback otherwise, +for example on Windows Subsystem for Linux (WSL). Storage is managed via LXD and requires a minimum pool size of 5 GiB. The storage backend manages container root filesystems, workshop-specific data volumes, diff --git a/docs/reference/workshops.rst b/docs/reference/workshops.rst index ef21d8e8d..441d8f18a 100644 --- a/docs/reference/workshops.rst +++ b/docs/reference/workshops.rst @@ -183,8 +183,9 @@ Storage pools and drivers ------------------------- |ws_markup| stores its containers and data on a storage pool. -On Linux, |ws_markup| uses ZFS, -while on Windows Subsystem for Linux it automatically uses Btrfs. +It uses ZFS on systems where |ws_markup| detects that ZFS is available, +and otherwise falls back to Btrfs +(for example on Windows Subsystem for Linux). This approach consolidates container images, :program:`apt` caches, SDKs and other workshop content under a single system. diff --git a/docs/tutorial/part-1-get-started.rst b/docs/tutorial/part-1-get-started.rst index 073b8f413..cc05f0913 100644 --- a/docs/tutorial/part-1-get-started.rst +++ b/docs/tutorial/part-1-get-started.rst @@ -47,8 +47,9 @@ Prerequisites |ws_markup| is supported on Ubuntu and other :program:`snap`-enabled Linux distributions; -it is also compatible with Windows Subsystem for Linux (WSL2), -where it uses Btrfs instead of ZFS for storage. +it is also compatible with Windows Subsystem for Linux (WSL2). +For storage, it uses ZFS where |ws_markup| detects that ZFS is available, +and otherwise falls back to Btrfs. |ws_markup| relies on `LXD 6.8+ `_ diff --git a/internal/workshop/lxd/export_test.go b/internal/workshop/lxd/export_test.go index 9ccf39553..54de2733c 100644 --- a/internal/workshop/lxd/export_test.go +++ b/internal/workshop/lxd/export_test.go @@ -23,6 +23,8 @@ var ( HandleImageUpdate = handleImageUpdate CheckServerVersion = checkVersion GenerateCNAME = generateCNAME + PreferredDriver = preferredDriver + DriverSupported = driverSupported ) func MockFirewallChecker(f func(string) string) func() { diff --git a/internal/workshop/lxd/lxd_backend.go b/internal/workshop/lxd/lxd_backend.go index b49d8d7c0..215d21131 100644 --- a/internal/workshop/lxd/lxd_backend.go +++ b/internal/workshop/lxd/lxd_backend.go @@ -54,6 +54,11 @@ const ( storagePool = "workshop" storagePoolMinimalGiB = 5 + // ZFS is workshop's preferred storage driver; Btrfs is the fallback used + // on hosts where LXD reports ZFS as unavailable. + storageDriverZFS = "zfs" + storageDriverBtrfs = "btrfs" + networkName = "workshopbr0" networkType = "bridge" @@ -69,17 +74,12 @@ const ( var ( startCommandTimeout = 1 * time.Minute - storagePoolDriver = "zfs" ) //go:embed start_command.sh var startCommand string func init() { - if osutil.IsWSL() { - storagePoolDriver = "btrfs" - } - // Order matters: capabilities (version and storage) must be validated // before ensureBackendReady attempts to create the storage pool and // network. Registering it as a check also lets the daemon recover after @@ -162,19 +162,56 @@ func checkVersion(version string) error { return nil } -func checkStorageDriver(drivers []api.ServerStorageDriverInfo) error { - hasDriver := func(driver api.ServerStorageDriverInfo) bool { - return driver.Name == storagePoolDriver +// driverSupported reports whether LXD lists the named storage driver as +// supported. LXD builds this list by attempting to load each driver's kernel +// module (e.g. modprobe zfs), so it reflects what the host can actually back. +func driverSupported(supported []api.ServerStorageDriverInfo, name string) bool { + return slices.ContainsFunc(supported, func(d api.ServerStorageDriverInfo) bool { + return d.Name == name + }) +} + +// preferredDriver picks the driver for a new workshop pool: ZFS when LXD +// reports it as supported, otherwise Btrfs. +func preferredDriver(supported []api.ServerStorageDriverInfo) string { + if driverSupported(supported, storageDriverZFS) { + return storageDriverZFS + } + return storageDriverBtrfs +} + +// poolUsesZFS reports whether the workshop storage pool is backed by ZFS. The +// driver is read from the pool itself, since it is fixed when the pool is +// created and can differ from what the host would pick today. +func poolUsesZFS(conn lxd.InstanceServer) (bool, error) { + pool, _, err := conn.GetStoragePool(storagePool) + if err != nil { + return false, err } - if slices.ContainsFunc(drivers, hasDriver) { + return pool.Driver == storageDriverZFS, nil +} + +// checkStoragePool verifies the workshop storage pool is usable. When the pool +// exists but cannot be loaded, most likely because its storage driver's kernel +// module is gone (e.g. a ZFS pool after booting a kernel without the ZFS +// module), it returns an actionable error that puts the daemon into degraded +// mode. When the pool does not exist yet, it ensures a supported driver is +// available to create it with. +func checkStoragePool(conn lxd.InstanceServer, supported []api.ServerStorageDriverInfo) error { + _, _, err := conn.GetStoragePool(storagePool) + if err == nil { return nil } + if !api.StatusErrorCheck(err, http.StatusNotFound) { + return fmt.Errorf(`cannot use the %q storage pool, its storage driver may be unavailable (for example a ZFS pool after booting a kernel without the ZFS module): %w +Boot into a kernel that provides the pool's storage driver, or reinstall Workshop to recreate the pool on an available driver`, storagePool, err) + } - // The LXD error message when creating a pool is: - // Error: Error loading "zfs" module: Failed to run: modprobe -b zfs: - // exit status 1 (modprobe: FATAL: Module zfs not found ...) - // We keep the first part for consistency, the rest doesn't add much. - return fmt.Errorf(`suitable storage backend not found: error loading %q module`, storagePoolDriver) + // The pool doesn't exist yet; make sure it can be created. + if !driverSupported(supported, preferredDriver(supported)) { + return fmt.Errorf(`suitable storage backend not found: neither %q nor %q is available`, storageDriverZFS, storageDriverBtrfs) + } + return nil } // checkStorageSpace puts the daemon into degraded mode when the workshop @@ -236,7 +273,7 @@ func checkServerCapabilities() error { return err } - return checkStorageDriver(info.Environment.StorageSupportedDrivers) + return checkStoragePool(conn, info.Environment.StorageSupportedDrivers) } func checkWorkshopFormats() error { @@ -289,15 +326,22 @@ func ensureBackendReady() error { } defer conn.Disconnect() - // Create LXD storage pool if it doesn't exist. - pools, err := conn.GetStoragePools() + // Create LXD storage pool if it doesn't exist. GetStoragePoolNames avoids + // loading each pool's driver, so an existing but currently-unloadable pool + // (e.g. a ZFS pool whose module is gone) is reported by checkStoragePool + // rather than failing here. + names, err := conn.GetStoragePoolNames() if err != nil { return err } - if idx := slices.IndexFunc(pools, func(p api.StoragePool) bool { return p.Name == storagePool }); idx < 0 { + if !slices.Contains(names, storagePool) { + info, _, err := conn.GetServer() + if err != nil { + return err + } req := api.StoragePoolsPost{ Name: storagePool, - Driver: storagePoolDriver, + Driver: preferredDriver(info.Environment.StorageSupportedDrivers), } op, err := conn.CreateStoragePool(req) if err != nil { @@ -336,8 +380,6 @@ func ensureBackendReady() error { logger.Noticef("On ensureBackendReady: set storage pool to the minimal size: %dGiB", storagePoolMinimalGiB) } - } else if pools[idx].Driver != storagePoolDriver { - return fmt.Errorf("storage pool %q already exists with a different driver: %q (expected %q)", storagePool, pools[idx].Driver, storagePoolDriver) } network, etag, err := conn.GetNetwork(networkName) diff --git a/internal/workshop/lxd/lxd_backend_snapshots.go b/internal/workshop/lxd/lxd_backend_snapshots.go index c9abfbd06..765911f25 100644 --- a/internal/workshop/lxd/lxd_backend_snapshots.go +++ b/internal/workshop/lxd/lxd_backend_snapshots.go @@ -346,7 +346,11 @@ func (s *Backend) TakeSnapshot(ctx context.Context, name string, snapshot worksh if inst.Devices == nil { inst.Devices = map[string]map[string]string{} } - if err := mergeDevices(inst.Devices, snapshot.Sdks, name); err != nil { + usesZFS, err := poolUsesZFS(conn) + if err != nil { + return err + } + if err := mergeDevices(inst.Devices, snapshot.Sdks, name, usesZFS); err != nil { return err } @@ -598,12 +602,12 @@ func mergeConfig(source, target, config map[string]string) { maps.Copy(source, config) } -func mergeDevices(source map[string]map[string]string, sdks []sdk.ContentID, w string) error { +func mergeDevices(source map[string]map[string]string, sdks []sdk.ContentID, w string, usesZFS bool) error { maps.DeleteFunc(source, func(k string, v map[string]string) bool { return k != "root" }) - if storagePoolDriver == "zfs" { + if usesZFS { root := maps.Clone(source["root"]) if source == nil || root == nil { return fmt.Errorf("internal error: %q workshop has no rootfs", w) @@ -734,7 +738,11 @@ func (s *Backend) copyInstance(src, dst lxd.InstanceServer, srcName, dstName str req := *srcInst - if storagePoolDriver == "zfs" { + usesZFS, err := poolUsesZFS(dst) + if err != nil { + return err + } + if usesZFS { req.Devices = maps.Clone(req.Devices) root := maps.Clone(req.Devices["root"]) if req.Devices == nil || root == nil { diff --git a/internal/workshop/lxd/lxd_backend_test.go b/internal/workshop/lxd/lxd_backend_test.go index fdd365948..7728a0806 100644 --- a/internal/workshop/lxd/lxd_backend_test.go +++ b/internal/workshop/lxd/lxd_backend_test.go @@ -17,6 +17,7 @@ package lxdbackend_test import ( "testing" + "github.com/canonical/lxd/shared/api" "gopkg.in/check.v1" "github.com/canonical/workshop/internal/testutil" @@ -134,3 +135,31 @@ func (f *LxdBeTests) TestCheckLxdVersion(c *check.C) { err = lxdbackend.CheckServerVersion("6.7.9") c.Assert(err, check.ErrorMatches, `(?s).*LXD server version.*is not supported.*`) } + +func driverInfo(names ...string) []api.ServerStorageDriverInfo { + infos := make([]api.ServerStorageDriverInfo, 0, len(names)) + for _, n := range names { + infos = append(infos, api.ServerStorageDriverInfo{Name: n}) + } + return infos +} + +// preferredDriver keeps ZFS whenever LXD reports it, and falls back to Btrfs +// otherwise. Detection now trusts LXD's supported-drivers list (which LXD +// builds by trying to load each driver's module) rather than probing modules. +func (f *LxdBeTests) TestPreferredDriver(c *check.C) { + c.Check(lxdbackend.PreferredDriver(driverInfo("zfs", "btrfs", "dir")), check.Equals, "zfs") + c.Check(lxdbackend.PreferredDriver(driverInfo("btrfs", "zfs")), check.Equals, "zfs") + + c.Check(lxdbackend.PreferredDriver(driverInfo("btrfs", "dir")), check.Equals, "btrfs") + c.Check(lxdbackend.PreferredDriver(driverInfo("dir")), check.Equals, "btrfs") + c.Check(lxdbackend.PreferredDriver(nil), check.Equals, "btrfs") +} + +func (f *LxdBeTests) TestDriverSupported(c *check.C) { + supported := driverInfo("zfs", "btrfs") + c.Check(lxdbackend.DriverSupported(supported, "zfs"), check.Equals, true) + c.Check(lxdbackend.DriverSupported(supported, "btrfs"), check.Equals, true) + c.Check(lxdbackend.DriverSupported(supported, "lvm"), check.Equals, false) + c.Check(lxdbackend.DriverSupported(nil, "zfs"), check.Equals, false) +}