diff --git a/README.md b/README.md index 513e4de..5e59f3b 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,30 @@ A `machines` entry only needs `host`; `name` overrides the display name. Machines listed at the top level (outside any group) go into a `Machines` section instead. +`host` accepts brace patterns that are expanded at load time: + +- `{1,2,3}` — enumeration +- `{2..5,8..10}` — inclusive numeric ranges +- `{a3,a4}` — labels (any non-range item is kept literally) +- several groups multiply in cartesian fashion; leading zeros are preserved + +```yaml +groups: + - name: "Server room" + machines: + - host: server-{a3,a4}-{1..10}.example.org +``` + +expands to `server-a3-1.example.org`, `server-a3-2.example.org`, ..., +`server-a4-10.example.org`. A `name:` applies to every expanded machine; +without one, each machine is named after its host. Ranges must go from a +smaller to a larger number, and their endpoints must be integers. + +Malformed patterns make startup fail with an error instead of expanding +silently: unmatched or nested `{...}` groups, ranges with non-integer +endpoints, or ranges with extra dots (e.g. `{1..5..10}`). A single pattern may +expand to at most 10 000 machines; anything larger is rejected too. + Per-machine overrides for `ping` and `ssh` are supported, and inherit unset values from the top level: diff --git a/example.config.yaml b/example.config.yaml index 9dac60a..740b05f 100644 --- a/example.config.yaml +++ b/example.config.yaml @@ -104,6 +104,26 @@ # ssh: # interval: 1m # user: root +# +# # host patterns are expanded at load time. Inside a {...} group: +# # {1,2,3} enumeration -> 1, 2, 3 +# # {2..5,8..10} inclusive numeric range -> 2, 3, 4, 5, 8, 9, 10 +# # {a3,a4} labels (any non-range item is kept literally) +# # several groups multiply in cartesian fashion: +# # server-{a3,a4}-{1..2}.example.org +# # -> server-a3-1.example.org, server-a3-2.example.org, +# # server-a4-1.example.org, server-a4-2.example.org +# # leading zeros are preserved: {01..03} -> 01, 02, 03 +# # a name: applies to every expanded machine. +# # +# # Bad patterns fail at startup: unmatched/nested braces, ranges with +# # non-integer endpoints or extra dots (e.g. {1..5..10}), and descending +# # ranges. A single pattern may expand to at most 10000 machines. +# - name: Server room +# machines: +# - host: server-{a3,a4}-{1..10}.example.org +# ssh: +# interval: 1m # ---------- machines without a group (shown under "Machines") ---------- # machines: diff --git a/server/config.go b/server/config.go index e92f2f3..ff22b68 100644 --- a/server/config.go +++ b/server/config.go @@ -3,6 +3,8 @@ package main import ( "fmt" "os" + "strconv" + "strings" "time" "gopkg.in/yaml.v3" @@ -144,15 +146,187 @@ func LoadConfig(path string) (*Config, error) { cfg.Metrics = raw.Metrics for _, g := range raw.Groups { for _, mc := range g.Machines { - cfg.Machines = append(cfg.Machines, resolveMachine(mc, g.Name, cfg)) + ms, err := expandMachine(mc, g.Name, cfg) + if err != nil { + return nil, err + } + cfg.Machines = append(cfg.Machines, ms...) } } for _, mc := range raw.Machines { - cfg.Machines = append(cfg.Machines, resolveMachine(mc, "", cfg)) + ms, err := expandMachine(mc, "", cfg) + if err != nil { + return nil, err + } + cfg.Machines = append(cfg.Machines, ms...) } return cfg, nil } +// expandMachine expands a MachineConfig's host pattern into one or more +// concrete Machine entries. A host may contain brace groups that are expanded +// cartesian-style, e.g. "server-{a3,a4}-{1..10}.example.org". +func expandMachine(mc MachineConfig, group string, cfg *Config) ([]Machine, error) { + hosts, err := expandHostPattern(mc.Host) + if err != nil { + return nil, err + } + machines := make([]Machine, 0, len(hosts)) + for _, host := range hosts { + m := resolveMachine(mc, group, cfg) + m.Host = host + m.ID = host + if mc.Name == "" { + m.Name = host + } + machines = append(machines, m) + } + return machines, nil +} + +// maximum number of machines a single host pattern may expand to +const maxHostExpansion = 10000 + +// expandHostPattern expands brace groups in a host pattern. Supported syntax: +// +// {1,2,3} enumeration +// {2..5,8..10} inclusive numeric ranges +// {a3,a4} labels (any non-range item is taken literally) +// +// Multiple groups expand in cartesian fashion: +// +// server-{a3,a4}-{1..2}.example.org +// -> server-a3-1.example.org, server-a3-2.example.org, server-a4-1.example.org, ... +// +// A pattern without braces is returned unchanged. +func expandHostPattern(pattern string) ([]string, error) { + type segment struct { + prefix string + values []string + } + var segs []segment + rest := pattern + for { + open := strings.IndexByte(rest, '{') + if open < 0 { + if rest != "" { + segs = append(segs, segment{prefix: rest}) + } + break + } + close := strings.IndexByte(rest[open:], '}') + if close < 0 { + return nil, fmt.Errorf("host %q: unmatched '{'", pattern) + } + close += open + if strings.IndexByte(rest[open+1:close], '{') >= 0 { + return nil, fmt.Errorf("host %q: nested brace groups are not supported", pattern) + } + inner := rest[open+1 : close] + values, err := parseBraceGroup(inner) + if err != nil { + return nil, fmt.Errorf("host %q: %w", pattern, err) + } + segs = append(segs, segment{prefix: rest[:open], values: values}) + rest = rest[close+1:] + } + + result := []string{""} + count := 1 + for _, s := range segs { + if len(s.values) > 0 { + if maxHostExpansion/count < len(s.values) { + return nil, fmt.Errorf("host %q: expands to more than %d machines", pattern, maxHostExpansion) + } + count *= len(s.values) + } + } + for _, s := range segs { + if len(s.values) == 0 { + for i := range result { + result[i] += s.prefix + } + continue + } + next := make([]string, 0, len(result)*len(s.values)) + for _, r := range result { + for _, v := range s.values { + next = append(next, r+s.prefix+v) + } + } + result = next + } + return result, nil +} + +func parseBraceGroup(inner string) ([]string, error) { + var values []string + for _, part := range strings.Split(inner, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if lo, hi, ok := strings.Cut(part, ".."); ok { + vals, err := expandNumericRange(lo, hi, part) + if err != nil { + return nil, err + } + values = append(values, vals...) + } else { + values = append(values, part) + } + } + if len(values) == 0 { + return nil, fmt.Errorf("empty brace group") + } + return values, nil +} + +func expandNumericRange(lo, hi, raw string) ([]string, error) { + if lo == "" || hi == "" || !isNumeric(lo) || !isNumeric(hi) { + return nil, fmt.Errorf("invalid range %q: endpoints must be numbers", raw) + } + a, err := strconv.Atoi(lo) + if err != nil { + return nil, fmt.Errorf("invalid range %q: %w", raw, err) + } + b, err := strconv.Atoi(hi) + if err != nil { + return nil, fmt.Errorf("invalid range %q: %w", raw, err) + } + if b < a { + return nil, fmt.Errorf("invalid range %q: %d > %d", raw, a, b) + } + if b-a+1 > maxHostExpansion { + return nil, fmt.Errorf("range %q: expands to more than %d values", raw, maxHostExpansion) + } + pad := 0 + if len(lo) > 1 && lo[0] == '0' { + pad = len(lo) + } + out := make([]string, 0, b-a+1) + for n := a; n <= b; n++ { + if pad > 0 { + out = append(out, fmt.Sprintf("%0*d", pad, n)) + } else { + out = append(out, strconv.Itoa(n)) + } + } + return out, nil +} + +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + func resolveMachine(mc MachineConfig, group string, cfg *Config) Machine { m := Machine{ ID: mc.Host, diff --git a/server/config_fuzz_test.go b/server/config_fuzz_test.go new file mode 100644 index 0000000..3f6a974 --- /dev/null +++ b/server/config_fuzz_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func FuzzExpandHostPattern(f *testing.F) { + seeds := []string{ + "server{1..3}.example.org", + "server{1,2,3}.example.org", + "server-{a3,a4}-{1..2}.example.org", + "host{01..03}", + "router.example.net", + "host-{5..2}.example.org", + "host-{1,2.example.org", + "{1..10}", + "{2..5,8..10}.example.org", + "", + "host-{}", + "{1..}", + "{..5}", + "x}{y}", + "{a,b}{c,d}", + "{0..10001}", + } + for _, s := range seeds { + f.Add(s) + } + f.Fuzz(func(t *testing.T, pattern string) { + hosts, err := expandHostPattern(pattern) + if err != nil { + return + } + if !strings.Contains(pattern, "{") { + if !reflect.DeepEqual(hosts, []string{pattern}) { + t.Fatalf("pattern without braces %q expanded to %v", pattern, hosts) + } + return + } + if len(hosts) == 0 { + t.Fatalf("pattern %q produced no hosts", pattern) + } + for _, h := range hosts { + if strings.Contains(h, "{") { + t.Fatalf("expanded host %q still contains '{' (pattern %q)", h, pattern) + } + } + }) +} diff --git a/server/config_test.go b/server/config_test.go new file mode 100644 index 0000000..b550f62 --- /dev/null +++ b/server/config_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func TestExpandHostPattern(t *testing.T) { + tests := []struct { + pattern string + want []string + err string + }{ + { + pattern: "server{1..3}.example.org", + want: []string{"server1.example.org", "server2.example.org", "server3.example.org"}, + }, + { + pattern: "server{1,2,3}.example.org", + want: []string{"server1.example.org", "server2.example.org", "server3.example.org"}, + }, + { + pattern: "server{2..5,8..10}.example.org", + want: []string{ + "server2.example.org", "server3.example.org", "server4.example.org", + "server5.example.org", "server8.example.org", "server9.example.org", "server10.example.org", + }, + }, + { + pattern: "server-{a3,a4}-{1..2}.example.org", + want: []string{ + "server-a3-1.example.org", + "server-a3-2.example.org", + "server-a4-1.example.org", + "server-a4-2.example.org", + }, + }, + { + pattern: "node{01..03}", + want: []string{"node01", "node02", "node03"}, + }, + { + pattern: "router.example.net", + want: []string{"router.example.net"}, + }, + { + pattern: "host-{1..2}-{a,b}", + want: []string{"host-1-a", "host-1-b", "host-2-a", "host-2-b"}, + }, + { + pattern: "{1..10..2}.example.org", + err: "endpoints must be numbers", + }, + { + pattern: "host-{5..2}.example.org", + err: "5 > 2", + }, + { + pattern: "host-{x..y}.example.org", + err: "endpoints must be numbers", + }, + { + pattern: "host-{1,2.example.org", + err: "unmatched '{'", + }, + { + pattern: "host-{1..2-{3..4}}.example.org", + err: "nested", + }, + { + pattern: "{{", + err: "unmatched '{'", + }, + { + pattern: "{{1,2}}", + err: "nested", + }, + { + pattern: "{a}{{b}}", + err: "nested", + }, + { + pattern: "{1..5..10}", + err: "endpoints must be numbers", + }, + { + pattern: "{1...5}", + err: "endpoints must be numbers", + }, + { + pattern: "{1..2..3}", + err: "endpoints must be numbers", + }, + { + pattern: "{1..2.5}", + err: "endpoints must be numbers", + }, + } + + for _, tt := range tests { + got, err := expandHostPattern(tt.pattern) + if tt.err != "" { + if err == nil { + t.Errorf("%q: expected error containing %q, got nil", tt.pattern, tt.err) + } else if !strings.Contains(err.Error(), tt.err) { + t.Errorf("%q: expected error containing %q, got %q", tt.pattern, tt.err, err) + } + continue + } + if err != nil { + t.Errorf("%q: unexpected error: %v", tt.pattern, err) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q:\n got %v\nwant %v", tt.pattern, got, tt.want) + } + } +}