github.com/thanos-io/thanos@v0.32.5/pkg/reloader/reloader_test.go (about)

     1  // Copyright (c) The Thanos Authors.
     2  // Licensed under the Apache License 2.0.
     3  
     4  package reloader
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"net"
    10  	"net/http"
    11  	"net/url"
    12  	"os"
    13  	"path"
    14  	"path/filepath"
    15  	"runtime"
    16  	"strings"
    17  	"sync"
    18  	"testing"
    19  	"time"
    20  
    21  	"github.com/go-kit/log"
    22  	"github.com/prometheus/client_golang/prometheus"
    23  	promtest "github.com/prometheus/client_golang/prometheus/testutil"
    24  	"go.uber.org/atomic"
    25  	"go.uber.org/goleak"
    26  
    27  	"github.com/efficientgo/core/testutil"
    28  )
    29  
    30  func TestMain(m *testing.M) {
    31  	goleak.VerifyTestMain(m)
    32  }
    33  
    34  func TestReloader_ConfigApply(t *testing.T) {
    35  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
    36  	defer cancel()
    37  
    38  	l, err := net.Listen("tcp", "localhost:0")
    39  	testutil.Ok(t, err)
    40  
    41  	reloads := &atomic.Value{}
    42  	reloads.Store(0)
    43  	i := 0
    44  	srv := &http.Server{}
    45  	srv.Handler = http.HandlerFunc(func(resp http.ResponseWriter, r *http.Request) {
    46  		i++
    47  		if i%2 == 0 {
    48  			// Every second request, fail to ensure that retry works.
    49  			resp.WriteHeader(http.StatusServiceUnavailable)
    50  			return
    51  		}
    52  
    53  		reloads.Store(reloads.Load().(int) + 1) // The only writer.
    54  		resp.WriteHeader(http.StatusOK)
    55  	})
    56  	go func() { _ = srv.Serve(l) }()
    57  	defer func() { testutil.Ok(t, srv.Close()) }()
    58  
    59  	reloadURL, err := url.Parse(fmt.Sprintf("http://%s", l.Addr().String()))
    60  	testutil.Ok(t, err)
    61  
    62  	dir := t.TempDir()
    63  
    64  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "in"), os.ModePerm))
    65  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "out"), os.ModePerm))
    66  
    67  	var (
    68  		input  = filepath.Join(dir, "in", "cfg.yaml.tmpl")
    69  		output = filepath.Join(dir, "out", "cfg.yaml")
    70  	)
    71  	reloader := New(nil, nil, &Options{
    72  		ReloadURL:     reloadURL,
    73  		CfgFile:       input,
    74  		CfgOutputFile: output,
    75  		WatchedDirs:   nil,
    76  		WatchInterval: 9999 * time.Hour, // Disable interval to test watch logic only.
    77  		RetryInterval: 100 * time.Millisecond,
    78  		DelayInterval: 1 * time.Millisecond,
    79  	})
    80  
    81  	// Fail without config.
    82  	err = reloader.Watch(ctx)
    83  	testutil.NotOk(t, err)
    84  	testutil.Assert(t, strings.HasSuffix(err.Error(), "no such file or directory"), "expect error since there is no input config.")
    85  
    86  	testutil.Ok(t, os.WriteFile(input, []byte(`
    87  config:
    88    a: 1
    89    b: $(TEST_RELOADER_THANOS_ENV)
    90    c: $(TEST_RELOADER_THANOS_ENV2)
    91  `), os.ModePerm))
    92  
    93  	// Fail with config but without unset variables.
    94  	err = reloader.Watch(ctx)
    95  	testutil.NotOk(t, err)
    96  	testutil.Assert(t, strings.HasSuffix(err.Error(), `found reference to unset environment variable "TEST_RELOADER_THANOS_ENV"`), "expect error since there envvars are not set.")
    97  
    98  	testutil.Ok(t, os.Setenv("TEST_RELOADER_THANOS_ENV", "2"))
    99  	testutil.Ok(t, os.Setenv("TEST_RELOADER_THANOS_ENV2", "3"))
   100  
   101  	rctx, cancel2 := context.WithCancel(ctx)
   102  	g := sync.WaitGroup{}
   103  	g.Add(1)
   104  	go func() {
   105  		defer g.Done()
   106  		testutil.Ok(t, reloader.Watch(rctx))
   107  	}()
   108  
   109  	reloadsSeen := 0
   110  	attemptsCnt := 0
   111  Outer:
   112  	for {
   113  		select {
   114  		case <-ctx.Done():
   115  			break Outer
   116  		case <-time.After(300 * time.Millisecond):
   117  		}
   118  
   119  		rel := reloads.Load().(int)
   120  		reloadsSeen = rel
   121  
   122  		if reloadsSeen == 1 {
   123  			// Initial apply seen (without doing nothing).
   124  			f, err := os.ReadFile(output)
   125  			testutil.Ok(t, err)
   126  			testutil.Equals(t, `
   127  config:
   128    a: 1
   129    b: 2
   130    c: 3
   131  `, string(f))
   132  
   133  			// Change config, expect reload in another iteration.
   134  			testutil.Ok(t, os.WriteFile(input, []byte(`
   135  config:
   136    a: changed
   137    b: $(TEST_RELOADER_THANOS_ENV)
   138    c: $(TEST_RELOADER_THANOS_ENV2)
   139  `), os.ModePerm))
   140  		} else if reloadsSeen == 2 {
   141  			// Another apply, ensure we see change.
   142  			f, err := os.ReadFile(output)
   143  			testutil.Ok(t, err)
   144  			testutil.Equals(t, `
   145  config:
   146    a: changed
   147    b: 2
   148    c: 3
   149  `, string(f))
   150  
   151  			// Change the mode so reloader can't read the file.
   152  			testutil.Ok(t, os.Chmod(input, os.ModeDir))
   153  			attemptsCnt++
   154  			// That was the second attempt to reload config. All good, break.
   155  			if attemptsCnt == 2 {
   156  				break
   157  			}
   158  		}
   159  	}
   160  	cancel2()
   161  	g.Wait()
   162  
   163  	testutil.Ok(t, os.Unsetenv("TEST_RELOADER_THANOS_ENV"))
   164  	testutil.Ok(t, os.Unsetenv("TEST_RELOADER_THANOS_ENV2"))
   165  }
   166  
   167  func TestReloader_ConfigRollback(t *testing.T) {
   168  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
   169  	defer cancel()
   170  
   171  	l, err := net.Listen("tcp", "localhost:0")
   172  	testutil.Ok(t, err)
   173  
   174  	correctConfig := []byte(`
   175  config:
   176    a: 1
   177  `)
   178  	faultyConfig := []byte(`
   179  faulty_config:
   180    a: 1
   181  `)
   182  
   183  	dir := t.TempDir()
   184  
   185  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "in"), os.ModePerm))
   186  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "out"), os.ModePerm))
   187  
   188  	var (
   189  		input  = filepath.Join(dir, "in", "cfg.yaml.tmpl")
   190  		output = filepath.Join(dir, "out", "cfg.yaml")
   191  	)
   192  
   193  	reloads := &atomic.Value{}
   194  	reloads.Store(0)
   195  	srv := &http.Server{}
   196  
   197  	srv.Handler = http.HandlerFunc(func(resp http.ResponseWriter, r *http.Request) {
   198  		f, err := os.ReadFile(output)
   199  		testutil.Ok(t, err)
   200  
   201  		if string(f) == string(faultyConfig) {
   202  			resp.WriteHeader(http.StatusServiceUnavailable)
   203  			return
   204  		}
   205  
   206  		reloads.Store(reloads.Load().(int) + 1) // The only writer.
   207  		resp.WriteHeader(http.StatusOK)
   208  	})
   209  	go func() { _ = srv.Serve(l) }()
   210  	defer func() { testutil.Ok(t, srv.Close()) }()
   211  
   212  	reloadURL, err := url.Parse(fmt.Sprintf("http://%s", l.Addr().String()))
   213  	testutil.Ok(t, err)
   214  
   215  	reloader := New(nil, nil, &Options{
   216  		ReloadURL:     reloadURL,
   217  		CfgFile:       input,
   218  		CfgOutputFile: output,
   219  		WatchedDirs:   nil,
   220  		WatchInterval: 10 * time.Second, // 10 seconds to make the reload of faulty config fail quick
   221  		RetryInterval: 100 * time.Millisecond,
   222  		DelayInterval: 1 * time.Millisecond,
   223  	})
   224  
   225  	testutil.Ok(t, os.WriteFile(input, correctConfig, os.ModePerm))
   226  
   227  	rctx, cancel2 := context.WithCancel(ctx)
   228  	g := sync.WaitGroup{}
   229  	g.Add(1)
   230  	go func() {
   231  		defer g.Done()
   232  		testutil.Ok(t, reloader.Watch(rctx))
   233  	}()
   234  
   235  	reloadsSeen := 0
   236  	faulty := false
   237  
   238  	for {
   239  		select {
   240  		case <-ctx.Done():
   241  			t.Fatalf("Timeout with faulty = %t, reloadsSeen = %d", faulty, reloadsSeen)
   242  		case <-time.After(300 * time.Millisecond):
   243  		}
   244  
   245  		rel := reloads.Load().(int)
   246  		reloadsSeen = rel
   247  
   248  		if reloadsSeen == 1 && !faulty {
   249  			// Initial apply seen (without doing anything).
   250  			f, err := os.ReadFile(output)
   251  			testutil.Ok(t, err)
   252  			testutil.Equals(t, string(correctConfig), string(f))
   253  
   254  			// Change to a faulty config
   255  			testutil.Ok(t, os.WriteFile(input, faultyConfig, os.ModePerm))
   256  			faulty = true
   257  		} else if reloadsSeen == 1 && faulty {
   258  			// Faulty config will trigger a reload, but reload failed
   259  			f, err := os.ReadFile(output)
   260  			testutil.Ok(t, err)
   261  			testutil.Equals(t, string(faultyConfig), string(f))
   262  
   263  			// Rollback config
   264  			testutil.Ok(t, os.WriteFile(input, correctConfig, os.ModePerm))
   265  		} else if reloadsSeen >= 2 {
   266  			// Rollback to previous config should trigger a reload
   267  			f, err := os.ReadFile(output)
   268  			testutil.Ok(t, err)
   269  			testutil.Equals(t, string(correctConfig), string(f))
   270  
   271  			break
   272  		}
   273  	}
   274  	cancel2()
   275  	g.Wait()
   276  }
   277  
   278  func TestReloader_DirectoriesApply(t *testing.T) {
   279  	l, err := net.Listen("tcp", "localhost:0")
   280  	testutil.Ok(t, err)
   281  
   282  	i := 0
   283  	reloads := 0
   284  	reloadsMtx := sync.Mutex{}
   285  
   286  	srv := &http.Server{}
   287  	srv.Handler = http.HandlerFunc(func(resp http.ResponseWriter, r *http.Request) {
   288  		reloadsMtx.Lock()
   289  		defer reloadsMtx.Unlock()
   290  
   291  		i++
   292  		if i%2 == 0 {
   293  			// Fail every second request to ensure that retry works.
   294  			resp.WriteHeader(http.StatusServiceUnavailable)
   295  			return
   296  		}
   297  
   298  		reloads++
   299  		resp.WriteHeader(http.StatusOK)
   300  	})
   301  	go func() {
   302  		_ = srv.Serve(l)
   303  	}()
   304  	defer func() { testutil.Ok(t, srv.Close()) }()
   305  
   306  	reloadURL, err := url.Parse(fmt.Sprintf("http://%s", l.Addr().String()))
   307  	testutil.Ok(t, err)
   308  
   309  	ruleDir := t.TempDir()
   310  	tempRule1File := path.Join(ruleDir, "rule1.yaml")
   311  	tempRule3File := path.Join(ruleDir, "rule3.yaml")
   312  	tempRule4File := path.Join(ruleDir, "rule4.yaml")
   313  
   314  	testutil.Ok(t, os.WriteFile(tempRule1File, []byte("rule1-changed"), os.ModePerm))
   315  	testutil.Ok(t, os.WriteFile(tempRule3File, []byte("rule3-changed"), os.ModePerm))
   316  	testutil.Ok(t, os.WriteFile(tempRule4File, []byte("rule4-changed"), os.ModePerm))
   317  
   318  	dir := t.TempDir()
   319  	dir2 := t.TempDir()
   320  
   321  	// dir
   322  	// └─ rule-dir -> dir2/rule-dir
   323  	// dir2
   324  	// └─ rule-dir
   325  	testutil.Ok(t, os.Mkdir(path.Join(dir2, "rule-dir"), os.ModePerm))
   326  	testutil.Ok(t, os.Symlink(path.Join(dir2, "rule-dir"), path.Join(dir, "rule-dir")))
   327  
   328  	logger := log.NewNopLogger()
   329  	r := prometheus.NewRegistry()
   330  	reloader := New(
   331  		logger,
   332  		r,
   333  		&Options{
   334  			ReloadURL:     reloadURL,
   335  			CfgFile:       "",
   336  			CfgOutputFile: "",
   337  			WatchedDirs:   []string{dir, path.Join(dir, "rule-dir")},
   338  			WatchInterval: 9999 * time.Hour, // Disable interval to test watch logic only.
   339  			RetryInterval: 100 * time.Millisecond,
   340  		})
   341  
   342  	// dir
   343  	// ├─ rule-dir -> dir2/rule-dir
   344  	// └─ rule1.yaml
   345  	// dir2
   346  	// ├─ rule-dir
   347  	// │  └─ rule4.yaml
   348  	// ├─ rule3-001.yaml -> rule3-source.yaml
   349  	// └─ rule3-source.yaml
   350  	// The reloader watches 2 directories: dir and dir/rule-dir.
   351  	testutil.Ok(t, os.WriteFile(path.Join(dir, "rule1.yaml"), []byte("rule"), os.ModePerm))
   352  	testutil.Ok(t, os.WriteFile(path.Join(dir2, "rule3-source.yaml"), []byte("rule3"), os.ModePerm))
   353  	testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-source.yaml"), path.Join(dir2, "rule3-001.yaml")))
   354  	testutil.Ok(t, os.WriteFile(path.Join(dir2, "rule-dir", "rule4.yaml"), []byte("rule4"), os.ModePerm))
   355  
   356  	stepFunc := func(rel int) {
   357  		t.Log("Performing step number", rel)
   358  		switch rel {
   359  		case 0:
   360  			// Create rule2.yaml.
   361  			//
   362  			// dir
   363  			// ├─ rule-dir -> dir2/rule-dir
   364  			// ├─ rule1.yaml
   365  			// └─ rule2.yaml (*)
   366  			// dir2
   367  			// ├─ rule-dir
   368  			// │  └─ rule4.yaml
   369  			// ├─ rule3-001.yaml -> rule3-source.yaml
   370  			// └─ rule3-source.yaml
   371  			testutil.Ok(t, os.WriteFile(path.Join(dir, "rule2.yaml"), []byte("rule2"), os.ModePerm))
   372  		case 1:
   373  			// Update rule1.yaml.
   374  			//
   375  			// dir
   376  			// ├─ rule-dir -> dir2/rule-dir
   377  			// ├─ rule1.yaml (*)
   378  			// └─ rule2.yaml
   379  			// dir2
   380  			// ├─ rule-dir
   381  			// │  └─ rule4.yaml
   382  			// ├─ rule3-001.yaml -> rule3-source.yaml
   383  			// └─ rule3-source.yaml
   384  			testutil.Ok(t, os.Rename(tempRule1File, path.Join(dir, "rule1.yaml")))
   385  		case 2:
   386  			// Create dir/rule3.yaml (symlink to rule3-001.yaml).
   387  			//
   388  			// dir
   389  			// ├─ rule-dir -> dir2/rule-dir
   390  			// ├─ rule1.yaml
   391  			// ├─ rule2.yaml
   392  			// └─ rule3.yaml -> dir2/rule3-001.yaml (*)
   393  			// dir2
   394  			// ├─ rule-dir
   395  			// │  └─ rule4.yaml
   396  			// ├─ rule3-001.yaml -> rule3-source.yaml
   397  			// └─ rule3-source.yaml
   398  			testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-001.yaml"), path.Join(dir2, "rule3.yaml")))
   399  			testutil.Ok(t, os.Rename(path.Join(dir2, "rule3.yaml"), path.Join(dir, "rule3.yaml")))
   400  		case 3:
   401  			// Update the symlinked file and replace the symlink file to trigger fsnotify.
   402  			//
   403  			// dir
   404  			// ├─ rule-dir -> dir2/rule-dir
   405  			// ├─ rule1.yaml
   406  			// ├─ rule2.yaml
   407  			// └─ rule3.yaml -> dir2/rule3-002.yaml (*)
   408  			// dir2
   409  			// ├─ rule-dir
   410  			// │  └─ rule4.yaml
   411  			// ├─ rule3-002.yaml -> rule3-source.yaml (*)
   412  			// └─ rule3-source.yaml (*)
   413  			testutil.Ok(t, os.Rename(tempRule3File, path.Join(dir2, "rule3-source.yaml")))
   414  			testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-source.yaml"), path.Join(dir2, "rule3-002.yaml")))
   415  			testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-002.yaml"), path.Join(dir2, "rule3.yaml")))
   416  			testutil.Ok(t, os.Rename(path.Join(dir2, "rule3.yaml"), path.Join(dir, "rule3.yaml")))
   417  			testutil.Ok(t, os.Remove(path.Join(dir2, "rule3-001.yaml")))
   418  		case 4:
   419  			// Update rule4.yaml in the symlinked directory.
   420  			//
   421  			// dir
   422  			// ├─ rule-dir -> dir2/rule-dir
   423  			// ├─ rule1.yaml
   424  			// ├─ rule2.yaml
   425  			// └─ rule3.yaml -> rule3-source.yaml
   426  			// dir2
   427  			// ├─ rule-dir
   428  			// │  └─ rule4.yaml (*)
   429  			// └─ rule3-source.yaml
   430  			testutil.Ok(t, os.Rename(tempRule4File, path.Join(dir2, "rule-dir", "rule4.yaml")))
   431  		}
   432  	}
   433  
   434  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   435  	g := sync.WaitGroup{}
   436  	g.Add(1)
   437  	go func() {
   438  		defer g.Done()
   439  		defer cancel()
   440  
   441  		reloadsSeen := 0
   442  		init := false
   443  		for {
   444  			runtime.Gosched() // Ensure during testing on small machine, other go routines have chance to continue.
   445  
   446  			select {
   447  			case <-ctx.Done():
   448  				return
   449  			case <-time.After(500 * time.Millisecond):
   450  			}
   451  
   452  			reloadsMtx.Lock()
   453  			rel := reloads
   454  			reloadsMtx.Unlock()
   455  			if init && rel <= reloadsSeen {
   456  				continue
   457  			}
   458  
   459  			// Catch up if reloader is step(s) ahead.
   460  			for skipped := rel - reloadsSeen - 1; skipped > 0; skipped-- {
   461  				stepFunc(rel - skipped)
   462  			}
   463  
   464  			stepFunc(rel)
   465  
   466  			init = true
   467  			reloadsSeen = rel
   468  
   469  			if rel > 4 {
   470  				// All good.
   471  				return
   472  			}
   473  		}
   474  	}()
   475  	err = reloader.Watch(ctx)
   476  	cancel()
   477  	g.Wait()
   478  
   479  	testutil.Ok(t, err)
   480  	testutil.Equals(t, 6.0, promtest.ToFloat64(reloader.watcher.watchEvents))
   481  	testutil.Equals(t, 0.0, promtest.ToFloat64(reloader.watcher.watchErrors))
   482  	testutil.Equals(t, 4.0, promtest.ToFloat64(reloader.reloadErrors))
   483  	testutil.Equals(t, 9.0, promtest.ToFloat64(reloader.reloads))
   484  	testutil.Equals(t, 5, reloads)
   485  }
   486  
   487  func TestReloaderDirectoriesApplyBasedOnWatchInterval(t *testing.T) {
   488  	l, err := net.Listen("tcp", "localhost:0")
   489  	testutil.Ok(t, err)
   490  
   491  	reloads := &atomic.Value{}
   492  	reloads.Store(0)
   493  	srv := &http.Server{}
   494  	srv.Handler = http.HandlerFunc(func(resp http.ResponseWriter, r *http.Request) {
   495  		reloads.Store(reloads.Load().(int) + 1) // The only writer.
   496  		resp.WriteHeader(http.StatusOK)
   497  	})
   498  	go func() {
   499  		_ = srv.Serve(l)
   500  	}()
   501  	defer func() { testutil.Ok(t, srv.Close()) }()
   502  
   503  	reloadURL, err := url.Parse(fmt.Sprintf("http://%s", l.Addr().String()))
   504  	testutil.Ok(t, err)
   505  
   506  	dir := t.TempDir()
   507  	dir2 := t.TempDir()
   508  
   509  	// dir
   510  	// └─ rule-dir -> dir2/rule-dir
   511  	// dir2
   512  	// └─ rule-dir
   513  	testutil.Ok(t, os.Mkdir(path.Join(dir2, "rule-dir"), os.ModePerm))
   514  	testutil.Ok(t, os.Symlink(path.Join(dir2, "rule-dir"), path.Join(dir, "rule-dir")))
   515  
   516  	logger := log.NewNopLogger()
   517  	reloader := New(
   518  		logger,
   519  		nil,
   520  		&Options{
   521  			ReloadURL:     reloadURL,
   522  			CfgFile:       "",
   523  			CfgOutputFile: "",
   524  			WatchedDirs:   []string{dir, path.Join(dir, "rule-dir")},
   525  			WatchInterval: 1 * time.Second, // use a small watch interval.
   526  			RetryInterval: 9999 * time.Hour,
   527  		},
   528  	)
   529  
   530  	// dir
   531  	// ├─ rule-dir -> dir2/rule-dir
   532  	// └─ rule1.yaml
   533  	// dir2
   534  	// ├─ rule-dir
   535  	// │  └─ rule4.yaml
   536  	// ├─ rule3-001.yaml -> rule3-source.yaml
   537  	// └─ rule3-source.yaml
   538  	//
   539  	// The reloader watches 2 directories: dir and dir/rule-dir.
   540  	testutil.Ok(t, os.WriteFile(path.Join(dir, "rule1.yaml"), []byte("rule"), os.ModePerm))
   541  	testutil.Ok(t, os.WriteFile(path.Join(dir2, "rule3-source.yaml"), []byte("rule3"), os.ModePerm))
   542  	testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-source.yaml"), path.Join(dir2, "rule3-001.yaml")))
   543  	testutil.Ok(t, os.WriteFile(path.Join(dir2, "rule-dir", "rule4.yaml"), []byte("rule4"), os.ModePerm))
   544  
   545  	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   546  	g := sync.WaitGroup{}
   547  	g.Add(1)
   548  	go func() {
   549  		defer g.Done()
   550  		defer cancel()
   551  
   552  		reloadsSeen := 0
   553  		init := false
   554  		for {
   555  			runtime.Gosched() // Ensure during testing on small machine, other go routines have chance to continue.
   556  
   557  			select {
   558  			case <-ctx.Done():
   559  				return
   560  			case <-time.After(500 * time.Millisecond):
   561  			}
   562  
   563  			rel := reloads.Load().(int)
   564  			if init && rel <= reloadsSeen {
   565  				continue
   566  			}
   567  			init = true
   568  			reloadsSeen = rel
   569  
   570  			t.Log("Performing step number", rel)
   571  			switch rel {
   572  			case 0:
   573  				// Create rule3.yaml (symlink to rule3-001.yaml).
   574  				//
   575  				// dir
   576  				// ├─ rule-dir -> dir2/rule-dir
   577  				// ├─ rule1.yaml
   578  				// ├─ rule2.yaml
   579  				// └─ rule3.yaml -> dir2/rule3-001.yaml (*)
   580  				// dir2
   581  				// ├─ rule-dir
   582  				// │  └─ rule4.yaml
   583  				// ├─ rule3-001.yaml -> rule3-source.yaml
   584  				// └─ rule3-source.yaml
   585  				testutil.Ok(t, os.Symlink(path.Join(dir2, "rule3-001.yaml"), path.Join(dir2, "rule3.yaml")))
   586  				testutil.Ok(t, os.Rename(path.Join(dir2, "rule3.yaml"), path.Join(dir, "rule3.yaml")))
   587  			case 1:
   588  				// Update the symlinked file but do not replace the symlink in dir.
   589  				//
   590  				// fsnotify shouldn't send any event because the change happens
   591  				// in a directory that isn't watched but the reloader should detect
   592  				// the update thanks to the watch interval.
   593  				//
   594  				// dir
   595  				// ├─ rule-dir -> dir2/rule-dir
   596  				// ├─ rule1.yaml
   597  				// ├─ rule2.yaml
   598  				// └─ rule3.yaml -> dir2/rule3-001.yaml
   599  				// dir2
   600  				// ├─ rule-dir
   601  				// │  └─ rule4.yaml
   602  				// ├─ rule3-001.yaml -> rule3-source.yaml
   603  				// └─ rule3-source.yaml (*)
   604  				testutil.Ok(t, os.WriteFile(path.Join(dir2, "rule3-source.yaml"), []byte("rule3-changed"), os.ModePerm))
   605  			}
   606  
   607  			if rel > 1 {
   608  				// All good.
   609  				return
   610  			}
   611  		}
   612  	}()
   613  	err = reloader.Watch(ctx)
   614  	cancel()
   615  	g.Wait()
   616  
   617  	testutil.Ok(t, err)
   618  	testutil.Equals(t, 2, reloads.Load().(int))
   619  }
   620  
   621  func TestReloader_ConfigApplyWithWatchIntervalEqualsZero(t *testing.T) {
   622  	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
   623  	defer cancel()
   624  
   625  	l, err := net.Listen("tcp", "localhost:0")
   626  	testutil.Ok(t, err)
   627  
   628  	reloads := &atomic.Value{}
   629  	reloads.Store(0)
   630  	srv := &http.Server{}
   631  	srv.Handler = http.HandlerFunc(func(resp http.ResponseWriter, r *http.Request) {
   632  		reloads.Store(reloads.Load().(int) + 1)
   633  		resp.WriteHeader(http.StatusOK)
   634  	})
   635  	go func() { _ = srv.Serve(l) }()
   636  	defer func() { testutil.Ok(t, srv.Close()) }()
   637  
   638  	reloadURL, err := url.Parse(fmt.Sprintf("http://%s", l.Addr().String()))
   639  	testutil.Ok(t, err)
   640  
   641  	dir := t.TempDir()
   642  
   643  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "in"), os.ModePerm))
   644  	testutil.Ok(t, os.Mkdir(filepath.Join(dir, "out"), os.ModePerm))
   645  
   646  	var (
   647  		input  = filepath.Join(dir, "in", "cfg.yaml.tmpl")
   648  		output = filepath.Join(dir, "out", "cfg.yaml")
   649  	)
   650  	reloader := New(nil, nil, &Options{
   651  		ReloadURL:     reloadURL,
   652  		CfgFile:       input,
   653  		CfgOutputFile: output,
   654  		WatchedDirs:   nil,
   655  		WatchInterval: 0, // Set WatchInterval equals to 0
   656  		RetryInterval: 100 * time.Millisecond,
   657  		DelayInterval: 1 * time.Millisecond,
   658  	})
   659  
   660  	testutil.Ok(t, os.WriteFile(input, []byte(`
   661  config:
   662    a: 1
   663    b: 2
   664    c: 3
   665  `), os.ModePerm))
   666  
   667  	rctx, cancel2 := context.WithCancel(ctx)
   668  	g := sync.WaitGroup{}
   669  	g.Add(1)
   670  	go func() {
   671  		defer g.Done()
   672  		testutil.Ok(t, reloader.Watch(rctx))
   673  	}()
   674  
   675  Outer:
   676  	for {
   677  		select {
   678  		case <-ctx.Done():
   679  			break Outer
   680  		case <-time.After(300 * time.Millisecond):
   681  		}
   682  		if reloads.Load().(int) == 0 {
   683  			// Initial apply seen (without doing nothing).
   684  			f, err := os.ReadFile(output)
   685  			testutil.Ok(t, err)
   686  			testutil.Equals(t, `
   687  config:
   688    a: 1
   689    b: 2
   690    c: 3
   691  `, string(f))
   692  			break
   693  		}
   694  	}
   695  	cancel2()
   696  	g.Wait()
   697  	// Check no reload request made
   698  	testutil.Equals(t, 0, reloads.Load().(int))
   699  }