cmd: automatic restart of orchestrator on configuration change

This commit is contained in:
Vincent Bernat
2025-09-19 16:21:20 +02:00
parent 57185f67dd
commit c782312c2d
12 changed files with 244 additions and 65 deletions

View File

@@ -33,15 +33,17 @@ type ConfigRelatedOptions struct {
BeforeDump func(mapstructure.Metadata) BeforeDump func(mapstructure.Metadata)
} }
// Parse parses the configuration file (if present) and the // Parse parses the configuration file (if present) and the environment
// environment variables into the provided configuration. // variables into the provided configuration. It returns the paths to watch if
func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any) error { // we want to detect configuration changes.
func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any) ([]string, error) {
var rawConfig gin.H var rawConfig gin.H
var paths []string
if cfgFile := c.Path; cfgFile != "" { if cfgFile := c.Path; cfgFile != "" {
if strings.HasPrefix(cfgFile, "http://") || strings.HasPrefix(cfgFile, "https://") { if strings.HasPrefix(cfgFile, "http://") || strings.HasPrefix(cfgFile, "https://") {
u, err := url.Parse(cfgFile) u, err := url.Parse(cfgFile)
if err != nil { if err != nil {
return fmt.Errorf("cannot parse configuration URL: %w", err) return nil, fmt.Errorf("cannot parse configuration URL: %w", err)
} }
if u.Path == "" { if u.Path == "" {
u.Path = fmt.Sprintf("/api/v0/orchestrator/configuration/%s", component) u.Path = fmt.Sprintf("/api/v0/orchestrator/configuration/%s", component)
@@ -51,32 +53,36 @@ func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any)
} }
resp, err := http.Get(u.String()) resp, err := http.Get(u.String())
if err != nil { if err != nil {
return fmt.Errorf("unable to fetch configuration file: %w", err) return nil, fmt.Errorf("unable to fetch configuration file: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType) mediaType, _, err := mime.ParseMediaType(contentType)
if (mediaType != "application/x-yaml" && mediaType != "application/yaml") || err != nil { if (mediaType != "application/x-yaml" && mediaType != "application/yaml") || err != nil {
return fmt.Errorf("received configuration file is not YAML (%s)", contentType) return nil, fmt.Errorf("received configuration file is not YAML (%s)", contentType)
} }
input, err := io.ReadAll(resp.Body) input, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("unable to read configuration file: %w", err) return nil, fmt.Errorf("unable to read configuration file: %w", err)
} }
if err := yaml.Unmarshal(input, &rawConfig); err != nil { if err := yaml.Unmarshal(input, &rawConfig); err != nil {
return fmt.Errorf("unable to parse YAML configuration file: %w", err) return nil, fmt.Errorf("unable to parse YAML configuration file: %w", err)
} }
} else { } else {
cfgFile, err := filepath.EvalSymlinks(cfgFile) cfgFile, err := filepath.EvalSymlinks(cfgFile)
if err != nil { if err != nil {
return fmt.Errorf("cannot follow symlink: %w", err) return nil, fmt.Errorf("cannot follow symlink: %w", err)
} }
dirname, filename := filepath.Split(cfgFile) dirname, filename := filepath.Split(cfgFile)
if dirname == "" { if dirname == "" {
dirname = "." dirname = "."
} }
if _, err := yaml.UnmarshalWithInclude(os.DirFS(dirname), filename, &rawConfig); err != nil { paths, err = yaml.UnmarshalWithInclude(os.DirFS(dirname), filename, &rawConfig)
return fmt.Errorf("unable to parse YAML configuration file: %w", err) for i := range paths {
paths[i] = filepath.Clean(filepath.Join(dirname, paths[i]))
}
if err != nil {
return nil, fmt.Errorf("unable to parse YAML configuration file: %w", err)
} }
} }
} }
@@ -90,10 +96,10 @@ func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any)
decoderConfig.Metadata = &metadata decoderConfig.Metadata = &metadata
decoder, err := mapstructure.NewDecoder(decoderConfig) decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil { if err != nil {
return fmt.Errorf("unable to create configuration decoder: %w", err) return nil, fmt.Errorf("unable to create configuration decoder: %w", err)
} }
if err := decoder.Decode(rawConfig); err != nil { if err := decoder.Decode(rawConfig); err != nil {
return fmt.Errorf("unable to parse configuration: %w", err) return nil, fmt.Errorf("unable to parse configuration: %w", err)
} }
disableDefaultHook() disableDefaultHook()
disableZeroSliceHook() disableZeroSliceHook()
@@ -126,7 +132,7 @@ func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any)
} }
} }
if err := decoder.Decode(rawConfig); err != nil { if err := decoder.Decode(rawConfig); err != nil {
return fmt.Errorf("unable to parse override %q: %w", kv[0], err) return nil, fmt.Errorf("unable to parse override %q: %w", kv[0], err)
} }
} }
@@ -139,7 +145,7 @@ func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any)
} }
sort.Strings(invalidKeys) sort.Strings(invalidKeys)
if len(invalidKeys) > 0 { if len(invalidKeys) > 0 {
return fmt.Errorf("invalid configuration:\n%s", strings.Join(invalidKeys, "\n")) return nil, fmt.Errorf("invalid configuration:\n%s", strings.Join(invalidKeys, "\n"))
} }
// Validate and dump configuration if requested // Validate and dump configuration if requested
@@ -149,22 +155,22 @@ func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any)
if err := helpers.Validate.Struct(config); err != nil { if err := helpers.Validate.Struct(config); err != nil {
switch verr := err.(type) { switch verr := err.(type) {
case validator.ValidationErrors: case validator.ValidationErrors:
return fmt.Errorf("invalid configuration:\n%w", verr) return nil, fmt.Errorf("invalid configuration:\n%w", verr)
default: default:
return fmt.Errorf("unexpected internal error: %w", verr) return nil, fmt.Errorf("unexpected internal error: %w", verr)
} }
} }
if c.Dump { if c.Dump {
output, err := yaml.Marshal(config) output, err := yaml.Marshal(config)
if err != nil { if err != nil {
return fmt.Errorf("unable to dump configuration: %w", err) return nil, fmt.Errorf("unable to dump configuration: %w", err)
} }
out.Write([]byte("---\n")) out.Write([]byte("---\n"))
out.Write(output) out.Write(output)
out.Write([]byte("\n")) out.Write([]byte("\n"))
} }
return nil return paths, nil
} }
// DefaultHook will reset the destination value to its default using // DefaultHook will reset the destination value to its default using

