diff --git a/README.md b/README.md index ae81c84..61a5276 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Install ```sh -go get cloud.campbellwireless.net/git/barkstack/barkfile-parser@v1.0.0 +go get cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2@v2.0.0 ``` The module requires Go 1.24 or later. @@ -24,7 +24,7 @@ if err != nil { 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 ` 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_`. `idle_timeout` and `traffic_idle_timeout` accept Go duration strings. ```text pawsql { @@ -40,7 +40,7 @@ pawsql { postgres { image postgres:18 volume application-data - password_env APPLICATION_POSTGRES_PASSWORD + password_secret application_postgres_password idle_timeout 10m 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 ```go @@ -80,4 +86,4 @@ Gitea Actions runs tests on each `main` push. The release job reads Conventional | `type!:` or `BREAKING CHANGE:` | major | | other types | no release | -Go consumers update through standard module versions, for example `go get cloud.campbellwireless.net/git/barkstack/barkfile-parser@latest`. +Go consumers update through standard module versions, for example `go get cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2@latest`. diff --git a/go.mod b/go.mod index 05924bf..4de9390 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module cloud.campbellwireless.net/git/barkstack/barkfile-parser +module cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2 go 1.24 diff --git a/parser.go b/parser.go index fb9208e..692e040 100644 --- a/parser.go +++ b/parser.go @@ -247,11 +247,11 @@ func (p *parser) parsePostgres() (PostgresConfig, error) { 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) + case "password_secret": + if cfg.PasswordSecret != "" { + return PostgresConfig{}, fmt.Errorf("line %d: password_secret may only be specified once", directive.line) } - cfg.PasswordEnv = value + cfg.PasswordSecret = value case "idle_timeout": if idleTimeoutSet { return PostgresConfig{}, fmt.Errorf("line %d: idle_timeout may only be specified once", directive.line) diff --git a/parser_test.go b/parser_test.go index 3714675..289e19a 100644 --- a/parser_test.go +++ b/parser_test.go @@ -190,7 +190,7 @@ func TestParsePostgresContainer(t *testing.T) { postgres { image postgres:18 volume analytics-data - password_env ANALYTICS_POSTGRES_PASSWORD + password_secret analytics_password idle_timeout 15m traffic_idle_timeout 1h } @@ -200,7 +200,7 @@ func TestParsePostgresContainer(t *testing.T) { 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 { + 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) } if err := cfg.Validate(); err != nil { @@ -219,7 +219,7 @@ func TestParseRejectsInvalidPostgresIdleTimeout(t *testing.T) { postgres { image postgres:18 volume analytics-data - password_env ANALYTICS_POSTGRES_PASSWORD + password_secret analytics_password idle_timeout whenever } } @@ -240,7 +240,7 @@ func TestParseRejectsInvalidPostgresTrafficIdleTimeout(t *testing.T) { postgres { image postgres:18 volume analytics-data - password_env ANALYTICS_POSTGRES_PASSWORD + password_secret analytics_password traffic_idle_timeout whenever } } @@ -257,7 +257,7 @@ func TestValidateAllowsSupportedPostgresImages(t *testing.T) { TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, Databases: []DatabaseConfig{{ 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 { @@ -272,7 +272,7 @@ func TestValidateRejectsUnsupportedPostgresImage(t *testing.T) { TLS: TLSConfig{CertFile: "cert.pem", KeyFile: "key.pem"}, Databases: []DatabaseConfig{{ 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() @@ -281,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) { _, err := Parse([]byte("pawsql {\n tls {\n cert cert.pem\n")) if err == nil { diff --git a/schema.go b/schema.go index 11b95b1..8ab49d5 100644 --- a/schema.go +++ b/schema.go @@ -26,13 +26,15 @@ type DatabaseConfig struct { 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. +// PostgresConfig declares a PawSQL-managed PostgreSQL container. PasswordSecret +// is a short Barkfile reference resolved to a Docker secret named +// barkstack_. 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 + PasswordSecret string IdleTimeout time.Duration TrafficIdleTimeout time.Duration } diff --git a/validate.go b/validate.go index 9a1f304..8cfe7ab 100644 --- a/validate.go +++ b/validate.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net" + "regexp" "strconv" "strings" ) @@ -13,6 +14,16 @@ func NormalizeHostname(hostname string) string { 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. func (c Config) Validate() error { var errs []error @@ -74,8 +85,8 @@ func validatePostgres(postgres PostgresConfig) error { if strings.TrimSpace(postgres.Volume) == "" { return errors.New("volume is required") } - if strings.TrimSpace(postgres.PasswordEnv) == "" { - return errors.New("password_env is required") + if !ValidSecretReference(postgres.PasswordSecret) { + 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 { return errors.New("idle_timeout cannot be negative") @@ -86,6 +97,12 @@ func validatePostgres(postgres PostgresConfig) error { 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 { _, port, err := net.SplitHostPort(address) if err != nil {