diff --git a/parser.go b/parser.go index 692e040..cd64474 100644 --- a/parser.go +++ b/parser.go @@ -48,10 +48,51 @@ type parser struct { } func (p *parser) parse() (Config, error) { - p.skipNewlines() - if err := p.expectWord("pawsql"); err != nil { - return Config{}, err + var cfg Config + pawSQLSeen := false + for { + p.skipNewlines() + if p.current().kind == tokenEOF { + break + } + if p.current().kind != tokenWord { + return Config{}, p.errorf("expected top-level service block") + } + switch p.current().text { + case "pawsql": + if pawSQLSeen { + return Config{}, p.errorf("pawsql may only be specified once") + } + pawSQLSeen = true + p.index++ + pawSQL, err := p.parsePawSQL() + if err != nil { + return Config{}, err + } + cfg.Listen = pawSQL.Listen + cfg.TLS = pawSQL.TLS + cfg.Databases = pawSQL.Databases + case "treatvault": + if cfg.TreatVault != nil { + return Config{}, p.errorf("treatvault may only be specified once") + } + p.index++ + treatVault, err := p.parseTreatVault() + if err != nil { + return Config{}, err + } + cfg.TreatVault = &treatVault + default: + return Config{}, p.errorf("unknown top-level service %q", p.current().text) + } } + if !pawSQLSeen { + return Config{}, p.errorf("pawsql block is required") + } + return cfg, nil +} + +func (p *parser) parsePawSQL() (Config, error) { if err := p.expect(tokenOpenBrace, "{"); err != nil { return Config{}, err } @@ -60,7 +101,7 @@ func (p *parser) parse() (Config, error) { p.skipNewlines() if p.current().kind == tokenCloseBrace { p.index++ - break + return cfg, nil } if p.current().kind == tokenEOF { return Config{}, p.errorf("expected } to close pawsql block") @@ -107,11 +148,49 @@ func (p *parser) parse() (Config, error) { 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") +} + +func (p *parser) parseTreatVault() (TreatVaultConfig, error) { + if err := p.expect(tokenOpenBrace, "{"); err != nil { + return TreatVaultConfig{}, err + } + var cfg TreatVaultConfig + for { + p.skipNewlines() + if p.current().kind == tokenCloseBrace { + p.index++ + return cfg, nil + } + if p.current().kind == tokenEOF { + return TreatVaultConfig{}, p.errorf("expected } to close treatvault block") + } + directive := p.current() + if directive.kind != tokenWord { + return TreatVaultConfig{}, p.errorf("expected treatvault directive") + } + p.index++ + value, err := p.value("treatvault value") + if err != nil { + return TreatVaultConfig{}, err + } + switch directive.text { + case "file": + if cfg.File != "" { + return TreatVaultConfig{}, fmt.Errorf("line %d: file may only be specified once", directive.line) + } + cfg.File = value + case "identity_secret": + if cfg.IdentitySecret != "" { + return TreatVaultConfig{}, fmt.Errorf("line %d: identity_secret may only be specified once", directive.line) + } + cfg.IdentitySecret = value + default: + return TreatVaultConfig{}, fmt.Errorf("line %d: unknown treatvault directive %q", directive.line, directive.text) + } + if err := p.endLine(); err != nil { + return TreatVaultConfig{}, err + } } - return cfg, nil } func (p *parser) parseTLS() (TLSConfig, error) { diff --git a/parser_test.go b/parser_test.go index 289e19a..a92a2e3 100644 --- a/parser_test.go +++ b/parser_test.go @@ -307,6 +307,56 @@ func TestDockerSecretNamePrefixesShortReference(t *testing.T) { } } +func TestParseTreatVaultConfiguration(t *testing.T) { + cfg, err := Parse([]byte(`treatvault { + file ./secrets/treatvault.age + identity_secret treatvault_identity +} +pawsql { + listen :5432 + database analytics { + postgres { + image postgres:18 + volume analytics-data + password_secret analytics_password + } + } +}`)) + if err != nil { + t.Fatal(err) + } + if cfg.TreatVault == nil || cfg.TreatVault.File != "./secrets/treatvault.age" || cfg.TreatVault.IdentitySecret != "treatvault_identity" { + t.Fatalf("TreatVault = %#v", cfg.TreatVault) + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestValidateRejectsInvalidTreatVaultConfiguration(t *testing.T) { + cfg := Config{ + Listen: ":5432", + TreatVault: &TreatVaultConfig{IdentitySecret: "UPPERCASE"}, + Databases: []DatabaseConfig{{ + Name: "analytics", + Postgres: &PostgresConfig{ + Image: "postgres:18", + Volume: "analytics-data", + PasswordSecret: "UPPERCASE", + }, + }}, + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil") + } + for _, want := range []string{"treatvault file is required", "treatvault identity_secret", "password_secret must not reference"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Validate() error = %q, missing %q", err, want) + } + } +} + 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 8ab49d5..f2b8480 100644 --- a/schema.go +++ b/schema.go @@ -5,9 +5,17 @@ import "time" // Config is the typed representation of a Barkfile. type Config struct { - Listen string - TLS TLSConfig - Databases []DatabaseConfig + Listen string + TLS TLSConfig + Databases []DatabaseConfig + TreatVault *TreatVaultConfig +} + +// TreatVaultConfig identifies the encrypted secret source of truth and the +// bootstrap Docker secret containing its age X25519 identity. +type TreatVaultConfig struct { + File string + IdentitySecret string } // TLSConfig identifies the certificate material used to terminate client TLS. diff --git a/validate.go b/validate.go index 8cfe7ab..5251864 100644 --- a/validate.go +++ b/validate.go @@ -14,7 +14,15 @@ func NormalizeHostname(hostname string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hostname)), ".") } -const DockerSecretPrefix = "barkstack_" +const ( + DockerSecretPrefix = "barkstack_" + TreatVaultManagedLabel = "io.barkstack.treatvault" + TreatVaultSecretNameLabel = "io.barkstack.treatvault.name" + TreatVaultVaultIDLabel = "io.barkstack.treatvault.vault" + TreatVaultRevisionLabel = "io.barkstack.treatvault.revision" + TreatVaultConsumerLabel = "io.barkstack.treatvault.secrets" + TreatVaultNamesLabel = "io.barkstack.treatvault.names" +) var secretReferencePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) @@ -40,6 +48,14 @@ func (c Config) Validate() error { if len(c.Databases) == 0 { errs = append(errs, errors.New("at least one database route is required")) } + if c.TreatVault != nil { + if strings.TrimSpace(c.TreatVault.File) == "" { + errs = append(errs, errors.New("treatvault file is required")) + } + if !ValidSecretReference(c.TreatVault.IdentitySecret) { + errs = append(errs, fmt.Errorf("treatvault identity_secret %q must start with a lowercase letter, contain only lowercase letters, digits, or underscores, and be at most 54 characters", c.TreatVault.IdentitySecret)) + } + } seenHostnames := make(map[string]string, len(c.Databases)) seenNames := make(map[string]struct{}, len(c.Databases)) @@ -67,6 +83,9 @@ func (c Config) Validate() error { if err := validatePostgres(*database.Postgres); err != nil { errs = append(errs, fmt.Errorf("database %q: postgres: %w", name, err)) } + if c.TreatVault != nil && database.Postgres.PasswordSecret == c.TreatVault.IdentitySecret { + errs = append(errs, fmt.Errorf("database %q: password_secret must not reference the TreatVault identity secret", name)) + } } 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 {