Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions pkg/dispatcher/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ func buildVolumes(rd *blockstoriov1alpha1.ResourceDefinition, target *blockstori
SizeKib: vd.SizeKib,
StoragePool: pool,
SeedFromGI: seedFromGI(target, vd.VolumeNumber),
ObservedGI: observedGI(target, vd.VolumeNumber),
SourceSnapshot: srcSnapshot,
MetaPool: metaPool,
Minor: minor,
Expand Down Expand Up @@ -1014,6 +1015,18 @@ func seedFromGI(target *blockstoriov1alpha1.Resource, volumeNumber int32) string
return ""
}

// observedGI is the current GI the satellite last reported for the given
// volume of target, or "" when it never reported one.
func observedGI(target *blockstoriov1alpha1.Resource, volumeNumber int32) string {
for i := range target.Status.Volumes {
if target.Status.Volumes[i].VolumeNumber == volumeNumber {
return target.Status.Volumes[i].CurrentGI
}
}

return ""
}

// kubeInternalNICName is the well-known NetInterface name carrying the
// corev1.Node InternalIP (a host-routable address). Populated by the
// register / label-sync path when blockstor knows the kube view of a
Expand Down
53 changes: 53 additions & 0 deletions pkg/dispatcher/observed_gi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

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 dispatcher

import (
"testing"

blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
)

// The satellite tells its own crashed replica from a foreign superblock by
// the GI it last reported, so every desired volume carries it.
func TestBuildVolumesCarriesTheReportedGI(t *testing.T) {
t.Parallel()

rd := &blockstoriov1alpha1.ResourceDefinition{}
rd.Spec.VolumeDefinitions = []blockstoriov1alpha1.ResourceDefinitionVolume{
{VolumeNumber: 0, SizeKib: 4096},
{VolumeNumber: 1, SizeKib: 4096},
}

target := &blockstoriov1alpha1.Resource{}
target.Status.Volumes = []blockstoriov1alpha1.ResourceVolumeStatus{{VolumeNumber: 0, CurrentGI: "ABCDEF0123456789"}}

vols := buildVolumes(rd, target)
if len(vols) != 2 {
t.Fatalf("got %d volumes, want 2", len(vols))
}

if vols[0].GetObservedGI() != "ABCDEF0123456789" {
t.Errorf("volume 0 carries %q, want the reported GI", vols[0].GetObservedGI())
}

if vols[1].GetObservedGI() != "" {
t.Errorf("volume 1 never reported a GI and carries %q", vols[1].GetObservedGI())
}
}
248 changes: 248 additions & 0 deletions pkg/drbd/dumpmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

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 drbd

import (
"bufio"
"bytes"
"context"
"strings"

"github.com/cockroachdb/errors"
)

// MetadataUUIDs runs `drbdadm dump-md <target>` and returns every
// generation UUID the metadata carries: the current UUID, the bitmap UUID of
// every peer slot and the history UUIDs. `get-gi` shows only one peer slot,
// and when a Primary loses a peer the old current UUID moves into that
// peer's bitmap slot, so a check against get-gi alone misses it.
func (a *Adm) MetadataUUIDs(ctx context.Context, target string) ([]string, error) {
out, err := a.exec.Run(ctx, "drbdadm", "dump-md", target)
if err != nil {
return nil, errors.Wrapf(err, "drbdadm dump-md %s", target)
}

uuids, err := ParseMetadataUUIDs(out)
if err != nil {
return nil, errors.Wrapf(err, "drbdadm dump-md %s", target)
}

return uuids, nil
}

// The dump carries the whole bitmap, one line per run or per few words, so
// the lines are short; the buffer still grows to dumpLineMax for a dump whose
// format changes. The output as a whole is held in memory: for a large,
// heavily dirty volume that is tens of megabytes, once per check.
const (
dumpLineBuffer = 64 << 10
dumpLineMax = 16 << 20
)

// ParseMetadataUUIDs extracts the generation UUIDs from a dump-md listing,
// upper-cased and without the 0x prefix; zero UUIDs are left out.
//
// drbdmeta prints them as statements and blocks (print_dump_gi in
// drbd-utils' drbdmeta.c). In the v09 format the current UUID and each peer
// slot's bitmap UUID are single statements, and the history is a block of
// several UUIDs to a line:
//
// current-uuid 0x9F00AA11BB22CC33;
// peer[1] {
// bitmap-uuid 0x6E3A5D0B2C1F4A96;
// }
// history-uuids {
// 0x1234567890ABCDEF; 0x0000000000000000; ...
// }
//
// The v08 format has a single block of four UUIDs, current, bitmap and two
// of history, then the flags. Nothing else is read: the bitmap words and the
// device UUID are not generation identifiers.
//
// A dump that names no current UUID, a v09 `current-uuid` statement that
// gives one or a v08 `uuid` block that leads with one, or that yields no
// UUID at all, is not one
// this parser can read: empty output, a keyword
// drbdmeta renamed, or a line the scanner gave up on. That is an error, never
// an empty answer, since the caller would read an empty answer as "this
// superblock holds none of our generations" and re-initialise it.
func ParseMetadataUUIDs(dump []byte) ([]string, error) {
var parsed uuidDump

scanner := bufio.NewScanner(bytes.NewReader(dump))
scanner.Buffer(make([]byte, 0, dumpLineBuffer), dumpLineMax)

for scanner.Scan() {
parsed.line(strings.TrimSpace(scanner.Text()))
}

err := scanner.Err()
if err != nil {
return nil, errors.Wrap(err, "read the dump-md output")
}

if !parsed.current || len(parsed.out) == 0 {
return nil, ErrUnreadableMetadataDump
}

return parsed.out, nil
}

