feat: centralize Barkfile schema
Some checks failed
Test and Release Module / test (push) Failing after 8s
Test and Release Module / release (push) Has been skipped

This commit is contained in:
2026-09-15 19:44:54 -04:00
commit c7654ea72a
9 changed files with 1163 additions and 0 deletions

View File

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

83
README.md Normal file
View File

@@ -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 <host:port>` 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`.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.campbellwireless.net/barkstack/barkfile-parser
go 1.24

390
parser.go Normal file
View File

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

254
parser_test.go Normal file
View File

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

38
schema.go Normal file
View File

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

115
validate.go Normal file
View File

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

112
watch.go Normal file
View File

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

75
watch_test.go Normal file
View File

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