Browse Source
feat: CP11A-1 storage profile type, superblock persistence, and validation
feat: CP11A-1 storage profile type, superblock persistence, and validation
Add StorageProfile enum (single=0, striped=1 reserved) persisted at superblock offset 105. Existing volumes auto-map to single via zero-pad backward compatibility. CreateBlockVol rejects striped and invalid profile values before file creation. ParseStorageProfile is case-insensitive and whitespace-tolerant. 13 tests: enum string/parse, superblock persistence, backward compat, create/open/reopen, striped rejection, invalid profile rejection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>feature/sw-block
4 changed files with 306 additions and 0 deletions
-
12weed/storage/blockvol/blockvol.go
-
48weed/storage/blockvol/storage_profile.go
-
235weed/storage/blockvol/storage_profile_test.go
-
11weed/storage/blockvol/superblock.go
@ -0,0 +1,48 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
"strings" |
|||
) |
|||
|
|||
// StorageProfile controls the data layout strategy for a block volume.
|
|||
// - ProfileSingle (0): single-server, one file (default, backward-compat)
|
|||
// - ProfileStriped (1): multi-server striping (reserved, not yet implemented)
|
|||
type StorageProfile uint8 |
|||
|
|||
const ( |
|||
ProfileSingle StorageProfile = 0 // zero-value = backward compat
|
|||
ProfileStriped StorageProfile = 1 // reserved — rejected at creation time
|
|||
) |
|||
|
|||
var ( |
|||
ErrInvalidStorageProfile = errors.New("blockvol: invalid storage profile") |
|||
ErrStripedNotImplemented = errors.New("blockvol: striped profile is not yet implemented") |
|||
) |
|||
|
|||
// ParseStorageProfile converts a string to StorageProfile.
|
|||
// Empty string is treated as "single" for backward compatibility.
|
|||
// Parsing is case-insensitive.
|
|||
func ParseStorageProfile(s string) (StorageProfile, error) { |
|||
switch strings.ToLower(strings.TrimSpace(s)) { |
|||
case "", "single": |
|||
return ProfileSingle, nil |
|||
case "striped": |
|||
return ProfileStriped, nil |
|||
default: |
|||
return 0, fmt.Errorf("%w: %q", ErrInvalidStorageProfile, s) |
|||
} |
|||
} |
|||
|
|||
// String returns the canonical string representation.
|
|||
func (p StorageProfile) String() string { |
|||
switch p { |
|||
case ProfileSingle: |
|||
return "single" |
|||
case ProfileStriped: |
|||
return "striped" |
|||
default: |
|||
return fmt.Sprintf("unknown(%d)", p) |
|||
} |
|||
} |
|||
@ -0,0 +1,235 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"os" |
|||
"path/filepath" |
|||
"testing" |
|||
) |
|||
|
|||
func TestStorageProfile_String(t *testing.T) { |
|||
tests := []struct { |
|||
p StorageProfile |
|||
want string |
|||
}{ |
|||
{ProfileSingle, "single"}, |
|||
{ProfileStriped, "striped"}, |
|||
{StorageProfile(99), "unknown(99)"}, |
|||
} |
|||
for _, tt := range tests { |
|||
got := tt.p.String() |
|||
if got != tt.want { |
|||
t.Errorf("StorageProfile(%d).String() = %q, want %q", tt.p, got, tt.want) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestParseStorageProfile_Valid(t *testing.T) { |
|||
tests := []struct { |
|||
input string |
|||
want StorageProfile |
|||
}{ |
|||
{"single", ProfileSingle}, |
|||
{"striped", ProfileStriped}, |
|||
{"", ProfileSingle}, // empty = backward compat
|
|||
{"Single", ProfileSingle}, // case-insensitive
|
|||
{"STRIPED", ProfileStriped}, |
|||
{" single ", ProfileSingle}, // whitespace-tolerant
|
|||
} |
|||
for _, tt := range tests { |
|||
got, err := ParseStorageProfile(tt.input) |
|||
if err != nil { |
|||
t.Errorf("ParseStorageProfile(%q) error: %v", tt.input, err) |
|||
} |
|||
if got != tt.want { |
|||
t.Errorf("ParseStorageProfile(%q) = %v, want %v", tt.input, got, tt.want) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestParseStorageProfile_Invalid(t *testing.T) { |
|||
_, err := ParseStorageProfile("mirrored") |
|||
if !errors.Is(err, ErrInvalidStorageProfile) { |
|||
t.Errorf("ParseStorageProfile(mirrored) error = %v, want ErrInvalidStorageProfile", err) |
|||
} |
|||
} |
|||
|
|||
func TestStorageProfile_StringRoundTrip(t *testing.T) { |
|||
for _, p := range []StorageProfile{ProfileSingle, ProfileStriped} { |
|||
s := p.String() |
|||
got, err := ParseStorageProfile(s) |
|||
if err != nil { |
|||
t.Errorf("round-trip %v -> %q: parse error: %v", p, s, err) |
|||
} |
|||
if got != p { |
|||
t.Errorf("round-trip %v -> %q -> %v", p, s, got) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestSuperblock_ProfilePersistence(t *testing.T) { |
|||
// ProfileSingle persists and reads back correctly.
|
|||
sb, err := NewSuperblock(1*1024*1024*1024, CreateOptions{ |
|||
StorageProfile: ProfileSingle, |
|||
}) |
|||
if err != nil { |
|||
t.Fatalf("NewSuperblock: %v", err) |
|||
} |
|||
if sb.StorageProfile != uint8(ProfileSingle) { |
|||
t.Fatalf("StorageProfile = %d, want %d", sb.StorageProfile, ProfileSingle) |
|||
} |
|||
|
|||
var buf bytes.Buffer |
|||
sb.WriteTo(&buf) |
|||
|
|||
got, err := ReadSuperblock(&buf) |
|||
if err != nil { |
|||
t.Fatalf("ReadSuperblock: %v", err) |
|||
} |
|||
if got.StorageProfile != uint8(ProfileSingle) { |
|||
t.Errorf("StorageProfile = %d, want %d", got.StorageProfile, ProfileSingle) |
|||
} |
|||
} |
|||
|
|||
func TestSuperblock_BackwardCompat_ProfileZero(t *testing.T) { |
|||
// Existing volumes have 0 at profile offset → ProfileSingle.
|
|||
sb, _ := NewSuperblock(1*1024*1024*1024, CreateOptions{}) |
|||
var buf bytes.Buffer |
|||
sb.WriteTo(&buf) |
|||
|
|||
got, err := ReadSuperblock(&buf) |
|||
if err != nil { |
|||
t.Fatalf("ReadSuperblock: %v", err) |
|||
} |
|||
if StorageProfile(got.StorageProfile) != ProfileSingle { |
|||
t.Errorf("backward compat: StorageProfile = %d, want %d (single)", got.StorageProfile, ProfileSingle) |
|||
} |
|||
} |
|||
|
|||
func TestSuperblock_InvalidProfileRejected(t *testing.T) { |
|||
sb, _ := NewSuperblock(1*1024*1024*1024, CreateOptions{}) |
|||
sb.StorageProfile = 99 |
|||
|
|||
err := sb.Validate() |
|||
if err == nil { |
|||
t.Error("Validate should reject StorageProfile=99") |
|||
} |
|||
if !errors.Is(err, ErrInvalidSuperblock) { |
|||
t.Errorf("Validate error = %v, want ErrInvalidSuperblock", err) |
|||
} |
|||
} |
|||
|
|||
func TestCreate_WithProfile(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
vol, err := CreateBlockVol(path, CreateOptions{ |
|||
VolumeSize: 4 * 1024 * 1024, |
|||
StorageProfile: ProfileSingle, |
|||
}) |
|||
if err != nil { |
|||
t.Fatalf("Create: %v", err) |
|||
} |
|||
defer vol.Close() |
|||
if vol.Profile() != ProfileSingle { |
|||
t.Errorf("Profile() = %v, want single", vol.Profile()) |
|||
} |
|||
} |
|||
|
|||
func TestCreate_DefaultProfile(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
vol, err := CreateBlockVol(path, CreateOptions{VolumeSize: 4 * 1024 * 1024}) |
|||
if err != nil { |
|||
t.Fatalf("Create: %v", err) |
|||
} |
|||
defer vol.Close() |
|||
if vol.Profile() != ProfileSingle { |
|||
t.Errorf("default Profile() = %v, want single", vol.Profile()) |
|||
} |
|||
} |
|||
|
|||
func TestOpen_ProfileSurvivesReopen(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
vol, err := CreateBlockVol(path, CreateOptions{ |
|||
VolumeSize: 4 * 1024 * 1024, |
|||
StorageProfile: ProfileSingle, |
|||
}) |
|||
if err != nil { |
|||
t.Fatalf("Create: %v", err) |
|||
} |
|||
vol.Close() |
|||
|
|||
vol2, err := OpenBlockVol(path) |
|||
if err != nil { |
|||
t.Fatalf("Open: %v", err) |
|||
} |
|||
defer vol2.Close() |
|||
if vol2.Profile() != ProfileSingle { |
|||
t.Errorf("Profile() after reopen = %v, want single", vol2.Profile()) |
|||
} |
|||
} |
|||
|
|||
func TestCreate_StripedRejected(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
_, err := CreateBlockVol(path, CreateOptions{ |
|||
VolumeSize: 4 * 1024 * 1024, |
|||
StorageProfile: ProfileStriped, |
|||
}) |
|||
if !errors.Is(err, ErrStripedNotImplemented) { |
|||
t.Errorf("CreateBlockVol(striped) error = %v, want ErrStripedNotImplemented", err) |
|||
} |
|||
// File should not exist.
|
|||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { |
|||
t.Error("CreateBlockVol(striped) should not leave a file behind") |
|||
} |
|||
} |
|||
|
|||
func TestCreate_InvalidProfileRejected(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
_, err := CreateBlockVol(path, CreateOptions{ |
|||
VolumeSize: 4 * 1024 * 1024, |
|||
StorageProfile: StorageProfile(99), |
|||
}) |
|||
if !errors.Is(err, ErrInvalidStorageProfile) { |
|||
t.Errorf("CreateBlockVol(profile=99) error = %v, want ErrInvalidStorageProfile", err) |
|||
} |
|||
// File should not exist.
|
|||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { |
|||
t.Error("CreateBlockVol(invalid profile) should not leave a file behind") |
|||
} |
|||
} |
|||
|
|||
func TestOpen_InvalidProfileOnDisk(t *testing.T) { |
|||
dir := t.TempDir() |
|||
path := filepath.Join(dir, "test.blk") |
|||
|
|||
vol, err := CreateBlockVol(path, CreateOptions{VolumeSize: 4 * 1024 * 1024}) |
|||
if err != nil { |
|||
t.Fatalf("Create: %v", err) |
|||
} |
|||
vol.Close() |
|||
|
|||
// Corrupt the StorageProfile byte on disk (offset 105).
|
|||
f, err := os.OpenFile(path, os.O_RDWR, 0644) |
|||
if err != nil { |
|||
t.Fatalf("open: %v", err) |
|||
} |
|||
if _, err := f.WriteAt([]byte{99}, 105); err != nil { |
|||
t.Fatalf("write corrupt byte: %v", err) |
|||
} |
|||
f.Close() |
|||
|
|||
_, err = OpenBlockVol(path) |
|||
if err == nil { |
|||
t.Fatal("OpenBlockVol should fail with invalid StorageProfile") |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue