Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,7 @@ cooldown:
| `cooldown.packages` | - | Per-package overrides (keyed by PURL) |
| `cooldown.package_patterns` | - | Per-package glob overrides (keyed by PURL glob) |

Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable.
Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. Other units such as weeks (`1w`) are not supported. A `default`, `ecosystems` or `packages` value that can't be parsed applies no cooldown, so the proxy logs a warning at startup naming it. Invalid durations in `package_patterns` and inside `cooldown.profiles` are rejected at startup.

Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins.

Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ func (c *CooldownConfig) NormalizedPackages() map[string]string {

// Shortest returns the shortest cooldown configured anywhere, by default,
// ecosystem, package or package pattern, or 0 when none is. Unparsable
// durations are left out; Validate reports them.
// durations are left out; Config.Warnings reports them.
func (c *CooldownConfig) Shortest() time.Duration {
if c == nil {
return 0
Expand Down
15 changes: 15 additions & 0 deletions internal/config/cooldown_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ func (c *CooldownConfig) Policy() (*cooldown.Policy, error) {
}, c.PackagePatterns)
}

// durationWarning describes a top-level default, ecosystem or package
// cooldown duration that cannot be parsed, or returns "" when they all parse.
//
// The cooldown library reads such a duration as 0, so a typo such as "1w"
// turns cooldown off without notice. Profiles already reject these values, as
// does the library for package_patterns; the top level only warns, so configs
// that start today keep starting.
func (c *CooldownConfig) durationWarning() string {
top := CooldownConfig{Default: c.Default, Ecosystems: c.Ecosystems, Packages: c.Packages}
if err := top.validateProfileDurations(); err != nil {
return fmt.Sprintf("cooldown: %v; no cooldown applies where this value is used", err)
}
return ""
}

// ValidateProfiles checks names and policies before they become routes.
func (c *CooldownConfig) ValidateProfiles() error {
for _, name := range slices.Sorted(maps.Keys(c.Profiles)) {
Expand Down
71 changes: 71 additions & 0 deletions internal/config/cooldown_profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,74 @@ func TestLoadRejectsInvalidCooldownProfiles(t *testing.T) {
})
}
}

// TestWarnsOnInvalidTopLevelCooldown checks that a top-level duration the
// cooldown library cannot parse, and would read as 0, is reported. It warns
// rather than failing Validate so that configs which start today still start.
func TestWarnsOnInvalidTopLevelCooldown(t *testing.T) {
for _, tc := range []struct {
name string
set func(*CooldownConfig)
want string
}{
{"default weeks", func(c *CooldownConfig) { c.Default = "1w" }, "cooldown: invalid default"},
{"default words", func(c *CooldownConfig) { c.Default = "7 days" }, "cooldown: invalid default"},
{"default negative", func(c *CooldownConfig) { c.Default = "-3d" }, "cooldown: invalid default"},
{"ecosystem", func(c *CooldownConfig) { c.Ecosystems = map[string]string{"npm": "3days"} }, "cooldown: invalid ecosystems.npm"},
{"package", func(c *CooldownConfig) { c.Packages = map[string]string{"pkg:npm/lodash": "soon"} }, "cooldown: invalid packages.pkg:npm/lodash"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := Default()
tc.set(&cfg.Cooldown)
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate = %v, want nil: invalid top-level durations warn", err)
}
if !hasWarning(cfg.Warnings(), tc.want) {
t.Fatalf("Warnings = %q, want one containing %q", cfg.Warnings(), tc.want)
}
})
}
}

func TestNoWarningForValidTopLevelCooldown(t *testing.T) {
for _, value := range []string{"", "0", "3d", "1.5d", "48h", "90m"} {
cfg := Default()
cfg.Cooldown.Default = value
cfg.Cooldown.Ecosystems = map[string]string{"npm": value}
if hasWarning(cfg.Warnings(), "cooldown") {
t.Errorf("Warnings(%q) = %q, want no cooldown warning", value, cfg.Warnings())
}
}
}

func TestWarnsOnInvalidCooldownFromEnv(t *testing.T) {
t.Setenv("PROXY_COOLDOWN_DEFAULT", "1w")
cfg := Default()
cfg.LoadFromEnv()
if !hasWarning(cfg.Warnings(), "cooldown: invalid default") {
t.Fatalf("Warnings = %q, want invalid cooldown default", cfg.Warnings())
}
}

func hasWarning(warnings []string, substr string) bool {
for _, w := range warnings {
if strings.Contains(w, substr) {
return true
}
}
return false
}

// TestInvalidCooldownPatternFailsPolicy checks that an invalid package_patterns
// duration is left to the cooldown library, which already refuses it when the
// policy is built at startup, rather than reported as a warning.
func TestInvalidCooldownPatternFailsPolicy(t *testing.T) {
cfg := Default()
cfg.Cooldown.PackagePatterns = map[string]string{"pkg:npm/*": "1w"}
if _, err := cfg.Cooldown.Policy(); err == nil {
t.Fatal("Policy() = nil error, want the invalid pattern duration rejected")
}
if hasWarning(cfg.Warnings(), "cooldown") {
t.Errorf("Warnings = %q, want no cooldown warning for a pattern", cfg.Warnings())
}
}
3 changes: 3 additions & 0 deletions internal/config/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ func (c *Config) Warnings() []string {
if c.Mirror.Refresh != nil && !c.CacheMetadata {
warnings = append(warnings, "mirror.refresh without cache_metadata does not refresh Debian, Alpine or RPM packages: their indexes are found through the metadata cache")
}
if w := c.Cooldown.durationWarning(); w != "" {
warnings = append(warnings, w)
}
return warnings
}

Expand Down
Loading