diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 35a323583a8..e4760ff2481 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -66,6 +66,9 @@ const ( Autofs = "auto" Block = "block" + // Maxium number of nested symlinks to resolve + MaxLinkDepth = 4 + // Kernel and initrd paths KernelModulesDir = "/lib/modules" KernelPath = "/boot/vmlinuz" @@ -84,6 +87,8 @@ const ( GrubOEMEnv = "grub_oem_env" GrubEnv = "grubenv" GrubDefEntry = "Elemental" + GrubFallback = "default_fallback" + GrubPassiveSnapshots = "passive_snaps" ElementalBootloaderBin = "/usr/lib/elemental/bootloader" // Mountpoints of images and partitions @@ -170,6 +175,8 @@ const ( // Snapshotters MaxSnaps = 4 LoopDeviceSnapshotterType = "loopdevice" + ActiveSnapshot = "active" + PassiveSnapshot = "passive_%d" ) func GetKernelPatterns() []string { diff --git a/pkg/snapshotter/loopdevice.go b/pkg/snapshotter/loopdevice.go index cc0c6b3b9c9..1256fbe6e7e 100644 --- a/pkg/snapshotter/loopdevice.go +++ b/pkg/snapshotter/loopdevice.go @@ -17,9 +17,16 @@ limitations under the License. package snapshotter import ( + "bufio" + "fmt" "path/filepath" + "regexp" + "sort" + "strconv" + "strings" "github.com/rancher/elemental-toolkit/pkg/constants" + "github.com/rancher/elemental-toolkit/pkg/elemental" v1 "github.com/rancher/elemental-toolkit/pkg/types/v1" "github.com/rancher/elemental-toolkit/pkg/utils" @@ -30,48 +37,431 @@ const ( loopDeviceImgName = "snapshot.img" loopDeviceWorkDir = "snapshot.workDir" loopDeviceLabelPattern = "EL_SNAP%d" - loopDevicePassiveSnaps = loopDeviceSnapsPath + "/passives" + loopDevicePassivePath = loopDeviceSnapsPath + "/passives" ) var _ v1.Snapshotter = (*LoopDevice)(nil) type LoopDevice struct { - cfg v1.Config - snapshotterCfg v1.SnapshotterConfig - loopDevCfg v1.LoopDeviceConfig - rootDir string - /*currentSnapshotID int - activeSnapshotID int*/ - bootloader v1.Bootloader + cfg v1.Config + snapshotterCfg v1.SnapshotterConfig + loopDevCfg v1.LoopDeviceConfig + rootDir string + currentSnapshotID int + activeSnapshotID int + bootloader v1.Bootloader } -func NewLoopDeviceSnapshotter(cfg v1.Config, snapCfg v1.SnapshotterConfig, bootloader v1.Bootloader) *LoopDevice { - loopDevCfg := snapCfg.Config.(v1.LoopDeviceConfig) - return &LoopDevice{cfg: cfg, snapshotterCfg: snapCfg, loopDevCfg: loopDevCfg, bootloader: bootloader} +func NewLoopDeviceSnapshotter(cfg v1.Config, snapCfg v1.SnapshotterConfig, bootloader v1.Bootloader) (*LoopDevice, error) { + if snapCfg.Type != constants.LoopDeviceSnapshotterType { + msg := "invalid snapshotter type ('%s'), must be of '%s' type" + cfg.Logger.Errorf(msg, snapCfg.Type, constants.LoopDeviceSnapshotterType) + return nil, fmt.Errorf(msg, snapCfg.Type, constants.LoopDeviceSnapshotterType) + } + loopDevCfg, ok := snapCfg.Config.(v1.LoopDeviceConfig) + if !ok { + msg := "failed casting LoopDeviceConfig type" + cfg.Logger.Errorf(msg) + return nil, fmt.Errorf(msg) + } + return &LoopDevice{cfg: cfg, snapshotterCfg: snapCfg, loopDevCfg: loopDevCfg, bootloader: bootloader}, nil } func (l *LoopDevice) InitSnapshotter(rootDir string) error { l.cfg.Logger.Infof("Initiating a LoopDevice snapshotter at %s", rootDir) l.rootDir = rootDir - return utils.MkdirAll(l.cfg.Fs, filepath.Join(rootDir, loopDevicePassiveSnaps), constants.DirPerm) + + // TODO detect legacy layout in /cOS/active.img and /cOS/passive.img and create hard links to new locations + + return utils.MkdirAll(l.cfg.Fs, filepath.Join(rootDir, loopDevicePassivePath), constants.DirPerm) } func (l *LoopDevice) StartTransaction() (*v1.Snapshot, error) { - var snap *v1.Snapshot + l.cfg.Logger.Infof("Starting a snapshotter transaction") + nextID, err := l.getNextSnapshotID() + if err != nil { + return nil, err + } + + active, err := l.getActiveSnapshot() + if err != nil { + l.cfg.Logger.Errorf("failed to determine active snapshot: %v", err) + return nil, err + } + if active == l.currentSnapshotID { + l.activeSnapshotID = l.currentSnapshotID + } else { + l.activeSnapshotID = active + } + + l.cfg.Logger.Debugf( + "next snapshot: %d, current snapshot: %d, active snapshot: %d", + nextID, l.currentSnapshotID, l.activeSnapshotID, + ) + + snapPath := filepath.Join(l.rootDir, loopDeviceSnapsPath, strconv.FormatInt(int64(nextID), 10)) + err = utils.MkdirAll(l.cfg.Fs, snapPath, constants.DirPerm) + if err != nil { + _ = l.cfg.Fs.RemoveAll(snapPath) + return nil, err + } + + workDir := filepath.Join(snapPath, loopDeviceWorkDir) + err = utils.MkdirAll(l.cfg.Fs, workDir, constants.DirPerm) + if err != nil { + _ = l.cfg.Fs.RemoveAll(snapPath) + return nil, err + } + + err = utils.MkdirAll(l.cfg.Fs, constants.WorkingImgDir, constants.DirPerm) + if err != nil { + _ = l.cfg.Fs.RemoveAll(snapPath) + return nil, err + } + + err = l.cfg.Mounter.Mount(workDir, constants.WorkingImgDir, "bind", []string{"bind"}) + if err != nil { + _ = l.cfg.Fs.RemoveAll(snapPath) + _ = l.cfg.Fs.RemoveAll(constants.WorkingImgDir) + return nil, err + } - return snap, nil + snapshot := &v1.Snapshot{ + ID: nextID, + Path: filepath.Join(snapPath, loopDeviceImgName), + WorkDir: workDir, + MountPoint: constants.WorkingImgDir, + Label: fmt.Sprintf(loopDeviceLabelPattern, nextID), + InProgress: true, + } + + l.cfg.Logger.Infof("Transaction for snapshot %d successfully started", nextID) + return snapshot, nil } -func (l *LoopDevice) CloseTransactionOnError(_ *v1.Snapshot) error { +func (l *LoopDevice) CloseTransactionOnError(snapshot *v1.Snapshot) error { var err error + + if snapshot == nil { + return nil + } + + if snapshot.InProgress { + err = l.cfg.Mounter.Unmount(snapshot.MountPoint) + } + + rErr := l.cfg.Fs.RemoveAll(filepath.Dir(snapshot.Path)) + if rErr != nil && err == nil { + err = rErr + } + return err } -func (l *LoopDevice) CloseTransaction(_ *v1.Snapshot) (err error) { +func (l *LoopDevice) CloseTransaction(snapshot *v1.Snapshot) (err error) { + var linkDst, newPassive, activeSnap string + + if !snapshot.InProgress { + l.cfg.Logger.Debugf("No transaction to close for snapshot %d workdir", snapshot.ID) + return l.cfg.Fs.RemoveAll(filepath.Dir(snapshot.Path)) + } + defer func() { + if err != nil { + l.CloseTransactionOnError(snapshot) + } + }() + + l.cfg.Logger.Infof("Closing transaction for snapshot %d workdir", snapshot.ID) + l.cfg.Logger.Debugf("Unmount %s", constants.WorkingImgDir) + err = l.cfg.Mounter.Unmount(snapshot.MountPoint) + if err != nil { + l.cfg.Logger.Errorf("failed umounting snapshot %d workdir bind mount", snapshot.ID) + return err + } + + err = elemental.CreateImageFromTree(l.cfg, l.snapshotToImage(snapshot), snapshot.WorkDir, false) + if err != nil { + l.cfg.Logger.Errorf("failed creating image for snapshot %d: %v", snapshot.ID, err) + return err + } + + err = l.cfg.Fs.RemoveAll(snapshot.WorkDir) + if err != nil { + return err + } + + // Create fallback link for current active snapshot + newPassive = filepath.Join(l.rootDir, loopDevicePassivePath, fmt.Sprintf(constants.PassiveSnapshot, l.activeSnapshotID)) + if l.activeSnapshotID > 0 { + linkDst = fmt.Sprintf("../%d/%s", l.activeSnapshotID, loopDeviceImgName) + l.cfg.Logger.Debugf("creating symlink %s to %s", newPassive, linkDst) + err = l.cfg.Fs.Symlink(linkDst, newPassive) + if err != nil { + l.cfg.Logger.Errorf("failed creating the new passive link: %v", err) + return err + } + l.cfg.Logger.Infof("New passive snapshot fallback from active (%d) created", l.activeSnapshotID) + } + + // Remove old symlink and create a new one + activeSnap = filepath.Join(l.rootDir, loopDeviceSnapsPath, constants.ActiveSnapshot) + linkDst = fmt.Sprintf("%d/%s", snapshot.ID, loopDeviceImgName) + l.cfg.Logger.Debugf("creating symlink %s to %s", activeSnap, linkDst) + _ = l.cfg.Fs.Remove(activeSnap) + err = l.cfg.Fs.Symlink(linkDst, activeSnap) + if err != nil { + l.cfg.Logger.Errorf("failed default snapshot image for snapshot %d: %v", snapshot.ID, err) + _ = l.cfg.Fs.Remove(newPassive) + sErr := l.cfg.Fs.Symlink(fmt.Sprintf("%d/%s", l.activeSnapshotID, loopDeviceImgName), activeSnap) + if sErr != nil { + l.cfg.Logger.Warnf("could not restore previous active link") + } + return err + } + // From now on we do not error out as the transaction is already done, cleanup steps are only logged + // Active system does not require specific bootloader setup, only old snapshots + _ = l.cleanOldSnapshots() + _ = l.setBootloader() + + snapshot.InProgress = false return err } -func (l *LoopDevice) DeleteSnapshot(_ int) error { +func (l *LoopDevice) DeleteSnapshot(id int) error { var err error + var snapLink string + + l.cfg.Logger.Infof("Deleting snapshot %d", id) + inUse, err := l.isSnapshotInUse(id) + if err != nil { + return err + } + + if inUse { + return fmt.Errorf("cannot delete a snapshot that is currently in use") + } + + snaps, err := l.GetSnapshots() + if err != nil { + l.cfg.Logger.Errorf("failed getting current snapshots list: %v", err) + return err + } + + found := false + for _, snap := range snaps { + if snap == id { + found = true + break + } + } + if !found { + l.cfg.Logger.Warnf("Snapshot %d not found, nothing to delete", id) + return nil + } + + if l.activeSnapshotID == id { + snapLink = filepath.Join(l.rootDir, loopDeviceSnapsPath, constants.ActiveSnapshot) + } else { + snapLink = filepath.Join(l.rootDir, loopDevicePassivePath, fmt.Sprintf(constants.PassiveSnapshot, id)) + } + + err = l.cfg.Fs.Remove(snapLink) + if err != nil { + l.cfg.Logger.Errorf("failed removing snapshot link %s: %v", snapLink, err) + return err + } + + snapDir := filepath.Join(l.rootDir, loopDeviceSnapsPath, strconv.Itoa(id)) + err = l.cfg.Fs.RemoveAll(snapDir) + if err != nil { + l.cfg.Logger.Errorf("failed removing snaphot dir %s: %v", snapDir, err) + } return err } + +func (l *LoopDevice) GetSnapshots() ([]int, error) { + var ids []int + + ids, err := l.getPassiveSnapshots() + if err != nil { + return nil, err + } + + id, err := l.getActiveSnapshot() + if err != nil { + return nil, err + } + + if id > 0 { + return append(ids, id), nil + } + return ids, nil +} + +func (l *LoopDevice) getNextSnapshotID() (int, error) { + var id int + + ids, err := l.GetSnapshots() + if err != nil { + return -1, err + } + for _, i := range ids { + inUse, err := l.isSnapshotInUse(i) + if err != nil { + l.cfg.Logger.Errorf("failed checking if snapshot %d is in use: %v", i, err) + return -1, err + } + if inUse { + l.cfg.Logger.Debugf("Current snapshot: %d", i) + l.currentSnapshotID = i + } + if i > id { + id = i + } + } + return id + 1, nil +} + +func (l *LoopDevice) getActiveSnapshot() (int, error) { + snapPath := filepath.Join(l.rootDir, loopDeviceSnapsPath, constants.ActiveSnapshot) + exists, err := utils.Exists(l.cfg.Fs, snapPath, true) + if err != nil { + l.cfg.Logger.Errorf("failed checking active snapshot (%s) existence: %v", snapPath, err) + return -1, err + } + if !exists { + l.cfg.Logger.Infof("path %s does not exist", snapPath) + return 0, nil + } + + resolved, err := utils.ResolveLink(l.cfg.Fs, snapPath, l.rootDir, constants.MaxLinkDepth) + if err != nil { + l.cfg.Logger.Errorf("failed to resolve %s link", snapPath) + return -1, err + } + + id, err := strconv.ParseInt(filepath.Base(filepath.Dir(resolved)), 10, 32) + if err != nil { + l.cfg.Logger.Errorf("failed parsing snapshot ID from path %s: %v", resolved, err) + return -1, err + } + + return int(id), nil +} + +func (l *LoopDevice) isSnapshotInUse(id int) (bool, error) { + backedFiles, err := l.cfg.Runner.Run("losetup", "-ln", "--output", "BACK-FILE") + if err != nil { + return false, err + } + + scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(string(backedFiles)))) + for scanner.Scan() { + backedFile := scanner.Text() + suffix := filepath.Join(loopDeviceSnapsPath, strconv.Itoa(id), loopDeviceImgName) + if strings.HasSuffix(backedFile, suffix) { + return true, nil + } + } + return false, nil +} + +func (l *LoopDevice) snapshotToImage(snapshot *v1.Snapshot) *v1.Image { + return &v1.Image{ + File: snapshot.Path, + Label: snapshot.Label, + Size: l.loopDevCfg.Size, + FS: l.loopDevCfg.FS, + MountPoint: snapshot.MountPoint, + } +} + +func (l *LoopDevice) cleanOldSnapshots() error { + l.cfg.Logger.Infof("Cleaning old passive snapshots") + ids, err := l.GetSnapshots() + if err != nil { + l.cfg.Logger.Warnf("could not get current snapshots") + return err + } + + sort.Ints(ids) + for len(ids) > l.snapshotterCfg.MaxSnaps { + err = l.DeleteSnapshot(ids[0]) + if err != nil { + l.cfg.Logger.Warnf("could not delete snapshot %d", ids[0]) + return err + } + ids = ids[1:] + } + return nil +} + +func (l *LoopDevice) setBootloader() error { + var passives, fallbacks []string + + l.cfg.Logger.Infof("Setting bootloader with current passive snapshots") + ids, err := l.getPassiveSnapshots() + if err != nil { + l.cfg.Logger.Warnf("failed getting current passive snapshots: %v", err) + return err + } + for _, id := range ids { + passives = append(passives, fmt.Sprintf(constants.PassiveSnapshot, id)) + } + + // We count first is active, then all passives and finally the recovery + for i := 0; i <= len(ids)+1; i++ { + fallbacks = append(fallbacks, strconv.Itoa(i)) + } + snapsList := strings.Join(passives, " ") + fallbackList := strings.Join(fallbacks, " ") + envFile := filepath.Join(l.rootDir, constants.GrubOEMEnv) + + envs := map[string]string{ + constants.GrubFallback: fallbackList, + constants.GrubPassiveSnapshots: snapsList, + } + + err = l.bootloader.SetPersistentVariables(envFile, envs) + if err != nil { + l.cfg.Logger.Warnf("failed setting bootloader environment file %s: %v", envFile, err) + return err + } + + return err +} + +func (l *LoopDevice) getPassiveSnapshots() ([]int, error) { + var ids []int + + snapsPath := filepath.Join(l.rootDir, loopDevicePassivePath) + r := regexp.MustCompile(`passive_(\d+)`) + if ok, _ := utils.Exists(l.cfg.Fs, snapsPath); ok { + links, err := l.cfg.Fs.ReadDir(snapsPath) + if err != nil { + l.cfg.Logger.Errorf("failed reading %s contents", snapsPath) + return ids, err + } + for _, link := range links { + // Find snapshots based numeric directory names + if !r.MatchString(link.Name()) { + continue + } + matches := r.FindStringSubmatch(link.Name()) + id, err := strconv.ParseInt(matches[1], 10, 32) + if err != nil { + continue + } + linkPath := filepath.Join(snapsPath, link.Name()) + if exists, _ := utils.Exists(l.cfg.Fs, linkPath); exists { + ids = append(ids, int(id)) + } else { + l.cfg.Logger.Warnf("image for snapshot %d doesn't exist", id) + } + } + l.cfg.Logger.Debugf("Passive snaps: %v", ids) + return ids, nil + } + l.cfg.Logger.Errorf("path %s does not exist", snapsPath) + return ids, fmt.Errorf("cannot determine passive snapshots, initate snapshotter first") +} diff --git a/pkg/snapshotter/loopdevice_test.go b/pkg/snapshotter/loopdevice_test.go new file mode 100644 index 00000000000..87ef3c0ecc8 --- /dev/null +++ b/pkg/snapshotter/loopdevice_test.go @@ -0,0 +1,308 @@ +/* +Copyright © 2022 - 2023 SUSE LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package snapshotter_test + +import ( + "bytes" + "fmt" + "path/filepath" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + conf "github.com/rancher/elemental-toolkit/pkg/config" + "github.com/rancher/elemental-toolkit/pkg/constants" + v1mock "github.com/rancher/elemental-toolkit/pkg/mocks" + "github.com/rancher/elemental-toolkit/pkg/snapshotter" + v1 "github.com/rancher/elemental-toolkit/pkg/types/v1" + "github.com/rancher/elemental-toolkit/pkg/utils" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" +) + +var _ = Describe("LoopDevice", Label("snapshotter", "loopdevice"), func() { + var cfg v1.Config + var runner *v1mock.FakeRunner + var fs vfs.FS + var logger v1.Logger + var mounter *v1mock.FakeMounter + var cleanup func() + var bootloader *v1mock.FakeBootloader + var memLog *bytes.Buffer + var snapCfg v1.SnapshotterConfig + var rootDir string + + BeforeEach(func() { + rootDir = "/some/root" + runner = v1mock.NewFakeRunner() + mounter = v1mock.NewFakeMounter() + bootloader = &v1mock.FakeBootloader{} + memLog = &bytes.Buffer{} + logger = v1.NewBufferLogger(memLog) + logger.SetLevel(v1.DebugLevel()) + + var err error + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{}) + Expect(err).Should(BeNil()) + + cfg = *conf.NewConfig( + conf.WithFs(fs), + conf.WithRunner(runner), + conf.WithLogger(logger), + conf.WithMounter(mounter), + conf.WithPlatform("linux/amd64"), + ) + snapCfg = v1.SnapshotterConfig{ + Type: constants.LoopDeviceSnapshotterType, + MaxSnaps: constants.MaxSnaps, + Config: v1.NewLoopDeviceConfig(), + } + + Expect(utils.MkdirAll(fs, rootDir, constants.DirPerm)).To(Succeed()) + }) + + AfterEach(func() { + cleanup() + }) + + It("creates a new LoopDevice snapshotter instance", func() { + Expect(snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader)).Error().NotTo(HaveOccurred()) + + // Invalid snapshotter type + snapCfg.Type = "invalid" + Expect(snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader)).Error().To(HaveOccurred()) + + // Invalid snapshotter type + snapCfg.Type = constants.LoopDeviceSnapshotterType + snapCfg.Config = map[string]string{} + Expect(snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader)).Error().To(HaveOccurred()) + }) + + It("inits a snapshotter", func() { + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + Expect(utils.Exists(fs, filepath.Join(rootDir, ".snapshots"))).To(BeFalse()) + Expect(lp.InitSnapshotter(rootDir)).To(Succeed()) + Expect(utils.Exists(fs, filepath.Join(rootDir, ".snapshots"))).To(BeTrue()) + }) + + It("fails to init if it can't create working directories", func() { + cfg.Fs = vfs.NewReadOnlyFS(fs) + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + Expect(utils.Exists(fs, filepath.Join(rootDir, ".snapshots"))).To(BeFalse()) + Expect(lp.InitSnapshotter(rootDir)).NotTo(Succeed()) + Expect(utils.Exists(fs, filepath.Join(rootDir, ".snapshots"))).To(BeFalse()) + }) + + It("starts a transaction", func() { + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + Expect(lp.InitSnapshotter(rootDir)).To(Succeed()) + + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(1)) + Expect(snap.InProgress).To(BeTrue()) + Expect(snap.Path).To(Equal(filepath.Join(rootDir, ".snapshots/1/snapshot.img"))) + }) + + It("fails to start a transaction without being initiated first", func() { + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + Expect(lp.StartTransaction()).Error().To(HaveOccurred()) + }) + + It("fails to start a transaction if working directory bind mount fails", func() { + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + mounter.ErrorOnMount = true + + Expect(lp.InitSnapshotter(rootDir)).To(Succeed()) + Expect(lp.StartTransaction()).Error().To(HaveOccurred()) + }) + + It("fails to get available snapshots on a not initated system", func() { + lp, err := snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + + Expect(lp.GetSnapshots()).Error().To(HaveOccurred()) + }) + + Describe("using loopdevice on sixth snapshot", func() { + var err error + var lp *snapshotter.LoopDevice + var snapshotsPrefix string + + BeforeEach(func() { + var snapshotFile string + var i int + snapshotsPrefix = filepath.Join(rootDir, ".snapshots") + for i = 1; i < 6; i++ { + Expect(utils.MkdirAll(cfg.Fs, filepath.Join(rootDir, ".snapshots", strconv.Itoa(i)), constants.DirPerm)).To(Succeed()) + snapshotFile = filepath.Join(snapshotsPrefix, strconv.Itoa(i), "snapshot.img") + Expect(fs.WriteFile(snapshotFile, []byte(fmt.Sprintf("This is snapshot %d", i)), constants.FilePerm)).To(Succeed()) + } + Expect(fs.Symlink(filepath.Join(strconv.Itoa(5), "snapshot.img"), filepath.Join(snapshotsPrefix, constants.ActiveSnapshot))).To(Succeed()) + passivesPath := filepath.Join(snapshotsPrefix, "passives") + Expect(utils.MkdirAll(fs, passivesPath, constants.DirPerm)) + for i = 1; i < 5; i++ { + snapshotFile = filepath.Join("..", strconv.Itoa(i), "snapshot.img") + Expect(fs.Symlink(snapshotFile, filepath.Join(passivesPath, fmt.Sprintf(constants.PassiveSnapshot, i)))).To(Succeed()) + } + + runner.SideEffect = func(cmd string, args ...string) ([]byte, error) { + if cmd == "losetup" { + return []byte(".snapshots/5/snapshot.img"), nil + } + return []byte(""), nil + } + + lp, err = snapshotter.NewLoopDeviceSnapshotter(cfg, snapCfg, bootloader) + Expect(err).NotTo(HaveOccurred()) + Expect(lp.InitSnapshotter(rootDir)).To(Succeed()) + }) + + It("gets current snapshots", func() { + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + + It("starts a transaction with the expected snapshot values", func() { + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + }) + + It("fails to start a transaction if active snapshot can't be detected", func() { + // delete current active symlink and create a broken one + activeLink := filepath.Join(snapshotsPrefix, constants.ActiveSnapshot) + Expect(fs.Remove(activeLink)).To(Succeed()) + Expect(fs.Symlink("nonExistingFile", activeLink)).To(Succeed()) + + _, err = lp.StartTransaction() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("nonExistingFile: no such file or directory")) + }) + + It("closes a transaction on error with a nil snapshot", func() { + _, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(lp.CloseTransactionOnError(nil)).To(Succeed()) + }) + + It("closes a transaction on error", func() { + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(lp.CloseTransactionOnError(snap)).To(Succeed()) + }) + + It("closes a transaction on error and errors out umounting snapshot", func() { + mounter.ErrorOnUnmount = true + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(lp.CloseTransactionOnError(snap)).NotTo(Succeed()) + }) + + It("deletes a passiev snapshot", func() { + Expect(lp.DeleteSnapshot(4)).To(Succeed()) + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 5})) + }) + + It("fails to delete current snapshot", func() { + Expect(lp.DeleteSnapshot(5)).NotTo(Succeed()) + }) + + It("deletes nothing for non existing snapshots", func() { + Expect(lp.DeleteSnapshot(99)).To(Succeed()) + Expect(memLog.String()).To(ContainSubstring("nothing to delete")) + }) + + It("closes a started transaction and cleans old snapshots", func() { + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + Expect(lp.CloseTransaction(snap)).To(Succeed()) + Expect(lp.GetSnapshots()).To(Equal([]int{3, 4, 5, 6})) + }) + + It("closes a started transaction and cleans old snapshots up to current active", func() { + // Snapshot 2 is the current one + runner.SideEffect = func(cmd string, args ...string) ([]byte, error) { + if cmd == "losetup" { + return []byte(".snapshots/2/snapshot.img"), nil + } + return []byte(""), nil + } + + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + Expect(lp.CloseTransaction(snap)).To(Succeed()) + + // Could not delete 2 as it is in use and stopped cleaning + Expect(lp.GetSnapshots()).To(Equal([]int{2, 3, 4, 5, 6})) + }) + + It("closes and drops a started transaction if snapshot is not in progress", func() { + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + + snap.InProgress = false + Expect(lp.CloseTransaction(snap)).To(Succeed()) + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + + It("fails closing a transaction, can't unmount snapshot", func() { + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + + mounter.ErrorOnUnmount = true + + Expect(lp.CloseTransaction(snap)).NotTo(Succeed()) + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + + It("fails closing a transaction, can't create image from tree", func() { + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + snap, err := lp.StartTransaction() + Expect(err).NotTo(HaveOccurred()) + Expect(snap.ID).To(Equal(6)) + Expect(snap.InProgress).To(BeTrue()) + + snap.WorkDir = "nonExistingPath" + + Expect(lp.CloseTransaction(snap)).NotTo(Succeed()) + Expect(lp.GetSnapshots()).To(Equal([]int{1, 2, 3, 4, 5})) + }) + }) +}) diff --git a/pkg/snapshotter/snapshotter_suite_test.go b/pkg/snapshotter/snapshotter_suite_test.go new file mode 100644 index 00000000000..72c08a90426 --- /dev/null +++ b/pkg/snapshotter/snapshotter_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright © 2022 - 2023 SUSE LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package snapshotter_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "snapshotter test suite") +} diff --git a/pkg/types/v1/snapshotter.go b/pkg/types/v1/snapshotter.go index 976b2faafc3..d82b277a75c 100644 --- a/pkg/types/v1/snapshotter.go +++ b/pkg/types/v1/snapshotter.go @@ -29,6 +29,7 @@ type Snapshotter interface { CloseTransaction(snap *Snapshot) error CloseTransactionOnError(snap *Snapshot) error DeleteSnapshot(id int) error + GetSnapshots() ([]int, error) } type SnapshotterConfig struct { diff --git a/pkg/utils/common.go b/pkg/utils/common.go index 76b1a01ce44..6ba6a98299f 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -37,9 +37,6 @@ import ( v1 "github.com/rancher/elemental-toolkit/pkg/types/v1" ) -// Maxium number of nested symlinks to resolve -const maxLinkDepth = 4 - // BootedFrom will check if we are booting from the given label func BootedFrom(runner v1.Runner, label string) bool { out, _ := runner.Run("cat", "/proc/cmdline") @@ -425,7 +422,10 @@ func findFile(vfs v1.FS, rootDir, pattern string) (string, error) { return err } if match { - foundFile = ResolveLink(vfs, path, rootDir, d, maxLinkDepth) + foundFile, err = resolveLink(vfs, path, rootDir, d, constants.MaxLinkDepth) + if err != nil { + return err + } return io.EOF } return nil @@ -499,18 +499,21 @@ func getBaseDir(path string) string { } // resolveLink attempts to resolve a symlink, if any. Returns the original given path -// if not a symlink or if it can't be resolved. -func ResolveLink(vfs v1.FS, path string, rootDir string, d fs.DirEntry, depth int) string { +// if not a symlink. In case of error returns error and the original given path. +func resolveLink(vfs v1.FS, path string, rootDir string, d fs.DirEntry, depth int) (string, error) { var err error var resolved string var f fs.FileInfo f, err = d.Info() if err != nil { - return path + return path, err } - if f.Mode()&os.ModeSymlink == os.ModeSymlink && depth > 0 { + if f.Mode()&os.ModeSymlink == os.ModeSymlink { + if depth <= 0 { + return path, fmt.Errorf("can't resolve this path '%s', too many nested links", path) + } resolved, err = readlink(vfs, path) if err == nil { if !filepath.IsAbs(resolved) { @@ -519,11 +522,24 @@ func ResolveLink(vfs v1.FS, path string, rootDir string, d fs.DirEntry, depth in resolved = filepath.Join(rootDir, resolved) } if f, err = vfs.Lstat(resolved); err == nil { - return ResolveLink(vfs, resolved, rootDir, &statDirEntry{f}, depth-1) + return resolveLink(vfs, resolved, rootDir, &statDirEntry{f}, depth-1) } + return path, err } + return path, err } - return path + return path, nil +} + +// ResolveLink attempts to resolve a symlink, if any. Returns the original given path +// if not a symlink or if it can't be resolved. +func ResolveLink(vfs v1.FS, path string, rootDir string, depth int) (string, error) { + f, err := vfs.Lstat(path) + if err != nil { + return path, err + } + + return resolveLink(vfs, path, rootDir, &statDirEntry{f}, depth) } // CalcFileChecksum opens the given file and returns the sha256 checksum of it. diff --git a/pkg/utils/fs.go b/pkg/utils/fs.go index e587a033d6b..3d9aeb495e2 100644 --- a/pkg/utils/fs.go +++ b/pkg/utils/fs.go @@ -67,9 +67,15 @@ func DirSizeMB(fs v1.FS, path string) (uint, error) { return 0, fmt.Errorf("Negative size calculation: %d", sizeMB) } -// Check if a file or directory exists. -func Exists(fs v1.FS, path string) (bool, error) { - _, err := fs.Stat(path) +// Check if a file or directory exists. noFollow flag determines to +// not follow symlinks to check files existance. +func Exists(fs v1.FS, path string, noFollow ...bool) (bool, error) { + var err error + if len(noFollow) > 0 && noFollow[0] { + _, err = fs.Lstat(path) + } else { + _, err = fs.Stat(path) + } if err == nil { return true, nil } @@ -219,11 +225,6 @@ func (d *statDirEntry) IsDir() bool { return d.info.IsDir() } func (d *statDirEntry) Type() fs.FileMode { return d.info.Mode().Type() } func (d *statDirEntry) Info() (fs.FileInfo, error) { return d.info, nil } -// Return a DirEntry from a FileInfo -func DirEntryFromFileInfo(info fs.FileInfo) fs.DirEntry { - return &statDirEntry{info: info} -} - // WalkDirFs is the same as filepath.WalkDir but accepts a v1.Fs so it can be run on any v1.Fs type func WalkDirFs(fs v1.FS, root string, fn fs.WalkDirFunc) error { info, err := fs.Stat(root) diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 811a0efa19d..8a0f9bbc8d1 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -742,39 +742,33 @@ var _ = Describe("Utils", Label("utils"), func() { It("resolves a simple relative symlink", func() { systemPath := filepath.Join(rootDir, relSymlink) - f, err := fs.Lstat(systemPath) - Expect(err).To(BeNil()) - Expect(utils.ResolveLink(fs, systemPath, rootDir, utils.DirEntryFromFileInfo(f), 4)).To(Equal(filepath.Join(rootDir, file))) + Expect(utils.ResolveLink(fs, systemPath, rootDir, constants.MaxLinkDepth)).To(Equal(filepath.Join(rootDir, file))) }) It("resolves a simple absolute symlink", func() { systemPath := filepath.Join(rootDir, absSymlink) - f, err := fs.Lstat(systemPath) - Expect(err).To(BeNil()) - Expect(utils.ResolveLink(fs, systemPath, rootDir, utils.DirEntryFromFileInfo(f), 4)).To(Equal(filepath.Join(rootDir, file))) + Expect(utils.ResolveLink(fs, systemPath, rootDir, constants.MaxLinkDepth)).To(Equal(filepath.Join(rootDir, file))) }) It("resolves some nested symlinks", func() { systemPath := filepath.Join(rootDir, nestSymlink) - f, err := fs.Lstat(systemPath) - Expect(err).To(BeNil()) - Expect(utils.ResolveLink(fs, systemPath, rootDir, utils.DirEntryFromFileInfo(f), 4)).To(Equal(filepath.Join(rootDir, file))) + Expect(utils.ResolveLink(fs, systemPath, rootDir, constants.MaxLinkDepth)).To(Equal(filepath.Join(rootDir, file))) }) It("does not resolve broken links", func() { systemPath := filepath.Join(rootDir, brokenSymlink) - f, err := fs.Lstat(systemPath) - Expect(err).To(BeNil()) // Return the symlink path without resolving it - Expect(utils.ResolveLink(fs, systemPath, rootDir, utils.DirEntryFromFileInfo(f), 4)).To(Equal(systemPath)) + resolved, err := utils.ResolveLink(fs, systemPath, rootDir, constants.MaxLinkDepth) + Expect(resolved).To(Equal(systemPath)) + Expect(err).To(HaveOccurred()) }) It("does not resolve too many levels of netsed links", func() { systemPath := filepath.Join(rootDir, nestSymlink) - f, err := fs.Lstat(systemPath) - Expect(err).To(BeNil()) // Returns the symlink resolution up to the second level - Expect(utils.ResolveLink(fs, systemPath, rootDir, utils.DirEntryFromFileInfo(f), 2)).To(Equal(filepath.Join(rootDir, "/path/to/nest2nd"))) + resolved, err := utils.ResolveLink(fs, systemPath, rootDir, 2) + Expect(resolved).To(Equal(filepath.Join(rootDir, "/path/to/nest2nd"))) + Expect(err).To(HaveOccurred()) }) }) Describe("FindFile", func() {