Files
seaweedfs/weed/shell/command_volume_list_test.go
T
Chris LuandGitHub 627b5e9d59 shell: parse every collection filter the same way (#10955)
* worker: move the collection filter parser into weed/util/wildcard

The parser sits beside the volume-list filtering it was written for, in
weed/plugin/worker, which imports weed/shell — so the shell commands that
parse the same filter three other ways can never call it. Move it down to
weed/util/wildcard, next to the comma-separated wildcard helper it already
replaced, leaving the behavior unchanged.

* shell: parse every collection filter the same way

The shell parsed a collection filter three ways: compileCollectionPattern
compiled one regex for ec.encode, ec.decode, volume.balance and the tier
commands; volume.list and volume.deleteEmpty matched a single wildcard; and
volume.tier.move, volume.fix.replication and volume.configure.replication
called filepath.Match on their own. None of them took a list, so
"ec.encode -collection=a,b" selected nothing, the same way the admin UI did.

They all go through the shared matcher now: a comma-separated list of names,
"*" and "?" wildcards, "_default" for the collection with no name, and regex
entries. The one thing that stays per-command is what an empty value means -
every collection for -collectionPattern, the unnamed collection for the ec
and tier -collection flag - so compileCollectionPattern keeps that mapping.

The matchers are compiled once per command instead of once per volume, and a
regex entry now has to match the whole name unless it anchors itself, so
-collection=bucket no longer picks up mybucket2.

* shell: keep dots in collection names, and commas inside a regex

A dot no longer marks an entry as a regex, so a collection named "my.bucket"
matches itself and not "my-bucket" - the difference decides which volumes
volume.deleteEmpty and volume.tier.move touch. A dot still counts when it is
quantified, so "bucket.*" stays a prefix regex.

The comma split also leaves alone the commas inside a character class or a
repetition count, so "bucket[0-9]{1,3}" stays one entry instead of becoming
two broken fragments.

* shell: let a regex entry match its own spelling

A collection named after regex syntax, say "logs(2024)", was unreachable:
the entry compiled to a pattern that matches "logs2024" instead. Match the
entry verbatim as well, so naming a collection always selects it, whatever
characters it holds.

* shell: reject a collection filter that names no collection

A value of "," parsed to no entries and then matched every collection, so a
typo widened ec.encode or volume.deleteEmpty to the whole cluster. Only a
genuinely empty filter means "all collections"; anything else has to name one.

* shell: keep commas inside a regex group out of the entry split

The split already left alone the commas inside a character class or a
repetition count, but not the ones inside a group, so "bucket(foo,bar)"
was cut into two fragments that no longer compile.

* shell: cover escaping a collection name that is not a regex

A name like "logs(2024" does not parse as a regex on its own; escaping it,
"logs\(2024", reaches it. Pin that so the escape hatch does not regress.

* shell: split entries only on commas inside a closed regex construct

An unmatched "{" or "[" made the splitter swallow every comma after it, so
"foo{bar,videos" became one entry that matches neither collection - the
silent no-op this filter work exists to remove. A construct now has to close
before its commas stop separating entries.

* shell: skip character classes while scanning a regex group

A ")" inside a class is a literal, so "(a[)],b)" ended its group early and
split into two fragments that no longer compile.

* shell: cover escaping a comma inside a collection name

A comma separates entries, so a name holding one is reached by escaping it.

* shell: follow the regexp parser when scanning a character class

A "]" leading a class is a member of it, and a POSIX class such as
"[:alpha:]" carries its own "]", so stopping at the first one cut a valid
filter like "(a[]),],b)" into fragments and rejected it.
2026-08-25 18:03:52 -07:00

308 lines
11 KiB
Go

package shell
import (
"bytes"
"flag"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/stretchr/testify/assert"
//"google.golang.org/protobuf/proto"
"strconv"
"strings"
"testing"
"github.com/golang/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
func TestParsing(t *testing.T) {
topo := parseOutput(topoData)
assert.Equal(t, 5, len(topo.DataCenterInfos))
topo = parseOutput(topoData2)
dataNodes := topo.DataCenterInfos[0].RackInfos[0].DataNodeInfos
assert.Equal(t, 14, len(dataNodes))
diskInfo := dataNodes[0].DiskInfos[""]
assert.Equal(t, 1559, len(diskInfo.VolumeInfos))
assert.Equal(t, 6740, len(diskInfo.EcShardInfos))
}
// TODO: actually parsing all fields would be nice...
func parseOutput(output string) *master_pb.TopologyInfo {
lines := strings.Split(output, "\n")
var topo *master_pb.TopologyInfo
var dc *master_pb.DataCenterInfo
var rack *master_pb.RackInfo
var dn *master_pb.DataNodeInfo
var disk *master_pb.DiskInfo
for _, line := range lines {
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
switch parts[0] {
case "Topology":
if topo == nil {
topo = &master_pb.TopologyInfo{
Id: parts[1],
}
}
case "DataCenter":
if dc == nil {
dc = &master_pb.DataCenterInfo{
Id: parts[1],
}
topo.DataCenterInfos = append(topo.DataCenterInfos, dc)
} else {
dc = nil
}
case "Rack":
if rack == nil {
rack = &master_pb.RackInfo{
Id: parts[1],
}
dc.RackInfos = append(dc.RackInfos, rack)
} else {
rack = nil
}
case "DataNode":
if dn == nil {
dn = &master_pb.DataNodeInfo{
Id: parts[1],
DiskInfos: make(map[string]*master_pb.DiskInfo),
}
rack.DataNodeInfos = append(rack.DataNodeInfos, dn)
} else {
dn = nil
}
case "Disk":
if disk == nil {
diskType := parts[1][:strings.Index(parts[1], "(")]
volumeCountStr := parts[1][strings.Index(parts[1], ":")+1 : strings.Index(parts[1], "/")]
maxVolumeCountStr := parts[1][strings.Index(parts[1], "/")+1:]
maxVolumeCount, _ := strconv.Atoi(maxVolumeCountStr)
volumeCount, _ := strconv.Atoi(volumeCountStr)
disk = &master_pb.DiskInfo{
Type: diskType,
MaxVolumeCount: int64(maxVolumeCount),
VolumeCount: int64(volumeCount),
}
dn.DiskInfos[types.ToDiskType(diskType).String()] = disk
} else {
disk = nil
}
case "volume":
volumeLine := line[len("volume "):]
volume := &master_pb.VolumeInformationMessage{}
proto.UnmarshalText(volumeLine, volume)
disk.VolumeInfos = append(disk.VolumeInfos, volume)
case "ec":
ecVolumeLine := line[len("ec volume "):]
ecShard := &master_pb.VolumeEcShardInformationMessage{}
for _, part := range strings.Split(ecVolumeLine, " ") {
if strings.HasPrefix(part, "id:") {
id, _ := strconv.ParseInt(part[len("id:"):], 10, 64)
ecShard.Id = uint32(id)
}
if strings.HasPrefix(part, "collection:") {
ecShard.Collection = part[len("collection:"):]
}
// TODO: we need to parse EC shard sizes as well
if strings.HasPrefix(part, "shards:") {
shards := part[len("shards:["):]
shards = strings.TrimRight(shards, "]")
shardsInfo := erasure_coding.NewShardsInfo()
for _, shardId := range strings.Split(shards, ",") {
sid, _ := strconv.Atoi(shardId)
shardsInfo.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(sid), 0))
}
ecShard.EcIndexBits = shardsInfo.Bitmap()
ecShard.ShardSizes = shardsInfo.SizesInt64()
}
}
disk.EcShardInfos = append(disk.EcShardInfos, ecShard)
}
}
return topo
}
// TestWriteDataNodeInfo_SplitsCollapsedDisksByPhysicalDiskId verifies that
// the verbose Disk block in volume.list shows one entry per physical disk
// when the master collapsed several same-type disks under a single
// DiskInfos["hdd"] map entry. Before the split, six physical disks would
// appear as "Disk hdd ... id:0" with all volumes stacked on it.
func TestWriteDataNodeInfo_SplitsCollapsedDisksByPhysicalDiskId(t *testing.T) {
dn := &master_pb.DataNodeInfo{
Id: "node1:8081",
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {
Type: "hdd",
MaxVolumeCount: 60,
VolumeInfos: []*master_pb.VolumeInformationMessage{
{Id: 1, DiskId: 0, Collection: "c"},
{Id: 2, DiskId: 1, Collection: "c"},
{Id: 3, DiskId: 2, Collection: "c"},
},
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
{Id: 100, DiskId: 3, Collection: "c", EcIndexBits: 1},
{Id: 101, DiskId: 4, Collection: "c", EcIndexBits: 1},
{Id: 102, DiskId: 5, Collection: "c", EcIndexBits: 1},
},
},
},
}
c := &commandVolumeList{}
fs := flag.NewFlagSet("volume.list", flag.ContinueOnError)
c.dataCenter = fs.String("dataCenter", "", "")
c.rack = fs.String("rack", "", "")
c.dataNode = fs.String("dataNode", "", "")
c.readonly = fs.Bool("readonly", false, "")
c.writable = fs.Bool("writable", false, "")
c.volumeId = fs.Uint64("volumeId", 0, "")
var buf bytes.Buffer
verbosity := 5
rackInvocations := 0
c.writeDataNodeInfo(&buf, dn, verbosity, func() { rackInvocations++ })
out := buf.String()
// Match the "id:N\n" line ending so substring checks don't accept
// unrelated tokens like "ec volume id:101" as a match for id:1.
for diskID := 0; diskID < 6; diskID++ {
needle := fmt.Sprintf("id:%d\n", diskID)
if !strings.Contains(out, needle) {
t.Errorf("output missing %q; got:\n%s", needle, out)
}
}
// The parent's outRackInfo callback rides the same code path as the
// DataNode header — it fires from the inner writeDiskInfo callback,
// once per disk before the fix. After the fix the header guard runs
// outRackInfo at most once per DataNode. This proxy lets us pin the
// "header printed once" invariant without depending on the exact
// format of the rendered DataNode line.
if rackInvocations != 1 {
t.Errorf("DataNode header callback ran %d times; want 1 (regression: header printed once per split disk)", rackInvocations)
}
}
func TestWriteTopologyInfo_PrintsParentHeadersOnce(t *testing.T) {
topo := topoFromNodes(
volNode("node1:8081", &master_pb.VolumeInformationMessage{Id: 1, Collection: "c"}),
volNode("node2:8081", &master_pb.VolumeInformationMessage{Id: 2, Collection: "c"}),
)
topo.DiskInfos = map[string]*master_pb.DiskInfo{"hdd": {Type: "hdd"}}
topo.DataCenterInfos[0].DiskInfos = map[string]*master_pb.DiskInfo{"hdd": {Type: "hdd"}}
topo.DataCenterInfos[0].RackInfos[0].DiskInfos = map[string]*master_pb.DiskInfo{"hdd": {Type: "hdd"}}
c := &commandVolumeList{}
fs := flag.NewFlagSet("volume.list", flag.ContinueOnError)
c.dataCenter = fs.String("dataCenter", "", "")
c.rack = fs.String("rack", "", "")
c.dataNode = fs.String("dataNode", "", "")
c.readonly = fs.Bool("readonly", false, "")
c.writable = fs.Bool("writable", false, "")
c.volumeId = fs.Uint64("volumeId", 0, "")
var dcBuf bytes.Buffer
c.writeTopologyInfo(&dcBuf, topo, 30000, 1)
assert.Equal(t, 1, strings.Count(dcBuf.String(), " DataCenter dc1 hdd("))
var rackBuf bytes.Buffer
c.writeTopologyInfo(&rackBuf, topo, 30000, 2)
assert.Equal(t, 1, strings.Count(rackBuf.String(), " Rack rack1 hdd("))
}
// volNode builds a single-node topology from (volumeId, collection) pairs so the
// duplicate-detection tests can describe a cluster compactly.
func volNode(nodeId string, volumes ...*master_pb.VolumeInformationMessage) *master_pb.DataNodeInfo {
return &master_pb.DataNodeInfo{
Id: nodeId,
DiskInfos: map[string]*master_pb.DiskInfo{
"hdd": {Type: "hdd", VolumeInfos: volumes},
},
}
}
func topoFromNodes(nodes ...*master_pb.DataNodeInfo) *master_pb.TopologyInfo {
return &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: nodes}},
}},
}
}
// TestFindDuplicateVolumeIds covers the dangerous condition where the master
// reused a volume id across two collections (e.g. id 59788 in both infra-backup
// and ai-news-data-infrastructure) — the state that lets collection.delete
// destroy the wrong collection's data. The detector must flag exactly the shared
// ids, while same-id replicas of one volume must NOT be flagged.
func TestFindDuplicateVolumeIds(t *testing.T) {
t.Run("flags id shared across collections", func(t *testing.T) {
topo := topoFromNodes(
volNode("srv0487:8082", &master_pb.VolumeInformationMessage{Id: 59788, Collection: "infra-backup"}),
volNode("srv0502:8083", &master_pb.VolumeInformationMessage{Id: 59788, Collection: "ai-news-data-infrastructure"}),
// A clean, single-collection volume that must not be flagged.
volNode("srv0487:8085", &master_pb.VolumeInformationMessage{Id: 7069, Collection: "ai-news-data-infrastructure"}),
)
dup := findDuplicateVolumeIds(topo)
assert.Equal(t, map[uint32][]string{
59788: {"ai-news-data-infrastructure", "infra-backup"},
}, dup)
})
t.Run("replicas of one volume are not duplicates", func(t *testing.T) {
// Same id on three nodes, all the same collection: a normal replicated
// volume, not a cross-collection clash.
topo := topoFromNodes(
volNode("n1", &master_pb.VolumeInformationMessage{Id: 42, Collection: "c"}),
volNode("n2", &master_pb.VolumeInformationMessage{Id: 42, Collection: "c"}),
volNode("n3", &master_pb.VolumeInformationMessage{Id: 42, Collection: "c"}),
)
assert.Empty(t, findDuplicateVolumeIds(topo))
})
t.Run("default empty collection counts as its own collection", func(t *testing.T) {
topo := topoFromNodes(
volNode("n1", &master_pb.VolumeInformationMessage{Id: 1, Collection: ""}),
volNode("n2", &master_pb.VolumeInformationMessage{Id: 1, Collection: "x"}),
)
assert.Equal(t, map[uint32][]string{1: {"", "x"}}, findDuplicateVolumeIds(topo))
})
t.Run("normal volume and ec shard sharing an id across collections", func(t *testing.T) {
topo := topoFromNodes(volNode("n1", &master_pb.VolumeInformationMessage{Id: 5, Collection: "a"}))
topo.DataCenterInfos[0].RackInfos[0].DataNodeInfos[0].DiskInfos["hdd"].EcShardInfos =
[]*master_pb.VolumeEcShardInformationMessage{{Id: 5, Collection: "b", EcIndexBits: 1}}
assert.Equal(t, map[uint32][]string{5: {"a", "b"}}, findDuplicateVolumeIds(topo))
})
}
func TestWriteDuplicateVolumeIdWarning(t *testing.T) {
var buf bytes.Buffer
writeDuplicateVolumeIdWarning(&buf, map[uint32][]string{
59788: {"ai-news-data-infrastructure", "infra-backup"},
1: {"", "x"},
})
out := buf.String()
assert.Contains(t, out, "WARNING: 2 volume id(s) exist in more than one collection")
assert.Contains(t, out, "volume 59788 in collections: ai-news-data-infrastructure, infra-backup")
// Empty collection name is rendered readably rather than as a blank.
assert.Contains(t, out, `volume 1 in collections: "" (default), x`)
// Lowest id first.
assert.Less(t, strings.Index(out, "volume 1 "), strings.Index(out, "volume 59788 "))
// Clean topology prints nothing.
var empty bytes.Buffer
writeDuplicateVolumeIdWarning(&empty, nil)
assert.Empty(t, empty.String())
}