3 Commits

Author SHA1 Message Date
34ccc01c26 feat!: replace password environment with Docker secret references
All checks were successful
Test and Release Module / test (push) Successful in 29s
Test and Release Module / release (push) Successful in 7s
2026-09-16 15:01:15 -04:00
c3ad476b1a feat: make TLS optional in Barkfile schema
All checks were successful
Test and Release Module / test (push) Successful in 21s
Test and Release Module / release (push) Successful in 12s
2026-09-15 21:59:48 -04:00
1851797a3f feat!: use Gitea module import path
All checks were successful
Test and Release Module / test (push) Successful in 18s
Test and Release Module / release (push) Successful in 8s
2026-09-15 19:49:49 -04:00
6 changed files with 124 additions and 28 deletions

View File

@@ -5,7 +5,7 @@
## Install ## Install
```sh ```sh
go get git.campbellwireless.net/barkstack/barkfile-parser@v0.1.0 go get cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2@v2.0.0
``` ```
The module requires Go 1.24 or later. The module requires Go 1.24 or later.
@@ -21,10 +21,10 @@ if err != nil {
`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. `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: The current schema defines one `pawsql` block with a listener, an optional TLS `cert`/`key` pair, and one or more `database` routes. A route has exactly one of:
- `upstream <host:port>` for an external PostgreSQL server. - `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. - `postgres { ... }` for a managed PostgreSQL container. Its `image`, `volume`, and `password_secret` directives are required. `password_secret` is a short lowercase reference resolved to the Docker secret `barkstack_<reference>`. `idle_timeout` and `traffic_idle_timeout` accept Go duration strings.
```text ```text
pawsql { pawsql {
@@ -40,7 +40,7 @@ pawsql {
postgres { postgres {
image postgres:18 image postgres:18
volume application-data volume application-data
password_env APPLICATION_POSTGRES_PASSWORD password_secret application_postgres_password
idle_timeout 10m idle_timeout 10m
traffic_idle_timeout 1h traffic_idle_timeout 1h
} }
@@ -48,6 +48,12 @@ pawsql {
} }
``` ```
Create the referenced secret before deploying PawSQL:
```sh
docker secret create barkstack_application_postgres_password /secure/path/application-postgres-password
```
## Watch validated changes ## Watch validated changes
```go ```go
@@ -80,4 +86,4 @@ Gitea Actions runs tests on each `main` push. The release job reads Conventional
| `type!:` or `BREAKING CHANGE:` | major | | `type!:` or `BREAKING CHANGE:` | major |
| other types | no release | | other types | no release |
Go consumers update through standard module versions, for example `go get git.campbellwireless.net/barkstack/barkfile-parser@latest`. Go consumers update through standard module versions, for example `go get cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2@latest`.

2
go.mod
View File

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

View File

@@ -247,11 +247,11 @@ func (p *parser) parsePostgres() (PostgresConfig, error) {
return PostgresConfig{}, fmt.Errorf("line %d: volume may only be specified once", directive.line) return PostgresConfig{}, fmt.Errorf("line %d: volume may only be specified once", directive.line)
} }
cfg.Volume = value cfg.Volume = value
case "password_env": case "password_secret":
if cfg.PasswordEnv != "" { if cfg.PasswordSecret != "" {
return PostgresConfig{}, fmt.Errorf("line %d: password_env may only be specified once", directive.line) return PostgresConfig{}, fmt.Errorf("line %d: password_secret may only be specified once", directive.line)
} }
cfg.PasswordEnv = value cfg.PasswordSecret = value
case "idle_timeout": case "idle_timeout":
if idleTimeoutSet { if idleTimeoutSet {
return PostgresConfig{}, fmt.Errorf("line %d: idle_timeout may only be specified once", directive.line) return PostgresConfig{}, fmt.Errorf("line %d: idle_timeout may only be specified once", directive.line)

View File

@@ -81,13 +81,59 @@ func TestValidateMissingFieldsAndDuplicateHostname(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("Validate() error = nil") t.Fatal("Validate() error = nil")
} }
for _, want := range []string{"TLS private key is required", "upstream or postgres is required", "duplicate hostname"} { for _, want := range []string{"tls requires both cert and key", "upstream or postgres is required", "duplicate hostname"} {
if !strings.Contains(err.Error(), want) { if !strings.Contains(err.Error(), want) {
t.Errorf("Validate() error = %q, missing %q", err, want) t.Errorf("Validate() error = %q, missing %q", err, want)
} }
} }
} }
func TestValidateAllowsConfigurationWithoutTLS(t *testing.T) {
cfg := Config{
Listen: ":5432",
Databases: []DatabaseConfig{{
Name: "analytics",
Upstream: "postgres-foo:5432",
}},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestParseAndValidateWithoutTLSBlock(t *testing.T) {
cfg, err := Parse([]byte(`pawsql {
listen :5432
database analytics {
upstream postgres-foo:5432
}
}`))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if cfg.TLS.CertFile != "" || cfg.TLS.KeyFile != "" {
t.Errorf("TLS = %#v, want unset", cfg.TLS)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidateRejectsHalfConfiguredTLS(t *testing.T) {
cfg := Config{
Listen: ":5432",
TLS: TLSConfig{CertFile: "cert.pem"},
Databases: []DatabaseConfig{{
Name: "analytics",
Upstream: "postgres-foo:5432",
}},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "tls requires both cert and key") {
t.Fatalf("Validate() error = %v, want tls requires both cert and key", err)
}
}
func TestValidateRejectsMalformedUpstreamAddress(t *testing.T) { func TestValidateRejectsMalformedUpstreamAddress(t *testing.T) {
cfg := Config{ cfg := Config{
Listen: ":5432", Listen: ":5432",
@@ -144,7 +190,7 @@ func TestParsePostgresContainer(t *testing.T) {
postgres { postgres {
image postgres:18 image postgres:18
volume analytics-data volume analytics-data
password_env ANALYTICS_POSTGRES_PASSWORD password_secret analytics_password
idle_timeout 15m idle_timeout 15m
traffic_idle_timeout 1h traffic_idle_timeout 1h
} }
@@ -154,7 +200,7 @@ func TestParsePostgresContainer(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
database := cfg.Databases[0] 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 { if database.Postgres == nil || database.Postgres.Image != "postgres:18" || database.Postgres.Volume != "analytics-data" || database.Postgres.PasswordSecret != "analytics_password" || database.Postgres.IdleTimeout != 15*time.Minute || database.Postgres.TrafficIdleTimeout != time.Hour {
t.Errorf("Postgres = %#v", database.Postgres) t.Errorf("Postgres = %#v", database.Postgres)
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -173,7 +219,7 @@ func TestParseRejectsInvalidPostgresIdleTimeout(t *testing.T) {
postgres { postgres {
image postgres:18 image postgres:18
volume analytics-data volume analytics-data
password_env ANALYTICS_POSTGRES_PASSWORD password_secret analytics_password
idle_timeout whenever idle_timeout whenever
} }
} }
@@ -194,7 +240,7 @@ func TestParseRejectsInvalidPostgresTrafficIdleTimeout(t *testing.T) {
postgres { postgres {
image postgres:18 image postgres:18
volume analytics-data volume analytics-data
password_env ANALYTICS_POSTGRES_PASSWORD password_secret analytics_password
traffic_idle_timeout whenever traffic_idle_timeout whenever
} }
} }
@@ -211,7 +257,7 @@ func TestValidateAllowsSupportedPostgresImages(t *testing.T) {
TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"},
Databases: []DatabaseConfig{{ Databases: []DatabaseConfig{{
Name: "analytics", Name: "analytics",
Postgres: &PostgresConfig{Image: image, Volume: "analytics-data", PasswordEnv: "ANALYTICS_POSTGRES_PASSWORD"}, Postgres: &PostgresConfig{Image: image, Volume: "analytics-data", PasswordSecret: "analytics_password"},
}}, }},
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@@ -226,7 +272,7 @@ func TestValidateRejectsUnsupportedPostgresImage(t *testing.T) {
TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"},
Databases: []DatabaseConfig{{ Databases: []DatabaseConfig{{
Name: "analytics", Name: "analytics",
Postgres: &PostgresConfig{Image: "postgres:15", Volume: "analytics-data", PasswordEnv: "ANALYTICS_POSTGRES_PASSWORD"}, Postgres: &PostgresConfig{Image: "postgres:15", Volume: "analytics-data", PasswordSecret: "analytics_password"},
}}, }},
} }
err := cfg.Validate() err := cfg.Validate()
@@ -235,6 +281,32 @@ func TestValidateRejectsUnsupportedPostgresImage(t *testing.T) {
} }
} }
func TestValidateRejectsInvalidSecretReference(t *testing.T) {
for _, reference := range []string{"", "UPPERCASE", "../secret", "has-hyphen", strings.Repeat("a", 55)} {
cfg := Config{
Listen: ":5432",
Databases: []DatabaseConfig{{
Name: "analytics",
Postgres: &PostgresConfig{
Image: "postgres:18",
Volume: "analytics-data",
PasswordSecret: reference,
},
}},
}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "password_secret") {
t.Errorf("Validate() reference %q error = %v, want password_secret error", reference, err)
}
}
}
func TestDockerSecretNamePrefixesShortReference(t *testing.T) {
if got := DockerSecretName("analytics_password"); got != "barkstack_analytics_password" {
t.Fatalf("DockerSecretName() = %q", got)
}
}
func TestParseMalformedBlocksReportLine(t *testing.T) { func TestParseMalformedBlocksReportLine(t *testing.T) {
_, err := Parse([]byte("pawsql {\n tls {\n cert cert.pem\n")) _, err := Parse([]byte("pawsql {\n tls {\n cert cert.pem\n"))
if err == nil { if err == nil {

View File

@@ -26,13 +26,15 @@ type DatabaseConfig struct {
Postgres *PostgresConfig Postgres *PostgresConfig
} }
// PostgresConfig declares a PawSQL-managed PostgreSQL container. IdleTimeout // PostgresConfig declares a PawSQL-managed PostgreSQL container. PasswordSecret
// starts after the last proxied client session closes. TrafficIdleTimeout // is a short Barkfile reference resolved to a Docker secret named
// applies to open sessions with no proxied bytes. Zero disables either timeout. // barkstack_<reference>. 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 { type PostgresConfig struct {
Image string Image string
Volume string Volume string
PasswordEnv string PasswordSecret string
IdleTimeout time.Duration IdleTimeout time.Duration
TrafficIdleTimeout time.Duration TrafficIdleTimeout time.Duration
} }

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"regexp"
"strconv" "strconv"
"strings" "strings"
) )
@@ -13,6 +14,16 @@ func NormalizeHostname(hostname string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hostname)), ".") return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hostname)), ".")
} }
const DockerSecretPrefix = "barkstack_"
var secretReferencePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// DockerSecretName returns the namespaced Docker object name for a validated
// short Barkfile secret reference.
func DockerSecretName(reference string) string {
return DockerSecretPrefix + reference
}
// Validate verifies configuration invariants that do not require opening files. // Validate verifies configuration invariants that do not require opening files.
func (c Config) Validate() error { func (c Config) Validate() error {
var errs []error var errs []error
@@ -21,11 +32,10 @@ func (c Config) Validate() error {
} else if err := validateListenAddress(c.Listen); err != nil { } else if err := validateListenAddress(c.Listen); err != nil {
errs = append(errs, fmt.Errorf("listen address %q: %w", c.Listen, err)) errs = append(errs, fmt.Errorf("listen address %q: %w", c.Listen, err))
} }
if strings.TrimSpace(c.TLS.CertFile) == "" { certificateSet := strings.TrimSpace(c.TLS.CertFile) != ""
errs = append(errs, errors.New("TLS certificate is required")) keySet := strings.TrimSpace(c.TLS.KeyFile) != ""
} if certificateSet != keySet {
if strings.TrimSpace(c.TLS.KeyFile) == "" { errs = append(errs, errors.New("tls requires both cert and key"))
errs = append(errs, errors.New("TLS private key is required"))
} }
if len(c.Databases) == 0 { if len(c.Databases) == 0 {
errs = append(errs, errors.New("at least one database route is required")) errs = append(errs, errors.New("at least one database route is required"))
@@ -75,8 +85,8 @@ func validatePostgres(postgres PostgresConfig) error {
if strings.TrimSpace(postgres.Volume) == "" { if strings.TrimSpace(postgres.Volume) == "" {
return errors.New("volume is required") return errors.New("volume is required")
} }
if strings.TrimSpace(postgres.PasswordEnv) == "" { if !ValidSecretReference(postgres.PasswordSecret) {
return errors.New("password_env is required") return fmt.Errorf("password_secret %q must start with a lowercase letter, contain only lowercase letters, digits, or underscores, and be at most 54 characters", postgres.PasswordSecret)
} }
if postgres.IdleTimeout < 0 { if postgres.IdleTimeout < 0 {
return errors.New("idle_timeout cannot be negative") return errors.New("idle_timeout cannot be negative")
@@ -87,6 +97,12 @@ func validatePostgres(postgres PostgresConfig) error {
return nil return nil
} }
// ValidSecretReference reports whether reference is safe for use as a short
// Barkfile secret name and as part of a Docker secret name.
func ValidSecretReference(reference string) bool {
return len(reference) <= 54 && secretReferencePattern.MatchString(reference)
}
func validateListenAddress(address string) error { func validateListenAddress(address string) error {
_, port, err := net.SplitHostPort(address) _, port, err := net.SplitHostPort(address)
if err != nil { if err != nil {