feat: centralize Barkfile schema
This commit is contained in:
93
.gitea/workflows/test-and-release.yml
Normal file
93
.gitea/workflows/test-and-release.yml
Normal 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
83
README.md
Normal 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
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.campbellwireless.net/barkstack/barkfile-parser
|
||||||
|
|
||||||
|
go 1.24
|
||||||
390
parser.go
Normal file
390
parser.go
Normal 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
254
parser_test.go
Normal 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
38
schema.go
Normal 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
115
validate.go
Normal 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
112
watch.go
Normal 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
75
watch_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user