Fail loudly on an unparseable SENTRY_DSN and a malformed .env (closes #283)
All checks were successful
check / check (push) Successful in 2m54s
All checks were successful
check / check (push) Successful in 2m54s
This commit was merged in pull request #289.
This commit is contained in:
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -11,13 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/joho/godotenv"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
|
||||
// Populates the environment from a ./.env file automatically for
|
||||
// development configuration. Kept in one place only (here).
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -84,6 +83,12 @@ const (
|
||||
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
|
||||
// covers the same addresses as an IPv4 /8.
|
||||
mappedV4Offset = 96
|
||||
|
||||
// DotEnvPath is the optional file of KEY=value lines read into the
|
||||
// environment at startup, relative to the process working
|
||||
// directory. Exported so that documentation and tests name the
|
||||
// same path the loader opens.
|
||||
DotEnvPath = ".env"
|
||||
)
|
||||
|
||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||
@@ -107,6 +112,15 @@ var ErrInvalidCIDR = errors.New("invalid CIDR")
|
||||
// something that is not an IP address literal.
|
||||
var ErrInvalidBindAddress = errors.New("invalid bind address")
|
||||
|
||||
// ErrInvalidSentryDSN is returned when SENTRY_DSN is set to something
|
||||
// the Sentry SDK cannot parse as a DSN.
|
||||
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
|
||||
|
||||
// ErrDotEnvUnreadable is returned when the optional .env file exists
|
||||
// but cannot be read or parsed. A file that is not there is not an
|
||||
// error; a file that is there and broken is.
|
||||
var ErrDotEnvUnreadable = errors.New("unreadable .env file")
|
||||
|
||||
// ErrIncompleteMetricsAuth is returned when exactly one of
|
||||
// METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither
|
||||
// fallback is acceptable: serving /metrics on the username alone
|
||||
@@ -212,12 +226,62 @@ func (c *Config) MetricsAuthEnabled() bool {
|
||||
return c.MetricsUsername != "" && c.MetricsPassword != ""
|
||||
}
|
||||
|
||||
// SentryEnabled reports whether error reporting is shipped to Sentry.
|
||||
// It is the only answer to that question in the codebase: the SDK
|
||||
// initialisation, the sentryhttp middleware registration and the
|
||||
// startup log's sentryEnabled field all read this one method, so the
|
||||
// log cannot report reporting as on while nothing is sending.
|
||||
//
|
||||
// A non-empty DSN is enough because loadFromEnv already parsed it with
|
||||
// the SDK's own parser and refused to build a Config around one the
|
||||
// SDK would reject, and because initialising the SDK with a DSN that
|
||||
// parsed and failed anyway aborts the process rather than leaving this
|
||||
// true and the client absent.
|
||||
func (c *Config) SentryEnabled() bool {
|
||||
return c.SentryDSN != ""
|
||||
}
|
||||
|
||||
// envString returns the value of the named environment variable,
|
||||
// or an empty string if not set.
|
||||
func envString(key string) string {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
// LoadDotEnv reads DotEnvPath into the environment when that file is
|
||||
// present, and reports a file that is present but broken.
|
||||
//
|
||||
// It has to run before anything reads the environment, so that every
|
||||
// reader agrees on what the environment holds — the DATA_DIR lock
|
||||
// taken before the fx graph exists as much as loadFromEnv itself. A
|
||||
// variable already set in the real environment wins: godotenv never
|
||||
// overwrites one.
|
||||
//
|
||||
// A missing file is not an error. It is a development convenience and
|
||||
// most deployments set the environment directly.
|
||||
//
|
||||
// Any other failure is. godotenv parses the whole file before setting
|
||||
// anything, so a single malformed line applies none of it: every
|
||||
// variable in the file silently reverts to its default, which defeats
|
||||
// the fail-loud guarantee for all of them at once.
|
||||
func LoadDotEnv() error {
|
||||
return loadDotEnvFile(DotEnvPath)
|
||||
}
|
||||
|
||||
// loadDotEnvFile is LoadDotEnv over a named file, so tests can point
|
||||
// at a temporary one instead of the process working directory.
|
||||
func loadDotEnvFile(path string) error {
|
||||
err := godotenv.Load(path)
|
||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w: %s: %w; nothing in it was applied, so fix the file or "+
|
||||
"remove it",
|
||||
ErrDotEnvUnreadable, path, err,
|
||||
)
|
||||
}
|
||||
|
||||
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
|
||||
// or empty. It is exported so that entry points which must act on the
|
||||
// data directory before the fx graph exists — taking the exclusive
|
||||
@@ -462,6 +526,41 @@ func envBindAddress(key, defaultValue string) (string, error) {
|
||||
return addr.String(), nil
|
||||
}
|
||||
|
||||
// envSentryDSN returns the value of the named environment variable
|
||||
// checked as a Sentry DSN. An unset (or empty, or whitespace-only)
|
||||
// value yields "", which means error reporting stays off — the common
|
||||
// case, and a normal start.
|
||||
//
|
||||
// A set value is parsed with sentry.NewDsn, which is the call
|
||||
// sentry.Init makes on the DSN it is handed, so what passes here is
|
||||
// exactly what the SDK will accept later and the two cannot disagree.
|
||||
// Reproducing the check by hand instead would cost this package its
|
||||
// dependency on the SDK — already a module dependency, already linked
|
||||
// into the binary — in exchange for a second definition of "valid DSN"
|
||||
// free to drift from the one that decides.
|
||||
//
|
||||
// A set value that does not parse is a hard error naming the key, so
|
||||
// startup fails loudly. Losing error reporting is the failure this
|
||||
// variable exists to prevent, and a typo in a DSN is silent forever:
|
||||
// nothing later in the process can notice that reports are going
|
||||
// nowhere. The bad value is quoted because it is a URL to a public
|
||||
// endpoint carrying a public key, not a secret.
|
||||
func envSentryDSN(key string) (string, error) {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := sentry.NewDsn(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %s: %q: %w", ErrInvalidSentryDSN, key, v, err,
|
||||
)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// resolveMetricsAuth reads the /metrics basic-auth credentials and
|
||||
// rejects a half-set pair, naming both variables either way. The
|
||||
// error carries neither value: the password is a secret.
|
||||
@@ -594,6 +693,11 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sentryDSN, err := envSentryDSN("SENTRY_DSN")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DataDir: DataDir(),
|
||||
Debug: debug,
|
||||
@@ -603,7 +707,7 @@ func loadFromEnv() (*Config, error) {
|
||||
MetricsPassword: metricsPassword,
|
||||
Port: port,
|
||||
BindAddress: bindAddress,
|
||||
SentryDSN: envString("SENTRY_DSN"),
|
||||
SentryDSN: sentryDSN,
|
||||
RetentionSweepInterval: retentionSweepInterval,
|
||||
SessionIdleTimeout: sessionIdleTimeout,
|
||||
ReceiverRateLimit: receiverRateLimit,
|
||||
@@ -738,7 +842,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
"receiverRateLimit", s.ReceiverRateLimit,
|
||||
"trustedProxies", len(s.TrustedProxies),
|
||||
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
||||
"hasSentryDSN", s.SentryDSN != "",
|
||||
"sentryEnabled", s.SentryEnabled(),
|
||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user