diff --git a/client/container_opts.go b/client/container_opts.go index 04f2a9062114e..5298e4f478fcb 100644 --- a/client/container_opts.go +++ b/client/container_opts.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/containerd/containerd/v2/core/containers" "github.com/containerd/containerd/v2/core/content" @@ -52,6 +53,11 @@ type InfoConfig struct { Refresh bool } +const ( + devboxAnnotationPrefix = "devbox.sealos.io/" + devboxSnapshotLabelPrefix = "containerd.io/snapshot/devbox-" +) + // WithRuntime allows a user to specify the runtime name and additional options that should // be used to create tasks for the container func WithRuntime(name string, options interface{}) NewContainerOpts { @@ -255,10 +261,15 @@ func withNewSnapshot(id string, i Image, readonly bool, opts ...snapshots.Opt) N return err } + startOpts, err := withDevboxSnapshotLabels(opts...) + if err != nil { + return err + } + if readonly { - _, err = s.View(ctx, id, parent, opts...) + _, err = s.View(ctx, id, parent, startOpts...) } else { - _, err = s.Prepare(ctx, id, parent, opts...) + _, err = s.Prepare(ctx, id, parent, startOpts...) } if err != nil { return err @@ -269,6 +280,30 @@ func withNewSnapshot(id string, i Image, readonly bool, opts ...snapshots.Opt) N } } +func withDevboxSnapshotLabels(opts ...snapshots.Opt) ([]snapshots.Opt, error) { + base := snapshots.Info{} + for _, opt := range opts { + if err := opt(&base); err != nil { + return nil, fmt.Errorf("error applying snapshot option: %w", err) + } + } + + translated := make(map[string]string) + for label, value := range base.Labels { + if strings.HasPrefix(label, devboxAnnotationPrefix) { + translated[devboxSnapshotLabelPrefix+label[len(devboxAnnotationPrefix):]] = value + } + } + if len(translated) == 0 { + return opts, nil + } + + startOpts := make([]snapshots.Opt, 0, len(opts)+1) + startOpts = append(startOpts, snapshots.WithLabels(translated)) + startOpts = append(startOpts, opts...) + return startOpts, nil +} + // WithContainerExtension appends extension data to the container object. // Use this to decorate the container object with additional data for the client // integration. diff --git a/internal/cri/devboxsnapshotter/labels.go b/internal/cri/devboxsnapshotter/labels.go new file mode 100644 index 0000000000000..c0fe1cfae077a --- /dev/null +++ b/internal/cri/devboxsnapshotter/labels.go @@ -0,0 +1,52 @@ +/* + Copyright The containerd Authors. + + 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 devboxsnapshotter + +const ( + DevboxSnapshotter = "devbox" + StargzSnapshotter = "stargz" + SealosDevboxContentIDAnnotation = "devbox.sealos.io/content-id" + SealosDevboxStorageLimitAnnotation = "devbox.sealos.io/storage-limit" +) + +func LabelsFromAnnotations(annotations map[string]string) map[string]string { + if len(annotations) == 0 { + return nil + } + + labels := make(map[string]string) + if contentID := annotations[SealosDevboxContentIDAnnotation]; contentID != "" { + labels[SealosDevboxContentIDAnnotation] = contentID + } + if storageLimit := annotations[SealosDevboxStorageLimitAnnotation]; storageLimit != "" { + labels[SealosDevboxStorageLimitAnnotation] = storageLimit + } + if len(labels) == 0 { + return nil + } + + return labels +} + +func IsWritableSnapshotter(name string) bool { + switch name { + case DevboxSnapshotter, StargzSnapshotter: + return true + default: + return false + } +} diff --git a/internal/cri/devboxsnapshotter/labels_test.go b/internal/cri/devboxsnapshotter/labels_test.go new file mode 100644 index 0000000000000..50963abb7f30e --- /dev/null +++ b/internal/cri/devboxsnapshotter/labels_test.go @@ -0,0 +1,41 @@ +package devboxsnapshotter + +import "testing" + +func TestLabelsFromAnnotations(t *testing.T) { + labels := LabelsFromAnnotations(map[string]string{ + SealosDevboxContentIDAnnotation: "workspace-1", + SealosDevboxStorageLimitAnnotation: "20Gi", + "other.annotation": "ignored", + }) + + if got := labels[SealosDevboxContentIDAnnotation]; got != "workspace-1" { + t.Fatalf("content-id label = %q, want %q", got, "workspace-1") + } + if got := labels[SealosDevboxStorageLimitAnnotation]; got != "20Gi" { + t.Fatalf("storage-limit label = %q, want %q", got, "20Gi") + } + if _, ok := labels["other.annotation"]; ok { + t.Fatalf("unexpected non-devbox label preserved: %+v", labels) + } +} + +func TestIsWritableSnapshotter(t *testing.T) { + tests := []struct { + name string + snapshotter string + want bool + }{ + {name: "devbox", snapshotter: DevboxSnapshotter, want: true}, + {name: "stargz", snapshotter: StargzSnapshotter, want: true}, + {name: "overlayfs", snapshotter: "overlayfs", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsWritableSnapshotter(tt.snapshotter); got != tt.want { + t.Fatalf("IsWritableSnapshotter(%q) = %v, want %v", tt.snapshotter, got, tt.want) + } + }) + } +} diff --git a/internal/cri/server/container_create.go b/internal/cri/server/container_create.go index 4d7a7c80a3c10..eb51dbf37a4ef 100644 --- a/internal/cri/server/container_create.go +++ b/internal/cri/server/container_create.go @@ -40,6 +40,7 @@ import ( "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/containerd/v2/internal/cri/annotations" criconfig "github.com/containerd/containerd/v2/internal/cri/config" + "github.com/containerd/containerd/v2/internal/cri/devboxsnapshotter" cio "github.com/containerd/containerd/v2/internal/cri/io" crilabels "github.com/containerd/containerd/v2/internal/cri/labels" customopts "github.com/containerd/containerd/v2/internal/cri/opts" @@ -58,12 +59,15 @@ func init() { } func devboxSnapshotterOpts(config *runtime.PodSandboxConfig) (snapshots.Opt, error) { - labels := make(map[string]string) - if config != nil { - for k, v := range config.Annotations { - labels[k] = v - } + if config == nil { + return nil, nil + } + + labels := devboxsnapshotter.LabelsFromAnnotations(config.Annotations) + if len(labels) == 0 { + return nil, nil } + return snapshots.WithLabels(labels), nil } @@ -360,8 +364,8 @@ func (c *criService) createContainer(r *createContainerRequest) (_ string, retEr return "", err } - // Check if the snapshotter is devbox and add the devbox snapshotter opts. - if c.RuntimeSnapshotter(r.ctx, ociRuntime) == "devbox" { + runtimeSnapshotter := c.RuntimeSnapshotter(r.ctx, ociRuntime) + if devboxsnapshotter.IsWritableSnapshotter(runtimeSnapshotter) { devboxOpt, err := devboxSnapshotterOpts(r.podSandboxConfig) if err != nil { return "", err @@ -373,7 +377,7 @@ func (c *criService) createContainer(r *createContainerRequest) (_ string, retEr // Set snapshotter before any other options. opts := []containerd.NewContainerOpts{ - containerd.WithSnapshotter(c.RuntimeSnapshotter(r.ctx, ociRuntime)), + containerd.WithSnapshotter(runtimeSnapshotter), // Prepare container rootfs. This is always writeable even if // the container wants a readonly rootfs since we want to give // the runtime (runc) a chance to modify (e.g. to create mount diff --git a/internal/cri/server/container_stop.go b/internal/cri/server/container_stop.go index 0f4e2924e9eff..4a715505ca72b 100644 --- a/internal/cri/server/container_stop.go +++ b/internal/cri/server/container_stop.go @@ -24,6 +24,7 @@ import ( "time" eventtypes "github.com/containerd/containerd/api/events" + "github.com/containerd/containerd/v2/internal/cri/devboxsnapshotter" containerstore "github.com/containerd/containerd/v2/internal/cri/store/container" ctrdutil "github.com/containerd/containerd/v2/internal/cri/util" "github.com/containerd/containerd/v2/pkg/protobuf" @@ -85,8 +86,7 @@ func (c *criService) StopContainer(ctx context.Context, r *runtime.StopContainer log.G(ctx).Infof("Check snapshotter: %s", snapshotter) - // Check if the snapshotter is devbox and update the devbox snapshot. - if snapshotter == "devbox" { + if devboxsnapshotter.IsWritableSnapshotter(snapshotter) { err = c.client.UpdateDevboxSnapshot(ctx, snapshotter, i.ID, unmountLvm, "true") if err != nil { log.G(ctx).WithError(err).Errorf("Failed to update devbox snapshot: %s", err) diff --git a/internal/cri/server/devbox_snapshotter_test.go b/internal/cri/server/devbox_snapshotter_test.go new file mode 100644 index 0000000000000..fb70ac1836715 --- /dev/null +++ b/internal/cri/server/devbox_snapshotter_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "testing" + + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/internal/cri/devboxsnapshotter" + runtime "k8s.io/cri-api/pkg/apis/runtime/v1" +) + +func TestDevboxSnapshotterOpts(t *testing.T) { + opt, err := devboxSnapshotterOpts(&runtime.PodSandboxConfig{ + Annotations: map[string]string{ + devboxsnapshotter.SealosDevboxContentIDAnnotation: "workspace-9", + devboxsnapshotter.SealosDevboxStorageLimitAnnotation: "8Gi", + "other.annotation": "ignored", + }, + }) + if err != nil { + t.Fatalf("devboxSnapshotterOpts() error = %v", err) + } + if opt == nil { + t.Fatal("devboxSnapshotterOpts() returned nil opt") + } + + info := &snapshots.Info{Labels: make(map[string]string)} + if err := opt(info); err != nil { + t.Fatalf("applying snapshot opt error = %v", err) + } + + if got := info.Labels[devboxsnapshotter.SealosDevboxContentIDAnnotation]; got != "workspace-9" { + t.Fatalf("content-id label = %q, want %q", got, "workspace-9") + } + if got := info.Labels[devboxsnapshotter.SealosDevboxStorageLimitAnnotation]; got != "8Gi" { + t.Fatalf("storage-limit label = %q, want %q", got, "8Gi") + } + if _, ok := info.Labels["other.annotation"]; ok { + t.Fatalf("unexpected unrelated annotation preserved: %+v", info.Labels) + } +} diff --git a/internal/cri/server/events.go b/internal/cri/server/events.go index 6c8fb2ba9b213..68f13d86be6f3 100644 --- a/internal/cri/server/events.go +++ b/internal/cri/server/events.go @@ -30,6 +30,7 @@ import ( apitasks "github.com/containerd/containerd/api/services/tasks/v1" containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/internal/cri/devboxsnapshotter" containerstore "github.com/containerd/containerd/v2/internal/cri/store/container" sandboxstore "github.com/containerd/containerd/v2/internal/cri/store/sandbox" ctrdutil "github.com/containerd/containerd/v2/internal/cri/util" @@ -251,7 +252,7 @@ func (c *criService) handleContainerExit(ctx context.Context, e *eventtypes.Task if err != nil { return status, err } - if container.Snapshotter == "devbox" { + if devboxsnapshotter.IsWritableSnapshotter(container.Snapshotter) { if err := c.client.UpdateDevboxSnapshot(ctx, container.Snapshotter, container.ID, unmountLvm, "true"); err != nil { log.G(ctx).WithError(err).Errorf("failed to update devbox snapshot for container %s", cntr.Container.ID()) } diff --git a/internal/cri/server/images/image_pull.go b/internal/cri/server/images/image_pull.go index dc031ac73bd8f..365ba3e8a0c7f 100644 --- a/internal/cri/server/images/image_pull.go +++ b/internal/cri/server/images/image_pull.go @@ -50,6 +50,7 @@ import ( "github.com/containerd/containerd/v2/core/transfer/registry" "github.com/containerd/containerd/v2/internal/cri/annotations" criconfig "github.com/containerd/containerd/v2/internal/cri/config" + "github.com/containerd/containerd/v2/internal/cri/devboxsnapshotter" crilabels "github.com/containerd/containerd/v2/internal/cri/labels" "github.com/containerd/containerd/v2/internal/cri/util" snpkg "github.com/containerd/containerd/v2/pkg/snapshotters" @@ -206,7 +207,7 @@ func (c *CRIImageService) PullImage(ctx context.Context, name string, credential } var imageLabels map[string]string - if r == repoTag && snapshotter == "devbox" { + if r == repoTag && devboxsnapshotter.IsWritableSnapshotter(snapshotter) { imageLabels = make(map[string]string) for k, v := range labels { imageLabels[k] = v diff --git a/plugins/snapshots/devbox/devbox.go b/plugins/snapshots/devbox/devbox.go index 0fb57e22cb68a..01ad0a9eb36e1 100644 --- a/plugins/snapshots/devbox/devbox.go +++ b/plugins/snapshots/devbox/devbox.go @@ -137,6 +137,39 @@ type Snapshotter struct { options []string } +type devboxLVMPlan struct { + reuseExisting bool + resizeExisting bool + createNew bool + contentID string + useLimit string + existingLVName string +} + +func planDevboxLVM( + contentID, useLimit, existingLVName string, + contentIDProvided, storageLimitProvided bool, +) devboxLVMPlan { + plan := devboxLVMPlan{ + contentID: strings.TrimSpace(contentID), + useLimit: strings.TrimSpace(useLimit), + existingLVName: strings.TrimSpace(existingLVName), + } + + if !contentIDProvided || plan.contentID == "" { + return plan + } + if plan.existingLVName != "" { + plan.reuseExisting = true + plan.resizeExisting = storageLimitProvided && plan.useLimit != "" + return plan + } + if storageLimitProvided && plan.useLimit != "" { + plan.createNew = true + } + return plan +} + // NewSnapshotter returns a Snapshotter which uses overlayfs. The overlayfs // diffs are stored under the provided root. A metadata file is stored under // the root. @@ -233,6 +266,9 @@ func (o *Snapshotter) Update(ctx context.Context, info snapshots.Info, fieldpath if err != nil { return fmt.Errorf("failed to set devbox content status to unmounted: %w", err) } + if mountPath == "" { + return nil + } return o.unmountLvm(ctx, mountPath) } @@ -368,8 +404,8 @@ func (o *Snapshotter) RemoveDir(ctx context.Context, dir string) { func (o *Snapshotter) Remove(ctx context.Context, key string) (err error) { var ( - removals []string - removedLvNames []string + removals []string + removedContents []storage.RemovedDevboxContent ) log.G(ctx).WithFields(logrus.Fields{ @@ -381,13 +417,28 @@ func (o *Snapshotter) Remove(ctx context.Context, key string) (err error) { for _, dir := range removals { o.RemoveDir(ctx, dir) } - for _, lvName := range removedLvNames { - err := o.removeLv(ctx, lvName) + for _, content := range removedContents { + err := o.removeLv(ctx, content.LVName) if err != nil { - log.G(ctx).WithError(err).WithField("lvName", lvName).Warn("Remove: failed to destroy LVM logical volume") + log.G(ctx).WithError(err).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Warn("Remove: failed to destroy LVM logical volume") + if !isLVNotFoundError(err) { + continue + } + } + if cleanupErr := o.deleteDevboxContent(ctx, content.ContentID); cleanupErr != nil { + log.G(ctx).WithError(cleanupErr).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Warn("Remove: failed to delete devbox content metadata") continue } - log.G(ctx).Infof("Remove: LVM logical volume %s removed successfully", lvName) + log.G(ctx).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Info("Remove: devbox content cleanup completed") } } }() @@ -423,9 +474,9 @@ func (o *Snapshotter) Remove(ctx context.Context, key string) (err error) { if err != nil { return fmt.Errorf("unable to get directories for removal: %w", err) } - removedLvNames, err = o.getCleanupLvNames(ctx) + removedContents, err = o.getCleanupRemovedContents(ctx) if err != nil { - return fmt.Errorf("failed to get LVM logical volume names for snapshot %s: %w", key, err) + return fmt.Errorf("failed to get removable devbox contents for snapshot %s: %w", key, err) } } return nil @@ -455,7 +506,7 @@ func (o *Snapshotter) Walk(ctx context.Context, fn snapshots.WalkFunc, fs ...str // Cleanup cleans up disk resources from removed or abandoned snapshots. func (o *Snapshotter) Cleanup(ctx context.Context) error { log.G(ctx).Info("Cleanup called") - cleanup, cleanupLv, err := o.cleanupDirectories(ctx) + cleanup, removedContents, err := o.cleanupDirectories(ctx) if err != nil { return err } @@ -464,29 +515,43 @@ func (o *Snapshotter) Cleanup(ctx context.Context) error { o.RemoveDir(ctx, dir) } - for _, lvName := range cleanupLv { - - if err := o.removeLv(ctx, lvName); err != nil { - log.G(ctx).WithError(err).WithField("lvName", lvName).Warn("Cleanup: failed to destroy LVM logical volume") + for _, content := range removedContents { + if err := o.removeLv(ctx, content.LVName); err != nil { + log.G(ctx).WithError(err).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Warn("Cleanup: failed to destroy LVM logical volume") + if !isLVNotFoundError(err) { + continue + } + } + if err := o.deleteDevboxContent(ctx, content.ContentID); err != nil { + log.G(ctx).WithError(err).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Warn("Cleanup: failed to delete devbox content metadata") continue } - log.G(ctx).Infof("Cleanup: LVM logical volume %s removed successfully", lvName) + log.G(ctx).WithFields(logrus.Fields{ + "lvName": content.LVName, + "contentID": content.ContentID, + }).Info("Cleanup: devbox content cleanup completed") } return nil } -func (o *Snapshotter) cleanupDirectories(ctx context.Context) (_ []string, _ []string, err error) { +func (o *Snapshotter) cleanupDirectories(ctx context.Context) (_ []string, _ []storage.RemovedDevboxContent, err error) { var ( - cleanupDirs []string - removedLvNames []string + cleanupDirs []string + removedContents []storage.RemovedDevboxContent ) if err = o.ms.WithTransaction(ctx, true, func(ctx context.Context) error { cleanupDirs, err = o.getCleanupDirectories(ctx) if err != nil { return err } - removedLvNames, err = o.getCleanupLvNames(ctx) + removedContents, err = o.getCleanupRemovedContents(ctx) if err != nil { return err } @@ -496,26 +561,26 @@ func (o *Snapshotter) cleanupDirectories(ctx context.Context) (_ []string, _ []s } // Unmount any mounted LVs - for _, lvName := range removedLvNames { - devicePath := fmt.Sprintf("/dev/%s/%s", o.lvmVgName, lvName) + for _, content := range removedContents { + devicePath := o.devicePath(content.LVName) mountPoints, err := findMountPointByDevice(devicePath) if err != nil { - log.G(ctx).WithError(err).WithField("lvName", lvName).WithField("devicePath", devicePath). + log.G(ctx).WithError(err).WithField("lvName", content.LVName).WithField("devicePath", devicePath). Warn("Cleanup: failed to find mount point for LV, continuing") continue } for _, mountPoint := range mountPoints { if err := o.unmountLvm(ctx, mountPoint); err != nil { - log.G(ctx).WithError(err).WithField("lvName", lvName).WithField("mountPoint", mountPoint). + log.G(ctx).WithError(err).WithField("lvName", content.LVName).WithField("mountPoint", mountPoint). Warn("Cleanup: failed to unmount LV, will retry on next cleanup") // Continue to try to unmount other mount points } else { - log.G(ctx).Infof("Cleanup: successfully unmounted LV %s from %s", lvName, mountPoint) + log.G(ctx).Infof("Cleanup: successfully unmounted LV %s from %s", content.LVName, mountPoint) } } } - return cleanupDirs, removedLvNames, nil + return cleanupDirs, removedContents, nil } func (o *Snapshotter) getCleanupDirectories(ctx context.Context) ([]string, error) { @@ -547,28 +612,27 @@ func (o *Snapshotter) getCleanupDirectories(ctx context.Context) ([]string, erro return cleanup, nil } -func (o *Snapshotter) getCleanupLvNames(ctx context.Context) ([]string, error) { - nameMap, err := storage.GetDevboxLvNames(ctx) - if err != nil { - return nil, err - } +func (o *Snapshotter) getCleanupRemovedContents(ctx context.Context) ([]storage.RemovedDevboxContent, error) { + return storage.GetRemovedDevboxContents(ctx) +} - lvs, err := lvm.ListLVMLogicalVolumeByVG(ctx, o.lvmVgName, o.ThinPoolName) - if err != nil { - return nil, fmt.Errorf("failed to list LVM logical volumes: %w", err) - } +func (o *Snapshotter) devicePath(lvName string) string { + return fmt.Sprintf("/dev/%s/%s", o.lvmVgName, lvName) +} - cleanup := []string{} - for _, d := range lvs { - if _, ok := nameMap[d.Name]; ok { - continue - } - if strings.HasPrefix(d.Name, "devbox") { - cleanup = append(cleanup, d.Name) - } - } +func (o *Snapshotter) deleteDevboxContent(ctx context.Context, contentID string) error { + return o.ms.WithTransaction(ctx, true, func(ctx context.Context) error { + return storage.DeleteDevboxContent(ctx, contentID) + }) +} - return cleanup, nil +func isLVNotFoundError(err error) bool { + if err == nil { + return false + } + errMsg := err.Error() + return strings.Contains(errMsg, "not found in volume group") || + strings.Contains(errMsg, "Failed to find logical volume") } func (o *Snapshotter) resizeLVMVolume(ctx context.Context, lvName, useLimit string) error { @@ -779,35 +843,43 @@ func (o *Snapshotter) createSnapshot(ctx context.Context, kind snapshots.Kind, k npath = filepath.Join(snapshotDir, s.ID) - if idOk && limitOk { + var plan devboxLVMPlan + if idOk { var notExistErr error lvName, notExistErr = storage.GetDevboxLvName(ctx, contentID, "") - if notExistErr == nil && lvName != "" { - var isMounted bool - if isMounted, err = isMountPoint(npath); err != nil { - return fmt.Errorf("failed to check if path is a mount point: %w", err) - } else if isMounted { - log.G(ctx).Infof("Path %s is already mounted, skipping mount", npath) - } else { - if err = o.resizeLVMVolume(ctx, lvName, useLimit); err != nil { - return fmt.Errorf("failed to resize LVM logical volume %s: %w", lvName, err) - } + if notExistErr != nil && notExistErr != errdefs.ErrNotFound { + return fmt.Errorf("failed to get LVM logical volume name for key %s: %w", contentID, notExistErr) + } + plan = planDevboxLVM(contentID, useLimit, lvName, idOk, limitOk) + } - if err = storage.SetDevboxContent(ctx, key, contentID, lvName, npath); err != nil { - return fmt.Errorf("failed to set devbox content: %w", err) + if plan.reuseExisting { + var isMounted bool + if isMounted, err = isMountPoint(npath); err != nil { + return fmt.Errorf("failed to check if path is a mount point: %w", err) + } else if isMounted { + log.G(ctx).Infof("Path %s is already mounted, skipping mount", npath) + } else { + if plan.resizeExisting { + if err = o.resizeLVMVolume(ctx, plan.existingLVName, plan.useLimit); err != nil { + return fmt.Errorf("failed to resize LVM logical volume %s: %w", plan.existingLVName, err) } + } - if err = o.mountLvm(ctx, lvName, npath); err != nil { - return fmt.Errorf("failed to mount LVM logical volume %s: %w", lvName, err) - } - path = npath + if err = storage.SetDevboxContent(ctx, key, plan.contentID, plan.existingLVName, npath); err != nil { + return fmt.Errorf("failed to set devbox content: %w", err) } - return nil - } else if notExistErr != errdefs.ErrNotFound { - return fmt.Errorf("failed to get LVM logical volume name for key %s: %w", contentID, notExistErr) + + if err = o.mountLvm(ctx, plan.existingLVName, npath); err != nil { + return fmt.Errorf("failed to mount LVM logical volume %s: %w", plan.existingLVName, err) + } + path = npath } + return nil + } - td, lvName, err = o.prepareLvmDirectory(ctx, snapshotDir, contentID, useLimit) + if plan.createNew { + td, lvName, err = o.prepareLvmDirectory(ctx, snapshotDir, plan.contentID, plan.useLimit) defer func() { if err != nil { mountPath, err := storage.RemoveDevbox(ctx, key) @@ -868,7 +940,7 @@ func (o *Snapshotter) createSnapshot(ctx context.Context, kind snapshots.Kind, k } } - if idOk && limitOk { + if plan.createNew { err = o.unmountLvm(ctx, td) if err != nil { return fmt.Errorf("failed to unmount LVM logical volume %s: %w", lvName, err) @@ -995,13 +1067,14 @@ func (o *Snapshotter) prepareLvmDirectory(ctx context.Context, snapshotDir strin ThinProvision: o.ThinPoolName, }, } - // Track mount status for cleanup - // Track mount status for cleanup + // Track whether this helper created and mounted the LV so cleanup + // won't tear down pre-existing volumes on unrelated errors. + lvCreated := false mounted := false // Defer cleanup: unmount and force remove LV if any step fails defer func() { - if err != nil { + if err != nil && lvCreated { if mounted { // Unmount first if mounted if unmountErr := o.unmountLvm(ctx, td); unmountErr != nil { @@ -1020,6 +1093,7 @@ func (o *Snapshotter) prepareLvmDirectory(ctx context.Context, snapshotDir strin if err != nil { return td, lvName, fmt.Errorf("failed to create LVM logical volume %s: %w", lvName, err) } + lvCreated = true if err = o.mkfs(lvName); err != nil { return td, lvName, fmt.Errorf("failed to create filesystem on LVM logical volume %s: %w", lvName, err) } diff --git a/plugins/snapshots/devbox/devbox_test.go b/plugins/snapshots/devbox/devbox_test.go new file mode 100644 index 0000000000000..ba34adcd18c51 --- /dev/null +++ b/plugins/snapshots/devbox/devbox_test.go @@ -0,0 +1,86 @@ +//go:build linux + +package devbox + +import "testing" + +func TestPlanDevboxLVM(t *testing.T) { + tests := []struct { + name string + contentID string + useLimit string + existingLVName string + contentIDProvided bool + storageLimitProvided bool + wantReuseExisting bool + wantResizeExisting bool + wantCreateNew bool + }{ + { + name: "reuse existing lv without storage limit", + contentID: "content-1", + existingLVName: "devbox-content-1", + contentIDProvided: true, + storageLimitProvided: false, + wantReuseExisting: true, + }, + { + name: "reuse and resize existing lv when storage limit present", + contentID: "content-1", + useLimit: "20Gi", + existingLVName: "devbox-content-1", + contentIDProvided: true, + storageLimitProvided: true, + wantReuseExisting: true, + wantResizeExisting: true, + }, + { + name: "create new lv when content id and storage limit are present", + contentID: "content-1", + useLimit: "20Gi", + contentIDProvided: true, + storageLimitProvided: true, + wantCreateNew: true, + }, + { + name: "fall back when content id exists but no lv or storage limit", + contentID: "content-1", + contentIDProvided: true, + }, + { + name: "ignore storage limit without content id", + useLimit: "20Gi", + storageLimitProvided: true, + }, + { + name: "trim whitespace before planning", + contentID: " content-1 ", + existingLVName: " devbox-content-1 ", + contentIDProvided: true, + storageLimitProvided: false, + wantReuseExisting: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := planDevboxLVM( + tt.contentID, + tt.useLimit, + tt.existingLVName, + tt.contentIDProvided, + tt.storageLimitProvided, + ) + + if plan.reuseExisting != tt.wantReuseExisting { + t.Fatalf("reuseExisting = %v, want %v", plan.reuseExisting, tt.wantReuseExisting) + } + if plan.resizeExisting != tt.wantResizeExisting { + t.Fatalf("resizeExisting = %v, want %v", plan.resizeExisting, tt.wantResizeExisting) + } + if plan.createNew != tt.wantCreateNew { + t.Fatalf("createNew = %v, want %v", plan.createNew, tt.wantCreateNew) + } + }) + } +} diff --git a/plugins/snapshots/devbox/lvm/lvm.go b/plugins/snapshots/devbox/lvm/lvm.go index 9c75c40df63e0..9057a331e8212 100644 --- a/plugins/snapshots/devbox/lvm/lvm.go +++ b/plugins/snapshots/devbox/lvm/lvm.go @@ -139,6 +139,10 @@ func NewExecError(output []byte, err error) error { // buildLVMCreateArgs returns lvcreate arguments for the volume. func buildLVMCreateArgs(ctx context.Context, vol *apis.LVMVolume) []string { + return buildLVMCreateArgsWithThinPool(ctx, vol, lvThinExists(ctx, vol.Spec.VolGroup, vol.Spec.ThinProvision)) +} + +func buildLVMCreateArgsWithThinPool(ctx context.Context, vol *apis.LVMVolume, thinPoolExists bool) []string { var args []string volume := vol.Name @@ -148,7 +152,7 @@ func buildLVMCreateArgs(ctx context.Context, vol *apis.LVMVolume) []string { if len(vol.Spec.Capacity) != 0 { if strings.TrimSpace(vol.Spec.ThinProvision) == "" { args = append(args, "-L", size) - } else if !lvThinExists(ctx, vol.Spec.VolGroup, pool) { + } else if !thinPoolExists { args = append(args, "-L", getThinPoolSize(ctx, vol.Spec.VolGroup, vol.Spec.Capacity)) } } @@ -158,7 +162,10 @@ func buildLVMCreateArgs(ctx context.Context, vol *apis.LVMVolume) []string { } args = append(args, "-n", volume) - args = append(args, vol.Spec.VolGroup) + if strings.TrimSpace(vol.Spec.ThinProvision) == "" { + args = append(args, vol.Spec.VolGroup) + } + args = append(args, "-y") return args } @@ -320,17 +327,14 @@ func lvThinExists(ctx context.Context, vgName, thinPoolName string) bool { if strings.TrimSpace(thinPoolName) == "" { return false } - lvs, err := ListLVMLogicalVolumeByVG(ctx, vgName, "") + + output, _, err := RunCommandSplit(ctx, LVList, vgName+"/"+thinPoolName, "--noheadings", "-o", LVName) if err != nil { - klog.Warningf("failed to list lvm logical volumes for vg %q: %v", vgName, err) + klog.Warningf("failed to check thin pool %q in vg %q: %v", thinPoolName, vgName, err) return false } - for _, lv := range lvs { - if lv.Name == thinPoolName && lv.SegType == LVThinPool { - return true - } - } - return false + + return strings.TrimSpace(string(output)) == thinPoolName } func getThinPoolSize(ctx context.Context, vgName, requested string) string { diff --git a/plugins/snapshots/devbox/lvm/lvm_test.go b/plugins/snapshots/devbox/lvm/lvm_test.go new file mode 100644 index 0000000000000..2bad22967f5bb --- /dev/null +++ b/plugins/snapshots/devbox/lvm/lvm_test.go @@ -0,0 +1,95 @@ +//go:build linux + +package lvm + +import ( + "context" + "testing" + + apis "github.com/openebs/lvm-localpv/pkg/apis/openebs.io/lvm/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuildLVMCreateArgsThinVolume(t *testing.T) { + vol := &apis.LVMVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "devbox-devboxtemplatepod"}, + Spec: apis.VolumeInfo{ + Capacity: "10737418240", + VolGroup: "devbox-vg", + ThinProvision: "devbox-vg-thinpool", + }, + } + + args := buildLVMCreateArgsWithThinPool(context.Background(), vol, true) + expected := []string{ + "-T", "devbox-vg/devbox-vg-thinpool", + "-V", "10737418240b", + "-n", "devbox-devboxtemplatepod", + "-y", + } + + if len(args) != len(expected) { + t.Fatalf("args len = %d, want %d, args=%v", len(args), len(expected), args) + } + for i := range expected { + if args[i] != expected[i] { + t.Fatalf("args[%d] = %q, want %q, full args=%v", i, args[i], expected[i], args) + } + } +} + +func TestBuildLVMCreateArgsThinVolumeCreatesPool(t *testing.T) { + vol := &apis.LVMVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "devbox-devboxtemplatepod"}, + Spec: apis.VolumeInfo{ + Capacity: "10737418240", + VolGroup: "devbox-vg", + ThinProvision: "devbox-vg-thinpool", + }, + } + + args := buildLVMCreateArgsWithThinPool(context.Background(), vol, false) + expected := []string{ + "-L", "10737418240b", + "-T", "devbox-vg/devbox-vg-thinpool", + "-V", "10737418240b", + "-n", "devbox-devboxtemplatepod", + "-y", + } + + if len(args) != len(expected) { + t.Fatalf("args len = %d, want %d, args=%v", len(args), len(expected), args) + } + for i := range expected { + if args[i] != expected[i] { + t.Fatalf("args[%d] = %q, want %q, full args=%v", i, args[i], expected[i], args) + } + } +} + +func TestBuildLVMCreateArgsLinearVolume(t *testing.T) { + vol := &apis.LVMVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "devbox-linear"}, + Spec: apis.VolumeInfo{ + Capacity: "1073741824", + VolGroup: "devbox-vg", + }, + } + + args := buildLVMCreateArgsWithThinPool(context.Background(), vol, false) + expected := []string{ + "-L", "1073741824b", + "-n", "devbox-linear", + "devbox-vg", + "-y", + } + + if len(args) != len(expected) { + t.Fatalf("args len = %d, want %d, args=%v", len(args), len(expected), args) + } + for i := range expected { + if args[i] != expected[i] { + t.Fatalf("args[%d] = %q, want %q, full args=%v", i, args[i], expected[i], args) + } + } +} diff --git a/plugins/snapshots/devbox/storage/bolt.go b/plugins/snapshots/devbox/storage/bolt.go index 65995c83b3b01..238ae452efc7b 100644 --- a/plugins/snapshots/devbox/storage/bolt.go +++ b/plugins/snapshots/devbox/storage/bolt.go @@ -61,6 +61,11 @@ var ( DevboxStatusRemoved = []byte("removed") ) +type RemovedDevboxContent struct { + ContentID string + LVName string +} + // parentKey returns a composite key of the parent and child identifiers. The // parts of the key are separated by a zero byte. func parentKey(parent, child uint64) []byte { @@ -325,6 +330,7 @@ func CommitActive(ctx context.Context, key, name string, usage snapshots.Usage, } id = readID(sbkt) + contentID := sbkt.Get(DevboxKeyContentID) si := snapshots.Info{ Name: name, Parent: string(sbkt.Get(bucketKeyParent)), @@ -347,6 +353,22 @@ func CommitActive(ctx context.Context, key, name string, usage snapshots.Usage, if err := putUsage(cbkt, usage); err != nil { return err } + if len(contentID) > 0 { + if err := cbkt.Put(DevboxKeyContentID, contentID); err != nil { + return err + } + root := pbkt.Bucket(DevboxStoragePathBucket) + if root != nil { + if contentBkt := root.Bucket(contentID); contentBkt != nil { + snapshotKey := contentBkt.Get(DevboxKeySnapshotKey) + if len(snapshotKey) == 0 || string(snapshotKey) == key { + if err := contentBkt.Put(DevboxKeySnapshotKey, []byte(name)); err != nil { + return err + } + } + } + } + } if err := bkt.DeleteBucket([]byte(key)); err != nil { return err @@ -505,8 +527,9 @@ func SetDevboxContent(ctx context.Context, key, contentID, lvName, mountPath str }) } -// RemoveDevbox removes the devbox content association for a snapshot key and -// returns the mount path if one was recorded. +// RemoveDevbox removes the snapshot association for a devbox content record and +// returns the mount path if one was recorded. Removed content metadata is kept +// until the LV cleanup path deletes the volume successfully. func RemoveDevbox(ctx context.Context, key string) (string, error) { var mountPath string @@ -530,15 +553,22 @@ func RemoveDevbox(ctx context.Context, key string) (string, error) { if cbkt == nil { return nil } - mountPath = string(cbkt.Get(DevboxKeyPath)) + snapshotKey := string(cbkt.Get(DevboxKeySnapshotKey)) + if snapshotKey == key { + mountPath = string(cbkt.Get(DevboxKeyPath)) + } log.G(ctx).WithFields(log.Fields{ "key": key, "contentID": string(contentID), + "snapshotKey": snapshotKey, "mountPath": mountPath, "mountPath_empty": mountPath == "", }).Warnf("[REMOVE-DEVBOX-TRACE] Retrieved fields from snapshot bucket") - return root.DeleteBucket(contentID) + if snapshotKey != "" && snapshotKey == key { + return cbkt.Put(DevboxKeySnapshotKey, []byte("")) + } + return nil }) if err != nil { return "", err @@ -584,9 +614,10 @@ func GetDevboxLvName(ctx context.Context, contentID, snapshotKey string) (string return lvName, nil } -// GetDevboxLvNames returns all active devbox LV names keyed by LV name. -func GetDevboxLvNames(ctx context.Context) (map[string]struct{}, error) { - names := map[string]struct{}{} +// GetRemovedDevboxContents returns removed devbox contents that are no longer +// attached to any snapshot and are ready for LV cleanup. +func GetRemovedDevboxContents(ctx context.Context) ([]RemovedDevboxContent, error) { + var contents []RemovedDevboxContent err := withBucket(ctx, func(ctx context.Context, bkt, pbkt *bolt.Bucket) error { root := pbkt.Bucket(DevboxStoragePathBucket) if root == nil { @@ -600,7 +631,43 @@ func GetDevboxLvNames(ctx context.Context) (map[string]struct{}, error) { if cbkt == nil { return nil } - if status := cbkt.Get(DevboxKeyStatus); len(status) > 0 && string(status) == string(DevboxStatusRemoved) { + if status := cbkt.Get(DevboxKeyStatus); string(status) != string(DevboxStatusRemoved) { + return nil + } + if snapshotKey := cbkt.Get(DevboxKeySnapshotKey); len(snapshotKey) > 0 { + return nil + } + lvName := string(cbkt.Get(DevboxKeyLvName)) + if lvName == "" { + return nil + } + contents = append(contents, RemovedDevboxContent{ + ContentID: string(k), + LVName: lvName, + }) + return nil + }) + }) + if err != nil { + return nil, err + } + return contents, nil +} + +// GetDevboxLvNames returns all devbox LV names keyed by LV name. +func GetDevboxLvNames(ctx context.Context) (map[string]struct{}, error) { + names := map[string]struct{}{} + err := withBucket(ctx, func(ctx context.Context, bkt, pbkt *bolt.Bucket) error { + root := pbkt.Bucket(DevboxStoragePathBucket) + if root == nil { + return nil + } + return root.ForEach(func(k, v []byte) error { + if v != nil { + return nil + } + cbkt := root.Bucket(k) + if cbkt == nil { return nil } lvName := string(cbkt.Get(DevboxKeyLvName)) @@ -613,7 +680,17 @@ func GetDevboxLvNames(ctx context.Context) (map[string]struct{}, error) { return names, err } -// SetUnmountedWithKey marks the devbox content for a snapshot as unmounted and +func DeleteDevboxContent(ctx context.Context, contentID string) error { + return withBucket(ctx, func(ctx context.Context, bkt, pbkt *bolt.Bucket) error { + root := pbkt.Bucket(DevboxStoragePathBucket) + if root == nil { + return errdefs.ErrNotFound + } + return root.DeleteBucket([]byte(contentID)) + }) +} + +// SetUnmountedWithKey clears the snapshot association for a devbox content and // returns the recorded mount path. func SetUnmountedWithKey(ctx context.Context, key string) (string, error) { var mountPath string @@ -635,8 +712,11 @@ func SetUnmountedWithKey(ctx context.Context, key string) (string, error) { if cbkt == nil { return errdefs.ErrNotFound } - mountPath = string(cbkt.Get(DevboxKeyPath)) - return cbkt.Put(DevboxKeyStatus, DevboxStatusRemoved) + if snapshotKey := cbkt.Get(DevboxKeySnapshotKey); len(snapshotKey) > 0 && string(snapshotKey) == key { + mountPath = string(cbkt.Get(DevboxKeyPath)) + return cbkt.Put(DevboxKeySnapshotKey, []byte("")) + } + return nil }) if err != nil { return "", err @@ -664,34 +744,52 @@ func withBucket(ctx context.Context, fn func(context.Context, *bolt.Bucket, *bol if !ok || tx == nil { return ErrNoTransaction } + version := tx.Bucket(bucketKeyStorageVersion) + if version == nil { + return fmt.Errorf("bucket does not exist: %w", errdefs.ErrNotFound) + } + return fn(ctx, version.Bucket(bucketKeySnapshot), version.Bucket(bucketKeyParents)) +} + +func createBucketIfNotExists(ctx context.Context, fn func(context.Context, *bolt.Bucket, *bolt.Bucket) error) error { + tx, ok := ctx.Value(transactionKey{}).(*bolt.Tx) + if !ok || tx == nil { + return ErrNoTransaction + } version, err := tx.CreateBucketIfNotExists(bucketKeyStorageVersion) if err != nil { - return err + return fmt.Errorf("failed to create version bucket: %w", err) } bkt, err := version.CreateBucketIfNotExists(bucketKeySnapshot) if err != nil { - return err + return fmt.Errorf("failed to create snapshots bucket: %w", err) } pbkt, err := version.CreateBucketIfNotExists(bucketKeyParents) if err != nil { - return err + return fmt.Errorf("failed to create parents bucket: %w", err) } return fn(ctx, bkt, pbkt) } -func createBucketIfNotExists(ctx context.Context, fn func(context.Context, *bolt.Bucket, *bolt.Bucket) error) error { - return withBucket(ctx, fn) -} - func withSnapshotBucket(ctx context.Context, key string, fn func(context.Context, *bolt.Bucket, *bolt.Bucket) error) error { - return withBucket(ctx, func(ctx context.Context, bkt, pbkt *bolt.Bucket) error { - sbkt := bkt.Bucket([]byte(key)) - if sbkt == nil { - return fmt.Errorf("snapshot %q does not exist: %w", key, errdefs.ErrNotFound) - } - return fn(ctx, sbkt, pbkt) - }) + tx, ok := ctx.Value(transactionKey{}).(*bolt.Tx) + if !ok || tx == nil { + return ErrNoTransaction + } + version := tx.Bucket(bucketKeyStorageVersion) + if version == nil { + return fmt.Errorf("bucket does not exist: %w", errdefs.ErrNotFound) + } + bkt := version.Bucket(bucketKeySnapshot) + if bkt == nil { + return fmt.Errorf("snapshots bucket does not exist: %w", errdefs.ErrNotFound) + } + sbkt := bkt.Bucket([]byte(key)) + if sbkt == nil { + return fmt.Errorf("snapshot %q does not exist: %w", key, errdefs.ErrNotFound) + } + return fn(ctx, sbkt, version.Bucket(bucketKeyParents)) } func sequenceNext(bkt *bolt.Bucket) (uint64, error) { diff --git a/plugins/snapshots/devbox/storage/bolt_test.go b/plugins/snapshots/devbox/storage/bolt_test.go new file mode 100644 index 0000000000000..0f65540fbd1ff --- /dev/null +++ b/plugins/snapshots/devbox/storage/bolt_test.go @@ -0,0 +1,388 @@ +//go:build linux + +package storage + +import ( + "context" + "path/filepath" + "testing" + + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/errdefs" + bolt "go.etcd.io/bbolt" +) + +func newTestMetaStore(t *testing.T) *MetaStore { + t.Helper() + + ms, err := NewMetaStore(filepath.Join(t.TempDir(), "metadata.db")) + if err != nil { + t.Fatalf("NewMetaStore() error = %v", err) + } + t.Cleanup(func() { + if err := ms.Close(); err != nil { + t.Fatalf("MetaStore.Close() error = %v", err) + } + }) + return ms +} + +func withTestTransaction(t *testing.T, ms *MetaStore, writable bool, fn func(context.Context) error) { + t.Helper() + + if err := ms.WithTransaction(context.Background(), writable, fn); err != nil { + t.Fatalf("transaction error = %v", err) + } +} + +func createActiveSnapshotWithContent(t *testing.T, ms *MetaStore, key, contentID, lvName, mountPath string) { + t.Helper() + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + if _, err := CreateSnapshot(ctx, snapshots.KindActive, key, ""); err != nil { + return err + } + return SetDevboxContent(ctx, key, contentID, lvName, mountPath) + }) +} + +func readContentRecord(t *testing.T, ms *MetaStore, contentID string) (status, snapshotKey, mountPath string, err error) { + t.Helper() + + err = ms.WithTransaction(context.Background(), false, func(ctx context.Context) error { + return withBucket(ctx, func(ctx context.Context, bkt, pbkt *bolt.Bucket) error { + root := pbkt.Bucket(DevboxStoragePathBucket) + if root == nil { + return errdefs.ErrNotFound + } + cbkt := root.Bucket([]byte(contentID)) + if cbkt == nil { + return errdefs.ErrNotFound + } + status = string(cbkt.Get(DevboxKeyStatus)) + snapshotKey = string(cbkt.Get(DevboxKeySnapshotKey)) + mountPath = string(cbkt.Get(DevboxKeyPath)) + return nil + }) + }) + return +} + +func TestSetUnmountedWithKeyKeepsContentReferenced(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + mountPath, err := SetUnmountedWithKey(ctx, "active-key") + if err != nil { + return err + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } + return nil + }) + + withTestTransaction(t, ms, false, func(ctx context.Context) error { + lvName, err := GetDevboxLvName(ctx, "content-1", "") + if err != nil { + return err + } + if lvName != "devbox-content-1" { + t.Fatalf("lvName = %q, want %q", lvName, "devbox-content-1") + } + + lvs, err := GetDevboxLvNames(ctx) + if err != nil { + return err + } + if _, ok := lvs["devbox-content-1"]; !ok { + t.Fatalf("expected LV to remain referenced, got %#v", lvs) + } + return nil + }) + + status, snapshotKey, mountPath, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "" { + t.Fatalf("snapshotKey = %q, want empty", snapshotKey) + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } +} + +func TestMarkRemovedStaysReferencedUntilSnapshotRemoval(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + return SetDevboxContentStatusRemoved(ctx, "content-1") + }) + + withTestTransaction(t, ms, false, func(ctx context.Context) error { + lvs, err := GetDevboxLvNames(ctx) + if err != nil { + return err + } + if _, ok := lvs["devbox-content-1"]; !ok { + t.Fatalf("expected removed content to stay referenced before snapshot removal, got %#v", lvs) + } + return nil + }) + + status, snapshotKey, _, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusRemoved) { + t.Fatalf("status = %q, want %q", status, DevboxStatusRemoved) + } + if snapshotKey != "active-key" { + t.Fatalf("snapshotKey = %q, want %q", snapshotKey, "active-key") + } +} + +func TestRemoveDevboxKeepsActiveContentAfterSnapshotRemoval(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + mountPath, err := RemoveDevbox(ctx, "active-key") + if err != nil { + return err + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } + _, _, err = Remove(ctx, "active-key") + return err + }) + + withTestTransaction(t, ms, false, func(ctx context.Context) error { + lvName, err := GetDevboxLvName(ctx, "content-1", "") + if err != nil { + return err + } + if lvName != "devbox-content-1" { + t.Fatalf("lvName = %q, want %q", lvName, "devbox-content-1") + } + return nil + }) + + status, snapshotKey, _, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "" { + t.Fatalf("snapshotKey = %q, want empty", snapshotKey) + } +} + +func TestRemoveDevboxDeletesRemovedContentAfterSnapshotRemoval(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + if err := SetDevboxContentStatusRemoved(ctx, "content-1"); err != nil { + return err + } + mountPath, err := RemoveDevbox(ctx, "active-key") + if err != nil { + return err + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } + _, _, err = Remove(ctx, "active-key") + return err + }) + + withTestTransaction(t, ms, false, func(ctx context.Context) error { + lvName, err := GetDevboxLvName(ctx, "content-1", "") + if err != nil { + return err + } + if lvName != "devbox-content-1" { + t.Fatalf("lvName = %q, want %q", lvName, "devbox-content-1") + } + removed, err := GetRemovedDevboxContents(ctx) + if err != nil { + return err + } + if len(removed) != 1 { + t.Fatalf("removed content count = %d, want 1", len(removed)) + } + if removed[0].ContentID != "content-1" || removed[0].LVName != "devbox-content-1" { + t.Fatalf("removed content = %#v, want content-1/devbox-content-1", removed[0]) + } + return nil + }) + + status, snapshotKey, _, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusRemoved) { + t.Fatalf("status = %q, want %q", status, DevboxStatusRemoved) + } + if snapshotKey != "" { + t.Fatalf("snapshotKey = %q, want empty", snapshotKey) + } +} + +func TestRemoveDevboxDoesNotReturnMountPathAfterContentReassociation(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "old-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + if _, err := CreateSnapshot(ctx, snapshots.KindActive, "new-key", ""); err != nil { + return err + } + return SetDevboxContent(ctx, "new-key", "content-1", "devbox-content-1", "/snapshots/2") + }) + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + mountPath, err := RemoveDevbox(ctx, "old-key") + if err != nil { + return err + } + if mountPath != "" { + t.Fatalf("mountPath = %q, want empty", mountPath) + } + return nil + }) + + status, snapshotKey, mountPath, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "new-key" { + t.Fatalf("snapshotKey = %q, want %q", snapshotKey, "new-key") + } + if mountPath != "/snapshots/2" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/2") + } +} + +func TestSetUnmountedWithKeyDoesNotReturnMountPathAfterContentReassociation(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "old-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + if _, err := CreateSnapshot(ctx, snapshots.KindActive, "new-key", ""); err != nil { + return err + } + return SetDevboxContent(ctx, "new-key", "content-1", "devbox-content-1", "/snapshots/2") + }) + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + mountPath, err := SetUnmountedWithKey(ctx, "old-key") + if err != nil { + return err + } + if mountPath != "" { + t.Fatalf("mountPath = %q, want empty", mountPath) + } + return nil + }) + + status, snapshotKey, mountPath, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "new-key" { + t.Fatalf("snapshotKey = %q, want %q", snapshotKey, "new-key") + } + if mountPath != "/snapshots/2" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/2") + } +} + +func TestRemoveDevboxDoesNotReturnMountPathAfterSetUnmounted(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + _, err := SetUnmountedWithKey(ctx, "active-key") + return err + }) + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + mountPath, err := RemoveDevbox(ctx, "active-key") + if err != nil { + return err + } + if mountPath != "" { + t.Fatalf("mountPath = %q, want empty", mountPath) + } + return nil + }) + + status, snapshotKey, mountPath, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "" { + t.Fatalf("snapshotKey = %q, want empty", snapshotKey) + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } +} + +func TestCommitActiveSyncsDevboxContentBinding(t *testing.T) { + ms := newTestMetaStore(t) + createActiveSnapshotWithContent(t, ms, "active-key", "content-1", "devbox-content-1", "/snapshots/1") + + withTestTransaction(t, ms, true, func(ctx context.Context) error { + _, err := CommitActive(ctx, "active-key", "committed-key", snapshots.Usage{}) + return err + }) + + withTestTransaction(t, ms, false, func(ctx context.Context) error { + contentID, mountPath, err := GetSnapshotDevboxInfo(ctx, "committed-key") + if err != nil { + return err + } + if contentID != "content-1" { + t.Fatalf("contentID = %q, want %q", contentID, "content-1") + } + if mountPath != "/snapshots/1" { + t.Fatalf("mountPath = %q, want %q", mountPath, "/snapshots/1") + } + + if _, _, _, err := GetInfo(ctx, "active-key"); !errdefs.IsNotFound(err) { + t.Fatalf("GetInfo(active-key) error = %v, want not found", err) + } + return nil + }) + + status, snapshotKey, _, err := readContentRecord(t, ms, "content-1") + if err != nil { + t.Fatalf("readContentRecord() error = %v", err) + } + if status != string(DevboxStatusActive) { + t.Fatalf("status = %q, want %q", status, DevboxStatusActive) + } + if snapshotKey != "committed-key" { + t.Fatalf("snapshotKey = %q, want %q", snapshotKey, "committed-key") + } +}