Load every setting from one table in internal/config (closes #320)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Config.settings lists each setting once: its key, its environment variable and the function that loads it. Most rows load through field, whose arguments give the type, the default and the check. db_url and cache_max_bytes keep their own load functions. Loading, the unknown-key and unknown-variable checks and the names in error messages all read the table. allowlist_hosts now loads through parseHostList, as referer_blocklist does. isKnownConfigKey, envVarNames, strictLoader, validate, validateAllowlistHostsValue and getStringSlice are gone, with the getStringSlice tests. The key constants move to the tests, their only users. A new test checks that README.md and configs/config.example.yml name the keys of the table. Model: opus-5-5
This commit is contained in:
@@ -30,6 +30,29 @@ P2: security: per-origin rate limiting
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-26 one table in `internal/config` lists every setting once (closes
|
||||
#320). `Config.settings` gives each setting's key, its environment variable
|
||||
and the function that loads it.
|
||||
- Most rows load through `field`, whose arguments give the setting's type,
|
||||
its default and its check.
|
||||
- `db_url` and `cache_max_bytes` have their own load functions. The default
|
||||
of `db_url` comes from `state_dir`, and `cache_max_bytes` records whether
|
||||
it is set.
|
||||
- Loading, the unknown-key check, the unknown-variable check and the names
|
||||
in error messages all read the table.
|
||||
- `allowlist_hosts` loads as `referer_blocklist` does, through
|
||||
`parseHostList`.
|
||||
- The metrics username and password are checked together once the table has
|
||||
loaded.
|
||||
- When a config holds more than one invalid value, the error names the one
|
||||
that comes first in the table.
|
||||
- A test checks that `README.md` and `configs/config.example.yml` name the
|
||||
keys of the table.
|
||||
- Removed:
|
||||
- the key constants
|
||||
- `isKnownConfigKey`, `envVarNames`, `strictLoader` and `validate`
|
||||
- `validateAllowlistHostsValue` and `getStringSlice`, with the three
|
||||
tests of `getStringSlice`
|
||||
- 2026-10-26 a fetched image's format is detected from its bytes once, as an
|
||||
`imagerequest.Format` (closes #319). The upstream `Content-Type` check in the
|
||||
fetcher stays as the filter before downloading.
|
||||
|
||||
+343
-584
File diff suppressed because it is too large
Load Diff
@@ -1,97 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
)
|
||||
|
||||
// writeTestConfig writes yamlContent to a temp config file and returns
|
||||
// the file path.
|
||||
func writeTestConfig(t *testing.T, yamlContent string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
// checkAllowlistHosts loads the config at configPath and asserts that
|
||||
// getStringSlice returns the three expected hosts.
|
||||
func checkAllowlistHosts(t *testing.T, configPath string) {
|
||||
t.Helper()
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc)
|
||||
|
||||
if len(hosts) != 3 {
|
||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||
}
|
||||
|
||||
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
|
||||
for i, want := range expected {
|
||||
if i >= len(hosts) {
|
||||
t.Errorf("missing host at index %d: want %q", i, want)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if hosts[i] != want {
|
||||
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringSlice_YAMLList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yamlContent := `
|
||||
allowlist_hosts:
|
||||
- static.sneak.cloud
|
||||
- sneak.berlin
|
||||
- s3.sneak.cloud
|
||||
`
|
||||
|
||||
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||
}
|
||||
|
||||
func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Backwards compatibility with comma-separated string values.
|
||||
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||
|
||||
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
|
||||
}
|
||||
|
||||
func TestGetStringSlice_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configPath := writeTestConfig(t, `port: 8080`)
|
||||
|
||||
sc, err := loadTestConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
hosts := getStringSlice(sc)
|
||||
if len(hosts) != 0 {
|
||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
|
||||
// with a real signing key, as Getting Started in README.md has it copied
|
||||
// and edited, and checks that it allowlists no host: every image on an
|
||||
|
||||
@@ -15,8 +15,36 @@ import (
|
||||
_ "modernc.org/sqlite" // SQLite driver registration
|
||||
)
|
||||
|
||||
// The config keys the tests name in the errors they expect.
|
||||
const (
|
||||
keyDebug = "debug"
|
||||
keyMaintenanceMode = "maintenance_mode"
|
||||
keyPort = "port"
|
||||
keyStateDir = "state_dir"
|
||||
keySentryDSN = "sentry_dsn"
|
||||
keyDBURL = "db_url"
|
||||
keyMetrics = "metrics"
|
||||
keyMetricsUsername = "metrics.username"
|
||||
keyMetricsPassword = "metrics.password"
|
||||
keySigningKey = "signing_key"
|
||||
keyAllowlistHosts = "allowlist_hosts"
|
||||
keyRefererBlocklist = "referer_blocklist"
|
||||
keyAllowHTTP = "allow_http"
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyUpstreamConnections = "upstream_connections"
|
||||
keyMaxConcurrentProcessing = "max_concurrent_processing"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
keyTrustedProxies = "trusted_proxies"
|
||||
keyAccessControlAllowOrigin = "access_control_allow_origin"
|
||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||
keyDownstreamTimeout = "downstream_timeout"
|
||||
keyImageRequestsPerMinute = "image_requests_per_minute"
|
||||
)
|
||||
|
||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||
// minimum length requirement in validate().
|
||||
// minimum length requirement in validateSigningKey.
|
||||
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// signingKeyLine is a valid signing_key config line used as the base of
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReadmeNamesEverySetting checks that the Configuration section of
|
||||
// README.md names every key of the settings table in backticks. It also
|
||||
// checks that every name in backticks there written like a key with an
|
||||
// underscore, such as state_dir, is a key of the table. The other keys,
|
||||
// such as port and metrics.username, are checked only the first way.
|
||||
func TestReadmeNamesEverySetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
readme, err := os.ReadFile(filepath.Join("..", "..", "README.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read README.md: %v", err)
|
||||
}
|
||||
|
||||
_, section, found := strings.Cut(string(readme), "\n### Configuration\n")
|
||||
if !found {
|
||||
t.Fatal("README.md has no Configuration section")
|
||||
}
|
||||
|
||||
// The section ends at the next heading of its level or above.
|
||||
section, _, _ = strings.Cut(section, "\n## ")
|
||||
section, _, _ = strings.Cut(section, "\n### ")
|
||||
|
||||
named := map[string]bool{}
|
||||
|
||||
for _, match := range regexp.MustCompile("`([^`]+)`").
|
||||
FindAllStringSubmatch(section, -1) {
|
||||
named[match[1]] = true
|
||||
}
|
||||
|
||||
for _, s := range new(Config).settings() {
|
||||
if !named[s.key] {
|
||||
t.Errorf("README.md's Configuration section does not name %s", s.key)
|
||||
}
|
||||
|
||||
delete(named, s.key)
|
||||
}
|
||||
|
||||
keyWithUnderscore := regexp.MustCompile(`^[a-z]+(_[a-z]+)+$`)
|
||||
|
||||
for name := range named {
|
||||
if keyWithUnderscore.MatchString(name) {
|
||||
t.Errorf("README.md's Configuration section names %s, "+
|
||||
"which is not a setting", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleConfigNamesEverySetting checks that
|
||||
// configs/config.example.yml names exactly the keys of the settings
|
||||
// table, each one set or commented out with "# ". A key line holds the
|
||||
// key, a colon and at most a one-word value, which tells it from a
|
||||
// comment line that starts with a word and a colon. An indented key is
|
||||
// inside the map of the key above it, as username is inside metrics.
|
||||
func TestExampleConfigNamesEverySetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const examplePath = "configs/config.example.yml"
|
||||
|
||||
example, err := os.ReadFile(filepath.Join("..", "..", examplePath))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read %s: %v", examplePath, err)
|
||||
}
|
||||
|
||||
keyLine := regexp.MustCompile(`(?m)^(?:# )?( *)([a-z_]+):(?: \S+)?$`)
|
||||
named := map[string]bool{}
|
||||
|
||||
var mapKey string
|
||||
|
||||
for _, match := range keyLine.FindAllStringSubmatch(string(example), -1) {
|
||||
indent, key := match[1], match[2]
|
||||
if indent == "" {
|
||||
mapKey = key
|
||||
named[key] = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// The key holding the map is not itself a setting.
|
||||
delete(named, mapKey)
|
||||
|
||||
named[mapKey+"."+key] = true
|
||||
}
|
||||
|
||||
// The env section sets environment variables; it is not a setting.
|
||||
delete(named, "env")
|
||||
|
||||
for _, s := range new(Config).settings() {
|
||||
if !named[s.key] {
|
||||
t.Errorf("%s does not name %s", examplePath, s.key)
|
||||
}
|
||||
|
||||
delete(named, s.key)
|
||||
}
|
||||
|
||||
for key := range named {
|
||||
t.Errorf("%s names %s, which is not a setting", examplePath, key)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,8 @@ func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
|
||||
|
||||
// TestSettingVariablesAndConfigPathAreAccepted checks that every
|
||||
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown
|
||||
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
|
||||
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the
|
||||
// settings table.
|
||||
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||
// A config file's env section loaded by another test can leave a
|
||||
// PIXA_ variable set for the whole process, so every one is unset
|
||||
@@ -159,8 +160,8 @@ func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
|
||||
|
||||
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
|
||||
|
||||
for _, name := range envVarNames() {
|
||||
t.Setenv(name, "")
|
||||
for _, s := range new(Config).settings() {
|
||||
t.Setenv(s.envVar, "")
|
||||
}
|
||||
|
||||
err := validateKnownEnvVars()
|
||||
|
||||
Reference in New Issue
Block a user