View File

@@ -92,7 +92,7 @@ module1:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err == nil { if _, err := c.Parse(out, "dummy", &parsed); err == nil {
t.Fatal("Parse() didn't error") t.Fatal("Parse() didn't error")
} else if diff := helpers.Diff(err.Error(), `invalid configuration: } else if diff := helpers.Diff(err.Error(), `invalid configuration:
Key: 'dummyConfiguration.Module1.Topic' Error:Field validation for 'Topic' failed on the 'gte' tag Key: 'dummyConfiguration.Module1.Topic' Error:Field validation for 'Topic' failed on the 'gte' tag
@@ -126,7 +126,7 @@ module2:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
// Expected configuration // Expected configuration
@@ -226,7 +226,7 @@ module2:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
// Expected configuration // Expected configuration
@@ -280,7 +280,7 @@ module2:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
// Expected configuration // Expected configuration
@@ -325,7 +325,7 @@ module1:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
}) })
@@ -345,7 +345,7 @@ module1:
parsed := dummyConfiguration{} parsed := dummyConfiguration{}
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err == nil { if _, err := c.Parse(out, "dummy", &parsed); err == nil {
t.Fatal("Parse() didn't error") t.Fatal("Parse() didn't error")
} else if diff := helpers.Diff(err.Error(), `invalid configuration: } else if diff := helpers.Diff(err.Error(), `invalid configuration:
invalid key "Module1.extra" invalid key "Module1.extra"
@@ -384,7 +384,7 @@ modules:
parsed := struct { parsed := struct {
Modules []dummyConfiguration Modules []dummyConfiguration
}{} }{}
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
return parsed return parsed
@@ -452,7 +452,7 @@ modules:
parsed := struct { parsed := struct {
Modules []*dummyConfiguration Modules []*dummyConfiguration
}{} }{}
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
return parsed return parsed
@@ -524,7 +524,7 @@ func TestDevNullDefault(t *testing.T) {
var parsed dummyConfiguration var parsed dummyConfiguration
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
// Expected configuration // Expected configuration
@@ -603,7 +603,7 @@ module2:
}, },
} }
out := bytes.NewBuffer([]byte{}) out := bytes.NewBuffer([]byte{})
if err := c.Parse(out, "dummy", &parsed); err != nil { if _, err := c.Parse(out, "dummy", &parsed); err != nil {
t.Fatalf("Parse() error:\n%+v", err) t.Fatalf("Parse() error:\n%+v", err)
} }
if diff := helpers.Diff(parsed, expected); diff != "" { if diff := helpers.Diff(parsed, expected); diff != "" {

View File

@@ -60,7 +60,7 @@ manage collected flows.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config := ConsoleConfiguration{} config := ConsoleConfiguration{}
ConsoleOptions.Path = args[0] ConsoleOptions.Path = args[0]
if err := ConsoleOptions.Parse(cmd.OutOrStdout(), "console", &config); err != nil { if _, err := ConsoleOptions.Parse(cmd.OutOrStdout(), "console", &config); err != nil {
return err return err
} }

View File

@@ -57,7 +57,7 @@ and answers SNMP requests.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config := DemoExporterConfiguration{} config := DemoExporterConfiguration{}
DemoExporterOptions.Path = args[0] DemoExporterOptions.Path = args[0]
if err := DemoExporterOptions.Parse(cmd.OutOrStdout(), "demo-exporter", &config); err != nil { if _, err := DemoExporterOptions.Parse(cmd.OutOrStdout(), "demo-exporter", &config); err != nil {
return err return err
} }

View File

@@ -51,7 +51,7 @@ and export to Kafka.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config := InletConfiguration{} config := InletConfiguration{}
InletOptions.Path = args[0] InletOptions.Path = args[0]
if err := InletOptions.Parse(cmd.OutOrStdout(), "inlet", &config); err != nil { if _, err := InletOptions.Parse(cmd.OutOrStdout(), "inlet", &config); err != nil {
return err return err
} }

View File

@@ -6,9 +6,13 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"path/filepath"
"reflect" "reflect"
"slices" "slices"
"sync/atomic"
"github.com/fsnotify/fsnotify"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -27,14 +31,15 @@ import (
// OrchestratorConfiguration represents the configuration file for the orchestrator command. // OrchestratorConfiguration represents the configuration file for the orchestrator command.
type OrchestratorConfiguration struct { type OrchestratorConfiguration struct {
Reporting reporter.Configuration AutomaticRestart bool
HTTP httpserver.Configuration Reporting reporter.Configuration
ClickHouse clickhouse.Configuration HTTP httpserver.Configuration
ClickHouseDB clickhousedb.Configuration ClickHouse clickhouse.Configuration
Kafka kafka.Configuration ClickHouseDB clickhousedb.Configuration
GeoIP geoip.Configuration Kafka kafka.Configuration
Orchestrator orchestrator.Configuration `mapstructure:",squash" yaml:",inline"` GeoIP geoip.Configuration
Schema schema.Configuration Orchestrator orchestrator.Configuration `mapstructure:",squash" yaml:",inline"`
Schema schema.Configuration
// Other service configurations // Other service configurations
Inlet []InletConfiguration `validate:"dive"` Inlet []InletConfiguration `validate:"dive"`
Outlet []OutletConfiguration `validate:"dive"` Outlet []OutletConfiguration `validate:"dive"`
@@ -51,14 +56,15 @@ func (c *OrchestratorConfiguration) Reset() {
consoleConfiguration := ConsoleConfiguration{} consoleConfiguration := ConsoleConfiguration{}
consoleConfiguration.Reset() consoleConfiguration.Reset()
*c = OrchestratorConfiguration{ *c = OrchestratorConfiguration{
Reporting: reporter.DefaultConfiguration(), AutomaticRestart: true,
HTTP: httpserver.DefaultConfiguration(), Reporting: reporter.DefaultConfiguration(),
ClickHouse: clickhouse.DefaultConfiguration(), HTTP: httpserver.DefaultConfiguration(),
ClickHouseDB: clickhousedb.DefaultConfiguration(), ClickHouse: clickhouse.DefaultConfiguration(),
Kafka: kafka.DefaultConfiguration(), ClickHouseDB: clickhousedb.DefaultConfiguration(),
GeoIP: geoip.DefaultConfiguration(), Kafka: kafka.DefaultConfiguration(),
Orchestrator: orchestrator.DefaultConfiguration(), GeoIP: geoip.DefaultConfiguration(),
Schema: schema.DefaultConfiguration(), Orchestrator: orchestrator.DefaultConfiguration(),
Schema: schema.DefaultConfiguration(),
// Other service configurations // Other service configurations
Inlet: []InletConfiguration{inletConfiguration}, Inlet: []InletConfiguration{inletConfiguration},
Outlet: []OutletConfiguration{outletConfiguration}, Outlet: []OutletConfiguration{outletConfiguration},
@@ -83,6 +89,7 @@ var orchestratorCmd = &cobra.Command{
components and centralizes configuration of the various other components.`, components and centralizes configuration of the various other components.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
restart:
config := OrchestratorConfiguration{} config := OrchestratorConfiguration{}
OrchestratorOptions.Path = args[0] OrchestratorOptions.Path = args[0]
OrchestratorOptions.BeforeDump = func(metadata mapstructure.Metadata) { OrchestratorOptions.BeforeDump = func(metadata mapstructure.Metadata) {
@@ -108,15 +115,42 @@ components and centralizes configuration of the various other components.`,
config.Console[idx].Schema = config.Schema config.Console[idx].Schema = config.Schema
} }
} }
if err := OrchestratorOptions.Parse(cmd.OutOrStdout(), "orchestrator", &config); err != nil { // Parse and check the configuration a first time to start monitoring
// file changes if automatic restart is enabled.
paths, err := OrchestratorOptions.Parse(cmd.OutOrStdout(), "orchestrator", &config)
if err != nil {
return err return err
} }
OrchestratorOptions.Dump = false
// Start a few of the components we need for automatic restart
r, err := reporter.New(config.Reporting) r, err := reporter.New(config.Reporting)
if err != nil { if err != nil {
return fmt.Errorf("unable to initialize reporter: %w", err) return fmt.Errorf("unable to initialize reporter: %w", err)
} }
return orchestratorStart(r, config, OrchestratorOptions.CheckMode) daemonComponent, err := daemon.New(r)
if err != nil {
return fmt.Errorf("unable to initialize daemon component: %w", err)
}
configurationModified := atomic.Bool{}
if config.AutomaticRestart && !OrchestratorOptions.CheckMode {
orchestratorWatch(r, daemonComponent, paths, &configurationModified)
}
// Then, a second time for the remaining of the configuration.
config = OrchestratorConfiguration{}
if _, err := OrchestratorOptions.Parse(cmd.OutOrStdout(), "orchestrator", &config); err != nil {
return err
}
if err := orchestratorStart(r, config, daemonComponent, OrchestratorOptions.CheckMode); err != nil {
return err
}
if configurationModified.Load() {
goto restart
}
return nil
}, },
} }
@@ -128,11 +162,7 @@ func init() {
"Check configuration, but does not start") "Check configuration, but does not start")
} }
func orchestratorStart(r *reporter.Reporter, config OrchestratorConfiguration, checkOnly bool) error { func orchestratorStart(r *reporter.Reporter, config OrchestratorConfiguration, daemonComponent daemon.Component, checkOnly bool) error {
daemonComponent, err := daemon.New(r)
if err != nil {
return fmt.Errorf("unable to initialize daemon component: %w", err)
}
httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{ httpComponent, err := httpserver.New(r, config.HTTP, httpserver.Dependencies{
Daemon: daemonComponent, Daemon: daemonComponent,
}) })
@@ -208,6 +238,73 @@ func orchestratorStart(r *reporter.Reporter, config OrchestratorConfiguration, c
return StartStopComponents(r, daemonComponent, components) return StartStopComponents(r, daemonComponent, components)
} }
// orchestratorWatch will listen to changes to the given path and trigger a
// restart of the orchestrator if any. When a modification is detected, the
// modified chan is closed. The internal goroutine is also stopped if there the
// modified chan is closed.
func orchestratorWatch(r *reporter.Reporter, daemonComponent daemon.Component, paths []string, modified *atomic.Bool) {
r.Info().Strs("paths", paths).Msg("watching loaded configuration files for changes")
watcher, err := fsnotify.NewWatcher()
if err != nil {
r.Err(err).Msg("cannot setup watcher for configuration changes")
return
}
for _, path := range paths {
if err := watcher.Add(filepath.Dir(path)); err != nil {
r.Err(err).Msg("cannot watch configuration file")
watcher.Close()
return
}
}
go func() {
defer watcher.Close()
for {
select {
case err, ok := <-watcher.Errors:
if !ok {
r.Error().Msg("configuration file watcher died")
}
r.Err(err).Msg("error from configuration file watcher")
return
case event, ok := <-watcher.Events:
if !ok {
r.Error().Msg("configuration file watcher died")
return
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
// Check if we have one of the monitored path matching
r.Debug().Str("name", event.Name).Msg("detected potential configuration change")
found := false
for _, path := range paths {
if filepath.Clean(event.Name) == path {
found = true
break
}
}
if !found {
continue
}
// Check if the configuration is correct
_, err = OrchestratorOptions.Parse(io.Discard, "orchestrator", &OrchestratorConfiguration{})
if err != nil {
r.Err(err).Msg("cannot validate new configuration, not reloading")
continue
}
// Request termination to reexec
r.Debug().Msg("request a restart on configuration change")
modified.Store(true)
daemonComponent.Terminate()
return
}
case <-daemonComponent.Terminated():
return
}
}
}()
}
// orchestratorGeoIPMigrationHook migrates GeoIP configuration from inlet // orchestratorGeoIPMigrationHook migrates GeoIP configuration from inlet
// component to clickhouse component // component to clickhouse component
func orchestratorGeoIPMigrationHook() mapstructure.DecodeHookFunc { func orchestratorGeoIPMigrationHook() mapstructure.DecodeHookFunc {

View File

@@ -5,13 +5,17 @@ package cmd
import ( import (
"bytes" "bytes"
"io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time"
"akvorado/common/daemon"
"akvorado/common/helpers" "akvorado/common/helpers"
"akvorado/common/helpers/yaml" "akvorado/common/helpers/yaml"
"akvorado/common/reporter" "akvorado/common/reporter"
@@ -21,7 +25,7 @@ func TestOrchestratorStart(t *testing.T) {
r := reporter.NewMock(t) r := reporter.NewMock(t)
config := OrchestratorConfiguration{} config := OrchestratorConfiguration{}
config.Reset() config.Reset()
if err := orchestratorStart(r, config, true); err != nil { if err := orchestratorStart(r, config, daemon.NewMock(t), true); err != nil {
t.Fatalf("orchestratorStart() error:\n%+v", err) t.Fatalf("orchestratorStart() error:\n%+v", err)
} }
} }
@@ -108,3 +112,70 @@ func TestOrchestrator(t *testing.T) {
t.Errorf("`orchestrator` error:\n%+v", err) t.Errorf("`orchestrator` error:\n%+v", err)
} }
} }
func TestOrchestratorWatch(t *testing.T) {
tmp := t.TempDir()
if err := os.CopyFS(tmp, os.DirFS("../config")); err != nil {
t.Fatalf("CopyFS() error:\n%+v", err)
}
OrchestratorOptions.Path = filepath.Join(tmp, "akvorado.yaml")
config := OrchestratorConfiguration{}
paths, err := OrchestratorOptions.Parse(io.Discard, "orchestrator", &config)
if err != nil {
t.Fatalf("Parse() error:\n%+v", err)
}
modified := atomic.Bool{}
r := reporter.NewMock(t)
daemonComponent, err := daemon.New(r)
if err != nil {
t.Fatalf("daemon.New() error:\n%+v", err)
}
orchestratorWatch(r, daemonComponent, paths, &modified)
daemonComponent.Start()
// Add a file: no change
if err := os.WriteFile(filepath.Join(tmp, "titi.yaml"), []byte("---\n"), 0o666); err != nil {
t.Fatalf("WriteFile() error:\n%+v", err)
}
time.Sleep(20 * time.Millisecond)
if modified.Load() {
t.Fatal("orchestratorWatch() detected a change that should not be")
}
// Make a configuration error: no change
if err := os.Rename(filepath.Join(tmp, "inlet.yaml"), filepath.Join(tmp, "inlet-old.yaml")); err != nil {
t.Fatalf("Rename() error:\n%+v", err)
}
if err := os.WriteFile(filepath.Join(tmp, "inlet-new.yaml"), []byte("---\nflows: 767643\n"), 0o666); err != nil {
t.Fatalf("WriteFile() error:\n%+v", err)
}
if err := os.Rename(filepath.Join(tmp, "inlet-new.yaml"), filepath.Join(tmp, "inlet.yaml")); err != nil {
t.Fatalf("Rename() error:\n%+v", err)
}
time.Sleep(20 * time.Millisecond)
if modified.Load() {
t.Fatal("orchestratorWatch() detected a change that should be rejected")
}
// Modify a file: change
f, err := os.OpenFile(filepath.Join(tmp, "outlet.yaml"), os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
t.Fatalf("OpenFile() error:\n%+v", err)
}
f.WriteString("\n")
f.Close()
if err := os.Rename(filepath.Join(tmp, "inlet-old.yaml"), filepath.Join(tmp, "inlet.yaml")); err != nil {
t.Fatalf("Rename() error:\n%+v", err)
}
// Check there is a restart attempted
select {
case <-daemonComponent.Terminated():
case <-time.After(time.Second):
t.Fatalf("orchestratorWatch() did not restart the service")
}
daemonComponent.Stop()
if !modified.Load() {
t.Fatal("orchestratorWatch() did not register a change")
}
}

View File

@@ -75,7 +75,7 @@ enrichment and export to Kafka.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
config := OutletConfiguration{} config := OutletConfiguration{}
OutletOptions.Path = args[0] OutletOptions.Path = args[0]
if err := OutletOptions.Parse(cmd.OutOrStdout(), "outlet", &config); err != nil { if _, err := OutletOptions.Parse(cmd.OutOrStdout(), "outlet", &config); err != nil {
return err return err
} }

View File

@@ -40,7 +40,7 @@ var RootCmd = &cobra.Command{
log.Logger = zerolog.New(w).With().Timestamp().Logger() log.Logger = zerolog.New(w).With().Timestamp().Logger()
} }
zerolog.SetGlobalLevel(zerolog.InfoLevel) zerolog.SetGlobalLevel(zerolog.InfoLevel)
if debug { if debug || helpers.Testing() {
zerolog.SetGlobalLevel(zerolog.DebugLevel) zerolog.SetGlobalLevel(zerolog.DebugLevel)
} }
}, },

View File

@@ -4,10 +4,12 @@ The orchestrator service is configured through YAML files (provided in the
`config/` directory) and includes the configuration of the other services. `config/` directory) and includes the configuration of the other services.
> [!TIP] > [!TIP]
> Other services query the orchestrator through HTTP on startup to get their > Other services query the orchestrator through HTTP on startup to get their
> configuration. This means that if you change the configuration for one > configuration. By default, the orchestrator restarts automatically if it
> service, you always need to restart the orchestrator first, then the service > detects a configuration change, but this may fail if there is a configuration
> whose configuration has changed. > error. Look at the logs of the orchestrator service or restart it if you think
> a configuration change is not applied.
You can get the default configuration with `docker compose run --rm --no-deps You can get the default configuration with `docker compose run --rm --no-deps
akvorado-orchestrator orchestrator --dump --check /dev/null`. Note that akvorado-orchestrator orchestrator --dump --check /dev/null`. Note that
@@ -621,7 +623,9 @@ efficiently.
## Orchestrator service ## Orchestrator service
The three main components of the orchestrator service are `schema`, The three main components of the orchestrator service are `schema`,
`clickhouse`, and `kafka`. `clickhouse`, and `kafka`. The `automatic-restart` directive tells the
orchestrator to watch for configuration changes and restart if there are any. It
is enable by default.
### Schema ### Schema

