From c7654ea72a967111d01daa99a0976166da0cb89a Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Tue, 15 Sep 2026 19:44:54 -0400 Subject: [PATCH] feat: centralize Barkfile schema --- .gitea/workflows/test-and-release.yml | 93 ++++++ README.md | 83 ++++++ go.mod | 3 + parser.go | 390 ++++++++++++++++++++++++++ parser_test.go | 254 +++++++++++++++++ schema.go | 38 +++ validate.go | 115 ++++++++ watch.go | 112 ++++++++ watch_test.go | 75 +++++ 9 files changed, 1163 insertions(+) create mode 100644 .gitea/workflows/test-and-release.yml create mode 100644 README.md create mode 100644 go.mod create mode 100644 parser.go create mode 100644 parser_test.go create mode 100644 schema.go create mode 100644 validate.go create mode 100644 watch.go create mode 100644 watch_test.go diff --git a/.gitea/workflows/test-and-release.yml b/.gitea/workflows/test-and-release.yml new file mode 100644 index 0000000..1b07e6e --- /dev/null +++ b/.gitea/workflows/test-and-release.yml @@ -0,0 +1,93 @@ +name: Test and Release Module + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + env: + REPO_URL: https://cloud.campbellwireless.net/git/${{ github.repository }}.git + run: | + set -eux + git init . + git remote add origin "$REPO_URL" + auth="$(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64 | tr -d '\n')" + git config --local http.https://cloud.campbellwireless.net/.extraheader "AUTHORIZATION: basic $auth" + git fetch --prune --no-recurse-submodules origin +refs/heads/*:refs/remotes/origin/* +refs/tags/*:refs/tags/* + git checkout --detach "${{ github.sha }}" + + - name: Test module + run: go test ./... + + release: + if: ${{ github.event_name == 'push' }} + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout full history + env: + REPO_URL: https://cloud.campbellwireless.net/git/${{ github.repository }}.git + run: | + set -eux + git init . + git remote add origin "$REPO_URL" + auth="$(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64 | tr -d '\n')" + git config --local http.https://cloud.campbellwireless.net/.extraheader "AUTHORIZATION: basic $auth" + git fetch --prune --no-recurse-submodules origin +refs/heads/*:refs/remotes/origin/* +refs/tags/*:refs/tags/* + git checkout --detach "${{ github.sha }}" + + - name: Determine semantic version + id: version + run: | + set -eu + previous="$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1)" + range=HEAD + if [ -n "$previous" ]; then + range="${previous}..HEAD" + fi + messages="$(git log --format='%B%n---commit---' "$range")" + if [ -z "$messages" ]; then + exit 0 + fi + + bump="" + if printf '%s\n' "$messages" | grep -Eq '(^[a-z]+(\([^)]+\))?!:|BREAKING CHANGE:)'; then + bump=major + elif printf '%s\n' "$messages" | grep -Eq '^feat(\([^)]+\))?:'; then + bump=minor + elif printf '%s\n' "$messages" | grep -Eq '^(fix|perf)(\([^)]+\))?:'; then + bump=patch + else + exit 0 + fi + + version="${previous#v}" + if [ -z "$version" ]; then + version=0.0.0 + fi + IFS=. read -r major minor patch <> "$GITHUB_OUTPUT" + + - name: Create and push version tag + if: ${{ steps.version.outputs.tag != '' }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -eu + git config user.name "Gitea Actions" + git config user.email "actions@campbellwireless.net" + git tag --annotate "$TAG" --message "Release $TAG" + git push origin "$TAG" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d7b962 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Barkfile Parser + +`barkfile-parser` is the Go source of truth for the Barkfile schema. It parses and validates the current PawSQL `pawsql` document and watches a Barkfile for validated revisions so control-plane consumers can reconfigure without owning another parser. + +## Install + +```sh +go get git.campbellwireless.net/barkstack/barkfile-parser@v0.1.0 +``` + +The module requires Go 1.24 or later. + +## Parse and validate + +```go +cfg, err := barkfile.Load("/etc/pawsql/Barkfile") +if err != nil { + return err +} +``` + +`Load` reads, parses, and validates a file. `Parse` parses bytes when a caller owns file I/O; call `Config.Validate` before applying a parsed configuration. + +The current schema defines one `pawsql` block with a listener, TLS certificate/key paths, and one or more `database` routes. A route has exactly one of: + +- `upstream ` for an external PostgreSQL server. +- `postgres { ... }` for a managed PostgreSQL container. Its `image`, `volume`, and `password_env` directives are required. `idle_timeout` and `traffic_idle_timeout` accept Go duration strings. + +```text +pawsql { + listen :5432 + + tls { + cert /etc/pawsql/tls/fullchain.pem + key /etc/pawsql/tls/privkey.pem + } + + database application { + hostname app.db.example.com + postgres { + image postgres:18 + volume application-data + password_env APPLICATION_POSTGRES_PASSWORD + idle_timeout 10m + traffic_idle_timeout 1h + } + } +} +``` + +## Watch validated changes + +```go +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +changes, err := barkfile.Watch(ctx, "/etc/pawsql/Barkfile", barkfile.WatchOptions{}) +if err != nil { + return err +} +for change := range changes { + if change.Err != nil { + log.Printf("Barkfile update rejected: %v", change.Err) + continue + } + apply(change.Config) +} +``` + +`Watch` emits the initial valid configuration, then each content change. It polls every 250ms by default; set `WatchOptions.PollInterval` to override it. Invalid or temporarily unreadable revisions are emitted as `Change.Err` and do not stop the watcher. A later valid revision is emitted normally. + +## Releases + +Gitea Actions runs tests on each `main` push. The release job reads Conventional Commit messages since the last `v*` tag and pushes a new semantic-version tag when needed: + +| Commit | Version change | +| --- | --- | +| `feat:` | minor | +| `fix:` or `perf:` | patch | +| `type!:` or `BREAKING CHANGE:` | major | +| other types | no release | + +Go consumers update through standard module versions, for example `go get git.campbellwireless.net/barkstack/barkfile-parser@latest`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4288e4e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.campbellwireless.net/barkstack/barkfile-parser + +go 1.24 diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..fb9208e --- /dev/null +++ b/parser.go @@ -0,0 +1,390 @@ +package barkfile + +import ( + "fmt" + "os" + "strings" + "time" + "unicode" +) + +func ParseFile(path string) (Config, error) { + contents, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read Barkfile: %w", err) + } + return Parse(contents) +} + +// Parse parses a Barkfile document using the current Barkfile schema. +func Parse(input []byte) (Config, error) { + tokens, err := lex(string(input)) + if err != nil { + return Config{}, err + } + p := parser{tokens: tokens} + return p.parse() +} + +type tokenKind uint8 + +const ( + tokenWord tokenKind = iota + tokenOpenBrace + tokenCloseBrace + tokenNewline + tokenEOF +) + +type token struct { + kind tokenKind + text string + line int +} + +type parser struct { + tokens []token + index int +} + +func (p *parser) parse() (Config, error) { + p.skipNewlines() + if err := p.expectWord("pawsql"); err != nil { + return Config{}, err + } + if err := p.expect(tokenOpenBrace, "{"); err != nil { + return Config{}, err + } + var cfg Config + for { + p.skipNewlines() + if p.current().kind == tokenCloseBrace { + p.index++ + break + } + if p.current().kind == tokenEOF { + return Config{}, p.errorf("expected } to close pawsql block") + } + if p.current().kind != tokenWord { + return Config{}, p.errorf("expected directive") + } + switch p.current().text { + case "listen": + if cfg.Listen != "" { + return Config{}, p.errorf("listen may only be specified once") + } + p.index++ + value, err := p.value("listen address") + if err != nil { + return Config{}, err + } + cfg.Listen = value + if err := p.endLine(); err != nil { + return Config{}, err + } + case "tls": + if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" { + return Config{}, p.errorf("tls may only be specified once") + } + p.index++ + tlsConfig, err := p.parseTLS() + if err != nil { + return Config{}, err + } + cfg.TLS = tlsConfig + case "database": + p.index++ + name, err := p.value("database name") + if err != nil { + return Config{}, err + } + database, err := p.parseDatabase(name) + if err != nil { + return Config{}, err + } + cfg.Databases = append(cfg.Databases, database) + default: + return Config{}, p.errorf("unknown directive %q", p.current().text) + } + } + p.skipNewlines() + if p.current().kind != tokenEOF { + return Config{}, p.errorf("unexpected content after pawsql block") + } + return cfg, nil +} + +func (p *parser) parseTLS() (TLSConfig, error) { + if err := p.expect(tokenOpenBrace, "{"); err != nil { + return TLSConfig{}, err + } + var cfg TLSConfig + for { + p.skipNewlines() + if p.current().kind == tokenCloseBrace { + p.index++ + return cfg, nil + } + if p.current().kind == tokenEOF { + return TLSConfig{}, p.errorf("expected } to close tls block") + } + name := p.current() + if name.kind != tokenWord { + return TLSConfig{}, p.errorf("expected tls directive") + } + p.index++ + value, err := p.value("tls value") + if err != nil { + return TLSConfig{}, err + } + switch name.text { + case "cert": + if cfg.CertFile != "" { + return TLSConfig{}, fmt.Errorf("line %d: cert may only be specified once", name.line) + } + cfg.CertFile = value + case "key": + if cfg.KeyFile != "" { + return TLSConfig{}, fmt.Errorf("line %d: key may only be specified once", name.line) + } + cfg.KeyFile = value + default: + return TLSConfig{}, fmt.Errorf("line %d: unknown tls directive %q", name.line, name.text) + } + if err := p.endLine(); err != nil { + return TLSConfig{}, err + } + } +} + +func (p *parser) parseDatabase(name string) (DatabaseConfig, error) { + if err := p.expect(tokenOpenBrace, "{"); err != nil { + return DatabaseConfig{}, err + } + database := DatabaseConfig{Name: name} + for { + p.skipNewlines() + if p.current().kind == tokenCloseBrace { + p.index++ + return database, nil + } + if p.current().kind == tokenEOF { + return DatabaseConfig{}, p.errorf("expected } to close database block") + } + directive := p.current() + if directive.kind != tokenWord { + return DatabaseConfig{}, p.errorf("expected database directive") + } + p.index++ + if directive.text == "postgres" { + if database.Postgres != nil { + return DatabaseConfig{}, fmt.Errorf("line %d: postgres may only be specified once", directive.line) + } + postgres, err := p.parsePostgres() + if err != nil { + return DatabaseConfig{}, err + } + database.Postgres = &postgres + continue + } + value, err := p.value("database value") + if err != nil { + return DatabaseConfig{}, err + } + switch directive.text { + case "hostname": + if database.Hostname != "" { + return DatabaseConfig{}, fmt.Errorf("line %d: hostname may only be specified once", directive.line) + } + database.Hostname = value + case "upstream": + if database.Upstream != "" { + return DatabaseConfig{}, fmt.Errorf("line %d: upstream may only be specified once", directive.line) + } + database.Upstream = value + default: + return DatabaseConfig{}, fmt.Errorf("line %d: unknown database directive %q", directive.line, directive.text) + } + if err := p.endLine(); err != nil { + return DatabaseConfig{}, err + } + } +} + +func (p *parser) parsePostgres() (PostgresConfig, error) { + if err := p.expect(tokenOpenBrace, "{"); err != nil { + return PostgresConfig{}, err + } + var cfg PostgresConfig + idleTimeoutSet := false + trafficIdleTimeoutSet := false + for { + p.skipNewlines() + if p.current().kind == tokenCloseBrace { + p.index++ + return cfg, nil + } + if p.current().kind == tokenEOF { + return PostgresConfig{}, p.errorf("expected } to close postgres block") + } + directive := p.current() + if directive.kind != tokenWord { + return PostgresConfig{}, p.errorf("expected postgres directive") + } + p.index++ + value, err := p.value("postgres value") + if err != nil { + return PostgresConfig{}, err + } + switch directive.text { + case "image": + if cfg.Image != "" { + return PostgresConfig{}, fmt.Errorf("line %d: image may only be specified once", directive.line) + } + cfg.Image = value + case "volume": + if cfg.Volume != "" { + return PostgresConfig{}, fmt.Errorf("line %d: volume may only be specified once", directive.line) + } + cfg.Volume = value + case "password_env": + if cfg.PasswordEnv != "" { + return PostgresConfig{}, fmt.Errorf("line %d: password_env may only be specified once", directive.line) + } + cfg.PasswordEnv = value + case "idle_timeout": + if idleTimeoutSet { + return PostgresConfig{}, fmt.Errorf("line %d: idle_timeout may only be specified once", directive.line) + } + idleTimeoutSet = true + timeout, err := time.ParseDuration(value) + if err != nil { + return PostgresConfig{}, fmt.Errorf("line %d: invalid idle_timeout %q: %w", directive.line, value, err) + } + cfg.IdleTimeout = timeout + case "traffic_idle_timeout": + if trafficIdleTimeoutSet { + return PostgresConfig{}, fmt.Errorf("line %d: traffic_idle_timeout may only be specified once", directive.line) + } + trafficIdleTimeoutSet = true + timeout, err := time.ParseDuration(value) + if err != nil { + return PostgresConfig{}, fmt.Errorf("line %d: invalid traffic_idle_timeout %q: %w", directive.line, value, err) + } + cfg.TrafficIdleTimeout = timeout + default: + return PostgresConfig{}, fmt.Errorf("line %d: unknown postgres directive %q", directive.line, directive.text) + } + if err := p.endLine(); err != nil { + return PostgresConfig{}, err + } + } +} + +func (p *parser) value(description string) (string, error) { + current := p.current() + if current.kind != tokenWord { + return "", p.errorf("expected %s", description) + } + p.index++ + return current.text, nil +} + +func (p *parser) endLine() error { + if p.current().kind == tokenNewline { + p.skipNewlines() + return nil + } + if p.current().kind == tokenCloseBrace || p.current().kind == tokenEOF { + return nil + } + return p.errorf("expected end of line") +} + +func (p *parser) expectWord(word string) error { + if p.current().kind != tokenWord || p.current().text != word { + return p.errorf("expected %q", word) + } + p.index++ + return nil +} +func (p *parser) expect(kind tokenKind, name string) error { + if p.current().kind != kind { + return p.errorf("expected %s", name) + } + p.index++ + return nil +} +func (p *parser) skipNewlines() { + for p.current().kind == tokenNewline { + p.index++ + } +} +func (p *parser) current() token { return p.tokens[p.index] } +func (p *parser) errorf(format string, args ...any) error { + return fmt.Errorf("line %d: %s", p.current().line, fmt.Sprintf(format, args...)) +} + +func lex(input string) ([]token, error) { + var tokens []token + line := 1 + for index := 0; index < len(input); { + ch := input[index] + switch { + case ch == '#': + for index < len(input) && input[index] != '\n' { + index++ + } + case ch == '\n': + tokens = append(tokens, token{kind: tokenNewline, line: line}) + index++ + line++ + case unicode.IsSpace(rune(ch)): + index++ + case ch == '{': + tokens = append(tokens, token{kind: tokenOpenBrace, text: "{", line: line}) + index++ + case ch == '}': + tokens = append(tokens, token{kind: tokenCloseBrace, text: "}", line: line}) + index++ + case ch == '"': + startLine := line + index++ + var value strings.Builder + terminated := false + for index < len(input) { + if input[index] == '\n' { + return nil, fmt.Errorf("line %d: unterminated quoted string", startLine) + } + if input[index] == '"' { + index++ + terminated = true + break + } + if input[index] == '\\' && index+1 < len(input) { + index++ + value.WriteByte(input[index]) + index++ + continue + } + value.WriteByte(input[index]) + index++ + } + if !terminated { + return nil, fmt.Errorf("line %d: unterminated quoted string", startLine) + } + tokens = append(tokens, token{kind: tokenWord, text: value.String(), line: startLine}) + default: + start := index + for index < len(input) && !unicode.IsSpace(rune(input[index])) && !strings.ContainsRune("{}#\"", rune(input[index])) { + index++ + } + if start == index { + return nil, fmt.Errorf("line %d: unexpected character %q", line, input[index]) + } + tokens = append(tokens, token{kind: tokenWord, text: input[start:index], line: line}) + } + } + tokens = append(tokens, token{kind: tokenEOF, line: line}) + return tokens, nil +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..198f06d --- /dev/null +++ b/parser_test.go @@ -0,0 +1,254 @@ +package barkfile + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestParseValidConfiguration(t *testing.T) { + cfg, err := Parse([]byte(`pawsql { + listen :5432 + tls { + cert "./certs/fullchain.pem" + key ./certs/privkey.pem + } + database gramps { + hostname Gramps.PawSQL.Barkstack.Dev + upstream 192.168.27.10:5432 + } +}`)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if cfg.Listen != ":5432" { + t.Errorf("Listen = %q", cfg.Listen) + } + if cfg.TLS.CertFile != "./certs/fullchain.pem" || cfg.TLS.KeyFile != "./certs/privkey.pem" { + t.Errorf("TLS = %#v", cfg.TLS) + } + if len(cfg.Databases) != 1 { + t.Fatalf("databases = %d", len(cfg.Databases)) + } + if got := cfg.Databases[0]; got.Name != "gramps" || got.Hostname != "Gramps.PawSQL.Barkstack.Dev" || got.Upstream != "192.168.27.10:5432" { + t.Errorf("database = %#v", got) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestParseCommentsAndMultipleDatabases(t *testing.T) { + cfg, err := Parse([]byte(`# external comment +pawsql { + listen :5432 # client port + tls { cert cert.pem # inline + key key.pem } + database one { hostname one.pawsql.test + upstream postgres-one:5432 } + # route another application + database two { hostname two.pawsql.test + upstream 127.0.0.1:55432 } +}`)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if len(cfg.Databases) != 2 { + t.Fatalf("databases = %d, want 2", len(cfg.Databases)) + } + if cfg.Databases[1].Name != "two" || cfg.Databases[1].Upstream != "127.0.0.1:55432" { + t.Errorf("second database = %#v", cfg.Databases[1]) + } +} + +func TestValidateMissingFieldsAndDuplicateHostname(t *testing.T) { + cfg, err := Parse([]byte(`pawsql { + listen :5432 + tls { cert cert.pem } + database one { + hostname FOO.pawsql.test + } + database two { + hostname foo.pawsql.test + upstream postgres-two:5432 + } +}`)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + err = cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil") + } + for _, want := range []string{"TLS private key is required", "upstream or postgres is required", "duplicate hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Validate() error = %q, missing %q", err, want) + } + } +} + +func TestValidateRejectsMalformedUpstreamAddress(t *testing.T) { + cfg := Config{ + Listen: ":5432", + TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, + Databases: []DatabaseConfig{{ + Name: "foo", + Hostname: "foo.pawsql.test", + Upstream: "postgres-foo", + }}, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "missing port") { + t.Fatalf("Validate() error = %v, want malformed upstream address", err) + } +} + +func TestValidateAllowsDatabaseWithoutHostname(t *testing.T) { + cfg := Config{ + Listen: ":5432", + TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, + Databases: []DatabaseConfig{{ + Name: "analytics", + Upstream: "postgres-foo:5432", + }}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestValidateRejectsDuplicateDatabaseName(t *testing.T) { + cfg := Config{ + Listen: ":5432", + TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, + Databases: []DatabaseConfig{ + {Name: "analytics", Upstream: "postgres-foo:5432"}, + {Name: "analytics", Upstream: "postgres-bar:5432"}, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "duplicate database name") { + t.Fatalf("Validate() error = %v, want duplicate database name", err) + } +} + +func TestParsePostgresContainer(t *testing.T) { + cfg, err := Parse([]byte(`pawsql { + listen :5432 + tls { + cert cert.pem + key key.pem + } + database analytics { + postgres { + image postgres:18 + volume analytics-data + password_env ANALYTICS_POSTGRES_PASSWORD + idle_timeout 15m + traffic_idle_timeout 1h + } + } +}`)) + if err != nil { + t.Fatal(err) + } + database := cfg.Databases[0] + if database.Postgres == nil || database.Postgres.Image != "postgres:18" || database.Postgres.Volume != "analytics-data" || database.Postgres.PasswordEnv != "ANALYTICS_POSTGRES_PASSWORD" || database.Postgres.IdleTimeout != 15*time.Minute || database.Postgres.TrafficIdleTimeout != time.Hour { + t.Errorf("Postgres = %#v", database.Postgres) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestParseRejectsInvalidPostgresIdleTimeout(t *testing.T) { + _, err := Parse([]byte(`pawsql { + listen :5432 + tls { + cert cert.pem + key key.pem + } + database analytics { + postgres { + image postgres:18 + volume analytics-data + password_env ANALYTICS_POSTGRES_PASSWORD + idle_timeout whenever + } + } +}`)) + if err == nil || !strings.Contains(err.Error(), "invalid idle_timeout") { + t.Fatalf("Parse() error = %v, want invalid idle_timeout", err) + } +} + +func TestParseRejectsInvalidPostgresTrafficIdleTimeout(t *testing.T) { + _, err := Parse([]byte(`pawsql { + listen :5432 + tls { + cert cert.pem + key key.pem + } + database analytics { + postgres { + image postgres:18 + volume analytics-data + password_env ANALYTICS_POSTGRES_PASSWORD + traffic_idle_timeout whenever + } + } +}`)) + if err == nil || !strings.Contains(err.Error(), "invalid traffic_idle_timeout") { + t.Fatalf("Parse() error = %v, want invalid traffic_idle_timeout", err) + } +} + +func TestValidateAllowsSupportedPostgresImages(t *testing.T) { + for _, image := range []string{"postgres:16", "postgres:17", "postgres:18"} { + cfg := Config{ + Listen: ":5432", + TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, + Databases: []DatabaseConfig{{ + Name: "analytics", + Postgres: &PostgresConfig{Image: image, Volume: "analytics-data", PasswordEnv: "ANALYTICS_POSTGRES_PASSWORD"}, + }}, + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() image %q error = %v", image, err) + } + } +} + +func TestValidateRejectsUnsupportedPostgresImage(t *testing.T) { + cfg := Config{ + Listen: ":5432", + TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, + Databases: []DatabaseConfig{{ + Name: "analytics", + Postgres: &PostgresConfig{Image: "postgres:15", Volume: "analytics-data", PasswordEnv: "ANALYTICS_POSTGRES_PASSWORD"}, + }}, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "postgres:16") { + t.Fatalf("Validate() error = %v, want unsupported PostgreSQL image", err) + } +} + +func TestParseMalformedBlocksReportLine(t *testing.T) { + _, err := Parse([]byte("pawsql {\n tls {\n cert cert.pem\n")) + if err == nil { + t.Fatal("Parse() error = nil") + } + if !strings.Contains(err.Error(), "line 4") { + t.Errorf("error = %q, want line number", err) + } + + _, err = Parse([]byte("pawsql {\n listen :5432 unexpected\n}")) + if err == nil { + t.Fatal("Parse() error = nil for trailing directive") + } + if errors.Is(err, nil) { + t.Fatal("unexpected nil error") + } +} diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..11b95b1 --- /dev/null +++ b/schema.go @@ -0,0 +1,38 @@ +// Package barkfile parses, validates, and watches Barkfile configuration. +package barkfile + +import "time" + +// Config is the typed representation of a Barkfile. +type Config struct { + Listen string + TLS TLSConfig + Databases []DatabaseConfig +} + +// TLSConfig identifies the certificate material used to terminate client TLS. +type TLSConfig struct { + CertFile string + KeyFile string +} + +// DatabaseConfig describes one PostgreSQL database. Hostname is optional: +// clients without TLS SNI route by the database's configured Name. Exactly one +// of Upstream and Postgres must be configured. +type DatabaseConfig struct { + Name string + Hostname string + Upstream string + Postgres *PostgresConfig +} + +// PostgresConfig declares a PawSQL-managed PostgreSQL container. IdleTimeout +// starts after the last proxied client session closes. TrafficIdleTimeout +// applies to open sessions with no proxied bytes. Zero disables either timeout. +type PostgresConfig struct { + Image string + Volume string + PasswordEnv string + IdleTimeout time.Duration + TrafficIdleTimeout time.Duration +} diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..372e839 --- /dev/null +++ b/validate.go @@ -0,0 +1,115 @@ +package barkfile + +import ( + "errors" + "fmt" + "net" + "strconv" + "strings" +) + +// NormalizeHostname returns the canonical lookup form for a DNS hostname. +func NormalizeHostname(hostname string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hostname)), ".") +} + +// Validate verifies configuration invariants that do not require opening files. +func (c Config) Validate() error { + var errs []error + if strings.TrimSpace(c.Listen) == "" { + errs = append(errs, errors.New("listen address is required")) + } else if err := validateListenAddress(c.Listen); err != nil { + errs = append(errs, fmt.Errorf("listen address %q: %w", c.Listen, err)) + } + if strings.TrimSpace(c.TLS.CertFile) == "" { + errs = append(errs, errors.New("TLS certificate is required")) + } + if strings.TrimSpace(c.TLS.KeyFile) == "" { + errs = append(errs, errors.New("TLS private key is required")) + } + if len(c.Databases) == 0 { + errs = append(errs, errors.New("at least one database route is required")) + } + + seenHostnames := make(map[string]string, len(c.Databases)) + seenNames := make(map[string]struct{}, len(c.Databases)) + for _, database := range c.Databases { + name := strings.TrimSpace(database.Name) + if name == "" { + errs = append(errs, errors.New("database name is required")) + } else if _, exists := seenNames[name]; exists { + errs = append(errs, fmt.Errorf("duplicate database name %q", name)) + } else { + seenNames[name] = struct{}{} + } + hostname := NormalizeHostname(database.Hostname) + if hostname != "" { + if existing, ok := seenHostnames[hostname]; ok { + errs = append(errs, fmt.Errorf("duplicate hostname %q for databases %q and %q", hostname, existing, name)) + } else { + seenHostnames[hostname] = name + } + } + if database.Postgres != nil { + if database.Upstream != "" { + errs = append(errs, fmt.Errorf("database %q: upstream and postgres cannot both be configured", name)) + } + if err := validatePostgres(*database.Postgres); err != nil { + errs = append(errs, fmt.Errorf("database %q: postgres: %w", name, err)) + } + } else if strings.TrimSpace(database.Upstream) == "" { + errs = append(errs, fmt.Errorf("database %q: upstream or postgres is required", name)) + } else if err := validateAddress(database.Upstream); err != nil { + errs = append(errs, fmt.Errorf("database %q: upstream %q: %w", name, database.Upstream, err)) + } + } + return errors.Join(errs...) +} + +func validatePostgres(postgres PostgresConfig) error { + switch postgres.Image { + case "postgres:16", "postgres:17", "postgres:18": + default: + return fmt.Errorf("image %q must be postgres:16, postgres:17, or postgres:18", postgres.Image) + } + if strings.TrimSpace(postgres.Volume) == "" { + return errors.New("volume is required") + } + if strings.TrimSpace(postgres.PasswordEnv) == "" { + return errors.New("password_env is required") + } + if postgres.IdleTimeout < 0 { + return errors.New("idle_timeout cannot be negative") + } + if postgres.TrafficIdleTimeout < 0 { + return errors.New("traffic_idle_timeout cannot be negative") + } + return nil +} + +func validateListenAddress(address string) error { + _, port, err := net.SplitHostPort(address) + if err != nil { + return err + } + portNumber, err := strconv.ParseUint(port, 10, 16) + if err != nil || portNumber == 0 { + return errors.New("port must be between 1 and 65535") + } + return nil +} + +func validateAddress(address string) error { + host, port, err := net.SplitHostPort(address) + if err != nil { + return err + } + if strings.TrimSpace(host) == "" { + return errors.New("host is required") + } + portNumber, err := strconv.ParseUint(port, 10, 16) + if err != nil || portNumber == 0 { + return errors.New("port must be between 1 and 65535") + } + return nil +} diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..660b838 --- /dev/null +++ b/watch.go @@ -0,0 +1,112 @@ +package barkfile + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "time" +) + +const defaultPollInterval = 250 * time.Millisecond + +// Change is one validated Barkfile revision or an error reading or validating it. +// Changes are delivered in source order and include the initial valid revision. +type Change struct { + Config Config + Err error +} + +// WatchOptions configures file watching. A zero PollInterval uses 250ms. +type WatchOptions struct { + PollInterval time.Duration +} + +// Load reads, parses, and validates a Barkfile. +func Load(path string) (Config, error) { + contents, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read Barkfile: %w", err) + } + return parseAndValidate(contents) +} + +// Watch emits the initial valid Barkfile and each subsequent file-content change. +// An invalid or unreadable revision is delivered as Change.Err; watching continues +// so a later valid revision is delivered. Cancel ctx to close the returned channel. +func Watch(ctx context.Context, path string, options WatchOptions) (<-chan Change, error) { + if ctx == nil { + return nil, errors.New("watch context is required") + } + interval := options.PollInterval + if interval == 0 { + interval = defaultPollInterval + } + if interval < 0 { + return nil, errors.New("watch poll interval cannot be negative") + } + + initial, fingerprint, err := loadRevision(path) + if err != nil { + return nil, err + } + changes := make(chan Change) + go watchLoop(ctx, path, interval, initial, fingerprint, changes) + return changes, nil +} + +func watchLoop(ctx context.Context, path string, interval time.Duration, initial Config, fingerprint [sha256.Size]byte, changes chan<- Change) { + defer close(changes) + if !sendChange(ctx, changes, Change{Config: initial}) { + return + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + config, nextFingerprint, err := loadRevision(path) + if nextFingerprint == fingerprint { + continue + } + fingerprint = nextFingerprint + if !sendChange(ctx, changes, Change{Config: config, Err: err}) { + return + } + } + } +} + +func sendChange(ctx context.Context, changes chan<- Change, change Change) bool { + select { + case changes <- change: + return true + case <-ctx.Done(): + return false + } +} + +func loadRevision(path string) (Config, [sha256.Size]byte, error) { + contents, err := os.ReadFile(path) + if err != nil { + return Config{}, sha256.Sum256([]byte("read error:\x00" + err.Error())), fmt.Errorf("read Barkfile: %w", err) + } + fingerprint := sha256.Sum256(contents) + config, err := parseAndValidate(contents) + return config, fingerprint, err +} + +func parseAndValidate(contents []byte) (Config, error) { + config, err := Parse(contents) + if err != nil { + return Config{}, err + } + if err := config.Validate(); err != nil { + return Config{}, fmt.Errorf("invalid Barkfile: %w", err) + } + return config, nil +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..521ae4b --- /dev/null +++ b/watch_test.go @@ -0,0 +1,75 @@ +package barkfile + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestWatchDeliversInitialAndChangedConfiguration(t *testing.T) { + path := filepath.Join(t.TempDir(), "Barkfile") + writeWatchConfig(t, path, ":5432") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + changes, err := Watch(ctx, path, WatchOptions{PollInterval: 10 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if change := nextChange(t, changes); change.Err != nil || change.Config.Listen != ":5432" { + t.Fatalf("initial change = %#v", change) + } + + writeWatchConfig(t, path, ":6543") + if change := nextChange(t, changes); change.Err != nil || change.Config.Listen != ":6543" { + t.Fatalf("changed configuration = %#v", change) + } +} + +func TestWatchReportsInvalidRevisionAndRecovers(t *testing.T) { + path := filepath.Join(t.TempDir(), "Barkfile") + writeWatchConfig(t, path, ":5432") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + changes, err := Watch(ctx, path, WatchOptions{PollInterval: 10 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + _ = nextChange(t, changes) + if err := os.WriteFile(path, []byte("pawsql {\n listen :5432\n}"), 0o600); err != nil { + t.Fatal(err) + } + if change := nextChange(t, changes); change.Err == nil { + t.Fatal("invalid revision did not report an error") + } + + writeWatchConfig(t, path, ":7654") + if change := nextChange(t, changes); change.Err != nil || change.Config.Listen != ":7654" { + t.Fatalf("recovered configuration = %#v", change) + } +} + +func nextChange(t *testing.T, changes <-chan Change) Change { + t.Helper() + select { + case change, open := <-changes: + if !open { + t.Fatal("watch channel closed before delivering a change") + } + return change + case <-time.After(time.Second): + t.Fatal("timed out waiting for Barkfile change") + return Change{} + } +} + +func writeWatchConfig(t *testing.T, path, listen string) { + t.Helper() + contents := "pawsql {\n listen " + listen + "\n tls {\n cert cert.pem\n key key.pem\n }\n database app {\n upstream 127.0.0.1:5432\n }\n}\n" + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +}