Files
seaweedfs/weed/storage/blockvol/snapshot_export_test.go
T
Ping QiuandClaude Opus 4.6 ed11a09a61 fix: CP11A-4 snapshot export/import safety — 3 bugs from review
BUG-CP11A4-1 (HIGH): ImportSnapshot now rejects when active snapshots
exist. Import overwrites the extent region that non-CoW'd snapshot blocks
read from, which would silently return import data instead of snapshot-time
data. New ErrImportActiveSnapshots error and snapMu-guarded check.

BUG-CP11A4-2 (HIGH): Double import without AllowOverwrite now correctly
rejected. Import bypasses WAL so nextLSN stays at 1; added FlagImported
(Superblock.Flags bit 0) set after successful import and checked alongside
nextLSN in the non-empty gate.

BUG-CP11A4-3 (MED): Replaced fixed exportTempSnapID (0xFFFFFFFE) with
atomic sequence counter (exportTempSnapBase + exportTempSnapSeq). Each
auto-export gets a unique temp snapshot ID, preventing concurrent export
races and user snapshot ID collisions.

Also added beginOp()/endOp() lifecycle guards to both ExportSnapshot and
ImportSnapshot, and documented the non-atomic import failure semantics.

5 new regression tests + QA-EX-3 rewritten for rejection behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:56:18 -07:00

493 lines
14 KiB
Go

