From aa8593ce0fbae0e8d81966ca2a43aa3d74f6dc32 Mon Sep 17 00:00:00 2001 From: Karl Gaissmaier Date: Wed, 17 Jun 2026 19:06:15 +0200 Subject: [PATCH 1/4] start with IPAM DSL grammar --- .github/dependabot.yml | 28 +++++++++ .github/workflows/go.yml | 40 ++++++++++++ .golangci.yml | 60 ++++++++++++++++++ go.mod | 5 ++ go.sum | 2 + internal/grammar/grammar.go | 105 +++++++++++++++++++++++++++++++ internal/grammar/grammar_test.go | 46 ++++++++++++++ 7 files changed, 286 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/go.yml create mode 100644 .golangci.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/grammar/grammar.go create mode 100644 internal/grammar/grammar_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..adf9bb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "gomod"] + commit-message: + prefix: "chore(deps)" + groups: + minor-and-patch: + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: ["dependencies", "github-actions"] + commit-message: + prefix: "chore(actions)" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..ff3dc26 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,40 @@ +name: CI +on: + push: + branches: ["main", "devel"] + paths: + - '**.go' + - '**.yml' + - '**.yaml' + pull_request: + branches: ["main", "devel"] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: stable + cache: true + - run: go test -v ./... + + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: stable + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.0 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..913974f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,60 @@ +version: '2' +run: + timeout: 5m +linters: + enable: + - decorder + - errorlint + - gocyclo + - govet + - gosec + - gosmopolitan + - intrange + - misspell + - predeclared + - reassign + - staticcheck + - unconvert + - unparam + - usestdlibvars + - wastedassign + - copyloopvar + - tparallel + - gochecknoglobals + - nolintlint + - modernize + - gocritic + settings: + govet: + enable: + - shadow + gocyclo: + min-complexity: 30 + exclusions: + generated: lax + rules: + - linters: + - gochecknoglobals + - gosec + - gocyclo + path: _test\.go + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ + +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a155276 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/gaissmai/ipam + +go 1.26 + +require github.com/gaissmai/grammar v0.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0566ad7 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gaissmai/grammar v0.3.0 h1:O90o3Fe5Vaw/3044pip3UEekN4RSl8mExKMKv2WuuGM= +github.com/gaissmai/grammar v0.3.0/go.mod h1:iAnAryYv9vmVajL9t7zKDnWjeySd8LrTacEwOMbnlvk= diff --git a/internal/grammar/grammar.go b/internal/grammar/grammar.go new file mode 100644 index 0000000..e9afef8 --- /dev/null +++ b/internal/grammar/grammar.go @@ -0,0 +1,105 @@ +// package grammar defines patterns and rules for IPAM domain specific language. +package grammar + +import ( + "regexp" + + "github.com/gaissmai/grammar" +) + +// g is ready to use after init(). +// +//nolint:gochecknoglobals +var g *grammar.Grammar + +// init compiles the grammar rules. +func init() { + g = grammar.New("IPAM") + + for name, rawPattern := range AllRules() { + if err := g.Add(name, rawPattern); err != nil { + panic(err) + } + } + + if err := g.Compile(); err != nil { + panic(err) + } +} + +// MustRx returns the regexp for rule from the underlying grammar. +func MustRx(name string) *regexp.Regexp { + rx, err := g.Rx(name) + if err != nil { + panic(err) + } + + return rx +} + +// AllRules to build the IPAM tokens. +func AllRules() map[string]string { + return map[string]string{ + "label": label, + "word": word, + "cswords": cswords, + "fqdn": fqdn, + "ident": ident, + "comment": comment, + "scoping": scoping, + "ip": ip, + "ipv4": ipv4, + "ipv6": ipv6, + } +} + +// ########################################################################### +// IPAM GRAMMAR +// slimmed down version, just some helper regexps for the lexer state machine +// ########################################################################### + +const ( + + // # comment til EOL + comment = `^ # .* $` + + scoping = `^ --- -* $` + + // a label is field of a FQDN + label = `(?: [- \w]+ )` // hyphen and word chars, the rest is done by the parser + + // word + word = `\w [-\w]*` + + // comma-separated words + cswords = `^ ${word} (?: , ${word} )* $` + + // full qualified domain name, com com. www.google.com. *.foo.bar + // the length restriction parsing etc. is done by the parser + // IDNA not supported, just plain ASCII + fqdn = `^ + (?: + (?: \*\. )? // alias can start with wildcard followed by dot + (?: ${label} \. )+ // one or more (label followed by dot) + ${label}? [a-zA-Z]+ // TLD ends with at least one ASCII letter + \.? // last dot optional + $)` + + // keywords, attributes and flags are uppercase + ident = `^ (?: [A-Z] [A-Z0-9_]+ )` // missing $ is intended + + // simple and fast patterns for lexing, true parsing with netip.ParseAddr() + ip = `^ (?: ${ipv4} | ${ipv6} ) ` // missing $ is intended + + ipv4 = `^ (?: + \d{1,3} \. + \d{1,3} \. + \d{1,3} \. + \d{1,3} + )` // missing $ is intended + + ipv6 = `^ (?: // xdigits and colons, no dot + [[:xdigit:] :]+ : + [[:xdigit:] :]* + )` // missing $ is intended +) diff --git a/internal/grammar/grammar_test.go b/internal/grammar/grammar_test.go new file mode 100644 index 0000000..45b56c9 --- /dev/null +++ b/internal/grammar/grammar_test.go @@ -0,0 +1,46 @@ +package grammar_test + +import ( + "testing" + + g "github.com/gaissmai/ipam/internal/grammar" +) + +func TestGrammar(t *testing.T) { + t.Parallel() + + for name := range g.AllRules() { + rx := g.MustRx(name) + t.Log(name, rx) + } +} + +func TestGrammarWords(t *testing.T) { + t.Parallel() + tt := []struct { + rule string + input string + want bool + }{ + { + rule: "word", + input: "with-hyphen", + want: true, + }, + { + rule: "cswords", + input: "std,open", + want: true, + }, + } + + for _, tc := range tt { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + got := g.MustRx(tc.rule).MatchString(tc.input) + if got != tc.want { + t.Fatalf("rule: %s, input: %s, got %v, want %v", tc.rule, tc.input, got, tc.want) + } + }) + } +} From 35836401a9aa68c282a7e84f46f4454e4cec97da Mon Sep 17 00:00:00 2001 From: Karl Gaissmaier Date: Wed, 17 Jun 2026 20:03:45 +0200 Subject: [PATCH 2/4] improve github workflow actions --- .github/workflows/go.yml | 12 ++++++++++-- internal/grammar/grammar.go | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ff3dc26..2a6a214 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -3,11 +3,17 @@ on: push: branches: ["main", "devel"] paths: + - 'go.mod' - '**.go' - '**.yml' - '**.yaml' pull_request: branches: ["main", "devel"] + paths: + - 'go.mod' + - '**.go' + - '**.yml' + - '**.yaml' permissions: contents: read @@ -21,7 +27,8 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: stable + persist-credentials: false + go-version-file: go.mod cache: true - run: go test -v ./... @@ -31,7 +38,8 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 with: - go-version: stable + persist-credentials: false + go-version-file: go.mod cache: true - name: golangci-lint diff --git a/internal/grammar/grammar.go b/internal/grammar/grammar.go index e9afef8..eb2e085 100644 --- a/internal/grammar/grammar.go +++ b/internal/grammar/grammar.go @@ -1,4 +1,4 @@ -// package grammar defines patterns and rules for IPAM domain specific language. +// Package grammar defines patterns and rules for IPAM domain specific language. package grammar import ( From 1ae335483a2a209a80dd61a80228a6e075c7b651 Mon Sep 17 00:00:00 2001 From: Karl Gaissmaier Date: Wed, 17 Jun 2026 20:32:01 +0200 Subject: [PATCH 3/4] more grammat tests --- internal/grammar/grammar.go | 2 +- internal/grammar/grammar_test.go | 97 +++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/internal/grammar/grammar.go b/internal/grammar/grammar.go index eb2e085..d6fa700 100644 --- a/internal/grammar/grammar.go +++ b/internal/grammar/grammar.go @@ -65,7 +65,7 @@ const ( scoping = `^ --- -* $` - // a label is field of a FQDN + // label is a field of a FQDN label = `(?: [- \w]+ )` // hyphen and word chars, the rest is done by the parser // word diff --git a/internal/grammar/grammar_test.go b/internal/grammar/grammar_test.go index 45b56c9..4bcbda9 100644 --- a/internal/grammar/grammar_test.go +++ b/internal/grammar/grammar_test.go @@ -15,31 +15,102 @@ func TestGrammar(t *testing.T) { } } -func TestGrammarWords(t *testing.T) { +func TestGrammarRules(t *testing.T) { t.Parallel() + tt := []struct { rule string input string want bool }{ - { - rule: "word", - input: "with-hyphen", - want: true, - }, - { - rule: "cswords", - input: "std,open", - want: true, - }, + // ── word ──────────────────────────────────────────────────────────── + {rule: "word", input: "hello", want: true}, + {rule: "word", input: "with-hyphen", want: true}, + {rule: "word", input: "word123", want: true}, + {rule: "word", input: "_underscore", want: true}, + {rule: "word", input: "", want: false}, + + // ── cswords ───────────────────────────────────────────────────────── + {rule: "cswords", input: "single", want: true}, + {rule: "cswords", input: "std,open", want: true}, + {rule: "cswords", input: "a,b-c,d", want: true}, + {rule: "cswords", input: ",leading,comma", want: false}, + {rule: "cswords", input: "trailing,comma,", want: false}, + {rule: "cswords", input: "a,,b", want: false}, // double comma + {rule: "cswords", input: "-starts,with,hyphen", want: false}, + {rule: "cswords", input: "with space", want: false}, // spaces not allowed + + // ── comment ───────────────────────────────────────────────────────── + {rule: "comment", input: "# this is a comment", want: true}, + {rule: "comment", input: "#", want: true}, + {rule: "comment", input: "# ", want: true}, + {rule: "comment", input: "not a comment", want: false}, + {rule: "comment", input: "", want: false}, + {rule: "comment", input: " # indented", want: false}, // ^ anchors at start + + // ── scoping ───────────────────────────────────────────────────────── + {rule: "scoping", input: "---", want: true}, + {rule: "scoping", input: "----", want: true}, + {rule: "scoping", input: "----------", want: true}, + {rule: "scoping", input: "--", want: false}, // minimum three dashes + {rule: "scoping", input: "- -", want: false}, // no spaces + {rule: "scoping", input: "--- x", want: false}, // nothing after dashes + + // ── fqdn ──────────────────────────────────────────────────────────── + {rule: "fqdn", input: "www.example.com", want: true}, + {rule: "fqdn", input: "www.example.com.", want: true}, // trailing dot allowed + {rule: "fqdn", input: "*.foo.bar", want: true}, // wildcard prefix + {rule: "fqdn", input: "example.com", want: true}, + {rule: "fqdn", input: "a.b.c.d.e.tld", want: true}, + {rule: "fqdn", input: "localhost", want: false}, // bare hostname, no dot + {rule: "fqdn", input: "192.168.1.1", want: false}, // TLD must end with letter + {rule: "fqdn", input: "", want: false}, + {rule: "fqdn", input: "foo bar.com", want: false}, // spaces not allowed in label + + // ── ident (no trailing $, anchored only at start) ─────────────────── + {rule: "ident", input: "VLAN", want: true}, + {rule: "ident", input: "VRF", want: true}, + {rule: "ident", input: "STATUS_OK", want: true}, + {rule: "ident", input: "VLAN10", want: true}, + {rule: "ident", input: "A9", want: true}, + {rule: "ident", input: "A", want: false}, // [A-Z0-9_]+ needs ≥1 more char + {rule: "ident", input: "lowercase", want: false}, + {rule: "ident", input: "Mixed", want: false}, + {rule: "ident", input: "1NVALID", want: false}, // must start with A-Z + + // ── ip (no trailing $, anchored only at start) ─────────────────────── + {rule: "ip", input: "192.168.1.1", want: true}, + {rule: "ip", input: "192.168.1.1/24", want: true}, // prefix is fine, no trailing $ + {rule: "ip", input: "10.0.0.1", want: true}, + {rule: "ip", input: "2001:db8::1", want: true}, + {rule: "ip", input: "::1", want: true}, + {rule: "ip", input: "not-an-ip", want: false}, + {rule: "ip", input: "", want: false}, + + // ── ipv4 (no trailing $) ───────────────────────────────────────────── + {rule: "ipv4", input: "192.168.1.1", want: true}, + {rule: "ipv4", input: "0.0.0.0", want: true}, + {rule: "ipv4", input: "255.255.255.255", want: true}, + {rule: "ipv4", input: "192.168.1.1/24", want: true}, // CIDR — no trailing $ intended + {rule: "ipv4", input: "2001:db8::1", want: false}, // IPv6 must not match ipv4 + {rule: "ipv4", input: "1.2.3", want: false}, // only three octets + {rule: "ipv4", input: "not-an-ip", want: false}, + + // ── ipv6 (no trailing $) ───────────────────────────────────────────── + {rule: "ipv6", input: "2001:db8::1 foo", want: true}, + {rule: "ipv6", input: "::1", want: true}, + {rule: "ipv6", input: "fe80::1", want: true}, + {rule: "ipv6", input: "2001:db8::1/64", want: true}, // prefix — no trailing $ intended + {rule: "ipv6", input: "192.168.1.1", want: false}, // pure dotted-decimal has no colon + {rule: "ipv6", input: "not-an-ip", want: false}, } for _, tc := range tt { - t.Run(tc.input, func(t *testing.T) { + t.Run(tc.rule+"/"+tc.input, func(t *testing.T) { t.Parallel() got := g.MustRx(tc.rule).MatchString(tc.input) if got != tc.want { - t.Fatalf("rule: %s, input: %s, got %v, want %v", tc.rule, tc.input, got, tc.want) + t.Fatalf("rule=%q input=%q: got %v, want %v", tc.rule, tc.input, got, tc.want) } }) } From 11f20c8df95eb52c4907bd1d5ec18c01dd723714 Mon Sep 17 00:00:00 2001 From: Karl Gaissmaier Date: Wed, 17 Jun 2026 20:56:01 +0200 Subject: [PATCH 4/4] use path anker in workflow yaml --- .github/workflows/go.yml | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 2a6a214..2bf6ac2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,47 +2,50 @@ name: CI on: push: branches: ["main", "devel"] - paths: + paths: &paths - 'go.mod' - '**.go' - '**.yml' - '**.yaml' pull_request: branches: ["main", "devel"] - paths: - - 'go.mod' - - '**.go' - - '**.yml' - - '**.yaml' + paths: *paths +# Best Practice: Restrict global permissions to minimum required (Read-Only) permissions: contents: read jobs: test: - strategy: - fail-fast: true runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-go@v6 with: + # Disable credential persisting for enhanced security persist-credentials: false + + - uses: actions/setup-go@v6 + with: go-version-file: go.mod - cache: true - - run: go test -v ./... + cache: true # Enable build caching for faster test execution + + - name: Run Tests + run: go test -v ./... linting: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-go@v6 with: persist-credentials: false + + - uses: actions/setup-go@v6 + with: go-version-file: go.mod - cache: true + cache: false # golangci-lint-action handles its own caching - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.12.0 + # Use 'latest' to prevent version mismatch with go.mod + version: latest