diff --git a/parse.go b/parse.go index 24302a1..7421872 100644 --- a/parse.go +++ b/parse.go @@ -81,6 +81,10 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) { } var classes []string + // Entries of the block sit at one indentation level. A class's value may + // itself be a nested block (class parameters, which encapi emits keyed by + // role name), whose deeper lines are values, not class names. + entryIndent := -1 j := i + 1 for ; j < len(lines); j++ { line := lines[j] @@ -88,18 +92,23 @@ func parseClasses(lines []string, i int, inline string) ([]string, int, error) { continue } trimmed := strings.TrimSpace(line) + indent := len(line) - len(strings.TrimLeft(line, " \t")) + // An unindented, non-list line is the next top-level key, so stop. + if indent == 0 && !strings.HasPrefix(trimmed, "-") { + break + } + if entryIndent < 0 { + entryIndent = indent + } + if indent > entryIndent { + continue + } if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { // Block list form, at either the parent indent ("- roles::base", // as yaml.dump emits) or nested (" - roles::base"). classes = append(classes, unquote(strings.TrimSpace(trimmed[1:]))) continue } - // An indented, non-list line is a nested map entry belonging to - // classes (" roles::base: {}"). An unindented, non-list line is the - // next top-level key, so stop. - if line[0] != ' ' && line[0] != '\t' { - break - } name, _, ok := splitKV(trimmed) if !ok { return nil, 0, fmt.Errorf("unexpected classes entry: %q", line) diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..317c16d --- /dev/null +++ b/parse_test.go @@ -0,0 +1,53 @@ +package main + +import "testing" + +// Nodes imported from cobbler carry class parameters, which encapi emits as a +// nested block under the role name. ausyd1nxvm2020/2021/2022 carry the role +// name as its own class parameter, so a line-based parser that treats every +// indented line as a class name emits the role twice. +func TestParseClassesNestedBlockIsOneClass(t *testing.T) { + body := []byte("classes:\n" + + " roles::infra::proxy::jumphost:\n" + + " roles::infra::proxy::jumphost: '~'\n" + + "environment: develop\n" + + "parameters: {}\n") + + doc, err := parseCobbler(body) + if err != nil { + t.Fatalf("parseCobbler returned error: %v", err) + } + want := []string{"roles::infra::proxy::jumphost"} + if len(doc.classes) != len(want) || doc.classes[0] != want[0] { + t.Errorf("classes = %q, want %q", doc.classes, want) + } + if doc.environment != "develop" { + t.Errorf("environment = %q, want develop", doc.environment) + } +} + +// The same shape with ordinary class parameters: the parameter keys are values +// of the class, never classes themselves. +func TestParseClassesNestedParamsAreNotClasses(t *testing.T) { + body := []byte("classes:\n" + + " roles::base:\n" + + " listen_port: 8140\n" + + " tls: true\n" + + " roles::infra::dns::master: {}\n" + + "environment: production\n" + + "parameters: {}\n") + + doc, err := parseCobbler(body) + if err != nil { + t.Fatalf("parseCobbler returned error: %v", err) + } + want := []string{"roles::base", "roles::infra::dns::master"} + if len(doc.classes) != len(want) { + t.Fatalf("classes = %q, want %q", doc.classes, want) + } + for i := range want { + if doc.classes[i] != want[i] { + t.Errorf("classes[%d] = %q, want %q", i, doc.classes[i], want[i]) + } + } +}