Skip to content
Draft
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
66 changes: 54 additions & 12 deletions ee/tables/execparsers/remotectl/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,46 @@ func (p *parser) parseDumpstate(reader io.Reader) (any, error) {
results := make(map[string]map[string]any)

p.scanner = bufio.NewScanner(reader)
for p.scanner.Scan() {
p.lastReadLine = p.scanner.Text()

// Process each device
if p.isDeviceName() {
currentDeviceName := p.extractDeviceName()
currentDeviceResults, err := p.parseDevice()
if err != nil {
return nil, err
// Seed the first line.
if !p.scanner.Scan() {
return results, nil
}
p.lastReadLine = p.scanner.Text()

for {
// Skip empty lines between devices.
if strings.TrimSpace(p.lastReadLine) == "" {
if !p.scanner.Scan() {
break
}
results[currentDeviceName] = currentDeviceResults
p.lastReadLine = p.scanner.Text()
continue
}

return nil, errors.New("no device name(s) given in remotectl dumpstate output")
if !p.isDeviceName() {
return nil, errors.New("no device name(s) given in remotectl dumpstate output")
}

currentDeviceName := p.extractDeviceName()
currentDeviceResults, err := p.parseDevice()
if err != nil {
return nil, err
}
results[currentDeviceName] = currentDeviceResults

// parseDevice returns with p.lastReadLine holding its exit trigger:
// - empty line (device delimiter): advance the scanner for the next iteration.
// - device name (sub-parser consumed it): loop back and reuse directly, no Scan needed.
// - anything else (last tab-indented line before EOF): scanner exhausted, stop.
if strings.TrimSpace(p.lastReadLine) == "" {
if !p.scanner.Scan() {
break
}
p.lastReadLine = p.scanner.Text()
} else if !p.isDeviceName() {
break
}
}

return results, nil
Expand Down Expand Up @@ -129,6 +154,12 @@ func (p *parser) parseDevice() (map[string]any, error) {
return deviceResults, nil
}

// If the exit line from a sub-parser is a new device name, return now.
// parseDumpstate will advance the scanner on its next iteration.
if p.isDeviceName() {
return deviceResults, nil
}

// We have a top-level key with a value we should extract to store in `results`
propertyKey, propertyValue, err := extractTopLevelKeyValue(p.lastReadLine)
if err != nil {
Expand Down Expand Up @@ -269,6 +300,10 @@ func (p *parser) parseObjectArray() ([]map[string]any, bool, error) {

// One more level indented -- we have properties attached to the item we processed last. Extract them.
if currentIndentationLevel >= arrayItemPropertyIndentationLevel {
if len(arrayResults) == 0 {
// Property appeared before any item — skip it.
continue
}
lastProcessedItem := arrayResults[len(arrayResults)-1]

if strings.HasPrefix(strings.TrimSpace(p.lastReadLine), "Properties:") {
Expand Down Expand Up @@ -318,7 +353,14 @@ func (p *parser) parseKeyValList() (map[string]any, error) {
}

func (p *parser) getCurrentIndentationLevel() int {
return strings.LastIndex(p.lastReadLine, "\t")
count := 0
for _, c := range p.lastReadLine {
if c != '\t' {
break
}
count++
}
return count
}

func extractPropertyKeyValue(line string) (string, string, error) {
Expand All @@ -332,7 +374,7 @@ func extractTopLevelKeyValue(line string) (string, string, error) {
}

func extractKeyValue(line, delimiter string) (string, string, error) {
extracted := strings.Split(line, delimiter)
extracted := strings.SplitN(line, delimiter, 2)
if len(extracted) != 2 {
return "", "", fmt.Errorf("top-level key/value pair `%s` in remotectl output is in an unexpected format", line)
}
Expand Down
116 changes: 116 additions & 0 deletions ee/tables/execparsers/remotectl/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@
expectedValueCount: 0,
expectedErr: true,
},
{
name: "property before any array item does not panic",

Check failure on line 90 in ee/tables/execparsers/remotectl/parse_test.go

View workflow job for this annotation

GitHub Actions / lint (macos-latest)

File is not properly formatted (gofmt)

Check failure on line 90 in ee/tables/execparsers/remotectl/parse_test.go

View workflow job for this annotation

GitHub Actions / lint (macos-latest)

File is not properly formatted (gofmt)
input: []byte("Local device\n\tServices:\n\t\t\tVersion: 1\n"),
expectedDeviceCount: 1,
expectedValueCount: 0,
expectedErr: false,
},
{
// parseStringArray (used for Heartbeat) exits when it reads a line whose
// indentation is <= the starting level, consuming the next device name into
// p.lastReadLine. parseDumpstate must reuse that line instead of advancing
// the scanner, otherwise the next device's first property is dropped.
name: "device following Heartbeat section captures all properties",
input: []byte("Local device\n\tHeartbeat:\n\t\theartbeat-item\nFound ncm-0 (ncm-device)\n\tState: disconnected\n"),
expectedDeviceCount: 2,
expectedValueCount: 2,
expectedErr: false,
},
{
name: "Identity block followed immediately by next device does not error",
input: []byte(`Local device
State: connected
Identity:
Public key SHA256: abc123
Found ncm-0 (ncm-device)
State: disconnected
`),
expectedDeviceCount: 2,
expectedValueCount: 3,
expectedErr: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -125,6 +156,16 @@
continue
}

if topLevelKey == "Identity" {
identity := topLevelValue.(map[string]any)
for identityKey, identityValue := range identity {
actualValueCount += 1
validateKeyValueInCommandOutput(t, identityKey, identityValue.(string), tt.input)
}

continue
}

if topLevelKey == "Heartbeat" {
for _, heartbeat := range topLevelValue.([]string) {
actualValueCount += 1
Expand Down Expand Up @@ -179,6 +220,81 @@
}
}

func TestExtractKeyValue(t *testing.T) {
t.Parallel()

tests := []struct {
name string
line string
delimiter string
expectedKey string
expectedValue string
expectedErr bool
}{
{
name: "simple colon pair",
line: "State: connected",
delimiter: ":",
expectedKey: "State",
expectedValue: "connected",
},
{
name: "value contains delimiter (URL)",
line: "Server: https://example.com:8080",
delimiter: ":",
expectedKey: "Server",
expectedValue: "https://example.com:8080",
},
{
name: "no delimiter",
line: "no colon here",
delimiter: ":",
expectedErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
k, v, err := extractKeyValue(tt.line, tt.delimiter)
if tt.expectedErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectedKey, k)
require.Equal(t, tt.expectedValue, v)
})
}
}

func TestGetCurrentIndentationLevel(t *testing.T) {
t.Parallel()

p := &parser{}

tests := []struct {
name string
line string
expected int
}{
{"empty line", "", 0},
{"no tabs", "Key: value", 0},
{"one leading tab", "\tKey: value", 1},
{"two leading tabs", "\t\tKey: value", 2},
{"tab in value only", "Key: val\there", 0},
{"leading tab and tab in value", "\tKey: val\there", 1},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
p.lastReadLine = tt.line
require.Equal(t, tt.expected, p.getCurrentIndentationLevel())
})
}
}

func validateKeyValueInCommandOutput(t *testing.T, key, val string, commandOutput []byte) {
// First, confirm that the key and value both exists in the original output
assert.True(t, bytes.Contains(commandOutput, []byte(key)))
Expand Down
Loading