View File

@@ -12,6 +12,7 @@ identified with a specific icon:
## Unreleased ## Unreleased
-*orchestrator*: automatic restart of the orchestrator service on configuration change
- 🌱 *console*: submit form on Ctrl-Enter or Cmd-Enter while selecting dimensions - 🌱 *console*: submit form on Ctrl-Enter or Cmd-Enter while selecting dimensions
- 🌱 *cmd*: make `akvorado version` shorter (use `-d` for full output) - 🌱 *cmd*: make `akvorado version` shorter (use `-d` for full output)
- 🌱 *build*: switch from NPM to PNPM for JavaScript build - 🌱 *build*: switch from NPM to PNPM for JavaScript build

View File

@@ -123,12 +123,12 @@ func (c *Component) Start() error {
c.r.Err(err).Msg("cannot setup watcher for GeoIP databases") c.r.Err(err).Msg("cannot setup watcher for GeoIP databases")
return fmt.Errorf("cannot setup watcher: %w", err) return fmt.Errorf("cannot setup watcher: %w", err)
} }
dirs := map[string]struct{}{} dirs := map[string]bool{}
for _, path := range c.config.GeoDatabase { for _, path := range c.config.GeoDatabase {
dirs[filepath.Dir(path)] = struct{}{} dirs[filepath.Dir(path)] = true
} }
for _, path := range c.config.ASNDatabase { for _, path := range c.config.ASNDatabase {
dirs[filepath.Dir(path)] = struct{}{} dirs[filepath.Dir(path)] = true
} }
for k := range dirs { for k := range dirs {
if err := watcher.Add(k); err != nil { if err := watcher.Add(k); err != nil {