From 89db38417d7d3bd9e9d7808abf12874a771894c1 Mon Sep 17 00:00:00 2001 From: Shaun Campbell Date: Tue, 15 Sep 2026 19:52:13 -0400 Subject: [PATCH] feat: use shared Barkfile parser --- .gitea/workflows/test-and-release.yml | 98 +++++ cmd/pawsql/main.go | 2 +- go.mod | 2 + go.sum | 2 + internal/config/config.go | 38 -- internal/config/parser.go | 390 ------------------ internal/config/parser_test.go | 254 ------------ internal/config/validate.go | 115 ------ internal/postgres/provisioner.go | 2 +- .../postgres/provisioner_integration_test.go | 2 +- internal/postgres/resolver.go | 2 +- internal/postgres/resolver_test.go | 2 +- internal/router/routes.go | 2 +- internal/router/routes_test.go | 2 +- internal/server/server_test.go | 2 +- 15 files changed, 110 insertions(+), 805 deletions(-) create mode 100644 .gitea/workflows/test-and-release.yml create mode 100644 go.sum delete mode 100644 internal/config/config.go delete mode 100644 internal/config/parser.go delete mode 100644 internal/config/parser_test.go delete mode 100644 internal/config/validate.go diff --git a/.gitea/workflows/test-and-release.yml b/.gitea/workflows/test-and-release.yml new file mode 100644 index 0000000..f3eccd9 --- /dev/null +++ b/.gitea/workflows/test-and-release.yml @@ -0,0 +1,98 @@ +name: Test and Release PawSQL + +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: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test PawSQL + 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/cmd/pawsql/main.go b/cmd/pawsql/main.go index a8b3575..bec5b01 100644 --- a/cmd/pawsql/main.go +++ b/cmd/pawsql/main.go @@ -11,7 +11,7 @@ import ( "os/signal" "syscall" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" "github.com/barkstack/pawsql/internal/postgres" "github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/server" diff --git a/go.mod b/go.mod index 21d973e..201c780 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/barkstack/pawsql go 1.24 + +require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4233a68 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0 h1:81V2fr9ln2WNqA2JHb40fvav0aW+5OQAOsnuhMkuRes= +cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0/go.mod h1:UnKTlB8ifO3cmsrkh2LDAM+Y2ipaCrBeiURwrSRPPe4= diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index e86011c..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package config defines PawSQL's declarative runtime configuration. -package config - -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/internal/config/parser.go b/internal/config/parser.go deleted file mode 100644 index 74ac418..0000000 --- a/internal/config/parser.go +++ /dev/null @@ -1,390 +0,0 @@ -package config - -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 Barkfile contents. Syntax deliberately covers only PawSQL's MVP. -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/internal/config/parser_test.go b/internal/config/parser_test.go deleted file mode 100644 index c0080eb..0000000 --- a/internal/config/parser_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package config - -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/internal/config/validate.go b/internal/config/validate.go deleted file mode 100644 index 999c9b2..0000000 --- a/internal/config/validate.go +++ /dev/null @@ -1,115 +0,0 @@ -package config - -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/internal/postgres/provisioner.go b/internal/postgres/provisioner.go index 809641e..b40756c 100644 --- a/internal/postgres/provisioner.go +++ b/internal/postgres/provisioner.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" ) const managedDatabaseLabel = "io.barkstack.pawsql.database" diff --git a/internal/postgres/provisioner_integration_test.go b/internal/postgres/provisioner_integration_test.go index df237c8..60d9200 100644 --- a/internal/postgres/provisioner_integration_test.go +++ b/internal/postgres/provisioner_integration_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" ) func TestProvisionerCreatesPersistentPostgres18(t *testing.T) { diff --git a/internal/postgres/resolver.go b/internal/postgres/resolver.go index 4afef0c..cc54d18 100644 --- a/internal/postgres/resolver.go +++ b/internal/postgres/resolver.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" "github.com/barkstack/pawsql/internal/router" ) diff --git a/internal/postgres/resolver_test.go b/internal/postgres/resolver_test.go index 3266e4d..7ae3be6 100644 --- a/internal/postgres/resolver_test.go +++ b/internal/postgres/resolver_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" "github.com/barkstack/pawsql/internal/router" ) diff --git a/internal/router/routes.go b/internal/router/routes.go index 9c6bf40..e6ea5ec 100644 --- a/internal/router/routes.go +++ b/internal/router/routes.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" ) var ( diff --git a/internal/router/routes_test.go b/internal/router/routes_test.go index e3e1ef9..479c1aa 100644 --- a/internal/router/routes_test.go +++ b/internal/router/routes_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" ) func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e83714c..541dd0c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - "github.com/barkstack/pawsql/internal/config" + config "cloud.campbellwireless.net/git/barkstack/barkfile-parser" "github.com/barkstack/pawsql/internal/pgwire" "github.com/barkstack/pawsql/internal/router" )