feat: use shared Barkfile parser
Some checks failed
Build and Push Image / docker-build-and-push (push) Failing after 1m22s
Test and Release PawSQL / test (push) Failing after 32s
Test and Release PawSQL / release (push) Has been skipped

This commit is contained in:
2026-09-15 19:52:13 -04:00
parent 865f7c26c9
commit 89db38417d
15 changed files with 110 additions and 805 deletions

View File

@@ -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 <<EOF
$version
EOF
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
echo "tag=v${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"

View File

@@ -11,7 +11,7 @@ import (
"os/signal" "os/signal"
"syscall" "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/postgres"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
"github.com/barkstack/pawsql/internal/server" "github.com/barkstack/pawsql/internal/server"

2
go.mod
View File

@@ -1,3 +1,5 @@
module github.com/barkstack/pawsql module github.com/barkstack/pawsql
go 1.24 go 1.24
require cloud.campbellwireless.net/git/barkstack/barkfile-parser v1.0.0

2
go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
) )
const managedDatabaseLabel = "io.barkstack.pawsql.database" const managedDatabaseLabel = "io.barkstack.pawsql.database"

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
) )
func TestProvisionerCreatesPersistentPostgres18(t *testing.T) { func TestProvisionerCreatesPersistentPostgres18(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )

View File

@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
) )
var ( var (

View File

@@ -5,7 +5,7 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/barkstack/pawsql/internal/config" config "cloud.campbellwireless.net/git/barkstack/barkfile-parser"
) )
func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) { func TestStaticResolverRoutesCaseInsensitiveHostnames(t *testing.T) {

View File

@@ -14,7 +14,7 @@ import (
"testing" "testing"
"time" "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/pgwire"
"github.com/barkstack/pawsql/internal/router" "github.com/barkstack/pawsql/internal/router"
) )