Files
barkfile-parser/validate.go
Shaun Campbell 34ccc01c26
All checks were successful
Test and Release Module / test (push) Successful in 29s
Test and Release Module / release (push) Successful in 7s
feat!: replace password environment with Docker secret references
2026-09-16 15:01:15 -04:00

132 lines
4.3 KiB
Go

package barkfile
import (
"errors"
"fmt"
"net"
"regexp"
"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)), ".")
}
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
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))
}
certificateSet := strings.TrimSpace(c.TLS.CertFile) != ""
keySet := strings.TrimSpace(c.TLS.KeyFile) != ""
if certificateSet != keySet {
errs = append(errs, errors.New("tls requires both cert and key"))
}
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 !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")
}
if postgres.TrafficIdleTimeout < 0 {
return errors.New("traffic_idle_timeout cannot be negative")
}
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 {
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
}