// uuidDump is what ParseMetadataUUIDs has read so far.
type uuidDump struct {
out []string
block bool
current bool
// v08Lead is set while the v08 block has not yet given its first
// statement, which is the current UUID.
v08Lead bool
}

func (d *uuidDump) line(line string) {
if d.block {
body, _, closed := strings.Cut(line, "}")
d.blockText(body, closed)

return
}

key, rest, _ := strings.Cut(line, " ")

switch key {
case dumpKeyCurrent, "bitmap-uuid":
before := len(d.out)
d.out = appendUUIDStatements(d.out, rest)
// A current statement counts only when it gave a UUID: one
// without the 0x prefix, not hex, or zero is unreadable too.
d.current = d.current || (key == dumpKeyCurrent && len(d.out) > before)
case "history-uuids", dumpKeyV08:
d.v08Lead = key == dumpKeyV08

body, opens := strings.CutPrefix(strings.TrimSpace(rest), "{")
if !opens {
d.blockText(rest, true)

return
}

body, _, closed := strings.Cut(body, "}")
d.blockText(body, closed)
}
}

// blockText reads text of an open block, which closed ends. In the v08
// block the first statement must be a readable UUID for the dump to name a
// current one.
func (d *uuidDump) blockText(text string, closed bool) {
if first, ok := firstStatement(text); ok && d.v08Lead {
_, readable := uuidStatement(first)
d.current = d.current || readable
d.v08Lead = false
}

d.out = appendUUIDStatements(d.out, text)
d.block = !closed
d.v08Lead = d.v08Lead && d.block
}

// The statements that name a current UUID: v09's own one, and v08's block
// that leads with it.
const (
dumpKeyCurrent = "current-uuid"
dumpKeyV08 = "uuid"
)

// ErrUnreadableMetadataDump reports dump-md output that names no current
// UUID, which this parser therefore cannot vouch for.
var ErrUnreadableMetadataDump = errors.New("dump-md printed no current UUID this parser recognises")

// appendUUIDStatements adds every statement of text that is a bare non-zero
// hex UUID, such as `0x6E3A5D0B2C1F4A96;`. A keyed statement, such as v08's
// `flags 0x00000011;` inside the uuid block, is not one.
func appendUUIDStatements(out []string, text string) []string {
for statement := range strings.SplitSeq(text, ";") {
if uuid, ok := uuidStatement(statement); ok {
out = append(out, uuid)
}
}

return out
}

// uuidStatement answers the UUID a statement is, when it is a bare non-zero
// hex UUID.
func uuidStatement(statement string) (string, bool) {
digits, ok := strings.CutPrefix(strings.TrimSpace(statement), "0x")
if !ok || !isHex(digits) || strings.Trim(digits, "0") == "" {
return "", false
}

return strings.ToUpper(digits), true
}

// firstStatement answers the first non-empty statement of text, if it has
// one.
func firstStatement(text string) (string, bool) {
for statement := range strings.SplitSeq(text, ";") {
if trimmed := strings.TrimSpace(statement); trimmed != "" {
return trimmed, true
}
}

return "", false
}

func isHex(digits string) bool {
if digits == "" {
return false
}

for _, c := range strings.ToLower(digits) {
if !strings.ContainsRune("0123456789abcdef", c) {
return false
}
}

return true
}

// SameGeneration compares two generation UUIDs the way DRBD does: the
// lowest bit marks a UUID issued while Primary and is cleared when the UUID
// moves into history, so it takes no part in the identity.
func SameGeneration(a, b string) bool {
return strings.EqualFold(clearPrimaryBit(a), clearPrimaryBit(b))
}

func clearPrimaryBit(uuid string) string {
uuid = strings.ToUpper(strings.TrimPrefix(strings.TrimPrefix(uuid, "0x"), "0X"))
if uuid == "" {
return uuid
}

last := uuid[len(uuid)-1]

const hex = "0123456789ABCDEF"

idx := strings.IndexByte(hex, last)
if idx < 0 {
return uuid
}

return uuid[:len(uuid)-1] + string(hex[idx&^1])
}
Loading
Loading