Feature 01 task 1: Go module, fail-fast config and /health (#2)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
// Package config loads MealQuest configuration from MEALQUEST_* environment
|
||||
// variables. Secrets are read only from files named by *_FILE variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
EnvHTTPAddr = "MEALQUEST_HTTP_ADDR"
|
||||
EnvPublicURL = "MEALQUEST_PUBLIC_URL"
|
||||
EnvDataDir = "MEALQUEST_DATA_DIR"
|
||||
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
|
||||
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
|
||||
)
|
||||
|
||||
// Config is the validated service configuration.
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
PublicURL *url.URL
|
||||
DataDir string
|
||||
ShutdownTimeout time.Duration
|
||||
SecretKey Secret
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
// that are safe to log.
|
||||
func (c Config) PublicOrigin() string {
|
||||
return c.PublicURL.Scheme + "://" + c.PublicURL.Host
|
||||
}
|
||||
|
||||
// Load reads and validates configuration from the process environment.
|
||||
// It reports every missing or invalid variable in a single *Error.
|
||||
func Load() (Config, error) {
|
||||
return load(os.LookupEnv)
|
||||
}
|
||||
|
||||
func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
r := reader{lookup: lookup}
|
||||
cfg := Config{
|
||||
HTTPAddr: r.listenAddr(EnvHTTPAddr),
|
||||
PublicURL: r.httpURL(EnvPublicURL),
|
||||
DataDir: r.dir(EnvDataDir),
|
||||
ShutdownTimeout: r.duration(EnvShutdownTimeout),
|
||||
SecretKey: r.secretFile(EnvSecretKeyFile),
|
||||
}
|
||||
if len(r.problems) > 0 {
|
||||
return Config{}, &Error{Problems: r.problems}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Problem describes one missing or invalid variable.
|
||||
type Problem struct {
|
||||
Var string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error lists every configuration problem found by Load.
|
||||
type Error struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("invalid configuration:")
|
||||
for _, p := range e.Problems {
|
||||
fmt.Fprintf(&b, "\n %s: %s", p.Var, p.Reason)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Secret holds a sensitive value. It never prints its contents.
|
||||
type Secret struct {
|
||||
value string
|
||||
}
|
||||
|
||||
// Reveal returns the secret value. Call it only where the value is used.
|
||||
func (s Secret) Reveal() string { return s.value }
|
||||
|
||||
// String redacts the secret so it cannot leak through fmt or logs.
|
||||
func (s Secret) String() string { return "[REDACTED]" }
|
||||
|
||||
// GoString redacts the secret for %#v.
|
||||
func (s Secret) GoString() string { return "[REDACTED]" }
|
||||
|
||||
// reader reads variables and collects every problem instead of stopping at
|
||||
// the first one.
|
||||
type reader struct {
|
||||
lookup func(string) (string, bool)
|
||||
problems []Problem
|
||||
}
|
||||
|
||||
func (r *reader) fail(name, format string, args ...any) {
|
||||
r.problems = append(r.problems, Problem{Var: name, Reason: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// required returns the variable's value, recording a problem when it is
|
||||
// unset or empty.
|
||||
func (r *reader) required(name string) (string, bool) {
|
||||
v, ok := r.lookup(name)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
r.fail(name, "required but not set")
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
host, port, err := net.SplitHostPort(v)
|
||||
if err != nil {
|
||||
r.fail(name, "invalid listen address %q (want host:port or :port)", v)
|
||||
return ""
|
||||
}
|
||||
// An empty host listens on all interfaces.
|
||||
if host != "" && net.ParseIP(host) == nil && !validHostname(host) {
|
||||
r.fail(name, "invalid host %q in listen address (want IP address or hostname)", host)
|
||||
return ""
|
||||
}
|
||||
if _, err := strconv.ParseUint(port, 10, 16); err != nil {
|
||||
r.fail(name, "invalid port %q in listen address (want 0-65535)", port)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// validHostname reports whether h is a DNS hostname: dot-separated labels of
|
||||
// 1-63 letters, digits and hyphens, not starting or ending with a hyphen.
|
||||
func validHostname(h string) bool {
|
||||
if len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
|
||||
if len(label) < 1 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *reader) httpURL(name string) *url.URL {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// The value is never echoed: it may carry credentials.
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
|
||||
r.fail(name, "invalid URL (want absolute http:// or https:// URL)")
|
||||
return nil
|
||||
}
|
||||
if u.User != nil {
|
||||
r.fail(name, "URL must not contain user credentials")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (r *reader) dir(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(v)
|
||||
if err != nil {
|
||||
r.fail(name, "cannot access directory %q: %v", v, err)
|
||||
return ""
|
||||
}
|
||||
if !info.IsDir() {
|
||||
r.fail(name, "%q is not a directory", v)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (r *reader) duration(name string) time.Duration {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
r.fail(name, "invalid duration %q (want e.g. 10s, 1m)", v)
|
||||
return 0
|
||||
}
|
||||
if d <= 0 {
|
||||
r.fail(name, "duration must be positive, got %q", v)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// secretFile reads a secret from the file named by name, which must end in
|
||||
// _FILE. Setting the secret inline (name without _FILE) is rejected.
|
||||
func (r *reader) secretFile(name string) Secret {
|
||||
inline := strings.TrimSuffix(name, "_FILE")
|
||||
if _, set := r.lookup(inline); set {
|
||||
r.fail(inline, "secrets must not be set inline; use %s", name)
|
||||
}
|
||||
path, ok := r.required(name)
|
||||
if !ok {
|
||||
return Secret{}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Report the cause without echoing any file contents.
|
||||
r.fail(name, "cannot read secret file %q: %v", path, unwrapPathError(err))
|
||||
return Secret{}
|
||||
}
|
||||
value := strings.TrimRight(string(data), "\r\n")
|
||||
if strings.TrimSpace(value) == "" {
|
||||
r.fail(name, "secret file %q is empty", path)
|
||||
return Secret{}
|
||||
}
|
||||
return Secret{value: value}
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return pe.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user