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
5 changes: 4 additions & 1 deletion deploy/kube/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -769,8 +769,11 @@ data:
# reload_drain_timeout: 30s
# # reload_rate_limit specifies the rate limit timeout duration to apply to the HTTP reload interface.
# # The reload interface is disabled for this duration of time whenever a config reload request is
# # made that fails because the underlying config file is unmodified. default is 3
# # made that fails because the underlying config file is unmodified. default is 3s
# reload_rate_limit: 3s
# # auto_reload_interval controls how often Trickster checks the configuration file for changes.
# # A zero value disables automatic reloads. default is 0s
# auto_reload_interval: 10s

# # config_handler_path provides the HTTP path to view a read-only printout of the running configuration
# # which can be reached at http://your-trickster-endpoint:port/$config_handler_path
Expand Down
13 changes: 12 additions & 1 deletion docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,18 @@ Trickster can validate a configuration file by running `trickster -validate-conf

Trickster can gracefully reload the configuration file from disk without impacting the uptime and responsiveness of the application.

Trickster provides 2 ways to reload the Trickster configuration: by requesting an HTTP endpoint, or by sending a SIGHUP (e.g., `kill -1 $TRICKSTER_PID`) to the Trickster process. In both cases, the underlying running Configuration File must have been modified such that the last modified time of the file is different than from when it was previously loaded.
Trickster supports manual reloads by requesting an HTTP endpoint or sending a SIGHUP (e.g., `kill -1 $TRICKSTER_PID`) to the Trickster process. It can also poll the file automatically. In all cases, the running configuration file must have been modified since it was loaded.

### Automatic Config Reload

Trickster can also poll the configuration file and reload it after a change. This is disabled by default. Set `mgmt.auto_reload_interval` to a positive duration to enable it:

```yaml
mgmt:
auto_reload_interval: 10s
```

Polling uses the same validation and graceful reload path as SIGHUP and the management endpoint. The interval itself is reloadable, so a successful configuration update can change or disable automatic reloads. Polling is suitable for Kubernetes ConfigMap projected volumes, whose atomic symlink updates are not reliably represented as writes to the mounted file by filesystem notification APIs.

### Config Reload via SIGHUP

Expand Down
5 changes: 4 additions & 1 deletion examples/conf/example.full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,11 @@ backends:
# reload_drain_timeout: 30s
# # reload_rate_limit specifies the rate limit timeout duration to apply to the HTTP reload interface.
# # The reload interface is disabled for this duration of time whenever a config reload request is
# # made that fails because the underlying config file is unmodified. default is 3
# # made that fails because the underlying config file is unmodified. default is 3s
# reload_rate_limit: 3s
# # auto_reload_interval controls how often Trickster checks the configuration file for changes.
# # A zero value disables automatic reloads. default is 0s
# auto_reload_interval: 10s

# # config_handler_path provides the HTTP path to view a read-only printout of the running configuration
# # which can be reached at http://your-trickster-endpoint:port/$config_handler_path
Expand Down
36 changes: 28 additions & 8 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,25 @@ func (c *Config) CheckFileLastModified() time.Time {
return file.ModTime()
}

// HasConfigChanged reports whether the configuration file has changed since it was loaded.
// Unlike IsStale, it does not apply or update the reload rate limiter.
func (c *Config) HasConfigChanged() bool {
if c == nil || c.Main == nil {
return false
}
c.Main.stalenessCheckLock.Lock()
defer c.Main.stalenessCheckLock.Unlock()
return c.hasConfigChanged()
}

func (c *Config) hasConfigChanged() bool {
if c.Main.configFilePath == "" {
return false
}
t := c.CheckFileLastModified()
return !t.IsZero() && !t.Equal(c.Main.configLastModified)
}

// Process converts various raw config options into internal data structures
// as needed
func (c *Config) Process() error {
Expand Down Expand Up @@ -308,10 +327,13 @@ func (c *Config) Clone() *Config {

// IsStale returns true if the running config is stale versus the config on disk
func (c *Config) IsStale() bool {
if c == nil || c.Main == nil {
return false
}
c.Main.stalenessCheckLock.Lock()
defer c.Main.stalenessCheckLock.Unlock()

if c.Main == nil || c.Main.configFilePath == "" ||
if c.Main.configFilePath == "" ||
time.Now().Before(c.Main.configRateLimitTime) {
return false
}
Expand All @@ -321,20 +343,18 @@ func (c *Config) IsStale() bool {
}

c.Main.configRateLimitTime = time.Now().Add(time.Duration(c.MgmtConfig.ReloadRateLimit))
t := c.CheckFileLastModified()
if t.IsZero() {
return false
}
return !t.Equal(c.Main.configLastModified)
return c.hasConfigChanged()
}

// CheckAndMarkReloadInProgress checks if the config is stale and
// marks it as being reloaded to prevent duplicate reloads.
func (c *Config) CheckAndMarkReloadInProgress() bool {
if c == nil || c.Main == nil || c.Main.configFilePath == "" {
return false
}
c.Main.stalenessCheckLock.Lock()
defer c.Main.stalenessCheckLock.Unlock()
if c.Main == nil || c.Main.configFilePath == "" ||
time.Now().Before(c.Main.configRateLimitTime) {
if time.Now().Before(c.Main.configRateLimitTime) {
return false
}
if c.MgmtConfig == nil {
Expand Down
99 changes: 99 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
rule "github.com/trickstercache/trickster/v2/pkg/backends/rule/options"
ct "github.com/trickstercache/trickster/v2/pkg/config/types"
tracing "github.com/trickstercache/trickster/v2/pkg/observability/tracing/options"
"github.com/trickstercache/trickster/v2/pkg/parsing/timeconv"
auth "github.com/trickstercache/trickster/v2/pkg/proxy/authenticator/options"
"github.com/trickstercache/trickster/v2/pkg/proxy/headers"
po "github.com/trickstercache/trickster/v2/pkg/proxy/paths/options"
Expand Down Expand Up @@ -395,6 +396,104 @@ func TestCheckAndMarkReloadInProgress(t *testing.T) {
}
}

func TestHasConfigChangedDoesNotApplyRateLimit(t *testing.T) {
var nilConfig *Config
if nilConfig.HasConfigChanged() {
t.Error("nil config reported changed")
}
if NewConfig().HasConfigChanged() {
t.Error("config without a file path reported changed")
}
missingConfig := NewConfig()
missingConfig.Main.configFilePath = filepath.Join(t.TempDir(), "missing.yaml")
if missingConfig.HasConfigChanged() {
t.Error("missing config file reported changed")
}

testFile := filepath.Join(t.TempDir(), "trickster_test.conf")
_, yml := emptyTestConfig()
if err := os.WriteFile(testFile, []byte(yml), 0o600); err != nil {
t.Fatal(err)
}

c, err := Load([]string{"-config", testFile})
if err != nil {
t.Fatal(err)
}
c.MgmtConfig.ReloadRateLimit = timeconv.Duration(time.Hour)
if c.HasConfigChanged() {
t.Fatal("freshly loaded config reported changed")
}

if err := os.WriteFile(testFile, []byte(yml+"\n"), 0o600); err != nil {
t.Fatal(err)
}
modified := c.Main.configLastModified.Add(time.Second)
if err := os.Chtimes(testFile, modified, modified); err != nil {
t.Fatal(err)
}
if !c.HasConfigChanged() || !c.HasConfigChanged() {
t.Error("read-only change check was unexpectedly rate limited")
}
}

func TestHasConfigChangedAfterProjectedVolumeSwap(t *testing.T) {
root := t.TempDir()
firstRevision := filepath.Join(root, "..2026_01")
secondRevision := filepath.Join(root, "..2026_02")
for _, dir := range []string{firstRevision, secondRevision} {
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
}

const backendYAML = "backends:\n test:\n provider: test\n origin_url: http://1\n"
const firstYAML = backendYAML + "frontend:\n listen_port: 8480\n"
const secondYAML = backendYAML + "frontend:\n listen_port: 8481\n"
firstConfig := filepath.Join(firstRevision, "trickster.yaml")
secondConfig := filepath.Join(secondRevision, "trickster.yaml")
if err := os.WriteFile(firstConfig, []byte(firstYAML), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(secondConfig, []byte(secondYAML), 0o600); err != nil {
t.Fatal(err)
}
firstModified := time.Now().Add(-2 * time.Hour)
secondModified := firstModified.Add(time.Hour)
if err := os.Chtimes(firstConfig, firstModified, firstModified); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(secondConfig, secondModified, secondModified); err != nil {
t.Fatal(err)
}

dataLink := filepath.Join(root, "..data")
if err := os.Symlink(firstRevision, dataLink); err != nil {
t.Skipf("symlinks are unavailable: %v", err)
}
configPath := filepath.Join(root, "trickster.yaml")
if err := os.Symlink(filepath.Join("..data", "trickster.yaml"), configPath); err != nil {
t.Skipf("symlinks are unavailable: %v", err)
}

c, err := Load([]string{"-config", configPath})
if err != nil {
t.Fatal(err)
}
if c.HasConfigChanged() {
t.Fatal("fresh projected config reported changed")
}
if err := os.Remove(dataLink); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secondRevision, dataLink); err != nil {
t.Fatal(err)
}
if !c.HasConfigChanged() {
t.Error("projected volume symlink swap was not detected")
}
}

func TestConfigFilePath(t *testing.T) {
c, _ := emptyTestConfig()

Expand Down
10 changes: 10 additions & 0 deletions pkg/config/mgmt/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ type Options struct {
// This prevents a bad actor from stating the config file with millions of concurrent requests
// The rate limit does not apply to SIGHUP-based reload requests
ReloadRateLimit timeconv.Duration `yaml:"reload_rate_limit,omitempty"`
// AutoReloadInterval controls how often Trickster checks the configuration file for
// changes. A zero value disables automatic reloads.
AutoReloadInterval timeconv.Duration `yaml:"auto_reload_interval,omitempty"`
}

// ErrInvalidPprofListenerName returns an error for invalid pprof listener name
Expand All @@ -67,6 +70,9 @@ var ErrInvalidPprofListenerName = errors.New("invalid pprof listener name")
// ErrInvalidConfigHandlerListenerName returns an error for an invalid config handler listener name
var ErrInvalidConfigHandlerListenerName = errors.New("invalid config handler listener name")

// ErrInvalidAutoReloadInterval indicates that the configured interval is negative.
var ErrInvalidAutoReloadInterval = errors.New("auto reload interval cannot be negative")

// New returns a new Options references with Default Values set
func New() *Options {
return &Options{
Expand All @@ -86,6 +92,10 @@ func New() *Options {
}

func (o *Options) Validate() error {
if o.AutoReloadInterval < 0 {
return ErrInvalidAutoReloadInterval
}

switch o.ConfigHandlerListener {
case ListenerNameMetrics, ListenerNameMgmt, ListenerNameOff, ListenerNameBoth:
case "":
Expand Down
43 changes: 42 additions & 1 deletion pkg/config/mgmt/mgmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@

package mgmt

import "testing"
import (
"errors"
"testing"
"time"

"github.com/trickstercache/trickster/v2/pkg/parsing/timeconv"

"gopkg.in/yaml.v2"
)

func TestValidate(t *testing.T) {
c := New()
Expand Down Expand Up @@ -69,6 +77,39 @@ func TestValidatePprofListenerNames(t *testing.T) {
t.Errorf("expected pprof listener name %q to be invalid, got %v", name, err)
}
}

c := New()
c.AutoReloadInterval = timeconv.Duration(-time.Second)
if err := c.Validate(); !errors.Is(err, ErrInvalidAutoReloadInterval) {
t.Errorf("error = %v; want %v", err, ErrInvalidAutoReloadInterval)
}
}

func TestReloadOptionsYAML(t *testing.T) {
o := New()
const yml = `reload_handler_path: /reload
reload_drain_timeout: 17s
reload_rate_limit: 2s
auto_reload_interval: 10s
`
if err := yaml.Unmarshal([]byte(yml), o); err != nil {
t.Fatal(err)
}
if o.ReloadHandlerPath != "/reload" {
t.Errorf("reload handler path = %q; want %q", o.ReloadHandlerPath, "/reload")
}
if o.ReloadDrainTimeout != timeconv.Duration(17*time.Second) {
t.Errorf("reload drain timeout = %v; want %v", o.ReloadDrainTimeout, 17*time.Second)
}
if o.ReloadRateLimit != timeconv.Duration(2*time.Second) {
t.Errorf("reload rate limit = %v; want %v", o.ReloadRateLimit, 2*time.Second)
}
if o.AutoReloadInterval != timeconv.Duration(10*time.Second) {
t.Errorf("auto reload interval = %v; want %v", o.AutoReloadInterval, 10*time.Second)
}
if got := o.Clone().AutoReloadInterval; got != o.AutoReloadInterval {
t.Errorf("cloned auto reload interval = %v; want %v", got, o.AutoReloadInterval)
}
}

func TestClone(t *testing.T) {
Expand Down
Loading
Loading