package blockvol
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"path/filepath"
"testing"
)
func TestManifest_RoundTrip(t *testing.T) {
m := &SnapshotArtifactManifest{
FormatVersion: SnapshotArtifactFormatV1,
SourceVolume: "/data/vol1",
SourceSizeBytes: 1 << 20,
SourceBlockSize: 4096,
StorageProfile: "single",
CreatedAt: "2026-03-13T00:00:00Z",
ArtifactLayout: ArtifactLayoutSingleFile,
DataObjectKey: "backup/vol1/data.raw",
DataSizeBytes: 1 << 20,
SHA256: "abcd1234",
Compression: "none",
ExportToolVersion: ExportToolVersion,
}
data, err := MarshalManifest(m)
if err != nil {
t.Fatalf("MarshalManifest: %v", err)
}
got, err := UnmarshalManifest(data)
if err != nil {
t.Fatalf("UnmarshalManifest: %v", err)
}
if got.FormatVersion != m.FormatVersion {
t.Errorf("FormatVersion = %d, want %d", got.FormatVersion, m.FormatVersion)
}
if got.SourceSizeBytes != m.SourceSizeBytes {
t.Errorf("SourceSizeBytes = %d, want %d", got.SourceSizeBytes, m.SourceSizeBytes)
}
if got.SHA256 != m.SHA256 {
t.Errorf("SHA256 = %q, want %q", got.SHA256, m.SHA256)
}
if got.DataObjectKey != m.DataObjectKey {
t.Errorf("DataObjectKey = %q, want %q", got.DataObjectKey, m.DataObjectKey)
}
}
func TestManifest_Validate_BadVersion(t *testing.T) {
m := validManifest()
m.FormatVersion = 99
if err := ValidateManifest(m); err == nil {
t.Fatal("expected error for bad version")
}
}
func TestManifest_Validate_BadProfile(t *testing.T) {
m := validManifest()
m.StorageProfile = "striped"
if err := ValidateManifest(m); err == nil {
t.Fatal("expected error for bad profile")
}
}
func TestManifest_Validate_BadLayout(t *testing.T) {
m := validManifest()
m.ArtifactLayout = "multi-part"
if err := ValidateManifest(m); err == nil {
t.Fatal("expected error for bad layout")
}
}
func TestManifest_Validate_MissingFields(t *testing.T) {
cases := []struct {
name string
mutate func(*SnapshotArtifactManifest)
}{
{"no size", func(m *SnapshotArtifactManifest) { m.SourceSizeBytes = 0 }},
{"no block size", func(m *SnapshotArtifactManifest) { m.SourceBlockSize = 0 }},
{"no sha256", func(m *SnapshotArtifactManifest) { m.SHA256 = "" }},
{"no data size", func(m *SnapshotArtifactManifest) { m.DataSizeBytes = 0 }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := validManifest()
tc.mutate(m)
if err := ValidateManifest(m); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestExportSnapshot_Basic(t *testing.T) {
vol := createExportTestVol(t, 64*1024) // 64KB volume
// Write known pattern.
data := make([]byte, 4096)
for i := range data {
data[i] = 0xAA
}
if err := vol.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := vol.ExportSnapshot(context.Background(), &buf, ExportOptions{
DataObjectKey: "test/data.raw",
})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
if manifest.DataSizeBytes != 64*1024 {
t.Errorf("DataSizeBytes = %d, want %d", manifest.DataSizeBytes, 64*1024)
}
if manifest.SourceSizeBytes != 64*1024 {
t.Errorf("SourceSizeBytes = %d, want %d", manifest.SourceSizeBytes, 64*1024)
}
if manifest.SHA256 == "" {
t.Error("SHA256 is empty")
}
if manifest.StorageProfile != "single" {
t.Errorf("StorageProfile = %q, want single", manifest.StorageProfile)
}
if uint64(buf.Len()) != manifest.DataSizeBytes {
t.Errorf("buffer len = %d, want %d", buf.Len(), manifest.DataSizeBytes)
}
}
func TestExportSnapshot_ChecksumCorrect(t *testing.T) {
vol := createExportTestVol(t, 64*1024)
data := make([]byte, 4096)
for i := range data {
data[i] = 0xBB
}
if err := vol.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := vol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Independently compute SHA-256 of the exported data.
h := sha256.Sum256(buf.Bytes())
want := hex.EncodeToString(h[:])
if manifest.SHA256 != want {
t.Errorf("manifest SHA256 = %q, independently computed = %q", manifest.SHA256, want)
}
}
func TestExportSnapshot_ExistingSnapshot(t *testing.T) {
vol := createExportTestVol(t, 64*1024)
data := make([]byte, 4096)
data[0] = 0xCC
if err := vol.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Create snapshot manually.
if err := vol.CreateSnapshot(42); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
// Write more data after snapshot.
data[0] = 0xDD
if err := vol.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Export from existing snapshot — should get pre-write data.
var buf bytes.Buffer
_, err := vol.ExportSnapshot(context.Background(), &buf, ExportOptions{SnapshotID: 42})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// First byte should be 0xCC (snapshot-time), not 0xDD (current).
if buf.Bytes()[0] != 0xCC {
t.Errorf("exported first byte = 0x%02X, want 0xCC", buf.Bytes()[0])
}
vol.DeleteSnapshot(42)
}
func TestExportSnapshot_ProfileReject(t *testing.T) {
vol := createExportTestVol(t, 64*1024)
// Force profile to non-single for test.
vol.super.StorageProfile = uint8(ProfileStriped)
var buf bytes.Buffer
_, err := vol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err == nil {
t.Fatal("expected error for non-single profile")
}
}
func TestImportSnapshot_Basic(t *testing.T) {
// Create source, write pattern, export.
srcVol := createExportTestVol(t, 64*1024)
pattern := make([]byte, 4096)
for i := range pattern {
pattern[i] = byte(i % 251) // prime-mod pattern for uniqueness
}
if err := srcVol.WriteLBA(0, pattern); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{
DataObjectKey: "test/data.raw",
})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Create target, import.
dstVol := createExportTestVol(t, 64*1024)
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{})
if err != nil {
t.Fatalf("ImportSnapshot: %v", err)
}
// Read back and verify pattern.
got, err := dstVol.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if !bytes.Equal(got, pattern) {
t.Error("imported data does not match source pattern")
}
}
func TestImportSnapshot_SizeMismatch(t *testing.T) {
srcVol := createExportTestVol(t, 64*1024)
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Create target with different size.
dstVol := createExportTestVol(t, 128*1024)
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{})
if err == nil {
t.Fatal("expected size mismatch error")
}
}
func TestImportSnapshot_NonEmptyReject(t *testing.T) {
srcVol := createExportTestVol(t, 64*1024)
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Create target and write to it (makes it non-empty).
dstVol := createExportTestVol(t, 64*1024)
if err := dstVol.WriteLBA(0, make([]byte, 4096)); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{})
if err == nil {
t.Fatal("expected non-empty target error")
}
}
func TestImportSnapshot_AllowOverwrite(t *testing.T) {
// Source with known pattern.
srcVol := createExportTestVol(t, 64*1024)
pattern := make([]byte, 4096)
pattern[0] = 0xEE
if err := srcVol.WriteLBA(0, pattern); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Target with existing data.
dstVol := createExportTestVol(t, 64*1024)
if err := dstVol.WriteLBA(0, make([]byte, 4096)); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{AllowOverwrite: true})
if err != nil {
t.Fatalf("ImportSnapshot with AllowOverwrite: %v", err)
}
got, err := dstVol.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if got[0] != 0xEE {
t.Errorf("overwritten data first byte = 0x%02X, want 0xEE", got[0])
}
}
func TestImportSnapshot_ChecksumMismatch(t *testing.T) {
srcVol := createExportTestVol(t, 64*1024)
if err := srcVol.WriteLBA(0, make([]byte, 4096)); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Corrupt the data.
exported := buf.Bytes()
exported[0] ^= 0xFF
dstVol := createExportTestVol(t, 64*1024)
err = dstVol.ImportSnapshot(context.Background(), manifest, bytes.NewReader(exported), ImportOptions{})
if err == nil {
t.Fatal("expected checksum mismatch error")
}
}
func TestImportSnapshot_DoubleImportReject(t *testing.T) {
// BUG-CP11A4-2: After import, nextLSN stays at 1 because import bypasses WAL.
// A second import without AllowOverwrite must still be rejected via FlagImported.
srcVol := createExportTestVol(t, 64*1024)
pattern := make([]byte, 4096)
pattern[0] = 0xAA
if err := srcVol.WriteLBA(0, pattern); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
exportedData := buf.Bytes()
dstVol := createExportTestVol(t, 64*1024)
// First import succeeds.
err = dstVol.ImportSnapshot(context.Background(), manifest, bytes.NewReader(exportedData), ImportOptions{})
if err != nil {
t.Fatalf("first ImportSnapshot: %v", err)
}
// Second import without AllowOverwrite must fail.
err = dstVol.ImportSnapshot(context.Background(), manifest, bytes.NewReader(exportedData), ImportOptions{})
if err == nil {
t.Fatal("expected non-empty error on double import")
}
// Second import with AllowOverwrite must succeed.
err = dstVol.ImportSnapshot(context.Background(), manifest, bytes.NewReader(exportedData), ImportOptions{AllowOverwrite: true})
if err != nil {
t.Fatalf("AllowOverwrite import: %v", err)
}
}
func TestExportSnapshot_ClosedVolume(t *testing.T) {
// BUG-CP11A4-1: ExportSnapshot/ImportSnapshot must reject closed volumes via beginOp().
vol := createExportTestVol(t, 64*1024)
vol.Close()
var buf bytes.Buffer
_, err := vol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != ErrVolumeClosed {
t.Fatalf("ExportSnapshot on closed vol: got %v, want ErrVolumeClosed", err)
}
}
func TestImportSnapshot_ClosedVolume(t *testing.T) {
// BUG-CP11A4-1: ImportSnapshot must reject closed volumes.
srcVol := createExportTestVol(t, 64*1024)
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
dstVol := createExportTestVol(t, 64*1024)
dstVol.Close()
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{})
if err != ErrVolumeClosed {
t.Fatalf("ImportSnapshot on closed vol: got %v, want ErrVolumeClosed", err)
}
}
func TestImportSnapshot_ActiveSnapshotsReject(t *testing.T) {
// BUG-CP11A4-1: Import with active snapshots would corrupt non-CoW'd snapshot reads.
srcVol := createExportTestVol(t, 64*1024)
if err := srcVol.WriteLBA(0, make([]byte, 4096)); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
var buf bytes.Buffer
manifest, err := srcVol.ExportSnapshot(context.Background(), &buf, ExportOptions{})
if err != nil {
t.Fatalf("ExportSnapshot: %v", err)
}
// Target with an active snapshot.
dstVol := createExportTestVol(t, 64*1024)
if err := dstVol.CreateSnapshot(1); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}
err = dstVol.ImportSnapshot(context.Background(), manifest, &buf, ImportOptions{AllowOverwrite: true})
if err == nil {
t.Fatal("expected ErrImportActiveSnapshots")
}
if err != ErrImportActiveSnapshots {
t.Fatalf("got %v, want ErrImportActiveSnapshots", err)
}
// After deleting the snapshot, import should succeed.
if err := dstVol.DeleteSnapshot(1); err != nil {
t.Fatalf("DeleteSnapshot: %v", err)
}
err = dstVol.ImportSnapshot(context.Background(), manifest, bytes.NewReader(buf.Bytes()), ImportOptions{AllowOverwrite: true})
if err != nil {
t.Fatalf("ImportSnapshot after snapshot delete: %v", err)
}
}
func TestExportSnapshot_UniqueTempSnapIDs(t *testing.T) {
// BUG-CP11A4-3: Each auto-export must use a unique temp snapshot ID.
vol := createExportTestVol(t, 64*1024)
var buf1, buf2 bytes.Buffer
_, err := vol.ExportSnapshot(context.Background(), &buf1, ExportOptions{})
if err != nil {
t.Fatalf("first export: %v", err)
}
_, err = vol.ExportSnapshot(context.Background(), &buf2, ExportOptions{})
if err != nil {
t.Fatalf("second export: %v", err)
}
// Both exports should succeed without snapshot ID collision.
if buf1.Len() != buf2.Len() {
t.Errorf("export sizes differ: %d vs %d", buf1.Len(), buf2.Len())
}
}
// createExportTestVol creates a temporary BlockVol with a specified size for export tests.
func createExportTestVol(t *testing.T, volumeSize uint64) *BlockVol {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.vol")
vol, err := CreateBlockVol(path, CreateOptions{
VolumeSize: volumeSize,
WALSize: 32 << 10, // 32KB WAL
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
t.Cleanup(func() { vol.Close() })
return vol
}
// validManifest returns a valid manifest for test mutation.
func validManifest() *SnapshotArtifactManifest {
return &SnapshotArtifactManifest{
FormatVersion: SnapshotArtifactFormatV1,
SourceVolume: "/data/vol1",
SourceSizeBytes: 1 << 20,
SourceBlockSize: 4096,
StorageProfile: "single",
CreatedAt: "2026-03-13T00:00:00Z",
ArtifactLayout: ArtifactLayoutSingleFile,
DataObjectKey: "backup/data.raw",
DataSizeBytes: 1 << 20,
SHA256: "abc123",
Compression: "none",
ExportToolVersion: ExportToolVersion,
}
}